152 Commits

Author SHA1 Message Date
44bb095a03 blalala
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-28 13:02:21 +05:00
Renovate Bot
39712f0591 chore(deps): update https://gitea.com/actions/setup-node action to v5 2025-09-28 07:43:25 +00:00
Renovate Bot
60b508af18 chore(deps): update https://gitea.com/actions/checkout action to v5 2025-09-28 07:40:23 +00:00
Renovate Bot
b6637b4163 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to dd5721b 2025-09-28 07:34:37 +00:00
Renovate Bot
6d9eed42f8 chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.13.2 2025-09-28 00:01:38 +00:00
7372e3b7f5 chore: added zstd comp to appimage
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 17:52:32 +05:00
e0d5bd7993 chore: update appimage fork
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 07:11:11 +00:00
Renovate Bot
12f8067af1 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 2098143 2025-09-24 17:38:45 +00:00
Renovate Bot
716a813ca9 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.22 2025-09-24 17:37:21 +00:00
c62cc6853f chore(check-translation): disable untill yaspeller fixed
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:31:19 +05:00
2e018b4690 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:27:15 +05:00
ad5b25f713 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:17:29 +05:00
3fb8201305 feat(file explorer): added ThumbnailLoader class
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:14:13 +05:00
04d8302d6c chore(logs): start translate
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 21:05:58 +05:00
Renovate Bot
f868b21178 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 06348c5 2025-09-23 12:10:57 +00:00
Renovate Bot
ebe25b41d8 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.20 2025-09-23 12:07:17 +00:00
Renovate Bot
fae6cad52d chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to edaa35b 2025-09-23 07:14:54 +00:00
Renovate Bot
42bce11ada chore(deps): update archlinux:base-devel docker digest to 0589aa8 2025-09-23 07:12:08 +00:00
f088c01768 chore(renovate): validate and fix renovate.json configuration
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 12:10:19 +05:00
e7eee85ed4 feat(dev-scripts): regenerate uv.lock on bump ver
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 11:58:10 +05:00
ecfe252ae3 v0.1.6
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 11:45:36 +05:00
1ad19bff6a chore: hide legendary login
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 10:51:33 +05:00
98f07a9792 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:20:58 +05:00
d5c53ed1aa feat(completion): added --debug-level
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:19:40 +05:00
5a2ab36b60 feat(cli): added --debug-level= argument
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 00:14:51 +05:00
8e25c04f56 chore(logs): start rework
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-18 17:29:38 +05:00
f249b01dc6 chore(readme): fix logo path
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-17 12:53:38 +05:00
9f32afe6a3 fix: dialog navigation on gamepad
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 21:30:57 +05:00
f475e6e0b2 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 17:03:21 +05:00
43a7c37e91 feat: use mouse extra button to back
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 17:01:43 +05:00
f1cf0ffd68 fix ecodes again meh
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 16:40:10 +05:00
70ed3abcb5 fix add game dialog navigation on keyboard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 16:28:19 +05:00
f061b1597e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 12:17:57 +05:00
0f37a8fc6f fix: disable input manager if window is not focused
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 12:15:12 +05:00
850bc57a16 chore: added prompts license to readme
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:55:52 +05:00
0dcc3ea13f chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:51:38 +05:00
1c82b34e36 feat: added ps controllers hint
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 11:46:48 +05:00
a8c4ae6f7b chore: clean icons
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-13 10:24:34 +05:00
dd4f658b66 feat: rework createControlHintsWidget
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-12 15:07:25 +05:00
bff6b7fd34 chore(build): update setuptools
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-09 10:59:41 +05:00
1e191bbba3 chore(build): added fedora 43 build
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-09 10:07:52 +05:00
4356e653b8 feat: added control hint
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 20:48:03 +05:00
4fc95511f1 docs(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 18:47:23 +05:00
4d4e14ea52 fix: Prevent fullscreen toggle on 'Select' button press during game launch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-08 18:45:30 +05:00
c39f5ad83b chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:58:15 +05:00
f3325ca35f feat(theme-manager): implement singleton and caching for improved theme handling
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:54:25 +05:00
50645066dd chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:30:16 +05:00
7945dd8980 fix(input_manager): exclude ASRock LED controller from gamepad detection
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-07 22:28:34 +05:00
59c38f9c57 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-06 12:44:43 +05:00
a2d5d28884 fix(cache): add cleanup of related cache files on JSON updates
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-06 12:43:22 +05:00
16af4b410a chore(renovate): disable almost python-version update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-03 19:17:13 +05:00
e8e42b5a86 chore(renovate): disable python-version update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-03 19:08:58 +05:00
d16e2cdf43 chore(renovate): dont update github-runners
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 22:56:03 +05:00
Renovate Bot
b60fd0d593 chore(deps): pin dependencies 2025-09-02 17:31:21 +00:00
d93f23fe8c chore(renovate): added GITHUB_TOKEN
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 22:28:10 +05:00
5423ada8f1 fix(theme-security): check standart theme too
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 17:05:35 +05:00
2547c7c78d chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-02 00:11:35 +05:00
2e93073446 feat(theme-security): add theme safety checks and unify loading via ThemeManager
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-01 23:58:38 +05:00
Gitea Actions
9657ff20d3 chore: update steam apps list 2025-09-01T15:10:40Z 2025-09-01 15:10:40 +00:00
849333c283 feat(dev-scripts): add import and function safety checks to theme pre-commit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-01 11:42:06 +05:00
8e11dac987 chore: v0.1.5
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 16:08:43 +05:00
358afbdbdb chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:29:11 +05:00
83730499e2 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:23:55 +05:00
84f560ed30 feat(tray): add modal game launch dialog with process detection and cancellation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:20:52 +05:00
888c9ac387 chore(theme): drop unstable mark from scale animation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:11:07 +05:00
68d06ca05c fix(FlowLayout): Align incomplete rows with the first card of the longest row
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:09:24 +05:00
6923a5f05c chore(theme): change placeholder aspect ratio
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 10:03:30 +05:00
f3f85441d8 fix: scale animation is less unstable
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-30 21:21:15 +05:00
eb90836710 chore: change cover aspect ratio
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-30 10:59:18 +05:00
dd125c975b fix(input_manager): revert dpad navigation to focusNextChild
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-29 14:46:42 +05:00
4521d3ca1c chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 17:15:34 +05:00
dd044dbd95 feat(tray_manager): added themes select to tray
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 17:12:31 +05:00
0047b29cd2 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:58:37 +05:00
d0fbc79168 fix(input_manager): fix keyboard and dpad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:56:55 +05:00
57f6ac9c4b feat: center cards in FlowLayout with equal margins
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:53:14 +05:00
60271f7a13 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:55:02 +05:00
38ab4acc86 chore(documentation): chore card_animation_type
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:53:01 +05:00
8f54f4814c feat: added scale animation to game card hover and focus
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:48:55 +05:00
37254b89f1 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-27 11:22:23 +05:00
893e33bdce feat(tray_manager): implement double-click to toggle main window visibility
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-27 11:14:14 +05:00
1ee784d890 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:22:26 +05:00
39f505079c chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:18:19 +05:00
46253115ff feat: returned tray and added favorites and recent to it
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:00:16 +05:00
31a7ef3e7e chore(deps): update lock file
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:44:17 +05:00
Renovate Bot
cb07904c1b fix(deps): lock file maintenance python dependencies 2025-08-24 17:39:06 +00:00
05e0d9d846 fix(renovate): disable poetry (bug in upstream)
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:35:30 +05:00
81433d3c56 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:13:50 +05:00
0ff66e282b fix(input_manager): enable Escape key to close dialogs
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:10:39 +05:00
831b7739ba fix(input-manager): enable drive list navigation with arrow keys in FileExplorer
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:05:33 +05:00
50e1dfda57 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 20:56:41 +05:00
fcf04e521d feat(file-explorer): add automatic scrolling for drives layout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 20:54:50 +05:00
74d0700d7c chore(renovate): use . for source uv
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 17:40:36 +05:00
0435c77630 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 17:39:52 +05:00
1cf93a60c8 feat: added favorites to file explorer
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 17:37:59 +05:00
31247d21c3 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:35:23 +05:00
c6017a7dce fix(file explorer): don't skip /run/media
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:23:39 +05:00
c74d209dbd chore(ci): replace uv github action to manual install
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:04:59 +05:00
5b257d3b62 fix(ci): disable cache from node
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:53:17 +05:00
4dcf1dbe6d fix(ci): I forget @
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:50:47 +05:00
8d6fe4aa65 chore(ci): install node 20 for uv
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:48:40 +05:00
022eb3f1e9 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:41:42 +05:00
11b847ed05 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:36:02 +05:00
1e4e0127a4 fix(i18n): add translation for File Explorer window title
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:33:19 +05:00
c045aa7a56 fix(input_manager): correct button mappings for increase/decrease size actions
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:31:22 +05:00
f18e7bae6b chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:28:02 +05:00
dcf8904037 feat(input_manager): enable cursor movement in QLineEdit with left/right arrows
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:25:26 +05:00
f9d24e385d fix(input_manager): prevent tab switching when using left/right arrows in QLineEdit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:22:06 +05:00
09028931be feat: use Backspace for move to parent directory in FileExplorer
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:12:15 +05:00
0294c90c54 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-19 17:54:40 +05:00
17dfef2d27 chore(tray): drop
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-19 17:52:09 +05:00
Renovate Bot
f0690f8811 fix(deps): lock file maintenance python dependencies
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:33:43 +05:00
ac20447ba3 chore(renovate): skip broken packages
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:19:25 +05:00
ba143c15a8 chore(renovate): added uv to container
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:02:04 +05:00
13068f3959 chore(renovate): fix work with uv
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:02:04 +05:00
Alex Smith
c8360d08ca fix(downloader): Clear cache entry for non-existent file 2025-08-14 21:42:18 +05:00
b070ff1fca fix(animations): fix all Qpainter conflicts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 13:14:28 +05:00
b5a2f41bdf chore(pre-commit): update all hooks
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 13:11:33 +05:00
9a37f31841 chore(renovate): use config from remote repo
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 12:55:51 +05:00
aeed0112cd chore(renovate): use latest container allways
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 12:52:13 +05:00
027ae68d4d chore(renovate): added pre-commit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 12:50:39 +05:00
37d41fef8d feat: use cef on EGS too
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-10 13:02:39 +05:00
e37422fc95 chore(todo): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-10 12:45:41 +05:00
d7951e8587 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-10 12:44:22 +05:00
556533785a chore(build): added python-websocket-client to dependency
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-10 12:39:41 +05:00
a13aca4d84 fix: websocket-client dependency
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-10 12:36:59 +05:00
35736e1723 chore: replace json to orjson
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-10 12:35:12 +05:00
Alex Smith
24a7c2e657 feat(steam): using steam cef when deleting a shortcut 2025-08-10 12:25:19 +05:00
Alex Smith
279f7ec36b feat(steam): added support steam cef 2025-08-10 12:25:05 +05:00
41f6943998 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-07 15:15:16 +05:00
3bf10dc4cd chore(documentation): fix anchors
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-07 15:13:52 +05:00
33b96d3185 chore(documentation): mention goBackDetailPage animation in theme guide
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-07 15:05:39 +05:00
3573b8e373 chore: temporary drop standart-light theme
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-07 14:23:35 +05:00
582ddd2218 feat: added animation to goBackDetailPage
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-07 14:22:22 +05:00
2753e53a4d refactor: move animations to separate module
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-07 10:29:13 +05:00
46973f35e1 chore: drop none from animation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-04 09:57:56 +05:00
8e34c92385 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:36:12 +05:00
d50b63bca7 fix(steam_api): re-download json lists if it is broken
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:33:00 +05:00
6966253e9b fix(add_game_dialog): check exe path before add game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:14:02 +05:00
13f3af7a42 fix(hltb): return None if all time zero
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:03:15 +05:00
c7bed80570 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 14:04:48 +05:00
6fde7c18db chore(documentation): fix anchors
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 11:57:01 +05:00
37782d4375 chore(documentation): mention animation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 11:51:28 +05:00
0a8a7c538c added more animation to detail_page
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 11:35:23 +05:00
Gitea Actions
9cc4b8c51d chore: update steam apps list 2025-08-01T13:12:19Z 2025-08-01 13:12:19 +00:00
397dede2be feat: use devicePixelRatio for image scale
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-29 12:15:22 +05:00
6a66f37ba1 fix: fix open context menu on gamepad
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 12:22:24 +05:00
4db1cce32c chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:44:43 +05:00
edaeca4f11 feat: set focus on first item of context menu
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:39:40 +05:00
11d44f091d fix(egs): prevent legendary list call when user.json is missing
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:32:14 +05:00
09d9c6510a chore: reduced duration of card opening animation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:13:54 +05:00
272be51bb0 feat(dev-script): added appimage cleaner script
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-22 14:17:16 +05:00
63933172f9 chore: pulse dropped from autoinstals
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-21 19:22:05 +05:00
120 changed files with 25155 additions and 5239 deletions

View File

@@ -12,17 +12,27 @@ jobs:
name: Build AppImage name: Build AppImage
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
- name: Install tools - name: Upgrade pip toolchain
run: | run: |
pip3 install git+https://github.com/Frederic98/appimage-builder.git python3 -m pip install --upgrade \
pip3 install uv pip setuptools setuptools-scm wheel packaging build
- name: Install appimage-builder
run: |
git clone https://github.com/Boria138/appimage-builder
cd appimage-builder
pip install .
- name: Install uv
run: |
pip install uv
- name: Build AppImage - name: Build AppImage
run: | run: |
@@ -42,7 +52,7 @@ jobs:
strategy: strategy:
matrix: matrix:
fedora_version: [41, 42, rawhide] fedora_version: [41, 42, 43, rawhide]
container: container:
image: fedora:${{ matrix.fedora_version }} image: fedora:${{ matrix.fedora_version }}
@@ -63,7 +73,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo - name: Checkout repo
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Copy fedora.spec - name: Copy fedora.spec
run: | run: |
@@ -84,7 +94,7 @@ jobs:
name: Build Arch Package name: Build Arch Package
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: container:
image: archlinux:base-devel image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host
@@ -124,7 +134,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Upload Arch package - name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -8,39 +8,12 @@ on:
env: env:
# Common version, will be used for tagging the release # Common version, will be used for tagging the release
VERSION: 0.1.4 VERSION: 0.1.6
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
jobs: jobs:
build-appimage:
name: Build AppImage
runs-on: ubuntu-22.04
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies
run: |
sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git
- name: Install tools
run: |
pip3 install git+https://github.com/Frederic98/appimage-builder.git
pip3 install uv
- name: Build AppImage
run: |
cd build-aux
appimage-builder
- name: Upload AppImage
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: PortProtonQt-AppImage
path: build-aux/PortProtonQt*.AppImage*
build-arch: build-arch:
name: Build Arch Package name: Build Arch Package
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -85,7 +58,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s" su user -c "yes '' | makepkg --noconfirm -s"
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@v5
- name: Upload Arch package - name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4
@@ -93,53 +66,9 @@ jobs:
name: PortProtonQt-Arch name: PortProtonQt-Arch
path: ${{ env.PKGDEST }}/* path: ${{ env.PKGDEST }}/*
build-fedora:
name: Build Fedora RPM
runs-on: ubuntu-latest
strategy:
matrix:
fedora_version: [41, 42, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
options: --privileged
steps:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
python3-build pyproject-rpm-macros python3-setuptools \
redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment
run: |
useradd rpmbuild -u 5002 -g users || true
mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
chown -R rpmbuild:users /home/rpmbuild
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@v4
- name: Copy fedora.spec
run: |
cp build-aux/fedora.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
chown -R rpmbuild:users /home/rpmbuild
- name: Build RPM
run: |
su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
- name: Upload RPM package
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
path: /home/rpmbuild/RPMS/**/*.rpm
release: release:
name: Create and Publish Release name: Create and Publish Release
needs: [build-appimage, build-arch, build-fedora] needs: [ build-arch ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
@@ -150,29 +79,6 @@ jobs:
sudo apt install -y original-awk unzip sudo apt install -y original-awk unzip
- name: Download all artifacts - name: Download all artifacts
uses: https://gitea.com/actions/download-artifact@v3 uses: https://gitea.com/actions/download-artifact@v5
with: with:
path: release/ path: release/
- name: Extract downloaded artifacts
run: |
mkdir -p extracted
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
find extracted/ -type f -exec mv {} release/ \;
rm -rf extracted/
- name: Extract changelog for version
id: changelog
run: |
VERSION="${{ env.VERSION }}"
awk "/^## \\[$VERSION\\]/ {flag=1; next} /^## \\[/ || /^---/ {flag=0} flag" CHANGELOG.md > changelog.txt
- name: Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
body_path: changelog.txt
token: ${{ env.GITEA_TOKEN }}
tag_name: v${{ env.VERSION }}
prerelease: true
files: release/**/*
sha256sum: true

View File

@@ -1,4 +1,4 @@
name: Check Translations name: Check Translations (disabled until yaspeller is fixed)
run-name: Check spelling in translation files run-name: Check spelling in translation files
on: on:
push: push:
@@ -12,13 +12,14 @@ on:
jobs: jobs:
check-translations: check-translations:
if: false
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@v5 uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

View File

@@ -18,7 +18,7 @@ jobs:
fedora: ${{ steps.check.outputs.fedora }} fedora: ${{ steps.check.outputs.fedora }}
arch: ${{ steps.check.outputs.arch }} arch: ${{ steps.check.outputs.arch }}
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -63,16 +63,16 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch' if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync zstd git
- name: Install tools - name: Install tools
run: | run: |
pip3 install git+https://github.com/Frederic98/appimage-builder.git pip3 install git+https://github.com/Boria138/appimage-builder.git
pip3 install uv pip3 install uv
- name: Build AppImage - name: Build AppImage
@@ -115,7 +115,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo - name: Checkout repo
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Copy fedora-git.spec - name: Copy fedora-git.spec
run: | run: |
@@ -138,7 +138,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container: container:
image: archlinux:base-devel image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host
@@ -178,7 +178,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Upload Arch package - name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -20,12 +20,18 @@ jobs:
name: Check code name: Check code
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv - name: Set up Node.js
uses: https://github.com/astral-sh/setup-uv@v6 uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: with:
enable-cache: true node-version: 20
- name: Install uv manually
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
uv --version
- name: Sync dependencies into venv - name: Sync dependencies into venv
run: uv sync --all-extras --dev run: uv sync --all-extras --dev

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@v5 uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

View File

@@ -8,11 +8,31 @@ on:
jobs: jobs:
renovate: renovate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:41.1.4 container: ghcr.io/renovatebot/renovate:latest@sha256:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- run: renovate
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 20
- name: Install uv manually
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
. $HOME/.local/bin/env
uv --version
- name: Download external renovate config
run: |
mkdir -p /tmp/renovate-config
curl -fsSL "https://git.linux-gaming.ru/Linux-Gaming/renovate-config/raw/branch/main/config.js" \
-o /tmp/renovate-config/config.js
- name: Run Renovate
run: renovate
env: env:
RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js" RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
LOG_LEVEL: "debug" LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}

View File

@@ -3,7 +3,7 @@
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)' exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@@ -11,15 +11,14 @@ repos:
- id: check-yaml - id: check-yaml
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.14 rev: 0.8.22
hooks: hooks:
- id: uv-lock - id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.5 rev: v0.13.2
hooks: hooks:
- id: ruff - id: ruff-check
args: [--fix]
- repo: local - repo: local
hooks: hooks:

View File

@@ -3,27 +3,108 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
### Changed
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
### Contributors
---
## [0.1.6] - 2025-09-23
### Added
- Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки
- Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS)
- Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада
- Подсказки по управлению в интерфейсе
- Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад"
- Аргумент cli --debug-level для указания уровня дебага
### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors
---
## [0.1.5] - 2025-08-31
### Added
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
- Анимация при закрытии карточки игры (подробности см. в документации).
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
- Система быстрого доступа (избранного) в диалоге выбора файлов.
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
- Пункт "Выход" в трей.
- Пункт "Темы" в трей для быстрого переключения тем.
- Двойной клик по иконке трея для показа/скрытия главного окна.
- Запуск через трей показывает модальное окно для слежки за процессом запуска
### Changed
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
- Контекстное меню при открытии теперь сразу фокусируется на первом элементе.
- Анимации теперь можно настраивать через темы (подробности см. в документации).
- Общие JSON-файлы (`steam_apps` и `anticheat_games`) теперь перекачиваются, если они повреждены.
- Временно удалена светлая тема.
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
- Обновлены все зависимости (затрагивает только AppImage).
- Приложение теперь не закрывается полностью, а сворачивается в трей.
- Карточки теперь все находятся друг под другом, а не в разнабой
- Изменено соотношение сторон карточек
### Fixed
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
- Скриншоты тем больше не теряют качество при масштабе, отличном от 100%.
- Данные от HLTB теперь не отображаются в карточке, если нет информации о времени прохождения.
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
- Заголовок окна диалога выбора файлов теперь можно перевести.
- Трей теперь можно перевести.
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
### Contributors
- @Alex Smith
---
## [0.1.4] - 2025-07-21 ## [0.1.4] - 2025-07-21
### Added ### Added
- Переводы в переопределениях (за подробностями в документацию) - Переводы в переопределениях (подробности см. в документации).
- Обложки и описания для всех автоинсталлов - Обложки и описания для всех автоинсталлов.
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры - Возможность указать ссылку для скачивания обложки в диалоге добавления игры.
- Интеграция с howlongtobeat.com - Интеграция с howlongtobeat.com.
### Changed ### Changed
- Оптимизированны обложки автоинсталлов - Оптимизированы обложки автоинсталлов.
- Папка custom_data исключена из сборки модуля для уменьшение его размера - Папка `custom_data` исключена из сборки модуля для уменьшения его размера.
- Бейдж PortProton теперь открывает PortProtonDB - Бейдж PortProton теперь открывает PortProtonDB.
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии - Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в Gamescope-сессии.
- Удалён аргумент `--session` так как тестирование gamescope сессии завершено - Удалён аргумент `--session`, так как тестирование Gamescope-сессии завершено.
- В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton" - В контекстном меню игр без exe-файла теперь отображается только пункт «Удалить из PortProton».
### Fixed ### Fixed
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси - Запрос к GitHub API при загрузке legendary теперь учитывает настройки прокси.
- Путь к portprotonqt-session-select в оверлее - Путь к `portprotonqt-session-select` в оверлее.
- Работа exiftool в AppImage - Работа `exiftool` в AppImage.
- Открытие контекстного меню у игр без exe - Открытие контекстного меню у игр без exe-файла.
### Contributors ### Contributors
- @Vector_null - @Vector_null
@@ -33,32 +114,32 @@
## [0.1.3] - 2025-07-05 ## [0.1.3] - 2025-07-05
### Added ### Added
- Аргумент `--session` для запуска приложения в gamescope (Исключительно в целях тестирования) - Аргумент `--session` для запуска приложения в Gamescope (исключительно в целях тестирования).
- Начальная поддержка EGS (Без EOS, скачивания игр и запуска игр из сторонних магазинов) - Начальная поддержка EGS (без EOS, скачивания и запуска игр из сторонних магазинов).
- Автодополнение bash для комманды portprotonqt - Автодополнение bash для команды `portprotonqt`.
- Поддержка геймпадов в диалоге выбора игры - Поддержка геймпадов в диалоге выбора игры.
- Быстрый запуск и остановка игры через контекстное меню - Быстрый запуск и остановка игры через контекстное меню.
- Иконки в контекстом меню - Иконки в контекстном меню.
- Обложки для части автоинсталлов - Обложки для части автоинсталлов.
### Changed ### Changed
- Удалены сборки для Fedora 40 - Удалены сборки для Fedora 40.
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем. - Параметры анимации GameCard перенесены в `styles.py` с подробной документацией для кастомизации тем.
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга - Статусы выделения и наведения на карточки теперь взаимоисключающие.
- Все desktop файлы создаются с коментарием "Запустить игру {название} через PortProton" - Все desktop-файлы создаются с комментарием «Запустить игру {название} через PortProton».
- Заполнители в переводах теперь стали более осмысленными - Заполнители в переводах стали более осмысленными.
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope - Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope.
- Текст бейджей теперь обрезается через ... если не помещается - Текст бейджей теперь обрезается троеточием, если не помещается.
### Fixed ### Fixed
- Дублирование обводки выделения карточек при быстром перемешении мыши - Дублирование обводки карточек при быстром перемещении мыши.
- Завершение приложения при закритие окна - Завершение приложения при закрытии окна.
- Использование системной палитры в темах - Использование системной палитры в темах.
- Ошибки темы в нативном пакете - Ошибки тем в нативном пакете.
- Ошибки темы в Gamescope - Ошибки тем в Gamescope.
- Размер иконок для desktop файлов теперь 128x128 - Размер иконок для desktop-файлов теперь 128x128.
- Пустая область при обновлении сетки игр - Пустая область при обновлении сетки игр.
- Запуск игры при открытом оверлее - Запуск игры при открытом оверлее.
### Contributors ### Contributors
- @Dervart - @Dervart
@@ -69,63 +150,63 @@
## [0.1.2] - 2025-06-15 ## [0.1.2] - 2025-06-15
### Added ### Added
- Кнопки сброса настроек и очистки кэша - Кнопки сброса настроек и очистки кэша.
- Бейдж PortProton - Бейдж PortProton.
- Зависимость от `xdg-utils` - Зависимость от `xdg-utils`.
- Интеграция статуса WeAntiCheatYet в карточку - Интеграция статуса WeAntiCheatYet в карточку.
- Переключение полноэкршанного режима через F11 или кнопку Select на геймпаде - Переключение полноэкранного режима через F11 или кнопку Select на геймпаде.
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде - Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде.
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде - Закрытие диалога добавления игры через ESC или кнопку B на геймпаде.
- Закрытие окна приложения по комбинации клавиш Ctrl+Q - Закрытие приложения комбинацией клавиш Ctrl+Q.
- Сохранение и восстановление размера окна при перезапуске - Сохранение и восстановление размера окна при перезапуске.
- Переключатель полноэкранного режима приложения - Переключатель полноэкранного режима приложения.
- Пункт в контекстном меню «Открыть папку игры» - Пункт в контекстном меню «Открыть папку игры».
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam» - Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam».
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного» - Пункты в контекстном меню «Добавить в избранное» и «Удалить из избранного».
- Метод сортировки «Сначала избранное» - Метод сортировки «Сначала избранное».
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена) - Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена).
- Поддержка управления геймпадом в `QMenu` и `QComboBox` - Поддержка управления геймпадом в `QMenu` и `QComboBox`.
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме - Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме.
- Оверлей на кнопку Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями - Оверлей на кнопку Insert или Xbox/PS-кнопку на геймпаде для закрытия приложения, выключения, перезагрузки, перехода в спящий режим или переключения между сессиями.
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt) - [Gamescope-сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt).
- Пресеты управления для DualShock 4 и DualSense - Пресеты управления для DualShock 4 и DualSense.
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена) - Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию отключена).
- Переводы пунктов настроек - Переводы пунктов настроек.
### Changed ### Changed
- Обновлены все иконки - Обновлены все иконки.
- Переименована функция `_get_steam_home` в `get_steam_home` - Функция `_get_steam_home` переименована в `get_steam_home`.
- Переименован `steam_game` в `game_source` - `steam_game` переименован в `game_source`.
- Логика контекстного меню вынесена в `ContextMenuManager` - Логика контекстного меню вынесена в `ContextMenuManager`.
- Бейдж Steam теперь открывает Steam Community - Бейдж Steam теперь открывает Steam Community.
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary - Лицензия изменена с MIT на GPL-3.0 для совместимости с кодом legendary.
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна - Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна.
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке - Бейджи с карточек теперь отображаются и на странице с деталями, а не только в библиотеке.
- Установлена ширина бейджа в две трети ширины карточки - Установлена ширина бейджа в 2/3 ширины карточки.
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites` - Бейджи источников (`Steam`, `EGS`, `PortProton`) отображаются только при активном фильтре `all` или `favorites`.
- Карточки теперь фокусируются в направлении движения стрелок или D-pad: - Карточки теперь фокусируются в направлении движения стрелок или D-pad.
- Поддерживается удержание D-pad для непрерывного переключения карточек - Поддерживается удержание D-pad для непрерывного переключения карточек.
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности - Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности.
- D-pad больше не переключает вкладки (только кнопки RB/LB) - D-pad больше не переключает вкладки (только кнопки RB/LB).
- Кнопка добавления игры больше не фокусируется - Кнопка добавления игры больше не получает фокус.
- Диалог добавления игры теперь открывается только в библиотеке - Диалог добавления игры открывается только в библиотеке.
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt - Все упоминания PortProtonQT заменены на PortProtonQt.
- Размер карточек теперь меняется только при отпускании слайдера - Размер карточек меняется только при отпускании слайдера.
- Слайдер теперь управляется через тригеры на геймпаде - Слайдер теперь управляется триггерами на геймпаде.
- Диалог добавления игры теперь открывается на X, а не на Y - Диалог добавления игры теперь открывается на X, а не на Y.
### Fixed ### Fixed
- Возврат к теме «standard» при выборе несуществующей темы - Возврат к теме «standard» при выборе несуществующей темы.
- Корректное открытие контекстного меню - Корректное открытие контекстного меню.
- Запуск приложения при отсутствии `exiftool` - Запуск приложения при отсутствии `exiftool`.
- Предотвращено бесконечное обращение к `get_portproton_location` - Предотвращено бесконечное обращение к `get_portproton_location`.
- Обновлены ссылки на документацию в README - Обновлены ссылки на документацию в README.
- Устранён traceback при отсутствии обложек (placeholder) - Исправлено падение при отсутствии обложек (placeholder).
- Устранены утечки памяти при загрузке обложек - Устранены утечки памяти при загрузке обложек.
- Исправлены ошибки при подключении геймпада - Исправлены ошибки при подключении геймпада.
- Предотвращено многократное открытие диалога добавления игры через геймпад - Предотвращено многократное открытие диалога добавления игры через геймпад.
- Корректная обработка событий геймпада во время игры - Корректная обработка событий геймпада во время игры.
- Убийсво всех процессов "зомби" при закрытии программы - Убийство всех процессов-зомби при закрытии программы.
### Contributors ### Contributors
- @Vector_null - @Vector_null
@@ -136,20 +217,20 @@
## [0.1.1] 2025-05-17 ## [0.1.1] 2025-05-17
### Added ### Added
- Алфавитная сортировка библиотеки - Алфавитная сортировка библиотеки.
- Проверка переводов через yaspeller - Проверка переводов через yaspeller.
- Сборка Fedora-пакета - Сборка Fedora-пакета.
- Сборка AppImage - Сборка AppImage.
### Changed ### Changed
- Удалён жёстко заданный размер окна - Удалён жёстко заданный размер окна.
- Использован `icoextract` как Python-модуль - Использован `icoextract` как Python-модуль.
### Fixed ### Fixed
- Скрытие статус-бара - Скрытие статус-бара.
- Чтение списка Steam-игр - Чтение списка Steam-игр.
- Зависание GUI - Зависание GUI.
- Сбой при повреждённом Steam - Сбой при повреждённом Steam.
### Contributors ### Contributors
- @Vector_null - @Vector_null

View File

@@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64"> <img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<h1 align="center">PortProtonQt</h1> <h1 align="center">PortProtonQt</h1>
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p> <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
</div> </div>
@@ -54,6 +54,7 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/)
Полный текст лицензий см. в файле [LICENSE](LICENSE). Полный текст лицензий см. в файле [LICENSE](LICENSE).

View File

@@ -41,7 +41,7 @@
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql) - [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam - [X] Добавить на карточку бейдж, указывающий, что игра из Steam
- [X] Добавить поддержку версий Steam для Flatpak и Snap - [X] Добавить поддержку версий Steam для Flatpak и Snap
- [ ] Реализовать добавление игры как сторонней в Steam без перезапуска - [X] Реализовать добавление игры как сторонней в Steam без перезапуска
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся - [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад» - [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide) - [X] Добавить перевод через gettext [Документация](documentation/localization_guide)

View File

@@ -1,25 +1,19 @@
version: 1 version: 1
script: script:
# 1) чистим старый AppDir
- rm -rf AppDir || true - rm -rf AppDir || true
# 2) создаём структуру каталога
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
# 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
- uv venv - uv venv
- uv pip install --no-cache-dir ../ - uv pip install --no-cache-dir ../
# 4) копируем всё из .venv в AppDir
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr - cp -r share AppDir/usr
# 5) чистим от ненужных модулей и бинарников
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
- shopt -s extglob - shopt -s extglob
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*) - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
AppDir: AppDir:
path: ./AppDir path: ./AppDir
after_bundle: after_bundle:
# Документация, справка, примеры
- rm -rf $TARGET_APPDIR/usr/share/man || true - rm -rf $TARGET_APPDIR/usr/share/man || true
- rm -rf $TARGET_APPDIR/usr/share/doc || true - rm -rf $TARGET_APPDIR/usr/share/doc || true
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true - rm -rf $TARGET_APPDIR/usr/share/doc-base || true
@@ -35,17 +29,14 @@ AppDir:
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true - rm -rf $TARGET_APPDIR/usr/share/metainfo || true
- rm -rf $TARGET_APPDIR/usr/include || true - rm -rf $TARGET_APPDIR/usr/include || true
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true - rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
# Статика и отладка
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true - find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
# Strip ELF бинарников (исключая Python extensions)
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true" - "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
# Удаление пустых папок
- find $TARGET_APPDIR -type d -empty -delete || true - find $TARGET_APPDIR -type d -empty -delete || true
app_info: app_info:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt icon: ru.linux_gaming.PortProtonQt
version: 0.1.4 version: 0.1.6
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:
@@ -64,15 +55,12 @@ AppDir:
- libimage-exiftool-perl - libimage-exiftool-perl
- xdg-utils - xdg-utils
exclude: exclude:
# Документация и man-страницы
- "*-doc" - "*-doc"
- "*-man" - "*-man"
- manpages - manpages
- mandb - mandb
# Статические библиотеки
- "*-dev" - "*-dev"
- "*-static" - "*-static"
# Дебаг-символы
- "*-dbg" - "*-dbg"
- "*-dbgsym" - "*-dbgsym"
runtime: runtime:
@@ -82,5 +70,5 @@ AppDir:
PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34' PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
AppImage: AppImage:
sign-key: None sign-key: None
comp: xz
arch: x86_64 arch: x86_64
comp: zstd

View File

@@ -1,12 +1,12 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.4 pkgver=0.1.6
pkgrel=1 pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any') arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4') 'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4') 'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -33,6 +33,7 @@ Requires: python3-babel
Requires: python3-evdev Requires: python3-evdev
Requires: python3-icoextract Requires: python3-icoextract
Requires: python3-numpy Requires: python3-numpy
Requires: python3-websocket-client
Requires: python3-orjson Requires: python3-orjson
Requires: python3-psutil Requires: python3-psutil
Requires: python3-pyside6 Requires: python3-pyside6

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.4 %global pypi_version 0.1.6
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1
@@ -30,6 +30,7 @@ Requires: python3-babel
Requires: python3-evdev Requires: python3-evdev
Requires: python3-icoextract Requires: python3-icoextract
Requires: python3-numpy Requires: python3-numpy
Requires: python3-websocket-client
Requires: python3-orjson Requires: python3-orjson
Requires: python3-psutil Requires: python3-psutil
Requires: python3-pyside6 Requires: python3-pyside6

View File

@@ -1,19 +1,30 @@
_portprotonqt() { _portprotonqt_completions() {
local cur prev local cur prev opts
_init_completion || return COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
case $prev in # Available options
--help|-h) opts="--fullscreen --debug-level --help -h"
return
# Debug level choices
debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
case "${prev}" in
--debug-level)
# Complete debug levels
COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) )
return 0
;;
*)
;; ;;
esac esac
if [[ "$cur" == -* ]]; then # Complete options
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) ) if [[ ${cur} == -* ]]; then
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0 return 0
fi fi
return 0
} }
complete -F _portprotonqt portprotonqt complete -F _portprotonqt_completions portprotonqt

View File

@@ -1,8 +0,0 @@
module.exports = {
"endpoint": "https://git.linux-gaming.ru/api/v1",
"gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
"platform": "gitea",
"onboardingConfigFileName": "renovate.json",
"autodiscover": true,
"optimizeForDisabled": true,
};

View File

@@ -765,7 +765,7 @@
}, },
{ {
"normalized_name": "lost ark", "normalized_name": "lost ark",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "archeage unchained", "normalized_name": "archeage unchained",
@@ -1777,7 +1777,7 @@
}, },
{ {
"normalized_name": "supervive", "normalized_name": "supervive",
"status": "Denied" "status": "Running"
}, },
{ {
"normalized_name": "splitgate 2", "normalized_name": "splitgate 2",
@@ -4426,5 +4426,121 @@
{ {
"normalized_name": "carx street", "normalized_name": "carx street",
"status": "Broken" "status": "Broken"
},
{
"normalized_name": "warcos 2",
"status": "Broken"
},
{
"normalized_name": "karos classic",
"status": "Broken"
},
{
"normalized_name": "dead island riptide",
"status": "Running"
},
{
"normalized_name": "lineage",
"status": "Broken"
},
{
"normalized_name": "day of dragons",
"status": "Running"
},
{
"normalized_name": "sonic rumble",
"status": "Broken"
},
{
"normalized_name": "black stigma",
"status": "Broken"
},
{
"normalized_name": "umamusume pretty derby",
"status": "Running"
},
{
"normalized_name": "dirt rally",
"status": "Supported"
},
{
"normalized_name": "minifighter",
"status": "Broken"
},
{
"normalized_name": "hide & hold out h2o",
"status": "Running"
},
{
"normalized_name": "battlefield 6",
"status": "Denied"
},
{
"normalized_name": "ghost of tsushima director's cut",
"status": "Denied"
},
{
"normalized_name": "sword of justice",
"status": "Broken"
},
{
"normalized_name": "blade & soul neo",
"status": "Broken"
},
{
"normalized_name": "the finals (cn)",
"status": "Broken"
},
{
"normalized_name": "tom clancy's rainbow six siege x",
"status": "Denied"
},
{
"normalized_name": "dragonheir silent gods",
"status": "Broken"
},
{
"normalized_name": "the quinfall",
"status": "Running"
},
{
"normalized_name": "redmatch 2",
"status": "Broken"
},
{
"normalized_name": "blade & soul heroes",
"status": "Broken"
},
{
"normalized_name": "blue archive",
"status": "Running"
},
{
"normalized_name": "midnight murder club",
"status": "Broken"
},
{
"normalized_name": "dungeon done",
"status": "Broken"
},
{
"normalized_name": "project wraith",
"status": "Broken"
},
{
"normalized_name": "solo leveling arise",
"status": "Broken"
},
{
"normalized_name": "freedom wars",
"status": "Running"
},
{
"normalized_name": "open fortress",
"status": "Running"
},
{
"normalized_name": "no more room in hell 2",
"status": "Running"
} }
] ]

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,12 +1,100 @@
[ [
{ {
"normalized_title": "return alive", "normalized_title": "astroneer",
"slug": "return-alive" "slug": "astroneer"
},
{
"normalized_title": "anno 2205",
"slug": "anno-2205"
},
{
"normalized_title": "anno 2070",
"slug": "anno-2070"
},
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{
"normalized_title": "ultrakill (early access)",
"slug": "ultrakill-early-access"
},
{
"normalized_title": "vintage story",
"slug": "vintage-story"
},
{
"normalized_title": "disco elysium the finul cut",
"slug": "disco-elysium-the-finul-cut"
},
{
"normalized_title": "warcraft iii reign of chaos",
"slug": "warcraft-iii-reign-of-chaos"
},
{
"normalized_title": "dying light",
"slug": "dying-light"
},
{
"normalized_title": "лихо одноглазое",
"slug": "liho-odnoglazoe"
},
{
"normalized_title": "indika",
"slug": "indika"
},
{
"normalized_title": "no sleep for kaname date from ai the somnium files",
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
},
{
"normalized_title": "dead island 2",
"slug": "dead-island-2"
},
{
"normalized_title": "dead island",
"slug": "dead-island-definitive-edition"
},
{
"normalized_title": "wuchang fallen feathers",
"slug": "wuchang-fallen-feathers"
},
{
"normalized_title": "mindseye",
"slug": "mindseye"
},
{
"normalized_title": "alan wake",
"slug": "alan-wake"
}, },
{ {
"normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a", "normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a",
"slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a" "slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a"
}, },
{
"normalized_title": "fifa 18",
"slug": "fifa-18"
},
{
"normalized_title": "eriksholm the stolen dream",
"slug": "eriksholm-the-stolen-dream"
},
{
"normalized_title": "caravan sandwitch",
"slug": "caravan-sandwitch"
},
{
"normalized_title": "expeditions a mudrunner game",
"slug": "expeditions-a-mudrunner-game"
},
{
"normalized_title": "#drive rally",
"slug": "drive-rally"
},
{
"normalized_title": "return alive",
"slug": "return-alive"
},
{ {
"normalized_title": "recore", "normalized_title": "recore",
"slug": "recore-definitive-edition" "slug": "recore-definitive-edition"
@@ -191,10 +279,6 @@
"normalized_title": "cardlife creative survival", "normalized_title": "cardlife creative survival",
"slug": "cardlife-creative-survival" "slug": "cardlife-creative-survival"
}, },
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{ {
"normalized_title": "kompas 3d v24 / компас 3d v24 beta", "normalized_title": "kompas 3d v24 / компас 3d v24 beta",
"slug": "kompas-3d-v24-kompas-3d-v24-beta" "slug": "kompas-3d-v24-kompas-3d-v24-beta"

Binary file not shown.

View File

@@ -17,4 +17,6 @@ Generated-By:
start.sh start.sh
EGS EGS
Stop Game Stop Game
Fullscreen
Fulscreen
\t \t

378
dev-scripts/appimage_clean.py Executable file
View File

@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""
PySide6 Dependencies Analyzer with ldd support
Анализирует зависимости PySide6 модулей используя ldd для определения
реальных зависимостей скомпилированных библиотек.
"""
import ast
import os
import sys
import subprocess
import re
from pathlib import Path
from typing import Set, Dict, List
import argparse
import json
class PySide6DependencyAnalyzer:
def __init__(self):
# Системные библиотеки, которые нужно всегда оставлять
self.system_libs = {
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
}
self.real_dependencies = {}
self.used_modules_code = set()
self.used_modules_ldd = set()
self.all_required_modules = set()
def find_python_files(self, directory: Path) -> List[Path]:
"""Находит все Python файлы в директории"""
python_files = []
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
for file in files:
if file.endswith('.py'):
python_files.append(Path(root) / file)
return python_files
def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
"""Находит все PySide6 библиотеки (.so файлы)"""
libs = {}
# Поиск в единственной локации
search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
print(f"Поиск PySide6 библиотек в: {search_path}")
if search_path.exists():
# Ищем .so файлы модулей
for so_file in search_path.glob("Qt*.*.so"):
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
if module_name.startswith('Qt'):
libs[module_name] = so_file
# Также ищем в подпапках
for subdir in search_path.iterdir():
if subdir.is_dir() and subdir.name.startswith('Qt'):
for so_file in subdir.glob("*.so*"):
if 'Qt' in so_file.name:
libs[subdir.name] = so_file
break
return libs
def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
"""Анализирует зависимости библиотеки с помощью ldd"""
qt_deps = set()
try:
result = subprocess.run(['ldd', str(lib_path)],
capture_output=True, text=True, check=True)
# Парсим вывод ldd и ищем Qt библиотеки
for line in result.stdout.split('\n'):
# Ищем строки вида: libQt6Core.so.6 => /path/to/lib
match = re.search(r'libQt6(\w+)\.so', line)
if match:
qt_module = f"Qt{match.group(1)}"
qt_deps.add(qt_module)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
return qt_deps
def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
"""Строит граф зависимостей на основе ldd анализа"""
dependencies = {}
print("Анализ реальных зависимостей с помощью ldd...")
for module, lib_path in pyside_libs.items():
print(f" Анализируется {module}...")
deps = self.analyze_ldd_dependencies(lib_path)
dependencies[module] = deps
if deps:
print(f" Зависимости: {', '.join(sorted(deps))}")
return dependencies
def analyze_file_imports(self, file_path: Path) -> Set[str]:
"""Анализирует один Python файл и возвращает используемые PySide6 модули"""
modules = set()
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith('PySide6.'):
module = alias.name.split('.', 2)[1]
if module.startswith('Qt'):
modules.add(module)
elif isinstance(node, ast.ImportFrom):
if node.module and node.module.startswith('PySide6.'):
module = node.module.split('.', 2)[1]
if module.startswith('Qt'):
modules.add(module)
except Exception as e:
print(f"Ошибка при анализе {file_path}: {e}")
return modules
def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
"""Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
all_deps = set(modules)
if not dependency_graph:
return all_deps
# Повторяем до тех пор, пока не найдем все транзитивные зависимости
changed = True
iteration = 0
while changed and iteration < 10: # Защита от бесконечного цикла
changed = False
current_deps = set(all_deps)
for module in current_deps:
if module in dependency_graph:
new_deps = dependency_graph[module] - all_deps
if new_deps:
all_deps.update(new_deps)
changed = True
iteration += 1
return all_deps
def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
"""Анализирует весь проект"""
python_files = self.find_python_files(project_path)
print(f"Найдено {len(python_files)} Python файлов")
# Анализ статических импортов
used_modules_code = set()
file_modules = {}
for file_path in python_files:
modules = self.analyze_file_imports(file_path)
if modules:
file_modules[str(file_path.relative_to(project_path))] = list(modules)
used_modules_code.update(modules)
print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
# Поиск PySide6 библиотек
search_base = appdir_path if appdir_path else project_path
pyside_libs = self.find_pyside6_libs(search_base)
if not pyside_libs:
print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
return {
'error': 'PySide6 библиотеки не найдены',
'analysis_method': 'failed',
'found_libraries': 0,
'directly_used_code': sorted(used_modules_code),
'all_required': [],
'removable': [],
'available_modules': [],
'file_usage': file_modules
}
print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
# Анализ реальных зависимостей с ldd
real_dependencies = self.build_real_dependency_graph(pyside_libs)
# Определяем модули, которые реально используются через ldd
used_modules_ldd = set()
for module in used_modules_code:
if module in real_dependencies:
used_modules_ldd.update(real_dependencies[module])
used_modules_ldd.add(module)
print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
# Объединяем результаты анализа кода и ldd
all_used_modules = used_modules_code | used_modules_ldd
# Получаем все необходимые модули включая зависимости
all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
# Все доступные PySide6 модули
available_modules = set(pyside_libs.keys())
# Модули, которые можно удалить
removable = available_modules - all_required
return {
'analysis_method': 'ldd + static analysis',
'found_libraries': len(pyside_libs),
'directly_used_code': sorted(used_modules_code),
'directly_used_ldd': sorted(used_modules_ldd),
'all_required': sorted(all_required),
'removable': sorted(removable),
'available_modules': sorted(available_modules),
'file_usage': file_modules,
'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
'library_paths': {k: str(v) for k, v in pyside_libs.items()},
'analysis_summary': {
'total_modules': len(available_modules),
'required_modules': len(all_required),
'removable_modules': len(removable),
'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
}
}
def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
"""Генерирует обновленный AppImage рецепт с командами очистки"""
# Читаем существующий рецепт
try:
with open(template_path, 'r', encoding='utf-8') as f:
recipe_content = f.read()
except FileNotFoundError:
print(f"Шаблон рецепта не найден: {template_path}")
return ""
# Генерируем новые команды очистки
cleanup_lines = []
# QML удаляем только если не используется
qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
if qml_modules.issubset(set(removable_modules)):
cleanup_lines.append(" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
# Инструменты разработки (всегда удаляем)
cleanup_lines.append(" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
# Модули для удаления
if removable_modules:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
# Генерируем команду для удаления нативных библиотек с сохранением нужных
required_libs = set()
for module in sorted(set(self.all_required_modules)):
required_libs.add(f"libQt6{module.replace('Qt', '')}*")
# Добавляем системные библиотеки
for lib in self.system_libs:
required_libs.add(f"{lib}*")
keep_pattern = '|'.join(sorted(required_libs))
cleanup_lines.extend([
" - shopt -s extglob",
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
])
# Заменяем блок очистки в рецепте
import re
# Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)'
new_cleanup_block = " # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
return updated_recipe
def main():
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
parser.add_argument('project_path', help='Путь к проекту для анализа')
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
args = parser.parse_args()
project_path = Path(args.project_path)
if not project_path.exists():
print(f"Ошибка: путь {project_path} не существует")
sys.exit(1)
appdir_path = Path(args.appdir) if args.appdir else None
if appdir_path and not appdir_path.exists():
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
appdir_path = None
analyzer = PySide6DependencyAnalyzer()
results = analyzer.analyze_project(project_path, appdir_path)
# Сохраняем в анализатор для генерации команд
analyzer.all_required_modules = set(results.get('all_required', []))
# Выводим результаты
print("\n" + "="*60)
print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
print("="*60)
if 'error' in results:
print(f"\nОШИБКА: {results['error']}")
sys.exit(1)
print(f"\nМетод анализа: {results['analysis_method']}")
print(f"Найдено библиотек: {results['found_libraries']}")
if results['directly_used_code']:
print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
for module in results['directly_used_code']:
print(f"{module}")
if results['directly_used_ldd']:
print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
for module in results['directly_used_ldd']:
print(f"{module}")
print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
for module in results['all_required']:
print(f"{module}")
print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
for module in results['removable']:
print(f"{module}")
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
if args.verbose and results['real_dependencies']:
Devlin(f"\nРеальные зависимости (ldd):")
for module, deps in results['real_dependencies'].items():
if deps:
print(f" {module}{', '.join(deps)}")
# Обновляем AppImage рецепт
recipe_path = Path("../build-aux/AppImageBuilder.yml")
if recipe_path.exists():
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
if updated_recipe:
with open(recipe_path, 'w', encoding='utf-8') as f:
f.write(updated_recipe)
print(f"\nAppImage рецепт обновлен: {recipe_path}")
else:
print(f"\nОШИБКА: не удалось обновить рецепт")
else:
print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
# Сохраняем результаты в JSON
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"Результаты сохранены в: {args.output}")
print("\n" + "="*60)
if __name__ == "__main__":
main()

View File

@@ -2,6 +2,7 @@
import argparse import argparse
import re import re
import subprocess
from pathlib import Path from pathlib import Path
from datetime import date from datetime import date
@@ -134,6 +135,12 @@ def main():
print(f"Updated version from {old} to {new} in {len(updated)} files:") print(f"Updated version from {old} to {new} in {len(updated)} files:")
for p in sorted(updated): for p in sorted(updated):
print(f" - {p}") print(f" - {p}")
try:
subprocess.run(["uv", "lock"], check=True)
print("Regenerated uv.lock")
except subprocess.CalledProcessError as e:
print(f"Failed to regenerate uv.lock: {e}")
else: else:
print(f"No occurrences of version {old} found in specified files.") print(f"No occurrences of version {old} found in specified files.")

View File

@@ -3,8 +3,9 @@
import sys import sys
from pathlib import Path from pathlib import Path
import re import re
import ast
# Запрещенные свойства # Запрещенные QSS-свойства
FORBIDDEN_PROPERTIES = { FORBIDDEN_PROPERTIES = {
"box-shadow", "box-shadow",
"backdrop-filter", "backdrop-filter",
@@ -12,15 +13,55 @@ FORBIDDEN_PROPERTIES = {
"text-shadow", "text-shadow",
} }
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_qss_files(): def check_qss_files():
has_errors = False has_errors = False
for qss_file in Path("portprotonqt/themes").glob("**/*.py"): for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
with open(qss_file, "r") as f: with open(qss_file, "r") as f:
content = f.read() content = f.read()
# Проверка на запрещённые QSS-свойства
for prop in FORBIDDEN_PROPERTIES: for prop in FORBIDDEN_PROPERTIES:
if re.search(rf"{prop}\s*:", content, re.IGNORECASE): if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}") print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
has_errors = True has_errors = True
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, (ast.Import, ast.ImportFrom)):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
has_errors = True
except SyntaxError as e:
print(f"ERROR: Syntax error in file {qss_file}: {e}")
has_errors = True
return has_errors return has_errors
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -3,10 +3,11 @@
--- ---
## 📋 Contents ## 📋 Contents
- [Overview](#overview) - [Overview](#-overview)
- [Adding a New Translation](#adding-a-new-translation) - [Adding a New Translation](#-adding-a-new-translation)
- [Updating Existing Translations](#updating-existing-translations) - [Updating Existing Translations](#-updating-existing-translations)
- [Compiling Translations](#compiling-translations) - [Compiling Translations](#-compiling-translations)
- [Spell Check](#-spell-check)
--- ---
@@ -20,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 197 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 197 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 of 197 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 |
--- ---

View File

@@ -3,10 +3,11 @@
--- ---
## 📋 Содержание ## 📋 Содержание
- [Обзор](#обзор) - [Обзор](#-обзор)
- [Добавление нового перевода](#добавление-нового-перевода) - [Добавление нового перевода](#-добавление-нового-перевода)
- [Обновление существующих переводов](#обновление-существующих-переводов) - [Обновление существующих переводов](#-обновление-существующих-переводов)
- [Компиляция переводов](#компиляция-переводов) - [Компиляция переводов](#-компиляция-переводов)
- [Проверка орфографии](#-проверка-орфографии)
--- ---
@@ -20,9 +21,9 @@
| Локаль | Прогресс | Переведено | | Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 197 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 197 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 из 197 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 |
--- ---

View File

@@ -3,15 +3,10 @@
--- ---
## 📋 Contents ## 📋 Contents
- [Overview](#overview) - [Overview](#-overview)
- [How It Works](#how-it-works) - [How It Works](#-how-it-works)
- [Data Priorities](#data-priorities) - [For Users](#-for-users)
- [File Structure](#file-structure) - [For Developers](#-for-developers)
- [For Users](#for-users)
- [Creating User Overrides](#creating-user-overrides)
- [Example](#example)
- [For Developers](#for-developers)
- [Adding Built-In Overrides](#adding-built-in-overrides)
--- ---

View File

@@ -3,15 +3,10 @@
--- ---
## 📋 Содержание ## 📋 Содержание
- [Обзор](#обзор) - [Обзор](#-обзор)
- [Как это работает](#как-это-работает) - [Как это работает](#-как-это-работает)
- [Приоритеты данных](#приоритеты-данных) - [Для пользователей](#-для-пользователей)
- [Структура файлов](#структура-файлов) - [Для разработчиков](#-для-разработчиков)
- [Для пользователей](#для-пользователей)
- [Создание пользовательских переопределений](#создание-пользовательских-переопределений)
- [Пример](#пример)
- [Для разработчиков](#для-разработчиков)
- [Добавление встроенных переопределений](#добавление-встроенных-переопределений)
--- ---

View File

@@ -3,12 +3,13 @@
--- ---
## 📋 Contents ## 📋 Contents
- [Overview](#overview) - [Overview](#-overview)
- [Creating the Theme Folder](#creating-the-theme-folder) - [Creating the Theme Folder](#-creating-the-theme-folder)
- [Style File](#style-file) - [Style File](#-style-file-stylespy)
- [Metadata](#metadata) - [Animation configuration](#-animation-configuration)
- [Screenshots](#screenshots) - [Metadata](#-metadata-metainfoini)
- [Fonts and Icons](#fonts-and-icons) - [Screenshots](#-screenshots)
- [Fonts and Icons](#-fonts-and-icons-optional)
--- ---
@@ -45,6 +46,163 @@ def custom_button_style(color1, color2):
--- ---
## 🎥 Animation configuration
The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game cards:
```python
GAME_CARD_ANIMATION = {
# Type of animation when entering or exiting the detail page
# Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Determines how the detail page appears and disappears
"detail_page_animation_type": "fade",
# Border width of the card in idle state (no hover or focus)
# Affects the thickness of the border around the card when it's not selected
# Value in pixels
"default_border_width": 2,
# Border width on hover
# Increases the border thickness when the cursor is over the card
# Value in pixels
"hover_border_width": 8,
# Border width on focus (e.g., when selected via keyboard)
# Increases the border thickness when the card is focused
# Value in pixels
"focus_border_width": 12,
# Minimum border width during pulsing animation
# Determines the minimum border thickness during the "breathing" animation
# Value in pixels
"pulse_min_border_width": 8,
# Maximum border width during pulsing animation
# Determines the maximum border thickness during pulsing
# Value in pixels
"pulse_max_border_width": 10,
# Duration of the border thickness animation (e.g., on hover or focus)
# Affects the speed of transition from one border width to another
# Value in milliseconds
"thickness_anim_duration": 300,
# Duration of one pulsing animation cycle
# Determines how fast the border "pulses" between min and max values
# Value in milliseconds
"pulse_anim_duration": 800,
# Duration of the gradient rotation animation
# Affects how fast the gradient border rotates around the card
# Value in milliseconds
"gradient_anim_duration": 3000,
# Starting angle of the gradient (in degrees)
# Determines the initial rotation point of the gradient at animation start
"gradient_start_angle": 360,
# Ending angle of the gradient (in degrees)
# Determines the final rotation point of the gradient
# Value 0 means a full 360° rotation
"gradient_end_angle": 0,
# Type of card animation on hover or focus
# Possible values: "gradient", "scale"
# "gradient" enables a rotating gradient for the border, "scale" enlarges the card
"card_animation_type": "gradient",
# Card scale in idle state
# Determines the base size of the card (1.0 = 100% of original size)
# Value as a fraction (e.g., 1.0 for normal size)
"default_scale": 1.0,
# Card scale on hover
# Increases the card size on hover
# Value as a fraction (e.g., 1.1 = 110% of original size)
"hover_scale": 1.1,
# Card scale on focus (e.g., when selected via keyboard)
# Increases the card size on focus
# Value as a fraction (e.g., 1.05 = 105% of original size)
"focus_scale": 1.05,
# Duration of scale animation
# Affects how fast the card changes size on hover or focus
# Value in milliseconds
"scale_anim_duration": 200,
# Easing curve type for border thickness increase animation (on hover/focus)
# Affects the "feel" of the animation (e.g., smooth acceleration or deceleration)
# Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack",
# Easing curve type for border thickness decrease animation (on hover/focus exit)
# Affects the "feel" of returning to the default border width
"thickness_easing_curve_out": "InBack",
# Easing curve type for scale increase animation (on hover/focus)
# Affects the "feel" of the scaling animation (e.g., with a "bounce" effect)
# Possible values: strings corresponding to QEasingCurve.Type
"scale_easing_curve": "OutBack",
# Easing curve type for scale decrease animation (on hover/focus exit)
# Affects the "feel" of returning to the original scale
"scale_easing_curve_out": "InBack",
# Gradient colors for animated border
# List of dictionaries, each specifying position (0.01.0) and color in hex format
# Affects the appearance of the border on hover or focus if card_animation_type="gradient"
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Starting color (cyan)
{"position": 0.33, "color": "#FF5733"}, # Color at 33% (orange)
{"position": 0.66, "color": "#9B59B6"}, # Color at 66% (purple)
{"position": 1, "color": "#00fff5"} # Ending color (back to cyan)
],
# Duration of fade animation when entering the detail page
# Affects the speed of page appearance with fade animation
# Value in milliseconds
"detail_page_fade_duration": 350,
# Duration of slide animation when entering the detail page
# Affects the speed of page sliding animation
# Value in milliseconds
"detail_page_slide_duration": 500,
# Duration of bounce animation when entering the detail page
# Affects the speed of page "bounce" animation
# Value in milliseconds
"detail_page_bounce_duration": 400,
# Duration of fade animation when exiting the detail page
# Affects the speed of page disappearance with fade animation
# Value in milliseconds
"detail_page_fade_duration_exit": 350,
# Duration of slide animation when exiting the detail page
# Affects the speed of page sliding animation
# Value in milliseconds
"detail_page_slide_duration_exit": 500,
# Duration of bounce animation when exiting the detail page
# Affects the speed of page "compression" animation
# Value in milliseconds
"detail_page_bounce_duration_exit": 400,
# Easing curve type for animations when entering the detail page
# Applied to slide and bounce animations; affects the "feel" of movement
# Possible values: strings corresponding to QEasingCurve.Type
"detail_page_easing_curve": "OutCubic",
# Easing curve type for animations when exiting the detail page
# Applied to slide and bounce animations; affects the "feel" of movement
# Possible values: strings corresponding to QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic"
}
```
---
## 📝 Metadata (`metainfo.ini`) ## 📝 Metadata (`metainfo.ini`)
```ini ```ini

View File

@@ -3,12 +3,13 @@
--- ---
## 📋 Содержание ## 📋 Содержание
- [Обзор](#обзор) - [Обзор](#-обзор)
- [Создание папки темы](#создание-папки-темы) - [Создание папки темы](#-создание-папки-темы)
- [Файл стилей](#файл-стилей) - [Файл стилей](#-файл-стилей-stylespy)
- [Метаинформация](#метаинформация) - [Конфигурация анимации](#-конфигурация-анимации)
- [Скриншоты](#скриншоты) - [Метаинформация](#-метаинформация-metainfoini)
- [Шрифты и иконки](#шрифты-и-иконки) - [Скриншоты](#-скриншоты)
- [Шрифты и иконки](#-шрифты-и-иконки-опционально)
--- ---
@@ -45,6 +46,163 @@ def custom_button_style(color1, color2):
--- ---
## 🎥 Конфигурация анимации
Словарь `GAME_CARD_ANIMATION` управляет всеми параметрами анимации для карточек игр:
```python
GAME_CARD_ANIMATION = {
# Тип анимации при входе и выходе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Определяет, как детальная страница появляется и исчезает
"detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
# Влияет на толщину рамки вокруг карточки, когда она не выделена
# Значение в пикселях
"default_border_width": 2,
# Ширина обводки при наведении курсора
# Увеличивает толщину рамки, когда курсор находится над карточкой
# Значение в пикселях
"hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры)
# Увеличивает толщину рамки, когда карточка в фокусе
# Значение в пикселях
"focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
# Значение в пикселях
"pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации
# Определяет максимальную толщину рамки при пульсации
# Значение в пикселях
"pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
# Влияет на скорость перехода от одной ширины обводки к другой
# Значение в миллисекундах
"thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации
# Определяет, как быстро рамка "пульсирует" между min и max значениями
# Значение в миллисекундах
"pulse_anim_duration": 800,
# Длительность анимации вращения градиента
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
# Значение в миллисекундах
"gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах)
# Определяет начальную точку вращения градиента при старте анимации
"gradient_start_angle": 360,
# Конечный угол градиента (в градусах)
# Определяет конечную точку вращения градиента
# Значение 0 означает полный поворот на 360 градусов
"gradient_end_angle": 0,
# Тип анимации для карточки при наведении или фокусе
# Возможные значения: "gradient", "scale"
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient",
# Масштаб карточки в состоянии покоя
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
# Значение в долях (например, 1.0 для нормального размера)
"default_scale": 1.0,
# Масштаб карточки при наведении курсора
# Увеличивает размер карточки при наведении
# Значение в долях (например, 1.1 = 110% от исходного размера)
"hover_scale": 1.1,
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
# Увеличивает размер карточки при фокусе
# Значение в долях (например, 1.05 = 105% от исходного размера)
"focus_scale": 1.05,
# Длительность анимации масштабирования
# Влияет на скорость изменения размера карточки при наведении или фокусе
# Значение в миллисекундах
"scale_anim_duration": 200,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходной ширине обводки
"thickness_easing_curve_out": "InBack",
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
# Возможные значения: строки, соответствующие QEasingCurve.Type
"scale_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходному масштабу
"scale_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
],
# Длительность анимации fade при входе на детальную страницу
# Влияет на скорость появления страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration": 500,
# Длительность анимации bounce при входе на детальную страницу
# Влияет на скорость "прыжка" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration": 400,
# Длительность анимации fade при выходе из детальной страницы
# Влияет на скорость исчезновения страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration_exit": 350,
# Длительность анимации slide при выходе из детальной страницы
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration_exit": 500,
# Длительность анимации bounce при выходе из детальной страницы
# Влияет на скорость "сжатия" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration_exit": 400,
# Тип кривой сглаживания для анимации при входе на детальную страницу
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve": "OutCubic",
# Тип кривой сглаживания для анимации при выходе из детальной страницы
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic"
}
```
---
## 📝 Метаинформация (`metainfo.ini`) ## 📝 Метаинформация (`metainfo.ini`)
```ini ```ini

387
portprotonqt/animations.py Normal file
View File

@@ -0,0 +1,387 @@
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
from collections.abc import Callable
from portprotonqt.logger import get_logger
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
logger = get_logger(__name__)
class SafeOpacityEffect(QGraphicsOpacityEffect):
def __init__(self, parent=None, disable_at_full=True):
super().__init__(parent)
self.disable_at_full = disable_at_full
def setOpacity(self, opacity: float):
opacity = max(0.0, min(1.0, opacity))
super().setOpacity(opacity)
if opacity < 1.0:
self.setEnabled(True)
elif self.disable_at_full:
self.setEnabled(False)
class GameCardAnimations:
def __init__(self, game_card, theme=None):
self.game_card = game_card
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.thickness_anim: QPropertyAnimation | None = None
self.gradient_anim: QPropertyAnimation | None = None
self.scale_anim: QPropertyAnimation | None = None
self.pulse_anim: QPropertyAnimation | None = None
self._isPulseAnimationConnected = False
def setup_animations(self):
"""Initialize animation properties based on theme."""
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient":
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
elif animation_type == "scale":
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
def start_pulse_animation(self):
"""Start pulse animation for border width when hovered or focused."""
if not (self.game_card._hovered or self.game_card._focused):
return
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
self.pulse_anim.setLoopCount(0)
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.start()
def handle_enter_event(self):
"""Handle mouse enter event animations."""
self.game_card._hovered = True
self.game_card.hoverChanged.emit(self.game_card.name, True)
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
if not self.thickness_anim:
self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
self.thickness_anim.finished.connect(self.start_pulse_animation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if animation_type == "gradient":
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"])
self.scale_anim.start()
def handle_leave_event(self):
"""Handle mouse leave event animations."""
self.game_card._hovered = False
self.game_card.hoverChanged.emit(self.game_card.name, False)
if not self.game_card._focused:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient":
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.start()
def handle_focus_in_event(self):
"""Handle focus in event animations."""
if not self.game_card._hovered:
self.game_card._focused = True
self.game_card.focusChanged.emit(self.game_card.name, True)
if not self.thickness_anim:
self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
self.thickness_anim.finished.connect(self.start_pulse_animation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if animation_type == "gradient":
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"])
self.scale_anim.start()
def handle_focus_out_event(self):
"""Handle focus out event animations."""
self.game_card._focused = False
self.game_card.focusChanged.emit(self.game_card.name, False)
if not self.game_card._hovered:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient":
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self.game_card._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.start()
def paint_border(self, painter: QPainter):
if not painter.isActive():
logger.debug("Painter is not active; skipping border paint")
return
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen()
pen.setWidth(self.game_card._borderWidth)
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
center = self.game_card.rect().center()
gradient = QConicalGradient(center, self.game_card._gradientAngle)
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
gradient.setColorAt(stop["position"], QColor(stop["color"]))
pen.setBrush(QBrush(gradient))
else:
pen.setColor(QColor(0, 0, 0, 0))
painter.setPen(pen)
radius = 18 * self.game_card._scale
bw = round(self.game_card._borderWidth / 2)
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
if rect.isEmpty():
return
painter.drawRoundedRect(rect, radius, radius)
class DetailPageAnimations:
def __init__(self, main_window, theme=None):
self.main_window = main_window
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
"""Animate the detail page based on theme settings."""
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
if animation_type == "fade":
original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
opacity_effect.setOpacity(0.0)
detail_page.setGraphicsEffect(opacity_effect)
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
animation.setDuration(duration)
animation.setStartValue(0.0)
animation.setEndValue(0.999)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
def restore_effect():
try:
detail_page.setGraphicsEffect(original_effect) # type: ignore
except RuntimeError:
logger.warning("Original effect already deleted")
animation.finished.connect(restore_effect)
animation.finished.connect(load_image_and_restore_effect)
animation.finished.connect(opacity_effect.deleteLater)
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
start_pos = {
"slide_left": QPoint(self.main_window.width(), 0),
"slide_right": QPoint(-self.main_window.width(), 0),
"slide_up": QPoint(0, self.main_window.height()),
"slide_down": QPoint(0, -self.main_window.height())
}[animation_type]
detail_page.move(start_pos)
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
animation.setDuration(duration)
animation.setStartValue(start_pos)
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
animation.setEasingCurve(easing_curve)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(cleanup_animation)
animation.finished.connect(load_image_and_restore_effect)
elif animation_type == "bounce":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
detail_page.setWindowOpacity(0.0)
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
opacity_anim.setDuration(duration)
opacity_anim.setStartValue(0.0)
opacity_anim.setEndValue(1.0)
initial_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
detail_page.width() // 2, detail_page.height() // 2)
final_rect = detail_page.geometry()
geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
geometry_anim.setDuration(duration)
geometry_anim.setStartValue(initial_rect)
geometry_anim.setEndValue(final_rect)
geometry_anim.setEasingCurve(easing_curve)
group_anim = QParallelAnimationGroup()
group_anim.addAnimation(opacity_anim)
group_anim.addAnimation(geometry_anim)
group_anim.finished.connect(load_image_and_restore_effect)
group_anim.finished.connect(cleanup_animation)
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = group_anim
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
"""Animate the detail page exit based on theme settings."""
try:
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
# Safely stop and remove any existing animation
if detail_page in self.animations:
try:
animation = self.animations[detail_page]
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
animation.stop()
except RuntimeError:
logger.warning("Animation already deleted for page")
except Exception as e:
logger.error(f"Error stopping existing animation: {e}", exc_info=True)
finally:
self.animations.pop(detail_page, None)
# Define animation based on type
if animation_type == "fade":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
opacity_effect.setOpacity(0.999)
detail_page.setGraphicsEffect(opacity_effect)
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
animation.setDuration(duration)
animation.setStartValue(0.999)
animation.setEndValue(0.0)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
def restore_and_cleanup():
try:
detail_page.setGraphicsEffect(original_effect) # type: ignore
except RuntimeError:
logger.debug("Original effect already deleted")
cleanup_callback()
animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater)
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
end_pos = {
"slide_left": QPoint(-self.main_window.width(), 0),
"slide_right": QPoint(self.main_window.width(), 0),
"slide_up": QPoint(0, self.main_window.height()),
"slide_down": QPoint(0, -self.main_window.height())
}[animation_type]
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
animation.setDuration(duration)
animation.setStartValue(detail_page.pos())
animation.setEndValue(end_pos)
animation.setEasingCurve(easing_curve)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation
animation.finished.connect(cleanup_callback)
elif animation_type == "bounce":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
opacity_anim.setDuration(duration)
opacity_anim.setStartValue(1.0)
opacity_anim.setEndValue(0.0)
final_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
detail_page.width() // 2, detail_page.height() // 2)
geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
geometry_anim.setDuration(duration)
geometry_anim.setStartValue(detail_page.geometry())
geometry_anim.setEndValue(final_rect)
geometry_anim.setEasingCurve(easing_curve)
group_anim = QParallelAnimationGroup()
group_anim.addAnimation(opacity_anim)
group_anim.addAnimation(geometry_anim)
group_anim.finished.connect(cleanup_callback)
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = group_anim
except Exception as e:
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
self.animations.pop(detail_page, None)
cleanup_callback()

View File

@@ -3,16 +3,13 @@ from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
from portprotonqt.tray import SystemTray from portprotonqt.config_utils import save_fullscreen_config
from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.logger import get_logger
from portprotonqt.cli import parse_args from portprotonqt.cli import parse_args
logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.4" __app_version__ = "0.1.6"
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -21,52 +18,36 @@ def main():
app.setApplicationName(__app_name__) app.setApplicationName(__app_name__)
app.setApplicationVersion(__app_version__) app.setApplicationVersion(__app_version__)
args = parse_args()
# Setup logger with specified debug level
setup_logger(args.debug_level)
# Reinitialize logger after setup to ensure it uses the new configuration
logger = get_logger(__name__)
system_locale = QLocale.system() system_locale = QLocale.system()
qt_translator = QTranslator() qt_translator = QTranslator()
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if qt_translator.load(system_locale, "qtbase", "_", translations_path): if qt_translator.load(system_locale, "qtbase", "_", translations_path):
app.installTranslator(qt_translator) app.installTranslator(qt_translator)
else: else:
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}") logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
args = parse_args() window = MainWindow(app_name=__app_name__)
window = MainWindow()
if args.fullscreen: if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag") logger.info("Launching in fullscreen mode due to --fullscreen flag")
save_fullscreen_config(True) save_fullscreen_config(True)
window.showFullScreen() window.showFullScreen()
current_theme_name = read_theme_from_config()
tray = SystemTray(app, current_theme_name)
tray.show_action.triggered.connect(window.show)
tray.hide_action.triggered.connect(window.hide)
def recreate_tray():
nonlocal tray
if tray:
logger.debug("Recreating system tray")
tray.cleanup()
tray = None
current_theme = read_theme_from_config()
tray = SystemTray(app, current_theme)
# Ensure window is not None before connecting signals
if window:
tray.show_action.triggered.connect(window.show)
tray.hide_action.triggered.connect(window.hide)
def cleanup_on_exit(): def cleanup_on_exit():
nonlocal tray, window nonlocal window
app.aboutToQuit.disconnect() app.aboutToQuit.disconnect()
if tray:
tray.cleanup()
tray = None
if window: if window:
window.close() window.close()
app.quit() app.quit()
window.settings_saved.connect(recreate_tray)
app.aboutToQuit.connect(cleanup_on_exit) app.aboutToQuit.connect(cleanup_on_exit)
window.show() window.show()

View File

@@ -1,16 +1,20 @@
import argparse import argparse
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def parse_args(): def parse_args():
""" """
Парсит аргументы командной строки. Parses command-line arguments.
""" """
parser = argparse.ArgumentParser(description="PortProtonQt CLI") parser = argparse.ArgumentParser(description="PortProtonQt CLI")
parser.add_argument( parser.add_argument(
"--fullscreen", "--fullscreen",
action="store_true", action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку" help="Launch the application in fullscreen mode and save this setting"
)
parser.add_argument(
"--debug-level",
choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
default='NOTSET',
help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
) )
return parser.parse_args() return parser.parse_args()

View File

@@ -7,7 +7,7 @@ logger = get_logger(__name__)
_portproton_location = None _portproton_location = None
# Пути к конфигурационным файлам # Paths to configuration files
CONFIG_FILE = os.path.join( CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")), os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"PortProtonQt.conf" "PortProtonQt.conf"
@@ -18,17 +18,32 @@ PORTPROTON_CONFIG_FILE = os.path.join(
"PortProton.conf" "PortProton.conf"
) )
# Пути к папкам с темами # Paths to theme directories
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
cp = configparser.ConfigParser()
if not os.path.exists(config_file):
logger.debug(f"Configuration file {config_file} not found")
return None
try:
cp.read(config_file, encoding="utf-8")
return cp
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.warning(f"Invalid configuration file format: {e}")
return None
except Exception as e:
logger.warning(f"Failed to read configuration file: {e}")
return None
def read_config(): def read_config():
""" """Reads the configuration file and returns a dictionary of parameters.
Читает конфигурационный файл и возвращает словарь параметров. Example line in config (no sections):
Пример строки в конфиге (без секций):
detail_level = detailed detail_level = detailed
""" """
config_dict = {} config_dict = {}
@@ -44,29 +59,17 @@ def read_config():
return config_dict return config_dict
def read_theme_from_config(): def read_theme_from_config():
"""Reads the theme from the [Appearance] section of the configuration file.
Returns 'standart' if the parameter is not set.
""" """
Читает из конфигурационного файла тему из секции [Appearance]. cp = read_config_safely(CONFIG_FILE)
Если параметр не задан, возвращает "standart". if cp is None:
""" return "standart"
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return "standart"
return cp.get("Appearance", "theme", fallback="standart") return cp.get("Appearance", "theme", fallback="standart")
def save_theme_to_config(theme_name): def save_theme_to_config(theme_name):
""" """Saves the selected theme name to the [Appearance] section of the configuration file."""
Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Appearance" not in cp: if "Appearance" not in cp:
cp["Appearance"] = {} cp["Appearance"] = {}
cp["Appearance"]["theme"] = theme_name cp["Appearance"]["theme"] = theme_name
@@ -74,34 +77,18 @@ def save_theme_to_config(theme_name):
cp.write(configfile) cp.write(configfile)
def read_time_config(): def read_time_config():
"""Reads time settings from the [Time] section of the configuration file.
If the section or parameter is missing, saves and returns 'detailed' as default.
""" """
Читает настройки времени из секции [Time] конфигурационного файла. cp = read_config_safely(CONFIG_FILE)
Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию. if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
""" save_time_config("detailed")
cp = configparser.ConfigParser() return "detailed"
if os.path.exists(CONFIG_FILE): return cp.get("Time", "detail_level", fallback="detailed").lower()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_time_config("detailed")
return "detailed"
if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
save_time_config("detailed")
return "detailed"
return cp.get("Time", "detail_level", fallback="detailed").lower()
return "detailed"
def save_time_config(detail_level): def save_time_config(detail_level):
""" """Saves the time detail level to the [Time] section of the configuration file."""
Сохраняет настройку уровня детализации времени в секции [Time]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Time" not in cp: if "Time" not in cp:
cp["Time"] = {} cp["Time"] = {}
cp["Time"]["detail_level"] = detail_level cp["Time"]["detail_level"] = detail_level
@@ -109,48 +96,42 @@ def save_time_config(detail_level):
cp.write(configfile) cp.write(configfile)
def read_file_content(file_path): def read_file_content(file_path):
""" """Reads the content of a file and returns it as a string."""
Читает содержимое файла и возвращает его как строку.
"""
with open(file_path, encoding="utf-8") as f: with open(file_path, encoding="utf-8") as f:
return f.read().strip() return f.read().strip()
def get_portproton_location(): def get_portproton_location():
""" """Returns the path to the PortProton directory.
Возвращает путь к директории PortProton. Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
Сначала проверяется кэшированный путь. Если он отсутствует, проверяется If the path is invalid, uses the default directory.
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
используется директория по умолчанию.
""" """
global _portproton_location global _portproton_location
if _portproton_location is not None: if _portproton_location is not None:
return _portproton_location return _portproton_location
# Попытка чтения пути из конфигурационного файла
if os.path.isfile(PORTPROTON_CONFIG_FILE): if os.path.isfile(PORTPROTON_CONFIG_FILE):
try: try:
location = read_file_content(PORTPROTON_CONFIG_FILE).strip() location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
if location and os.path.isdir(location): if location and os.path.isdir(location):
_portproton_location = location _portproton_location = location
logger.info(f"Путь PortProton из конфигурации: {location}") logger.info(f"PortProton path from configuration: {location}")
return _portproton_location return _portproton_location
logger.warning(f"Недействительный путь в конфиге PortProton: {location}") logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
except (OSError, PermissionError) as e: except (OSError, PermissionError) as e:
logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}") logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_dir): if os.path.isdir(default_dir):
_portproton_location = default_dir _portproton_location = default_dir
logger.info(f"Используется директория flatpak PortProton: {default_dir}") logger.info(f"Using flatpak PortProton directory: {default_dir}")
return _portproton_location return _portproton_location
logger.warning("Конфигурация и директория flatpak PortProton не найдены") logger.warning("PortProton configuration and flatpak directory not found")
return None return None
def parse_desktop_entry(file_path): def parse_desktop_entry(file_path):
""" """Reads and parses a .desktop file using configparser.
Читает и парсит .desktop файл с помощью configparser. Returns None if the [Desktop Entry] section is missing.
Если секция [Desktop Entry] отсутствует, возвращается None.
""" """
cp = configparser.ConfigParser(interpolation=None) cp = configparser.ConfigParser(interpolation=None)
cp.read(file_path, encoding="utf-8") cp.read(file_path, encoding="utf-8")
@@ -159,9 +140,8 @@ def parse_desktop_entry(file_path):
return cp["Desktop Entry"] return cp["Desktop Entry"]
def load_theme_metainfo(theme_name): def load_theme_metainfo(theme_name):
""" """Loads theme metadata from metainfo.ini in the theme's root directory.
Загружает метаинформацию темы из файла metainfo.ini в корне папки темы. Expected fields: author, author_link, description, name.
Ожидаемые поля: author, author_link, description, name.
""" """
meta = {} meta = {}
for themes_dir in THEMES_DIRS: for themes_dir in THEMES_DIRS:
@@ -179,34 +159,18 @@ def load_theme_metainfo(theme_name):
return meta return meta
def read_card_size(): def read_card_size():
"""Reads the card size (width) from the [Cards] section.
Returns 250 if the parameter is not set.
""" """
Читает размер карточек (ширину) из секции [Cards], cp = read_config_safely(CONFIG_FILE)
Если параметр не задан, возвращает 250. if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
""" save_card_size(250)
cp = configparser.ConfigParser() return 250
if os.path.exists(CONFIG_FILE): return cp.getint("Cards", "card_width", fallback=250)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_card_size(250)
return 250
if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
save_card_size(250)
return 250
return cp.getint("Cards", "card_width", fallback=250)
return 250
def save_card_size(card_width): def save_card_size(card_width):
""" """Saves the card size (width) to the [Cards] section."""
Сохраняет размер карточек (ширину) в секцию [Cards]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Cards" not in cp: if "Cards" not in cp:
cp["Cards"] = {} cp["Cards"] = {}
cp["Cards"]["card_width"] = str(card_width) cp["Cards"]["card_width"] = str(card_width)
@@ -214,34 +178,18 @@ def save_card_size(card_width):
cp.write(configfile) cp.write(configfile)
def read_sort_method(): def read_sort_method():
"""Reads the sort method from the [Games] section.
Returns 'last_launch' if the parameter is not set.
""" """
Читает метод сортировки из секции [Games]. cp = read_config_safely(CONFIG_FILE)
Если параметр не задан, возвращает last_launch. if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
""" save_sort_method("last_launch")
cp = configparser.ConfigParser() return "last_launch"
if os.path.exists(CONFIG_FILE): return cp.get("Games", "sort_method", fallback="last_launch").lower()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_sort_method("last_launch")
return "last_launch"
if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
save_sort_method("last_launch")
return "last_launch"
return cp.get("Games", "sort_method", fallback="last_launch").lower()
return "last_launch"
def save_sort_method(sort_method): def save_sort_method(sort_method):
""" """Saves the sort method to the [Games] section."""
Сохраняет метод сортировки в секцию [Games]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Games" not in cp: if "Games" not in cp:
cp["Games"] = {} cp["Games"] = {}
cp["Games"]["sort_method"] = sort_method cp["Games"]["sort_method"] = sort_method
@@ -249,34 +197,18 @@ def save_sort_method(sort_method):
cp.write(configfile) cp.write(configfile)
def read_display_filter(): def read_display_filter():
"""Reads the display_filter parameter from the [Games] section.
Returns 'all' if the parameter is missing.
""" """
Читает параметр display_filter из секции [Games]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает значение "all". if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
""" save_display_filter("all")
cp = configparser.ConfigParser() return "all"
if os.path.exists(CONFIG_FILE): return cp.get("Games", "display_filter", fallback="all").lower()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
save_display_filter("all")
return "all"
if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
save_display_filter("all")
return "all"
return cp.get("Games", "display_filter", fallback="all").lower()
return "all"
def save_display_filter(filter_value): def save_display_filter(filter_value):
""" """Saves the display_filter parameter to the [Games] section."""
Сохраняет параметр display_filter в секцию [Games] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Games" not in cp: if "Games" not in cp:
cp["Games"] = {} cp["Games"] = {}
cp["Games"]["display_filter"] = filter_value cp["Games"]["display_filter"] = filter_value
@@ -284,37 +216,23 @@ def save_display_filter(filter_value):
cp.write(configfile) cp.write(configfile)
def read_favorites(): def read_favorites():
"""Reads the list of favorite games from the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
Returns an empty list if the section or parameter is missing.
""" """
Читает список избранных игр из секции [Favorites] конфигурационного файла. cp = read_config_safely(CONFIG_FILE)
Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми. if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"):
Если секция или параметр отсутствуют, возвращает пустой список. return []
""" favs = cp.get("Favorites", "games", fallback="").strip()
cp = configparser.ConfigParser() if favs.startswith('"') and favs.endswith('"'):
if os.path.exists(CONFIG_FILE): favs = favs[1:-1]
try: return [s.strip() for s in favs.split(",") if s.strip()]
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
return []
if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
favs = cp.get("Favorites", "games", fallback="").strip()
# Если строка начинается и заканчивается кавычками, удаляем их
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [s.strip() for s in favs.split(",") if s.strip()]
return []
def save_favorites(favorites): def save_favorites(favorites):
"""Saves the list of favorite games to the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
""" """
Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Favorites" not in cp: if "Favorites" not in cp:
cp["Favorites"] = {} cp["Favorites"] = {}
fav_str = ", ".join(favorites) fav_str = ", ".join(favorites)
@@ -323,34 +241,18 @@ def save_favorites(favorites):
cp.write(configfile) cp.write(configfile)
def read_rumble_config(): def read_rumble_config():
"""Reads the gamepad rumble setting from the [Gamepad] section.
Returns False if the parameter is missing.
""" """
Читает настройку виброотдачи геймпада из секции [Gamepad]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает False по умолчанию. if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
""" save_rumble_config(False)
cp = configparser.ConfigParser() return False
if os.path.exists(CONFIG_FILE): return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_rumble_config(False)
return False
if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
save_rumble_config(False)
return False
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
return False
def save_rumble_config(rumble_enabled): def save_rumble_config(rumble_enabled):
""" """Saves the gamepad rumble setting to the [Gamepad] section."""
Сохраняет настройку виброотдачи геймпада в секцию [Gamepad]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Gamepad" not in cp: if "Gamepad" not in cp:
cp["Gamepad"] = {} cp["Gamepad"] = {}
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled) cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
@@ -358,41 +260,28 @@ def save_rumble_config(rumble_enabled):
cp.write(configfile) cp.write(configfile)
def ensure_default_proxy_config(): def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file.
Creates it with empty values if missing.
""" """
Проверяет наличие секции [Proxy] в конфигурационном файле. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Если секция отсутствует, создаёт её с пустыми значениями. if "Proxy" not in cp:
""" cp.add_section("Proxy")
cp = configparser.ConfigParser() cp["Proxy"]["proxy_url"] = ""
if os.path.exists(CONFIG_FILE): cp["Proxy"]["proxy_user"] = ""
try: cp["Proxy"]["proxy_password"] = ""
cp.read(CONFIG_FILE, encoding="utf-8") with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
except Exception as e: cp.write(configfile)
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return
if not cp.has_section("Proxy"):
cp.add_section("Proxy")
cp["Proxy"]["proxy_url"] = ""
cp["Proxy"]["proxy_user"] = ""
cp["Proxy"]["proxy_password"] = ""
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_proxy_config(): def read_proxy_config():
""" """Reads proxy settings from the [Proxy] section.
Читает настройки прокси из секции [Proxy] конфигурационного файла. Returns an empty dict if proxy_url is not set or empty.
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
""" """
ensure_default_proxy_config() ensure_default_proxy_config()
cp = configparser.ConfigParser() cp = read_config_safely(CONFIG_FILE)
try: if cp is None:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return {} return {}
proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip() proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
if proxy_url: if proxy_url:
# Если указаны логин и пароль, добавляем их к URL
proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip() proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip() proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password: if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
@@ -402,16 +291,10 @@ def read_proxy_config():
return {} return {}
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""): def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
"""Saves proxy settings to the [Proxy] section.
Creates the section if it does not exist.
""" """
Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Если секция отсутствует, создаёт её.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Proxy" not in cp: if "Proxy" not in cp:
cp["Proxy"] = {} cp["Proxy"] = {}
cp["Proxy"]["proxy_url"] = proxy_url cp["Proxy"]["proxy_url"] = proxy_url
@@ -421,34 +304,18 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
cp.write(configfile) cp.write(configfile)
def read_fullscreen_config(): def read_fullscreen_config():
"""Reads the fullscreen mode setting from the [Display] section.
Returns False if the parameter is missing.
""" """
Читает настройку полноэкранного режима приложения из секции [Display]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает False по умолчанию. if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
""" save_fullscreen_config(False)
cp = configparser.ConfigParser() return False
if os.path.exists(CONFIG_FILE): return cp.getboolean("Display", "fullscreen", fallback=False)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_fullscreen_config(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
save_fullscreen_config(False)
return False
return cp.getboolean("Display", "fullscreen", fallback=False)
return False
def save_fullscreen_config(fullscreen): def save_fullscreen_config(fullscreen):
""" """Saves the fullscreen mode setting to the [Display] section."""
Сохраняет настройку полноэкранного режима приложения в секцию [Display]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp: if "Display" not in cp:
cp["Display"] = {} cp["Display"] = {}
cp["Display"]["fullscreen"] = str(fullscreen) cp["Display"]["fullscreen"] = str(fullscreen)
@@ -456,33 +323,19 @@ def save_fullscreen_config(fullscreen):
cp.write(configfile) cp.write(configfile)
def read_window_geometry() -> tuple[int, int]: def read_window_geometry() -> tuple[int, int]:
"""Reads the window width and height from the [MainWindow] section.
Returns (0, 0) if the parameters are missing.
""" """
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла. cp = read_config_safely(CONFIG_FILE)
Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0). if cp is None or not cp.has_section("MainWindow"):
""" return (0, 0)
cp = configparser.ConfigParser() width = cp.getint("MainWindow", "width", fallback=0)
if os.path.exists(CONFIG_FILE): height = cp.getint("MainWindow", "height", fallback=0)
try: return (width, height)
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return (0, 0)
if cp.has_section("MainWindow"):
width = cp.getint("MainWindow", "width", fallback=0)
height = cp.getint("MainWindow", "height", fallback=0)
return (width, height)
return (0, 0)
def save_window_geometry(width: int, height: int): def save_window_geometry(width: int, height: int):
""" """Saves the window width and height to the [MainWindow] section."""
Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "MainWindow" not in cp: if "MainWindow" not in cp:
cp["MainWindow"] = {} cp["MainWindow"] = {}
cp["MainWindow"]["width"] = str(width) cp["MainWindow"]["width"] = str(width)
@@ -491,61 +344,67 @@ def save_window_geometry(width: int, height: int):
cp.write(configfile) cp.write(configfile)
def reset_config(): def reset_config():
""" """Resets the configuration file by deleting it.
Сбрасывает конфигурационный файл, удаляя его. Subsequent reads will use default values.
После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
""" """
if os.path.exists(CONFIG_FILE): if os.path.exists(CONFIG_FILE):
try: try:
os.remove(CONFIG_FILE) os.remove(CONFIG_FILE)
logger.info("Конфигурационный файл %s удалён", CONFIG_FILE) logger.info("Configuration file %s deleted", CONFIG_FILE)
except Exception as e: except Exception as e:
logger.error("Ошибка при удалении конфигурационного файла: %s", e) logger.warning(f"Failed to delete configuration file: {e}")
def clear_cache(): def clear_cache():
""" """Clears the PortProtonQt cache by deleting the cache directory."""
Очищает кэш PortProtonQt, удаляя папку кэша.
"""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
if os.path.exists(cache_dir): if os.path.exists(cache_dir):
try: try:
shutil.rmtree(cache_dir) shutil.rmtree(cache_dir)
logger.info("Кэш PortProtonQt удалён: %s", cache_dir) logger.info("PortProtonQt cache deleted: %s", cache_dir)
except Exception as e: except Exception as e:
logger.error("Ошибка при удалении кэша: %s", e) logger.warning(f"Failed to delete cache: {e}")
def read_auto_fullscreen_gamepad(): def read_auto_fullscreen_gamepad():
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
Returns False if the parameter is missing.
""" """
Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display]. cp = read_config_safely(CONFIG_FILE)
Если параметр отсутствует, сохраняет и возвращает False по умолчанию. if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
""" save_auto_fullscreen_gamepad(False)
cp = configparser.ConfigParser() return False
if os.path.exists(CONFIG_FILE): return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_auto_fullscreen_gamepad(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
save_auto_fullscreen_gamepad(False)
return False
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
return False
def save_auto_fullscreen_gamepad(auto_fullscreen): def save_auto_fullscreen_gamepad(auto_fullscreen):
""" """Saves the auto-fullscreen setting for gamepad to the [Display] section."""
Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display]. cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp: if "Display" not in cp:
cp["Display"] = {} cp["Display"] = {}
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen) cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
def read_favorite_folders():
"""Reads the list of favorite folders from the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
Returns an empty list if the section or parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"):
return []
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
def save_favorite_folders(folders):
"""Saves the list of favorite folders to the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
"""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "FavoritesFolders" not in cp:
cp["FavoritesFolders"] = {}
fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)

View File

@@ -4,7 +4,6 @@ import glob
import shutil import shutil
import subprocess import subprocess
import threading import threading
import logging
import orjson import orjson
import psutil import psutil
import signal import signal
@@ -12,13 +11,14 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.logger import get_logger
logger = logging.getLogger(__name__) logger = get_logger(__name__)
class ContextMenuSignals(QObject): class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads.""" """Signals for thread-safe UI updates from worker threads."""
@@ -62,7 +62,7 @@ class ContextMenuManager:
self.parent.statusBar().showMessage, self.parent.statusBar().showMessage,
Qt.ConnectionType.QueuedConnection Qt.ConnectionType.QueuedConnection
) )
logger.debug("Connected show_status_message signal to statusBar") logger.debug("Connected show_status_message signal to status bar")
self.signals.show_warning_dialog.connect( self.signals.show_warning_dialog.connect(
self._show_warning_dialog, self._show_warning_dialog,
Qt.ConnectionType.QueuedConnection Qt.ConnectionType.QueuedConnection
@@ -74,28 +74,28 @@ class ContextMenuManager:
def _show_warning_dialog(self, title: str, message: str): def _show_warning_dialog(self, title: str, message: str):
"""Show a warning dialog in the main thread.""" """Show a warning dialog in the main thread."""
logger.debug("Showing warning dialog: %s - %s", title, message) logger.debug("Displaying warning dialog: %s - %s", title, message)
QMessageBox.warning(self.parent, title, message) QMessageBox.warning(self.parent, title, message)
def _show_info_dialog(self, title: str, message: str): def _show_info_dialog(self, title: str, message: str):
"""Show an info dialog in the main thread.""" """Show an info dialog in the main thread."""
logger.debug("Showing info dialog: %s - %s", title, message) logger.debug("Displaying info dialog: %s - %s", title, message)
QMessageBox.information(self.parent, title, message) QMessageBox.information(self.parent, title, message)
def _show_status_message(self, message: str, timeout: int = 3000): def _show_status_message(self, message: str, timeout: int = 3000):
"""Show a status message on the status bar if available.""" """Show a status message on the status bar if available."""
if self.parent.statusBar(): if self.parent.statusBar():
self.parent.statusBar().showMessage(message, timeout) self.parent.statusBar().showMessage(message, timeout)
logger.debug("Direct status message: %s", message) logger.debug("Displayed status message: %s", message)
else: else:
logger.warning("Status bar not available for message: %s", message) logger.warning("Status bar unavailable for message: %s", message)
def _check_portproton(self): def _check_portproton(self):
"""Check if PortProton is available.""" """Check if PortProton is available."""
if self.portproton_location is None: if self.portproton_location is None:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("PortProton is not found") _("PortProton directory not found")
) )
return False return False
return True return True
@@ -119,7 +119,7 @@ class ContextMenuManager:
installed_games = orjson.loads(f.read()) installed_games = orjson.loads(f.read())
return app_name in installed_games return app_name in installed_games
except (OSError, orjson.JSONDecodeError) as e: except (OSError, orjson.JSONDecodeError) as e:
logger.error("Failed to read installed.json: %s", e) logger.error("Error reading installed.json: %s", e)
return False return False
def _is_game_running(self, game_card) -> bool: def _is_game_running(self, game_card) -> bool:
@@ -150,6 +150,84 @@ class ContextMenuManager:
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
def show_folder_context_menu(self, file_explorer, pos):
"""Shows the context menu for a folder in FileExplorer."""
try:
item = file_explorer.file_list.itemAt(pos)
if not item:
logger.debug("No folder selected at position %s", pos)
return
selected = item.text()
if not selected.endswith("/"):
logger.debug("Selected item is not a folder: %s", selected)
return # Only for folders
full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
if not os.path.isdir(full_path):
logger.debug("Path is not a directory: %s", full_path)
return
menu = QMenu(file_explorer)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
menu.setParent(file_explorer, Qt.WindowType.Popup) # Set transientParent for Wayland
favorite_folders = read_favorite_folders()
is_favorite = full_path in favorite_folders
action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
# Disconnect file_list signals to prevent navigation during menu interaction
try:
file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
except TypeError:
pass # Signals may not be connected
# Reconnect signals after menu closes
def reconnect_signals():
try:
file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
except Exception as e:
logger.error("Error reconnecting file list signals: %s", e)
menu.aboutToHide.connect(reconnect_signals)
# Set focus to the first menu item
actions = menu.actions()
if actions:
menu.setActiveAction(actions[0])
# Map local position to global for menu display
global_pos = file_explorer.file_list.mapToGlobal(pos)
menu.exec(global_pos)
except Exception as e:
logger.error("Error displaying folder context menu: %s", e)
def toggle_favorite_folder(self, file_explorer, folder_path, add):
"""Adds or removes a folder from favorites."""
favorite_folders = read_favorite_folders()
if add:
if folder_path not in favorite_folders:
favorite_folders.append(folder_path)
save_favorite_folders(favorite_folders)
logger.info("Added folder to favorites: %s", folder_path)
else:
if folder_path in favorite_folders:
favorite_folders.remove(folder_path)
save_favorite_folders(favorite_folders)
logger.info("Removed folder from favorites: %s", folder_path)
file_explorer.update_drives_list()
def _get_safe_icon(self, icon_name: str) -> QIcon:
"""Returns a QIcon, ensuring it is valid."""
icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon):
return icon
elif isinstance(icon, str) and os.path.exists(icon):
return QIcon(icon)
return QIcon()
def show_context_menu(self, game_card, pos: QPoint): def show_context_menu(self, game_card, pos: QPoint):
""" """
Show the context menu for a game card at the specified position. Show the context menu for a game card at the specified position.
@@ -158,14 +236,6 @@ class ContextMenuManager:
game_card: The GameCard instance requesting the context menu. game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear. pos: The position (in widget coordinates) where the menu should appear.
""" """
def get_safe_icon(icon_name: str) -> QIcon:
icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon):
return icon
elif isinstance(icon, str) and os.path.exists(icon):
return QIcon(icon)
return QIcon()
menu = QMenu(self.parent) menu = QMenu(self.parent)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
@@ -175,7 +245,7 @@ class ContextMenuManager:
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
if not exe_path: if not exe_path:
# Show only "Delete from PortProton" if no valid exe # Show only "Delete from PortProton" if no valid exe
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton")) delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
menu.exec(game_card.mapToGlobal(pos)) menu.exec(game_card.mapToGlobal(pos))
return return
@@ -184,7 +254,7 @@ class ContextMenuManager:
is_running = self._is_game_running(game_card) is_running = self._is_game_running(game_card)
action_text = _("Stop Game") if is_running else _("Launch Game") action_text = _("Stop Game") if is_running else _("Launch Game")
action_icon = "stop" if is_running else "play" action_icon = "stop" if is_running else "play"
launch_action = menu.addAction(get_safe_icon(action_icon), action_text) launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
launch_action.triggered.connect( launch_action.triggered.connect(
lambda: self._launch_game(game_card) lambda: self._launch_game(game_card)
) )
@@ -193,11 +263,11 @@ class ContextMenuManager:
is_favorite = game_card.name in favorites is_favorite = game_card.name in favorites
icon_name = "star_full" if is_favorite else "star" icon_name = "star_full" if is_favorite else "star"
text = _("Remove from Favorites") if is_favorite else _("Add to Favorites") text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
favorite_action = menu.addAction(get_safe_icon(icon_name), text) favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite)) favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
if game_card.game_source == "epic": if game_card.game_source == "epic":
import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary")) import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary"))
import_action.triggered.connect( import_action.triggered.connect(
lambda: self.import_to_legendary(game_card.name, game_card.appid) lambda: self.import_to_legendary(game_card.name, game_card.appid)
) )
@@ -205,13 +275,13 @@ class ContextMenuManager:
is_in_steam = is_game_in_steam(game_card.name) is_in_steam = is_game_in_steam(game_card.name)
icon_name = "delete" if is_in_steam else "steam" icon_name = "delete" if is_in_steam else "steam"
text = _("Remove from Steam") if is_in_steam else _("Add to Steam") text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(get_safe_icon(icon_name), text) steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
steam_action.triggered.connect( steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
if is_in_steam if is_in_steam
else self.add_egs_to_steam(game_card.name, game_card.appid) else self.add_egs_to_steam(game_card.name, game_card.appid)
) )
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder")) open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
open_folder_action.triggered.connect( open_folder_action.triggered.connect(
lambda: self.open_egs_game_folder(game_card.appid) lambda: self.open_egs_game_folder(game_card.appid)
) )
@@ -219,7 +289,7 @@ class ContextMenuManager:
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(desktop_path) else "desktop" icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
desktop_action = menu.addAction(get_safe_icon(icon_name), text) desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
desktop_action.triggered.connect( desktop_action.triggered.connect(
lambda: self.remove_egs_from_desktop(game_card.name) lambda: self.remove_egs_from_desktop(game_card.name)
if os.path.exists(desktop_path) if os.path.exists(desktop_path)
@@ -228,7 +298,7 @@ class ContextMenuManager:
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications") applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
menu_action = menu.addAction( menu_action = menu.addAction(
get_safe_icon("delete" if os.path.exists(menu_path) else "menu"), self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
_("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu") _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
) )
menu_action.triggered.connect( menu_action.triggered.connect(
@@ -242,19 +312,19 @@ class ContextMenuManager:
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(desktop_path) else "desktop" icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
desktop_action = menu.addAction(get_safe_icon(icon_name), text) desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
desktop_action.triggered.connect( desktop_action.triggered.connect(
lambda: self.remove_from_desktop(game_card.name) lambda: self.remove_from_desktop(game_card.name)
if os.path.exists(desktop_path) if os.path.exists(desktop_path)
else self.add_to_desktop(game_card.name, game_card.exec_line) else self.add_to_desktop(game_card.name, game_card.exec_line)
) )
edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut")) edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect( edit_action.triggered.connect(
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path) lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
) )
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton")) delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder")) open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
open_folder_action.triggered.connect( open_folder_action.triggered.connect(
lambda: self.open_game_folder(game_card.name, game_card.exec_line) lambda: self.open_game_folder(game_card.name, game_card.exec_line)
) )
@@ -262,7 +332,7 @@ class ContextMenuManager:
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(menu_path) else "menu" icon_name = "delete" if os.path.exists(menu_path) else "menu"
text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu") text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
menu_action = menu.addAction(get_safe_icon(icon_name), text) menu_action = menu.addAction(self._get_safe_icon(icon_name), text)
menu_action.triggered.connect( menu_action.triggered.connect(
lambda: self.remove_from_menu(game_card.name) lambda: self.remove_from_menu(game_card.name)
if os.path.exists(menu_path) if os.path.exists(menu_path)
@@ -271,7 +341,7 @@ class ContextMenuManager:
is_in_steam = is_game_in_steam(game_card.name) is_in_steam = is_game_in_steam(game_card.name)
icon_name = "delete" if is_in_steam else "steam" icon_name = "delete" if is_in_steam else "steam"
text = _("Remove from Steam") if is_in_steam else _("Add to Steam") text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(get_safe_icon(icon_name), text) steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
steam_action.triggered.connect( steam_action.triggered.connect(
lambda: ( lambda: (
self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
@@ -280,7 +350,12 @@ class ContextMenuManager:
) )
) )
menu.exec(game_card.mapToGlobal(pos)) # Set focus to the first menu item
actions = menu.actions()
if actions:
menu.setActiveAction(actions[0])
menu.exec(game_card.mapToGlobal(pos))
def _launch_game(self, game_card): def _launch_game(self, game_card):
""" """
@@ -417,7 +492,7 @@ class ContextMenuManager:
) )
return return
# Используем FileExplorer с directory_only=True # Use FileExplorer with directory_only=True
file_explorer = FileExplorer( file_explorer = FileExplorer(
parent=self.parent, parent=self.parent,
theme=self.theme, theme=self.theme,
@@ -447,10 +522,10 @@ class ContextMenuManager:
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name)) self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
threading.Thread(target=run_import, daemon=True).start() threading.Thread(target=run_import, daemon=True).start()
# Подключаем сигнал выбора файла/папки # Connect the file selection signal
file_explorer.file_signal.file_selected.connect(on_folder_selected) file_explorer.file_signal.file_selected.connect(on_folder_selected)
# Центрируем FileExplorer относительно родительского виджета # Center FileExplorer relative to the parent widget
parent_widget = self.parent parent_widget = self.parent
if parent_widget: if parent_widget:
parent_geometry = parent_widget.geometry() parent_geometry = parent_widget.geometry()
@@ -532,10 +607,10 @@ class ContextMenuManager:
exe_path = get_egs_executable(app_name, self.legendary_config_path) exe_path = get_egs_executable(app_name, self.legendary_config_path)
if exe_path and os.path.exists(exe_path): if exe_path and os.path.exists(exe_path):
if not generate_thumbnail(exe_path, icon_path, size=128): if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail from exe: {exe_path}") logger.error("Failed to generate thumbnail for EGS game: %s", exe_path)
icon_path = "" icon_path = ""
else: else:
logger.error(f"No executable found for EGS game: {app_name}") logger.error("No executable found for EGS game: %s", app_name)
icon_path = "" icon_path = ""
egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops") egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
@@ -675,7 +750,7 @@ Icon={icon_path}
if not exec_line: if not exec_line:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("No executable command in .desktop file for '{game_name}'").format(game_name=game_name) _("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
) )
return None return None
else: else:
@@ -687,7 +762,7 @@ Icon={icon_path}
except Exception as e: except Exception as e:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("Failed to read .desktop file: {error}").format(error=str(e)) _("Error reading .desktop file: {error}").format(error=str(e))
) )
return None return None
else: else:
@@ -709,7 +784,7 @@ Icon={icon_path}
try: try:
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line) logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
return None return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2] exe_path = entry_exec_split[2]
@@ -718,11 +793,11 @@ Icon={icon_path}
else: else:
exe_path = entry_exec_split[-1] exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path): if not exe_path or not os.path.exists(exe_path):
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None") logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None")
return None return None
return exe_path return exe_path
except Exception as e: except Exception as e:
logger.debug("Failed to parse executable for '%s': %s", game_name, e) logger.debug("Error parsing executable for game '%s': %s", game_name, e)
return None return None
def _remove_file(self, file_path, error_message, success_message, game_name, location=""): def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
@@ -784,7 +859,7 @@ Icon={icon_path}
_("Failed to delete custom data: {error}").format(error=str(e)) _("Failed to delete custom data: {error}").format(error=str(e))
) )
# Перезагрузка списка игр и обновление сетки # Reload games list and update grid
self.load_games() self.load_games()
self.update_game_grid() self.update_game_grid()
@@ -861,7 +936,7 @@ Icon={icon_path}
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png") icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
if not os.path.exists(icon_path): if not os.path.exists(icon_path):
if not generate_thumbnail(exe_path, icon_path, size=128): if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail for {exe_path}") logger.error("Failed to generate thumbnail for game: %s", exe_path)
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip() desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
os.makedirs(desktop_dir, exist_ok=True) os.makedirs(desktop_dir, exist_ok=True)
@@ -997,7 +1072,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name) exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path: if not exe_path:
return return
logger.debug("Adding '%s' to Steam", game_name) logger.debug("Adding game '%s' to Steam", game_name)
try: try:
success, message = add_to_steam(game_name, exec_line, cover_path) success, message = add_to_steam(game_name, exec_line, cover_path)
self.signals.show_info_dialog.emit( self.signals.show_info_dialog.emit(
@@ -1040,7 +1115,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name) exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path: if not exe_path:
return return
logger.debug("Removing non-EGS game '%s' from Steam", game_name) logger.debug("Removing game '%s' from Steam", game_name)
try: try:
success, message = remove_from_steam(game_name, exec_line) success, message = remove_from_steam(game_name, exec_line)
self.signals.show_info_dialog.emit( self.signals.show_info_dialog.emit(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

View File

@@ -1,3 +0,0 @@
name=Pulse Online
description_ru=Многопользовательская онлайн-игра в жанре MMORPG, действие которой происходит в научно-фантастическом мире с уникальной боевой системой и глубоким крафтом. Игроки могут исследовать обширные локации, выполнять квесты, сражаться с противниками и взаимодействовать с другими участниками игры.
description_en=A multiplayer online game in the MMORPG genre set in a sci-fi world with a unique combat system and deep crafting mechanics. Players can explore vast locations, complete quests, battle enemies, and interact with other participants in the game.

View File

@@ -5,30 +5,63 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
def compute_layout(nat_sizes, rect_width, spacing, max_scale): def compute_layout(nat_sizes, rect_width, spacing, max_scale):
""" """
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек. Computes the layout of elements considering spacing and potential scaling of cards.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота). nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
rect_width: доступная ширина контейнера. rect_width: Available container width.
spacing: отступ между элементами. spacing: Spacing between elements (horizontal and vertical).
max_scale: максимальный коэффициент масштабирования (например, 1.2). max_scale: Maximum scaling factor (e.g., 1.0).
Возвращает: Returns:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height]. result: Array (N, 4), where each row contains [x, y, new_width, new_height].
total_height: итоговая высота всех рядов. total_height: Total height of all rows.
""" """
N = nat_sizes.shape[0] N = nat_sizes.shape[0]
result = np.zeros((N, 4), dtype=np.int32) result = np.zeros((N, 4), dtype=np.int32)
y = 0 y = 0
i = 0 i = 0
min_margin = 20 # Minimum margin on edges
# Determine the maximum number of items per row and overall scale
max_items_per_row = 0
global_scale = 1.0
max_row_x_start = min_margin # Starting x position of the widest row
temp_i = 0
# First pass: Find the maximum number of items in a row
while temp_i < N:
sum_width = 0
count = 0
temp_j = temp_i
while temp_j < N:
w = nat_sizes[temp_j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
break
sum_width += w
count += 1
temp_j += 1
if count > max_items_per_row:
max_items_per_row = count
# Calculate scale for the most populated row
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
global_scale = desired_scale if desired_scale < max_scale else max_scale
# Store starting x position for the widest row
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
temp_i = temp_j
# Second pass: Place elements
while i < N: while i < N:
sum_width = 0 sum_width = 0
row_max_height = 0 row_max_height = 0
count = 0 count = 0
j = i j = i
# Подбираем количество элементов для текущего ряда
# Determine the number of items for the current row
while j < N: while j < N:
w = nat_sizes[j, 0] w = nat_sizes[j, 0]
# Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
if count > 0 and (sum_width + spacing + w) > rect_width:
break break
sum_width += w sum_width += w
count += 1 count += 1
@@ -36,13 +69,19 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
if h > row_max_height: if h > row_max_height:
row_max_height = h row_max_height = h
j += 1 j += 1
# Доступная ширина ряда с учетом обязательных отступов между элементами
available_width = rect_width - spacing * (count - 1) # Use global scale for all rows
desired_scale = available_width / sum_width if sum_width > 0 else 1.0 scale = global_scale
# Разрешаем увеличение карточек, но не более max_scale scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
scale = desired_scale if desired_scale < max_scale else max_scale
# Выравниваем по левому краю (offset = 0) # Determine starting x coordinate
x = 0 if count == max_items_per_row:
# Center the full row
x = max(min_margin, (rect_width - scaled_row_width) // 2)
else:
# Align incomplete row to the left, matching the widest row's start
x = max_row_x_start
for k in range(i, j): for k in range(i, j):
new_w = int(nat_sizes[k, 0] * scale) new_w = int(nat_sizes[k, 0] * scale)
new_h = int(nat_sizes[k, 1] * scale) new_h = int(nat_sizes[k, 1] * scale)
@@ -51,6 +90,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
result[k, 2] = new_w result[k, 2] = new_w
result[k, 3] = new_h result[k, 3] = new_h
x += new_w + spacing x += new_w + spacing
y += int(row_max_height * scale) + spacing y += int(row_max_height * scale) + spacing
i = j i = j
return result, y return result, y
@@ -59,18 +99,17 @@ class FlowLayout(QLayout):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.itemList = [] self.itemList = []
# Устанавливаем отступы контейнера в 0 и задаем spacing между карточками self.setContentsMargins(20, 20, 20, 20) # Margins around the layout
self.setContentsMargins(0, 0, 0, 0) self._spacing = 20 # Spacing for animation and overlap prevention
self._spacing = 3 # отступ между карточками self._max_scale = 1.0 # Scaling disabled in layout
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
def addItem(self, item: QLayoutItem) -> None: def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item) self.itemList.append(item)
def takeAt(self, index: int) -> QLayoutItem: def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList): if 0 <= index < len(self.itemList):
return self.itemList.pop(index) return self.itemList.pop(index)
raise IndexError("Index out of range") raise IndexError("Index out of range")
def count(self) -> int: def count(self) -> int:
return len(self.itemList) return len(self.itemList)
@@ -102,7 +141,7 @@ class FlowLayout(QLayout):
size = size.expandedTo(item.minimumSize()) size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins() margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(), size += QSize(margins.left() + margins.right(),
margins.top() + margins.bottom()) margins.top() + margins.bottom())
return size return size
def doLayout(self, rect, testOnly): def doLayout(self, rect, testOnly):
@@ -110,14 +149,12 @@ class FlowLayout(QLayout):
if N == 0: if N == 0:
return 0 return 0
# Собираем натуральные размеры всех элементов в массив NumPy
nat_sizes = np.empty((N, 2), dtype=np.int32) nat_sizes = np.empty((N, 2), dtype=np.int32)
for i, item in enumerate(self.itemList): for i, item in enumerate(self.itemList):
s = item.sizeHint() s = item.sizeHint()
nat_sizes[i, 0] = s.width() nat_sizes[i, 0] = s.width()
nat_sizes[i, 1] = s.height() nat_sizes[i, 1] = s.height()
# Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale) geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
if not testOnly: if not testOnly:
@@ -152,7 +189,7 @@ class ClickableLabel(QLabel):
self._icon_size = icon_size self._icon_size = icon_size
self._icon_space = icon_space self._icon_space = icon_space
self._font_scale_factor = font_scale_factor self._font_scale_factor = font_scale_factor
self._card_width = 250 # Значение по умолчанию self._card_width = 250
if change_cursor: if change_cursor:
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.updateFontSize() self.updateFontSize()
@@ -170,28 +207,23 @@ class ClickableLabel(QLabel):
self.update() self.update()
def setCardWidth(self, card_width: int): def setCardWidth(self, card_width: int):
"""Обновляет ширину карточки и пересчитывает размер шрифта."""
self._card_width = card_width self._card_width = card_width
self.updateFontSize() self.updateFontSize()
def updateFontSize(self): def updateFontSize(self):
"""Обновляет размер шрифта на основе card_width и font_scale_factor."""
font = self.font() font = self.font()
font_size = int(self._card_width * self._font_scale_factor) font_size = int(self._card_width * self._font_scale_factor)
font.setPointSize(max(8, font_size)) # Минимальный размер шрифта 8 font.setPointSize(max(8, font_size))
self.setFont(font) self.setFont(font)
self.update() self.update()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.contentsRect() rect = self.contentsRect()
alignment = self.alignment() alignment = self.alignment()
icon_size = self._icon_size icon_size = self._icon_size
spacing = self._icon_space spacing = self._icon_space
text = self.text() text = self.text()
if self._icon: if self._icon:
@@ -200,17 +232,11 @@ class ClickableLabel(QLabel):
pixmap = None pixmap = None
fm = QFontMetrics(self.font()) fm = QFontMetrics(self.font())
# Считаем, сколько места остаётся под текст
available_width = rect.width() available_width = rect.width()
if pixmap: if pixmap:
available_width -= (icon_size + spacing) available_width -= (icon_size + spacing)
# Отступы по 2px с каждой стороны
available_width = max(0, available_width - 4) available_width = max(0, available_width - 4)
# Получаем «обрезанный» текст с многоточием
display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width) display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
text_width = fm.horizontalAdvance(display_text) text_width = fm.horizontalAdvance(display_text)
text_height = fm.height() text_height = fm.height()
total_width = text_width + (icon_size + spacing if pixmap else 0) total_width = text_width + (icon_size + spacing if pixmap else 0)
@@ -280,8 +306,6 @@ class AutoSizeButton(QPushButton):
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFlat(True) self.setFlat(True)
# Изначально выставляем минимальную ширину
self.setMinimumWidth(50) self.setMinimumWidth(50)
self.adjustFontSize() self.adjustFontSize()
@@ -312,7 +336,6 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return return
# Определяем доступную ширину внутри кнопки
available_width = self.width() available_width = self.width()
if self._icon: if self._icon:
available_width -= self._icon_size available_width -= self._icon_size
@@ -323,7 +346,6 @@ class AutoSizeButton(QPushButton):
font = QFont(self._original_font) font = QFont(self._original_font)
text = self._original_text text = self._original_text
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
chosen_size = self._max_font_size chosen_size = self._max_font_size
for font_size in range(self._max_font_size, self._min_font_size - 1, -1): for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
font.setPointSize(font_size) font.setPointSize(font_size)
@@ -336,14 +358,12 @@ class AutoSizeButton(QPushButton):
font.setPointSize(chosen_size) font.setPointSize(chosen_size)
self.setFont(font) self.setFont(font)
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text) text_width = fm.horizontalAdvance(text)
required_width = text_width + margins.left() + margins.right() + self._padding * 2 required_width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon: if self._icon:
required_width += self._icon_size required_width += self._icon_size
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
if self.width() < required_width: if self.width() < required_width:
self.setMinimumWidth(required_width) self.setMinimumWidth(required_width)
@@ -353,7 +373,6 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return super().sizeHint() return super().sizeHint()
else: else:
# Вычисляем оптимальный размер кнопки на основе текста и отступов
font = self.font() font = self.font()
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(self._original_text) text_width = fm.horizontalAdvance(self._original_text)
@@ -364,7 +383,6 @@ class AutoSizeButton(QPushButton):
height = fm.height() + margins.top() + margins.bottom() + self._padding height = fm.height() + margins.top() + margins.bottom() + self._padding
return QSize(width, height) return QSize(width, height)
class NavLabel(QLabel): class NavLabel(QLabel):
clicked = Signal() clicked = Signal()
@@ -376,7 +394,6 @@ class NavLabel(QLabel):
self._isChecked = False self._isChecked = False
self.setProperty("checked", self._isChecked) self.setProperty("checked", self._isChecked)
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
# Explicitly enable focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def setCheckable(self, checkable): def setCheckable(self, checkable):
@@ -395,7 +412,6 @@ class NavLabel(QLabel):
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
# Ensure widget can take focus on click
self.setFocus(Qt.FocusReason.MouseFocusReason) self.setFocus(Qt.FocusReason.MouseFocusReason)
if self._checkable: if self._checkable:
self.setChecked(not self._isChecked) self.setChecked(not self._isChecked)

View File

@@ -4,23 +4,24 @@ import re
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar
) )
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
from icoextract import IconExtractor, IconExtractorError from icoextract import IconExtractor, IconExtractorError
from PIL import Image from PIL import Image
from portprotonqt.config_utils import get_portproton_location from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
import psutil
if TYPE_CHECKING: if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
logger = get_logger(__name__) logger = get_logger(__name__)
theme_manager = ThemeManager()
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True): def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
""" """
@@ -89,30 +90,112 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
class FileSelectedSignal(QObject): class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу file_selected = Signal(str) # Сигнал с путем к выбранному файлу
class GameLaunchDialog(QDialog):
"""Modal dialog to indicate game launch progress, similar to Steam's launch dialog."""
def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.game_name = game_name
self.target_exe = target_exe # Store the target executable name
self.setWindowTitle(_("Launching {0}").format(self.game_name))
self.setModal(True)
self.setFixedSize(400, 200)
self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
# Layout
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# Game name label
label = QLabel(_("Launching {0}").format(self.game_name))
label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
# Progress bar (indeterminate)
self.progress_bar = QProgressBar()
self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
self.progress_bar.setRange(0, 0) # Indeterminate mode
layout.addWidget(self.progress_bar)
# Cancel button
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.clicked.connect(self.reject)
layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter)
# Center dialog on parent
if parent:
parent_geometry = parent.geometry()
center_point = parent_geometry.center()
dialog_geometry = self.geometry()
dialog_geometry.moveCenter(center_point)
self.setGeometry(dialog_geometry)
# Timer to check if the game process is running
self.check_process_timer = QTimer(self)
self.check_process_timer.timeout.connect(self.check_target_exe)
self.check_process_timer.start(500)
def is_target_exe_running(self):
"""Check if the target executable is running using psutil."""
if not self.target_exe:
return False
for proc in psutil.process_iter(attrs=["name"]):
if proc.info["name"].lower() == self.target_exe.lower():
return True
return False
def check_target_exe(self):
"""Check if the game process is running and close the dialog if it is."""
if self.is_target_exe_running():
logger.info(f"Game {self.game_name} process detected as running, closing launch dialog")
self.accept() # Close dialog when game is running
self.check_process_timer.stop()
self.check_process_timer.deleteLater()
elif not hasattr(self.parent(), 'game_processes') or not any(proc.poll() is None for proc in cast("MainWindow", self.parent()).game_processes):
# If no child processes are running, stop the timer but keep dialog open
self.check_process_timer.stop()
self.check_process_timer.deleteLater()
def reject(self):
"""Handle dialog cancellation."""
logger.info(f"Game launch cancelled for {self.game_name}")
self.check_process_timer.stop()
self.check_process_timer.deleteLater()
super().reject()
class FileExplorer(QDialog): class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else default_styles self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.theme_manager = ThemeManager()
self.file_signal = FileSelectedSignal() self.file_signal = FileSelectedSignal()
self.file_filter = file_filter # Store the file filter self.file_filter = file_filter # Store the file filter
self.directory_only = directory_only # Store the directory_only flag self.directory_only = directory_only # Store the directory_only flag
self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection
self.path_history = {} # Dictionary to store last selected item per directory self.path_history = {} # Dictionary to store last selected item per directory
self.initial_path = initial_path # Store initial path if provided self.initial_path = initial_path # Store initial path if provided
self.thumbnail_cache = {} # Cache for loaded thumbnails
self.pending_thumbnails = set() # Track files pending thumbnail loading
self.setup_ui() self.setup_ui()
# Настройки окна # Window settings
self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
# Find InputManager from parent # Find InputManager and ContextMenuManager from parent
self.input_manager = None self.input_manager = None
self.context_menu_manager = None
parent = self.parent() parent = self.parent()
while parent: while parent:
if hasattr(parent, 'input_manager'): if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager self.input_manager = cast("MainWindow", parent).input_manager
break if hasattr(parent, 'context_menu_manager'):
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
parent = parent.parent() parent = parent.parent()
if self.input_manager: if self.input_manager:
@@ -127,8 +210,115 @@ class FileExplorer(QDialog):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list() self.update_file_list()
class ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject):
thumbnail_ready = Signal(str, QIcon) # Signal for ready thumbnail: file path and icon
def __init__(self, file_path, mime_type, size=64):
super().__init__()
self.file_path = file_path
self.mime_type = mime_type
self.size = size
self.signals = self.Signals()
@Slot()
def run(self):
"""Performs thumbnail loading in a background thread."""
try:
if self.mime_type.startswith("image/"):
pixmap = QPixmap(self.file_path)
if not pixmap.isNull():
scaled_pixmap = pixmap.scaled(self.size, self.size, Qt.AspectRatioMode.KeepAspectRatio)
self.signals.thumbnail_ready.emit(self.file_path, QIcon(scaled_pixmap))
else:
logger.warning("Failed to load image: %s", self.file_path)
elif self.file_path.lower().endswith(".exe"):
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
if generate_thumbnail(self.file_path, tmp.name, size=self.size):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
self.signals.thumbnail_ready.emit(self.file_path, QIcon(pixmap))
os.unlink(tmp.name)
else:
logger.warning("Failed to generate thumbnail for .exe: %s", self.file_path)
except Exception as e:
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
def async_load_thumbnails(self, files, mime_db):
"""
Asynchronously loads thumbnails for a list of files.
Args:
files (list): List of file names to process.
mime_db (QMimeDatabase): QMimeDatabase instance for file type detection.
"""
thread_pool = QThreadPool.globalInstance()
thread_pool.setMaxThreadCount(4) # Limit the number of threads
for f in files:
file_path = os.path.join(self.current_path, f)
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
continue # Skip if already cached or pending
mime_type = mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/") or file_path.lower().endswith(".exe"):
self.pending_thumbnails.add(file_path)
loader = self.ThumbnailLoader(file_path, mime_type, size=64)
loader.signals.thumbnail_ready.connect(self.update_thumbnail)
thread_pool.start(loader)
@Slot(str, QIcon)
def update_thumbnail(self, file_path, icon):
"""
Updates the icon for a file list item after thumbnail loading.
Args:
file_path (str): Path to the file for which the thumbnail was loaded.
icon (QIcon): Loaded icon.
"""
try:
# Cache the thumbnail
self.thumbnail_cache[file_path] = icon
self.pending_thumbnails.discard(file_path)
# Update the item in the file list
file_name = os.path.basename(file_path)
for i in range(self.file_list.count()):
item = self.file_list.item(i)
if item.text() == file_name:
item.setIcon(icon)
break
except Exception as e:
logger.error("Error updating thumbnail for %s: %s", file_path, str(e))
def load_visible_thumbnails(self):
"""Load thumbnails only for visible items in the file list."""
try:
visible_range = self.file_list.count()
first_visible = max(0, self.file_list.indexAt(self.file_list.viewport().rect().topLeft()).row())
last_visible = min(visible_range - 1, self.file_list.indexAt(self.file_list.viewport().rect().bottomRight()).row() + 5)
files_to_load = []
for i in range(first_visible, last_visible + 1):
item = self.file_list.item(i)
if not item:
continue
file_name = item.text()
if file_name.endswith("/"):
continue # Skip directories
file_path = os.path.join(self.current_path, file_name)
if file_path not in self.thumbnail_cache and file_path not in self.pending_thumbnails:
files_to_load.append(file_name)
if files_to_load:
self.async_load_thumbnails(files_to_load, self.mime_db)
except Exception as e:
logger.error("Error loading visible thumbnails: %s", str(e))
def get_mounted_drives(self): def get_mounted_drives(self):
"""Получение списка смонтированных дисков из /proc/mounts, исключая системные пути""" """Retrieve a list of mounted drives from /proc/mounts, excluding system paths."""
mounted_drives = [] mounted_drives = []
try: try:
with open('/proc/mounts') as f: with open('/proc/mounts') as f:
@@ -137,20 +327,21 @@ class FileExplorer(QDialog):
if len(parts) < 2: if len(parts) < 2:
continue continue
mount_point = parts[1] mount_point = parts[1]
# Исключаем системные и временные пути # Exclude system and temporary paths, but keep /run/media
if mount_point.startswith(('/run', '/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')): if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
continue continue
# Проверяем, является ли точка монтирования директорией и доступна ли она # Check if the mount point is a directory and accessible
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK): if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
mounted_drives.append(mount_point) mounted_drives.append(mount_point)
return sorted(mounted_drives) return sorted(mounted_drives)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении смонтированных дисков: {e}") logger.error(f"Error retrieving mounted drives: {e}")
return [] return []
def setup_ui(self): def setup_ui(self):
"""Настройка интерфейса""" """Set up the user interface."""
self.setWindowTitle("File Explorer") self.setWindowTitle(_("File Explorer"))
self.setGeometry(100, 100, 600, 600) self.setGeometry(100, 100, 600, 600)
self.main_layout = QVBoxLayout() self.main_layout = QVBoxLayout()
@@ -158,7 +349,7 @@ class FileExplorer(QDialog):
self.main_layout.setSpacing(10) self.main_layout.setSpacing(10)
self.setLayout(self.main_layout) self.setLayout(self.main_layout)
# Панель для смонтированных дисков # Panel for mounted drives and favorite folders
self.drives_layout = QHBoxLayout() self.drives_layout = QHBoxLayout()
self.drives_scroll = QScrollArea() self.drives_scroll = QScrollArea()
self.drives_scroll.setWidgetResizable(True) self.drives_scroll.setWidgetResizable(True)
@@ -169,25 +360,30 @@ class FileExplorer(QDialog):
self.drives_scroll.setFixedHeight(70) self.drives_scroll.setFixedHeight(70)
self.main_layout.addWidget(self.drives_scroll) self.main_layout.addWidget(self.drives_scroll)
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Allow focus on scroll area self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Путь # Path label
self.path_label = QLabel() self.path_label = QLabel()
self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE) self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE)
self.main_layout.addWidget(self.path_label) self.main_layout.addWidget(self.path_label)
# Список файлов # File list
self.file_list = QListWidget() self.file_list = QListWidget()
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE) self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
self.file_list.itemClicked.connect(self.handle_item_click) self.file_list.itemClicked.connect(self.handle_item_click)
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click) self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
self.main_layout.addWidget(self.file_list) self.main_layout.addWidget(self.file_list)
# Кнопки # Connect scroll signal for lazy loading
self.file_list.verticalScrollBar().valueChanged.connect(self.load_visible_thumbnails)
# Buttons
self.button_layout = QHBoxLayout() self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10) self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Select"), icon=self.theme_manager.get_icon("apply")) self.select_button = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel")) self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button) self.button_layout.addWidget(self.select_button)
@@ -197,41 +393,48 @@ class FileExplorer(QDialog):
self.select_button.clicked.connect(self.select_item) self.select_button.clicked.connect(self.select_item)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
def show_folder_context_menu(self, pos):
"""Shows the context menu for a folder using ContextMenuManager."""
if self.context_menu_manager:
self.context_menu_manager.show_folder_context_menu(self, pos)
else:
logger.warning("ContextMenuManager not found in parent")
def move_selection(self, direction): def move_selection(self, direction):
"""Перемещение выбора по списку""" """Move selection in the list."""
current_row = self.file_list.currentRow() current_row = self.file_list.currentRow()
if direction < 0 and current_row > 0: # Вверх if direction < 0 and current_row > 0: # Up
self.file_list.setCurrentRow(current_row - 1) self.file_list.setCurrentRow(current_row - 1)
elif direction > 0 and current_row < self.file_list.count() - 1: # Вниз elif direction > 0 and current_row < self.file_list.count() - 1: # Down
self.file_list.setCurrentRow(current_row + 1) self.file_list.setCurrentRow(current_row + 1)
self.file_list.scrollToItem(self.file_list.currentItem()) self.file_list.scrollToItem(self.file_list.currentItem())
def handle_item_click(self, item): def handle_item_click(self, item):
"""Обработка одинарного клика мышью""" """Handle single mouse click."""
try: try:
self.file_list.setCurrentItem(item) self.file_list.setCurrentItem(item)
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент self.path_history[self.current_path] = item.text() # Save selected item
logger.debug("Selected item: %s", item.text()) logger.debug("Selected item: %s", item.text())
except Exception as e: except Exception as e:
logger.error("Error in handle_item_click: %s", e) logger.error("Error in handle_item_click: %s", e)
def handle_item_double_click(self, item): def handle_item_double_click(self, item):
"""Обработка двойного клика мышью по элементу списка""" """Handle double mouse click on a list item."""
try: try:
self.file_list.setCurrentItem(item) self.file_list.setCurrentItem(item)
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент self.path_history[self.current_path] = item.text() # Save selected item
selected = item.text() selected = item.text()
full_path = os.path.join(self.current_path, selected) full_path = os.path.join(self.current_path, selected)
if os.path.isdir(full_path): if os.path.isdir(full_path):
if selected == "../": if selected == "../":
# Переходим в родительскую директорию # Navigate to parent directory
self.previous_dir() self.previous_dir()
else: else:
# Открываем директорию # Open directory
self.current_path = os.path.normpath(full_path) self.current_path = os.path.normpath(full_path)
self.update_file_list() self.update_file_list()
elif not self.directory_only: elif not self.directory_only:
# Выбираем файл, если directory_only=False # Select file if directory_only=False
self.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept() self.accept()
else: else:
@@ -240,7 +443,7 @@ class FileExplorer(QDialog):
logger.error("Error in handle_item_double_click: %s", e) logger.error("Error in handle_item_double_click: %s", e)
def select_item(self): def select_item(self):
"""Обработка выбора файла/папки""" """Handle file/folder selection."""
if self.file_list.count() == 0: if self.file_list.count() == 0:
return return
@@ -249,30 +452,30 @@ class FileExplorer(QDialog):
if os.path.isdir(full_path): if os.path.isdir(full_path):
if self.directory_only: if self.directory_only:
# Подтверждаем выбор директории # Confirm directory selection
self.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept() self.accept()
else: else:
# Открываем директорию # Open directory
self.current_path = os.path.normpath(full_path) self.current_path = os.path.normpath(full_path)
self.update_file_list() self.update_file_list()
else: else:
if not self.directory_only: if not self.directory_only:
# Для файла отправляем нормализованный путь # Emit normalized path for file
self.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept() self.accept()
else: else:
logger.debug("Selected item is not a directory, ignoring: %s", full_path) logger.debug("Selected item is not a directory, ignoring: %s", full_path)
def previous_dir(self): def previous_dir(self):
"""Возврат к родительской директории""" """Navigate to parent directory."""
try: try:
if self.current_path == "/": if self.current_path == "/":
return # Уже в корне return # Already at root
# Нормализуем путь (убираем конечный слеш, если есть) # Normalize path (remove trailing slash if present)
normalized_path = os.path.normpath(self.current_path) normalized_path = os.path.normpath(self.current_path)
# Получаем родительскую директорию # Get parent directory
parent_dir = os.path.dirname(normalized_path) parent_dir = os.path.dirname(normalized_path)
if not parent_dir: if not parent_dir:
@@ -286,84 +489,136 @@ class FileExplorer(QDialog):
except Exception as e: except Exception as e:
logger.error(f"Error navigating to parent directory: {e}") logger.error(f"Error navigating to parent directory: {e}")
def ensure_button_visible(self, button):
"""Ensure the specified button is visible in the drives_scroll area."""
try:
if not button or not self.drives_scroll:
return
# Ensure the button is visible in the scroll area
self.drives_scroll.ensureWidgetVisible(button, 50, 50)
logger.debug(f"Ensured button {button.text()} is visible in drives_scroll")
except Exception as e:
logger.error(f"Error ensuring button visible: {e}")
def update_drives_list(self): def update_drives_list(self):
"""Обновление списка смонтированных дисков""" """Update the list of mounted drives and favorite folders."""
for i in reversed(range(self.drives_layout.count())): for i in reversed(range(self.drives_layout.count())):
widget = self.drives_layout.itemAt(i).widget() item = self.drives_layout.itemAt(i)
if widget: if item and item.widget():
widget = item.widget()
self.drives_layout.removeWidget(widget)
widget.deleteLater() widget.deleteLater()
self.drive_buttons = []
drives = self.get_mounted_drives() drives = self.get_mounted_drives()
self.drive_buttons = [] # Store buttons for navigation favorite_folders = read_favorite_folders()
# Add mounted drives
for drive in drives: for drive in drives:
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point")) button = AutoSizeButton(drive_name, icon=theme_manager.get_icon("mount_point"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Make button focusable button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=drive: self.change_drive(path)) button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
self.drives_layout.addWidget(button) self.drives_layout.addWidget(button)
self.drive_buttons.append(button) self.drive_buttons.append(button)
self.drives_layout.addStretch()
# Set focus to first drive button if available # Add favorite folders
if self.drive_buttons: for folder in favorite_folders:
self.drive_buttons[0].setFocus() folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
button = AutoSizeButton(folder_name, icon=theme_manager.get_icon("folder"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=folder: self.change_drive(path))
self.drives_layout.addWidget(button)
self.drive_buttons.append(button)
# Add spacer to align elements
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.drives_layout.addWidget(spacer)
def select_drive(self): def select_drive(self):
"""Handle drive selection via gamepad""" """Handle drive or favorite folder selection via gamepad."""
focused_widget = QApplication.focusWidget() focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons: if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
drive_path = None drive_name = focused_widget.text().strip() # Remove whitespace
for drive in self.get_mounted_drives(): logger.debug(f"Selected name: {drive_name}")
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
if drive_name == focused_widget.text(): # Special handling for root directory
drive_path = drive if drive_name == "/":
break if os.path.isdir("/") and os.access("/", os.R_OK):
if drive_path and os.path.isdir(drive_path) and os.access(drive_path, os.R_OK): self.current_path = "/"
self.current_path = os.path.normpath(drive_path) self.update_file_list()
self.update_file_list() logger.info("Selected root directory")
else: return
logger.warning(f"Путь диска недоступен: {drive_path}") else:
logger.warning("Root directory is inaccessible: insufficient permissions or path error")
return
# Check favorite folders
favorite_folders = read_favorite_folders()
logger.debug(f"Favorite folders: {favorite_folders}")
for folder in favorite_folders:
folder_name = os.path.basename(os.path.normpath(folder)) or folder # For root paths
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
self.current_path = os.path.normpath(folder)
self.update_file_list()
logger.info(f"Selected favorite folder: {self.current_path}")
return
# Check mounted drives
mounted_drives = self.get_mounted_drives()
logger.debug(f"Mounted drives: {mounted_drives}")
for drive in mounted_drives:
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # For root paths
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
self.current_path = os.path.normpath(drive)
self.update_file_list()
logger.info(f"Selected mounted drive: {self.current_path}")
return
logger.warning(f"Path is inaccessible: {drive_name}.")
def change_drive(self, drive_path): def change_drive(self, drive_path):
"""Переход к выбранному диску""" """Navigate to the selected drive."""
if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK): if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
self.current_path = os.path.normpath(drive_path) self.current_path = os.path.normpath(drive_path)
self.update_file_list() self.update_file_list()
else: else:
logger.warning(f"Путь диска недоступен: {drive_path}") logger.warning(f"Drive path is inaccessible: {drive_path}")
def update_file_list(self): def update_file_list(self):
"""Обновление списка файлов с превью в виде иконок""" """Update the file list with asynchronous thumbnail loading."""
self.file_list.clear() self.file_list.clear()
self.thumbnail_cache.clear() # Clear cache when changing directories
self.pending_thumbnails.clear() # Clear pending thumbnails
try: try:
if self.current_path != "/": if self.current_path != "/":
item = QListWidgetItem("../") item = QListWidgetItem("../")
folder_icon = self.theme_manager.get_icon("folder") folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon): if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon) folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon): elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon folder_icon = QIcon()
item.setIcon(folder_icon) item.setIcon(folder_icon)
self.file_list.addItem(item) self.file_list.addItem(item)
items = os.listdir(self.current_path) items = os.listdir(self.current_path)
dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))] dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))]
# Добавляем директории # Add directories
for d in sorted(dirs): for d in sorted(dirs):
item = QListWidgetItem(f"{d}/") item = QListWidgetItem(f"{d}/")
folder_icon = self.theme_manager.get_icon("folder") folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon): if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon) folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon): elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon folder_icon = QIcon()
item.setIcon(folder_icon) item.setIcon(folder_icon)
self.file_list.addItem(item) self.file_list.addItem(item)
# Добавляем файлы только если directory_only=False # Add files only if directory_only=False
if not self.directory_only: if not self.directory_only:
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))] files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
if self.file_filter: if self.file_filter:
@@ -372,26 +627,14 @@ class FileExplorer(QDialog):
elif isinstance(self.file_filter, tuple): elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)] files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
# Add files to the list without immediate thumbnail loading
for f in sorted(files): for f in sorted(files):
item = QListWidgetItem(f) item = QListWidgetItem(f)
file_path = os.path.join(self.current_path, f)
mime_type = self.mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/"):
pixmap = QPixmap(file_path)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)))
elif file_path.lower().endswith(".exe"):
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
if generate_thumbnail(file_path, tmp.name, size=64):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap))
os.unlink(tmp.name)
self.file_list.addItem(item) self.file_list.addItem(item)
# Load thumbnails for visible items only
self.load_visible_thumbnails()
self.path_label.setText(_("Path: ") + self.current_path) self.path_label.setText(_("Path: ") + self.current_path)
# Restore last selected item for this directory # Restore last selected item for this directory
@@ -413,10 +656,10 @@ class FileExplorer(QDialog):
self.file_list.setAlternatingRowColors(True) self.file_list.setAlternatingRowColors(True)
except PermissionError: except PermissionError:
self.path_label.setText(f"Access denied: {self.current_path}") self.path_label.setText(_("Access denied: %s") % self.current_path)
def closeEvent(self, event): def closeEvent(self, event):
"""Закрытие окна""" """Handle window closing."""
try: try:
if self.input_manager: if self.input_manager:
self.input_manager.disable_file_explorer_mode() self.input_manager.disable_file_explorer_mode()
@@ -430,13 +673,13 @@ class FileExplorer(QDialog):
super().closeEvent(event) super().closeEvent(event)
def reject(self): def reject(self):
"""Закрытие диалога""" """Close the dialog."""
if self.input_manager: if self.input_manager:
self.input_manager.disable_file_explorer_mode() self.input_manager.disable_file_explorer_mode()
super().reject() super().reject()
def accept(self): def accept(self):
"""Принятие диалога""" """Accept the dialog."""
if self.input_manager: if self.input_manager:
self.input_manager.disable_file_explorer_mode() self.input_manager.disable_file_explorer_mode()
super().accept() super().accept()
@@ -445,8 +688,7 @@ class AddGameDialog(QDialog):
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None): def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent) super().__init__(parent)
from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт
self.theme = theme if theme else default_styles self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.theme_manager = ThemeManager()
self.edit_mode = edit_mode self.edit_mode = edit_mode
self.original_name = game_name self.original_name = game_name
self.last_exe_path = exe_path # Store last selected exe path self.last_exe_path = exe_path # Store last selected exe path
@@ -482,7 +724,7 @@ class AddGameDialog(QDialog):
if exe_path: if exe_path:
self.exeEdit.setText(exe_path) self.exeEdit.setText(exe_path)
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search")) exeBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
exeBrowseButton.clicked.connect(self.browseExe) exeBrowseButton.clicked.connect(self.browseExe)
exeBrowseButton.setObjectName("exeBrowseButton") # Для поиска кнопки exeBrowseButton.setObjectName("exeBrowseButton") # Для поиска кнопки
@@ -504,7 +746,7 @@ class AddGameDialog(QDialog):
if cover_path: if cover_path:
self.coverEdit.setText(cover_path) self.coverEdit.setText(cover_path)
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search")) coverBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
coverBrowseButton.clicked.connect(self.browseCover) coverBrowseButton.clicked.connect(self.browseCover)
coverBrowseButton.setObjectName("coverBrowseButton") # Для поиска кнопки coverBrowseButton.setObjectName("coverBrowseButton") # Для поиска кнопки
@@ -533,8 +775,8 @@ class AddGameDialog(QDialog):
# Dialog buttons # Dialog buttons
self.button_layout = QHBoxLayout() self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10) self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Apply"), icon=self.theme_manager.get_icon("apply")) self.select_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel")) self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button) self.button_layout.addWidget(self.select_button)
@@ -677,7 +919,10 @@ class AddGameDialog(QDialog):
exe_path = self.exeEdit.text().strip() exe_path = self.exeEdit.text().strip()
name = self.nameEdit.text().strip() name = self.nameEdit.text().strip()
if not exe_path or not name: if not exe_path or not os.path.isfile(exe_path):
return None, None
if not name:
return None, None return None, None
portproton_path = get_portproton_location() portproton_path = get_portproton_location()

View File

@@ -144,14 +144,21 @@ class Downloader(QObject):
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем") logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
return None return None
if url in self._cache: if url in self._cache:
return self._cache[url] cached_path = self._cache[url]
if os.path.exists(cached_path):
if os.path.abspath(cached_path) == os.path.abspath(local_path):
return cached_path
else:
del self._cache[url]
url_lock = self._get_url_lock(url) url_lock = self._get_url_lock(url)
with url_lock: with url_lock:
with self._global_lock: with self._global_lock:
if url in self._last_error: if url in self._last_error:
return None return None
if url in self._cache: if url in self._cache:
return self._cache[url] cached_path = self._cache[url]
if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path):
return cached_path
result = download_with_cache(url, local_path, timeout, self) result = download_with_cache(url, local_path, timeout, self)
with self._global_lock: with self._global_lock:
if result: if result:

View File

@@ -16,13 +16,14 @@ from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_la
from portprotonqt.config_utils import get_portproton_location from portprotonqt.config_utils import get_portproton_location
from portprotonqt.steam_api import ( from portprotonqt.steam_api import (
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async, get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
) )
import vdf import vdf
import shutil import shutil
import zlib import zlib
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
import base64
logger = get_logger(__name__) logger = get_logger(__name__)
downloader = Downloader() downloader = Downloader()
@@ -66,7 +67,8 @@ def get_cache_dir() -> Path:
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None: def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
""" """
Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script. Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script.
Also deletes associated cover files in the Steam grid directory.
Calls the callback with (success, message). Calls the callback with (success, message).
Args: Args:
@@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
portproton_dir: Path to the PortProton directory. portproton_dir: Path to the PortProton directory.
callback: Callback function to handle the result (success, message). callback: Callback function to handle the result (success, message).
""" """
if not portproton_dir: if not portproton_dir:
logger.error("PortProton directory not found") logger.error("PortProton directory not found")
callback((False, "PortProton directory not found")) callback((False, "PortProton directory not found"))
@@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
user_dir = os.path.join(userdata_dir, str(unsigned_id)) user_dir = os.path.join(userdata_dir, str(unsigned_id))
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf") steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
backup_path = f"{steam_shortcuts_path}.backup" grid_dir = os.path.join(user_dir, "config", "grid")
if not os.path.exists(steam_shortcuts_path): if not os.path.exists(steam_shortcuts_path):
logger.error("Steam shortcuts file not found") logger.error("Steam shortcuts file not found")
callback((False, "Steam shortcuts file not found")) callback((False, "Steam shortcuts file not found"))
return return
# Find appid for the shortcut
try:
with open(steam_shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get("shortcuts", {})
appid = None
for _key, entry in shortcuts.items():
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
appid = convert_steam_id(int(entry.get("appid")))
logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}")
break
if not appid:
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
callback((False, f"Game '{game_name}' not found in Steam"))
return
except Exception as e:
logger.error(f"Failed to load shortcuts.vdf: {e}")
callback((False, f"Failed to load shortcuts.vdf: {e}"))
return
# Try CEF API first
logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}")
api_response = call_steam_api("removeShortcut", appid)
if api_response is not None: # API responded, even if empty
logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API")
# Delete cover files
cover_files = [
os.path.join(grid_dir, f"{appid}.jpg"),
os.path.join(grid_dir, f"{appid}p.jpg"),
os.path.join(grid_dir, f"{appid}_hero.jpg"),
os.path.join(grid_dir, f"{appid}_logo.png")
]
for cover_file in cover_files:
if os.path.exists(cover_file):
try:
os.remove(cover_file)
logger.info(f"Deleted cover file: {cover_file}")
except Exception as e:
logger.error(f"Failed to delete cover file {cover_file}: {e}")
# Delete launch script
if os.path.exists(script_path):
try:
os.remove(script_path)
logger.info(f"Removed EGS script: {script_path}")
except OSError as e:
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect."))
return
# Fallback to VDF modification
logger.warning("CEF API failed for EGS game removal; falling back to VDF modification")
backup_path = f"{steam_shortcuts_path}.backup"
try: try:
shutil.copy2(steam_shortcuts_path, backup_path) shutil.copy2(steam_shortcuts_path, backup_path)
logger.info("Created backup of shortcuts.vdf at %s", backup_path) logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e: except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}") logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
callback((False, f"Failed to create backup of shortcuts.vdf: {e}")) callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
return return
try: try:
with open(steam_shortcuts_path, 'rb') as f: new_shortcuts = {}
shortcuts_data = vdf.binary_load(f) index = 0
except Exception as e: for _key, entry in shortcuts.items():
logger.error(f"Failed to load shortcuts.vdf: {e}") if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
callback((False, f"Failed to load shortcuts.vdf: {e}")) logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
return continue
new_shortcuts[str(index)] = entry
index += 1
shortcuts = shortcuts_data.get("shortcuts", {})
modified = False
new_shortcuts = {}
index = 0
for _key, entry in shortcuts.items():
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
modified = True
logger.info("Removing EGS game '%s' from Steam shortcuts", game_name)
continue
new_shortcuts[str(index)] = entry
index += 1
if not modified:
logger.error("Game '%s' not found in Steam shortcuts", game_name)
callback((False, f"Game '{game_name}' not found in Steam shortcuts"))
return
try:
with open(steam_shortcuts_path, 'wb') as f: with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": new_shortcuts}, f) vdf.binary_dump({"shortcuts": new_shortcuts}, f)
logger.info("Updated shortcuts.vdf, removed '%s'", game_name) logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
except Exception as e: except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}") logger.error(f"Failed to update shortcuts.vdf: {e}")
if os.path.exists(backup_path): if os.path.exists(backup_path):
@@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
callback((False, f"Failed to update shortcuts.vdf: {e}")) callback((False, f"Failed to update shortcuts.vdf: {e}"))
return return
# Delete cover files
cover_files = [
os.path.join(grid_dir, f"{appid}.jpg"),
os.path.join(grid_dir, f"{appid}p.jpg"),
os.path.join(grid_dir, f"{appid}_hero.jpg"),
os.path.join(grid_dir, f"{appid}_logo.png")
]
for cover_file in cover_files:
if os.path.exists(cover_file):
try:
os.remove(cover_file)
logger.info(f"Deleted cover file: {cover_file}")
except Exception as e:
logger.error(f"Failed to delete cover file {cover_file}: {e}")
# Delete launch script
if os.path.exists(script_path): if os.path.exists(script_path):
try: try:
os.remove(script_path) os.remove(script_path)
logger.info("Removed EGS script: %s", script_path) logger.info(f"Removed EGS script: {script_path}")
except OSError as e: except OSError as e:
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}") logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
@@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None: def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
""" """
Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag. Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag.
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper. Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh. Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail. Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
Calls the callback with (success, message). Calls the callback with (success, message).
Args:
app_name: The Legendary app_name (unique identifier for the game).
game_title: The display name of the game.
legendary_path: Path to the Legendary CLI executable.
callback: Callback function to handle the result (success, message).
""" """
if not app_name or not app_name.strip() or not game_title or not game_title.strip(): if not app_name or not app_name.strip() or not game_title or not game_title.strip():
logger.error("Invalid app_name or game_title: empty or whitespace") logger.error("Invalid app_name or game_title: empty or whitespace")
@@ -267,47 +330,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
grid_dir = user_dir / "config" / "grid" grid_dir = user_dir / "config" / "grid"
os.makedirs(grid_dir, exist_ok=True) os.makedirs(grid_dir, exist_ok=True)
# Backup shortcuts.vdf # Try CEF API first
backup_path = f"{steam_shortcuts_path}.backup" logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
if os.path.exists(steam_shortcuts_path): api_response = call_steam_api(
try: "createShortcut",
shutil.copy2(steam_shortcuts_path, backup_path) game_title,
logger.info(f"Created backup of shortcuts.vdf at {backup_path}") script_path,
except Exception as e: str(Path(script_path).parent),
logger.error(f"Failed to create backup of shortcuts.vdf: {e}") icon_path,
callback((False, f"Failed to create backup of shortcuts.vdf: {e}")) ""
return )
# Generate unique appid appid = None
unique_string = f"{script_path}{game_title}" was_api_used = False
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
appid = baseid | 0x80000000 if api_response and isinstance(api_response, dict) and 'id' in api_response:
if appid > 0x7FFFFFFF: appid = api_response['id']
aidvdf = appid - 0x100000000 was_api_used = True
logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
else: else:
aidvdf = appid logger.warning("CEF API failed for EGS game addition; falling back to VDF modification")
# Backup shortcuts.vdf
backup_path = f"{steam_shortcuts_path}.backup"
if os.path.exists(steam_shortcuts_path):
try:
shutil.copy2(steam_shortcuts_path, backup_path)
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
return
steam_appid = None # Generate unique appid
downloaded_count = 0 unique_string = f"{script_path}{game_title}"
total_covers = 4 baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
download_lock = threading.Lock() appid = baseid | 0x80000000
if appid > 0x7FFFFFFF:
aidvdf = appid - 0x100000000
else:
aidvdf = appid
def on_cover_download(cover_file: str, cover_type: str): # Create shortcut entry
nonlocal downloaded_count
try:
if cover_file and os.path.exists(cover_file):
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
else:
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
except Exception as e:
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
finalize_shortcut()
def finalize_shortcut():
tags_dict = {'0': 'PortProton'}
shortcut = { shortcut = {
"appid": aidvdf, "appid": aidvdf,
"AppName": game_title, "AppName": game_title,
@@ -322,7 +385,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
"Devkit": 0, "Devkit": 0,
"DevkitGameID": "", "DevkitGameID": "",
"LastPlayTime": 0, "LastPlayTime": 0,
"tags": tags_dict "tags": {'0': 'PortProton'}
} }
logger.info(f"Shortcut entry for EGS game: {shortcut}") logger.info(f"Shortcut entry for EGS game: {shortcut}")
@@ -353,6 +416,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
with open(steam_shortcuts_path, 'wb') as f: with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": shortcuts}, f) vdf.binary_dump({"shortcuts": shortcuts}, f)
logger.info(f"EGS game '{game_title}' added to Steam via VDF")
except Exception as e: except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}") logger.error(f"Failed to update shortcuts.vdf: {e}")
if os.path.exists(backup_path): if os.path.exists(backup_path):
@@ -364,8 +428,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
callback((False, f"Failed to update shortcuts.vdf: {e}")) callback((False, f"Failed to update shortcuts.vdf: {e}"))
return return
logger.info(f"EGS game '{game_title}' added to Steam") if not appid:
callback((True, f"Game '{game_title}' added to Steam with covers")) callback((False, "Failed to create shortcut via any method"))
return
steam_appid = None
downloaded_count = 0
total_covers = 4
download_lock = threading.Lock()
def on_cover_download(cover_file: str | None, cover_type: str, index: int):
nonlocal downloaded_count
try:
if cover_file is None or not os.path.exists(cover_file):
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
callback((True, f"Game '{game_title}' added to Steam with covers"))
return
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
if was_api_used:
try:
with open(cover_file, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}")
ext = Path(cover_type).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e:
logger.error(f"Error applying cover '{cover_type}' via API: {e}")
except Exception as e:
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
callback((True, f"Game '{game_title}' added to Steam with covers"))
def on_steam_apps(steam_data: tuple[list, dict]): def on_steam_apps(steam_data: tuple[list, dict]):
nonlocal steam_appid nonlocal steam_appid
@@ -375,24 +473,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
if not steam_appid: if not steam_appid:
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download") logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
finalize_shortcut() callback((True, f"Game '{game_title}' added to Steam"))
return return
cover_types = [ cover_types = [
(".jpg", "header.jpg"), (".jpg", "header.jpg", 0),
("p.jpg", "library_600x900_2x.jpg"), ("p.jpg", "library_600x900_2x.jpg", 1),
("_hero.jpg", "library_hero.jpg"), ("_hero.jpg", "library_hero.jpg", 2),
("_logo.png", "logo.png") ("_logo.png", "logo.png", 3)
] ]
for suffix, cover_type in cover_types: for suffix, cover_type, index in cover_types:
cover_file = os.path.join(grid_dir, f"{appid}{suffix}") cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}" cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
downloader.download_async( downloader.download_async(
cover_url, cover_url,
cover_file, cover_file,
timeout=5, timeout=5,
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype) callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx)
) )
get_steam_apps_and_index_async(on_steam_apps) get_steam_apps_and_index_async(on_steam_apps)
@@ -747,6 +845,11 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
games: list[tuple] = [] games: list[tuple] = []
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
user_json_path = cache_dir / "user.json"
if not user_json_path.exists():
callback(games)
return
def process_games(installed_games: list | None): def process_games(installed_games: list | None):
if installed_games is None: if installed_games is None:
logger.info("No installed Epic Games Store games found") logger.info("No installed Epic Games Store games found")
@@ -855,12 +958,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
app_name, app_name,
f"legendary:launch:{app_name}", f"legendary:launch:{app_name}",
"", "",
last_launch, # Время последнего запуска last_launch,
formatted_playtime, # Форматированное время игры formatted_playtime,
protondb_tier, # ProtonDB tier protondb_tier,
status or "", status or "",
last_launch_timestamp, # Временная метка последнего запуска last_launch_timestamp,
playtime_seconds, # Время игры в секундах playtime_seconds,
"epic" "epic"
) )
pending_images -= 1 pending_images -= 1
@@ -880,7 +983,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
get_protondb_tier_async(steam_appid, on_protondb_tier) get_protondb_tier_async(steam_appid, on_protondb_tier)
else: else:
logger.debug(f"No Steam app found for EGS game {title}") logger.debug(f"No Steam app found for EGS game {title}")
on_protondb_tier("") # Proceed with empty ProtonDB tier on_protondb_tier("")
get_steam_apps_and_index_async(on_steam_apps) get_steam_apps_and_index_async(on_steam_apps)

View File

@@ -1,38 +1,36 @@
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl from PySide6.QtCore import Signal, Property, Qt, QUrl
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.image_utils import load_pixmap_async, round_corners from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
import weakref from portprotonqt.animations import GameCardAnimations
from typing import cast from typing import cast
class GameCard(QFrame): class GameCard(QFrame):
borderWidthChanged = Signal() borderWidthChanged = Signal()
gradientAngleChanged = Signal() gradientAngleChanged = Signal()
# Signals for context menu actions scaleChanged = Signal()
editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path editShortcutRequested = Signal(str, str, str)
deleteGameRequested = Signal(str, str) # name, exec_line deleteGameRequested = Signal(str, str)
addToMenuRequested = Signal(str, str) # name, exec_line addToMenuRequested = Signal(str, str)
removeFromMenuRequested = Signal(str) # name removeFromMenuRequested = Signal(str)
addToDesktopRequested = Signal(str, str) # name, exec_line addToDesktopRequested = Signal(str, str)
removeFromDesktopRequested = Signal(str) # name removeFromDesktopRequested = Signal(str)
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path addToSteamRequested = Signal(str, str, str)
removeFromSteamRequested = Signal(str, str) # name, exec_line removeFromSteamRequested = Signal(str, str)
openGameFolderRequested = Signal(str, str) # name, exec_line openGameFolderRequested = Signal(str, str)
hoverChanged = Signal(str, bool) hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool) focusChanged = Signal(str, bool)
def __init__(self, name, description, cover_path, appid, controller_support, exec_line, def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source, last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None): select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
super().__init__(parent) super().__init__(parent)
self.name = name self.name = name
self.description = description self.description = description
@@ -47,14 +45,16 @@ class GameCard(QFrame):
self.game_source = game_source self.game_source = game_source
self.last_launch_ts = last_launch_ts self.last_launch_ts = last_launch_ts
self.playtime_seconds = playtime_seconds self.playtime_seconds = playtime_seconds
self.card_width = card_width self.base_card_width = card_width
self.base_pixmap = None
self.base_font_size = None
self.select_callback = select_callback self.select_callback = select_callback
self.context_menu_manager = context_menu_manager self.context_menu_manager = context_menu_manager
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu) self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else default_styles self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.display_filter = read_display_filter() self.display_filter = read_display_filter()
self.current_theme_name = read_theme_from_config() self.current_theme_name = read_theme_from_config()
@@ -65,80 +65,46 @@ class GameCard(QFrame):
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites")) self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
# Дополнительное пространство для анимации self.base_extra_margin = 20
extra_margin = 20
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE) self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
# Параметры анимации обводки
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"] self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"] self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
self._hovered = False self._hovered = False
self._focused = False self._focused = False
# Анимации self.animations = GameCardAnimations(self, self.theme)
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth")) self.animations.setup_animations()
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
self.gradient_anim = None
self.pulse_anim = None
# Флаг для отслеживания подключения слота startPulseAnimation self.shadow = QGraphicsDropShadowEffect(self)
self._isPulseAnimationConnected = False self.shadow.setBlurRadius(20)
self.shadow.setColor(QColor(0, 0, 0, 150))
self.shadow.setOffset(0, 0)
self.setGraphicsEffect(self.shadow)
# Тень self.layout_ = QVBoxLayout(self)
shadow = QGraphicsDropShadowEffect(self) self.layout_.setSpacing(5)
shadow.setBlurRadius(20) self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
shadow.setColor(QColor(0, 0, 0, 150))
shadow.setOffset(0, 0)
self.setGraphicsEffect(shadow)
# Отступы self.coverWidget = QWidget()
layout = QVBoxLayout(self) coverLayout = QStackedLayout(self.coverWidget)
layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
layout.setSpacing(5)
# Контейнер обложки
coverWidget = QWidget()
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
coverLayout = QStackedLayout(coverWidget)
coverLayout.setContentsMargins(0, 0, 0, 0) coverLayout.setContentsMargins(0, 0, 0, 0)
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll) coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
# Обложка
self.coverLabel = QLabel() self.coverLabel = QLabel()
self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE) self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
coverLayout.addWidget(self.coverLabel) coverLayout.addWidget(self.coverLabel)
# создаём слабую ссылку на label load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded)
label_ref = weakref.ref(self.coverLabel)
def on_cover_loaded(pixmap): self.favoriteLabel = ClickableLabel(self.coverWidget)
label = label_ref()
if label is None:
return
label.setPixmap(round_corners(pixmap, 15))
# асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
# Значок избранного (звёздочка) в левом верхнем углу обложки
self.favoriteLabel = ClickableLabel(coverWidget)
self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
self.favoriteLabel.move(8, 8)
self.favoriteLabel.clicked.connect(self.toggle_favorite) self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites() self.is_favorite = self.name in read_favorites()
self.update_favorite_icon() self.update_favorite_icon()
self.favoriteLabel.raise_() self.favoriteLabel.raise_()
# Определяем общие параметры для бейджей
badge_width = int(card_width * 2/3)
icon_size = int(card_width * 0.06) # 6% от ширины карточки
icon_space = int(card_width * 0.012) # 1.2% от ширины карточки
font_scale_factor = 0.06 # Шрифт будет 6% от card_width
# ProtonDB бейдж
tier_text = self.getProtonDBText(protondb_tier) tier_text = self.getProtonDBText(protondb_tier)
if tier_text: if tier_text:
icon_filename = self.getProtonDBIconFilename(protondb_tier) icon_filename = self.getProtonDBIconFilename(protondb_tier)
@@ -146,67 +112,50 @@ class GameCard(QFrame):
self.protondbLabel = ClickableLabel( self.protondbLabel = ClickableLabel(
tier_text, tier_text,
icon=icon, icon=icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setCardWidth(card_width) self.protondbLabel.setCardWidth(card_width)
else: else:
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) self.protondbLabel = ClickableLabel("", parent=self.coverWidget)
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setVisible(False) self.protondbLabel.setVisible(False)
# Steam бейдж
steam_icon = self.theme_manager.get_icon("steam") steam_icon = self.theme_manager.get_icon("steam")
self.steamLabel = ClickableLabel( self.steamLabel = ClickableLabel(
"Steam", "Steam",
icon=steam_icon, icon=steam_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.steamLabel.setFixedWidth(badge_width)
self.steamLabel.setCardWidth(card_width) self.steamLabel.setCardWidth(card_width)
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
# Epic Games Store бейдж
egs_icon = self.theme_manager.get_icon("epic_games") egs_icon = self.theme_manager.get_icon("epic_games")
self.egsLabel = ClickableLabel( self.egsLabel = ClickableLabel(
"Epic Games", "Epic Games",
icon=egs_icon, icon=egs_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06,
icon_space=icon_space,
font_scale_factor=font_scale_factor,
change_cursor=False change_cursor=False
) )
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.egsLabel.setFixedWidth(badge_width)
self.egsLabel.setCardWidth(card_width) self.egsLabel.setCardWidth(card_width)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
# PortProton бейдж portproton_icon = self.theme_manager.get_icon("portproton")
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
self.portprotonLabel = ClickableLabel( self.portprotonLabel = ClickableLabel(
"PortProton", "PortProton",
icon=portproton_icon, icon=portproton_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.portprotonLabel.setFixedWidth(badge_width)
self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setCardWidth(card_width)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
# WeAntiCheatYet бейдж
anticheat_text = self.getAntiCheatText(anticheat_status) anticheat_text = self.getAntiCheatText(anticheat_status)
if anticheat_text: if anticheat_text:
icon_filename = self.getAntiCheatIconFilename(anticheat_status) icon_filename = self.getAntiCheatIconFilename(anticheat_status)
@@ -214,40 +163,57 @@ class GameCard(QFrame):
self.anticheatLabel = ClickableLabel( self.anticheatLabel = ClickableLabel(
anticheat_text, anticheat_text,
icon=icon, icon=icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status)) self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setCardWidth(card_width) self.anticheatLabel.setCardWidth(card_width)
else: else:
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) self.anticheatLabel = ClickableLabel("", parent=self.coverWidget)
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setVisible(False) self.anticheatLabel.setVisible(False)
# Расположение бейджей
self._position_badges(card_width)
self.protondbLabel.clicked.connect(self.open_protondb_report) self.protondbLabel.clicked.connect(self.open_protondb_report)
self.steamLabel.clicked.connect(self.open_steam_page) self.steamLabel.clicked.connect(self.open_steam_page)
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page) self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
layout.addWidget(coverWidget) self.layout_.addWidget(self.coverWidget)
# Название игры self.nameLabel = QLabel(name)
nameLabel = QLabel(name) self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) self.layout_.addWidget(self.nameLabel)
layout.addWidget(nameLabel)
def _position_badges(self, card_width): font_size = self.nameLabel.font().pointSizeF()
"""Позиционирует бейджи на основе ширины карточки.""" self.base_font_size = font_size if font_size > 0 else 10.0
right_margin = 8
badge_spacing = int(card_width * 0.02) # 2% от ширины карточки self.update_scale()
top_y = 10
# Force initial layout update to ensure correct geometry
self.updateGeometry()
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
parent.updateGeometry()
def on_cover_loaded(self, pixmap):
self.base_pixmap = pixmap
self.update_cover_pixmap()
def update_cover_pixmap(self):
if self.base_pixmap:
scaled_width = int(self.base_card_width * self._scale)
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
self.coverLabel.setPixmap(rounded_pixmap)
def _position_badges(self, current_width):
right_margin = int(8 * self._scale)
badge_spacing = int(current_width * 0.02)
top_y = int(10 * self._scale)
badge_y_positions = [] badge_y_positions = []
badge_width = int(card_width * 2/3) badge_width = int(current_width * 2/3)
badges = [ badges = [
(self.steam_visible, self.steamLabel), (self.steam_visible, self.steamLabel),
@@ -259,80 +225,99 @@ class GameCard(QFrame):
for is_visible, badge in badges: for is_visible, badge in badges:
if is_visible: if is_visible:
badge_x = card_width - badge_width - right_margin badge_x = current_width - badge_width - right_margin
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
badge.move(badge_x, badge_y) badge.move(int(badge_x), int(badge_y))
badge_y_positions.append(badge_y + badge.height()) badge_y_positions.append(badge_y + badge.height())
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
self.anticheatLabel.raise_() self.anticheatLabel.raise_()
self.protondbLabel.raise_() self.protondbLabel.raise_()
self.portprotonLabel.raise_() self.portprotonLabel.raise_()
self.egsLabel.raise_() self.egsLabel.raise_()
self.steamLabel.raise_() self.steamLabel.raise_()
def update_card_size(self, new_width: int): def update_scale(self):
"""Обновляет размер карточки, обложки и бейджей.""" scaled_width = int(self.base_card_width * self._scale)
self.card_width = new_width scaled_height = int(self.base_card_width * 1.8 * self._scale)
extra_margin = 20 scaled_extra = int(self.base_extra_margin * self._scale)
self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin) self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
if self.coverLabel is None: self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
return self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
coverWidget = self.coverLabel.parentWidget() self.update_cover_pixmap()
if coverWidget is None:
return
coverWidget.setFixedSize(new_width, int(new_width * 1.2)) favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale))
self.coverLabel.setFixedSize(new_width, int(new_width * 1.2)) self.favoriteLabel.setFixedSize(*favorite_size)
self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
label_ref = weakref.ref(self.coverLabel) badge_width = int(scaled_width * 2/3)
def on_cover_loaded(pixmap): icon_size = int(scaled_width * 0.06)
label = label_ref() icon_space = int(scaled_width * 0.012)
if label:
scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, 15)
label.setPixmap(rounded_pixmap)
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
# Обновляем размеры и шрифты бейджей
badge_width = int(new_width * 2/3)
icon_size = int(new_width * 0.06)
icon_space = int(new_width * 0.012)
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]: for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
if label is not None: if label is not None:
label.setFixedWidth(badge_width) label.setFixedWidth(badge_width)
label.setIconSize(icon_size, icon_space) label.setIconSize(icon_size, icon_space)
label.setCardWidth(new_width) # Пересчитываем размер шрифта label.setCardWidth(scaled_width)
# Перепозиционируем бейджи self._position_badges(scaled_width)
self._position_badges(new_width)
if self.base_font_size is not None:
font = self.nameLabel.font()
new_font_size = self.base_font_size * self._scale
if new_font_size > 0:
font.setPointSizeF(new_font_size)
self.nameLabel.setFont(font)
self.shadow.setBlurRadius(int(20 * self._scale))
self.updateGeometry()
self.update() self.update()
# Ensure parent layout is updated safely
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.activate()
layout.update()
parent.updateGeometry()
def update_card_size(self, new_width: int):
self.base_card_width = new_width
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.5), self.on_cover_loaded)
self.update_scale()
def update_badge_visibility(self, display_filter: str): def update_badge_visibility(self, display_filter: str):
"""Обновляет видимость бейджей на основе display_filter."""
self.display_filter = display_filter self.display_filter = display_filter
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites")) self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites")) self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites")) self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
protondb_visible = bool(self.getProtonDBText(self.protondb_tier)) protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
# Обновляем видимость бейджей
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.protondbLabel.setVisible(protondb_visible) self.protondbLabel.setVisible(protondb_visible)
self.anticheatLabel.setVisible(anticheat_visible) self.anticheatLabel.setVisible(anticheat_visible)
# Перепозиционируем бейджи scaled_width = int(self.base_card_width * self._scale)
self._position_badges(self.card_width) self._position_badges(scaled_width)
# Update layout after visibility changes
self.updateGeometry()
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.update()
parent.updateGeometry()
def _show_context_menu(self, pos): def _show_context_menu(self, pos):
"""Delegate context menu display to ContextMenuManager."""
if self.context_menu_manager: if self.context_menu_manager:
self.context_menu_manager.show_context_menu(self, pos) self.context_menu_manager.show_context_menu(self, pos)
@@ -390,7 +375,6 @@ class GameCard(QFrame):
return "" return ""
def open_portproton_forum_topic(self): def open_portproton_forum_topic(self):
"""Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(self.name) result = self.portproton_api.get_forum_topic_slug(self.name)
base_url = "https://linux-gaming.ru/" base_url = "https://linux-gaming.ru/"
if result.startswith("search?q="): if result.startswith("search?q="):
@@ -450,138 +434,37 @@ class GameCard(QFrame):
self.gradientAngleChanged.emit() self.gradientAngleChanged.emit()
self.update() self.update()
def getScale(self) -> float:
return self._scale
def setScale(self, value: float):
if self._scale != value:
self._scale = value
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged)) borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
def paintEvent(self, event): def paintEvent(self, event):
super().paintEvent(event) super().paintEvent(event)
painter = QPainter(self) self.animations.paint_border(QPainter(self))
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen()
pen.setWidth(self._borderWidth)
if self._hovered or self._focused:
center = self.rect().center()
gradient = QConicalGradient(center, self._gradientAngle)
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
gradient.setColorAt(stop["position"], QColor(stop["color"]))
pen.setBrush(QBrush(gradient))
else:
pen.setColor(QColor(0, 0, 0, 0))
painter.setPen(pen)
radius = 18
bw = round(self._borderWidth / 2)
rect = self.rect().adjusted(bw, bw, -bw, -bw)
painter.drawRoundedRect(rect, radius, radius)
def startPulseAnimation(self):
if not (self._hovered or self._focused):
return
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
self.pulse_anim.setLoopCount(0)
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.start()
def enterEvent(self, event): def enterEvent(self, event):
self._hovered = True self.animations.handle_enter_event()
self.hoverChanged.emit(self.name, True)
self.setFocus(Qt.FocusReason.MouseFocusReason)
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
super().enterEvent(event) super().enterEvent(event)
def leaveEvent(self, event): def leaveEvent(self, event):
self._hovered = False self.animations.handle_leave_event()
self.hoverChanged.emit(self.name, False)
if not self._focused:
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.start()
super().leaveEvent(event) super().leaveEvent(event)
def focusInEvent(self, event): def focusInEvent(self, event):
if not self._hovered: self.animations.handle_focus_in_event()
self._focused = True
self.focusChanged.emit(self.name, True)
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
super().focusInEvent(event) super().focusInEvent(event)
def focusOutEvent(self, event): def focusOutEvent(self, event):
self._focused = False self.animations.handle_focus_out_event()
self.focusChanged.emit(self.name, False)
if not self._hovered:
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.start()
super().focusOutEvent(event) super().focusOutEvent(event)
def mousePressEvent(self, event): def mousePressEvent(self, event):
@@ -601,6 +484,7 @@ class GameCard(QFrame):
) )
super().mousePressEvent(event) super().mousePressEvent(event)
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self.select_callback( self.select_callback(

View File

@@ -219,9 +219,11 @@ class ResultParser:
("comp_plus", "main_extra"), ("comp_plus", "main_extra"),
("comp_100", "completionist") ("comp_100", "completionist")
] ]
all_zero = all(game_data.get(json_field, 0) == 0 for json_field, _ in time_fields)
for json_field, attr_name in time_fields: for json_field, attr_name in time_fields:
if json_field in game_data: if json_field in game_data:
time_hours = round(game_data[json_field] / 3600, 2) time_seconds = game_data[json_field]
time_hours = None if all_zero else round(time_seconds / 3600, 2)
setattr(game, attr_name, time_hours) setattr(game, attr_name, time_hours)
game.similarity = self._calculate_similarity(game) game.similarity = self._calculate_similarity(game)
return game return game

View File

@@ -3,7 +3,6 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.config_utils import read_theme_from_config from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
@@ -21,6 +20,13 @@ image_load_queue = Queue()
image_executor = ThreadPoolExecutor(max_workers=4) image_executor = ThreadPoolExecutor(max_workers=4)
queue_lock = threading.Lock() queue_lock = threading.Lock()
def get_device_pixel_ratio() -> float:
"""
Retrieves the device pixel ratio from QApplication, with a fallback of 1.0 if not available.
"""
app = QApplication.instance()
return app.devicePixelRatio() if isinstance(app, QApplication) else 1.0
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""): def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
""" """
Асинхронно загружает обложку через очередь задач. Асинхронно загружает обложку через очередь задач.
@@ -164,23 +170,21 @@ class FullscreenDialog(QDialog):
:param theme: Объект темы для стилизации (если None, используется default_styles) :param theme: Объект темы для стилизации (если None, используется default_styles)
""" """
super().__init__(parent) super().__init__(parent)
# Удаление диалога после закрытия
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus() self.setFocus()
self.images = images self.images = images
self.current_index = current_index self.current_index = current_index
self.theme = theme if theme else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
# Убираем стандартные элементы управления окна
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.init_ui() self.init_ui()
self.update_display() self.update_display()
# Фильтруем события для закрытия диалога по клику
self.imageLabel.installEventFilter(self) self.imageLabel.installEventFilter(self)
self.captionLabel.installEventFilter(self) self.captionLabel.installEventFilter(self)
@@ -190,32 +194,28 @@ class FullscreenDialog(QDialog):
self.mainLayout.setContentsMargins(0, 0, 0, 0) self.mainLayout.setContentsMargins(0, 0, 0, 0)
self.mainLayout.setSpacing(0) self.mainLayout.setSpacing(0)
# Контейнер для изображения и стрелок
self.imageContainer = QWidget() self.imageContainer = QWidget()
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT) self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
self.imageContainerLayout = QHBoxLayout(self.imageContainer) self.imageContainerLayout = QHBoxLayout(self.imageContainer)
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0) self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
self.imageContainerLayout.setSpacing(0) self.imageContainerLayout.setSpacing(0)
# Левая стрелка
self.prevButton = QToolButton() self.prevButton = QToolButton()
self.prevButton.setArrowType(Qt.ArrowType.LeftArrow) self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE) self.prevButton.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor) self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
self.prevButton.setFixedSize(40, 40) self.prevButton.setFixedSize(40, 40)
self.prevButton.clicked.connect(self.show_prev) self.prevButton.clicked.connect(self.show_prev)
self.imageContainerLayout.addWidget(self.prevButton) self.imageContainerLayout.addWidget(self.prevButton)
# Метка для изображения
self.imageLabel = QLabel() self.imageLabel = QLabel()
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT) self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1) self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
# Правая стрелка
self.nextButton = QToolButton() self.nextButton = QToolButton()
self.nextButton.setArrowType(Qt.ArrowType.RightArrow) self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) self.nextButton.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor) self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
self.nextButton.setFixedSize(40, 40) self.nextButton.setFixedSize(40, 40)
self.nextButton.clicked.connect(self.show_next) self.nextButton.clicked.connect(self.show_next)
@@ -223,16 +223,14 @@ class FullscreenDialog(QDialog):
self.mainLayout.addWidget(self.imageContainer) self.mainLayout.addWidget(self.imageContainer)
# Небольшой отступ между изображением и подписью
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
self.mainLayout.addItem(spacer) self.mainLayout.addItem(spacer)
# Подпись
self.captionLabel = QLabel() self.captionLabel = QLabel()
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.captionLabel.setFixedHeight(40) self.captionLabel.setFixedHeight(40)
self.captionLabel.setWordWrap(True) self.captionLabel.setWordWrap(True)
self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE) self.captionLabel.setStyleSheet(getattr(self.theme, "CAPTION_LABEL_STYLE", ""))
self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor) self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
self.mainLayout.addWidget(self.captionLabel) self.mainLayout.addWidget(self.captionLabel)
@@ -241,28 +239,37 @@ class FullscreenDialog(QDialog):
if not self.images: if not self.images:
return return
# Очищаем старое содержимое
self.imageLabel.clear() self.imageLabel.clear()
self.captionLabel.clear() self.captionLabel.clear()
QApplication.processEvents() QApplication.processEvents()
pixmap, caption = self.images[self.current_index] pixmap, caption = self.images[self.current_index]
# Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера # Учитываем devicePixelRatio для масштабирования высокого качества
device_pixel_ratio = get_device_pixel_ratio()
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
# Масштабируем изображение из оригинального pixmap
scaled_pixmap = pixmap.scaled( scaled_pixmap = pixmap.scaled(
self.FIXED_WIDTH - 80, # учитываем ширину стрелок target_width,
self.FIXED_HEIGHT, target_height,
Qt.AspectRatioMode.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation Qt.TransformationMode.SmoothTransformation
) )
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
self.imageLabel.setPixmap(scaled_pixmap) self.imageLabel.setPixmap(scaled_pixmap)
self.captionLabel.setText(caption) self.captionLabel.setText(caption)
self.setWindowTitle(caption) self.setWindowTitle(caption)
# Принудительная перерисовка виджетов
self.imageLabel.repaint() self.imageLabel.repaint()
self.captionLabel.repaint() self.captionLabel.repaint()
self.repaint() self.repaint()
def resizeEvent(self, event):
"""Обновляет изображение при изменении размера окна."""
super().resizeEvent(event)
self.update_display() # Перерисовываем изображение с учетом нового размера
def show_prev(self): def show_prev(self):
"""Показывает предыдущее изображение.""" """Показывает предыдущее изображение."""
if self.images: if self.images:
@@ -292,7 +299,6 @@ class FullscreenDialog(QDialog):
def mousePressEvent(self, event): def mousePressEvent(self, event):
"""Закрывает диалог при клике на пустую область.""" """Закрывает диалог при клике на пустую область."""
pos = event.pos() pos = event.pos()
# Проверяем, находится ли клик вне imageContainer и captionLabel
if not (self.imageContainer.geometry().contains(pos) or if not (self.imageContainer.geometry().contains(pos) or
self.captionLabel.geometry().contains(pos)): self.captionLabel.geometry().contains(pos)):
self.close() self.close()
@@ -305,15 +311,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
""" """
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None): def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
""" """
:param pixmap: QPixmap для отображения в карусели :param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение)
:param caption: Подпись к изображению :param caption: Подпись к изображению
:param images_list: Список всех изображений (кортежей (QPixmap, caption)), :param images_list: Список всех изображений (кортежей (QPixmap, caption))
чтобы в диалоге можно было перелистывать. :param index: Индекс текущего изображения в images_list
Если не передан, будет использован только текущее изображение. :param carousel: Ссылка на родительскую карусель (ImageCarousel)
:param index: Индекс текущего изображения в images_list.
:param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
""" """
super().__init__(pixmap) super().__init__()
self.original_pixmap = pixmap # Store original high-resolution pixmap
self.caption = caption self.caption = caption
self.images_list = images_list if images_list is not None else [(pixmap, caption)] self.images_list = images_list if images_list is not None else [(pixmap, caption)]
self.index = index self.index = index
@@ -323,6 +328,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
self._click_start_position = None self._click_start_position = None
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.update_pixmap() # Set initial pixmap
def update_pixmap(self, height=300):
"""Update the displayed pixmap by scaling from the original high-resolution pixmap."""
if self.original_pixmap.isNull():
return
# Scale pixmap to desired height, considering device pixel ratio
device_pixel_ratio = get_device_pixel_ratio()
scaled_pixmap = self.original_pixmap.scaledToHeight(
int(height * device_pixel_ratio),
Qt.TransformationMode.SmoothTransformation
)
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
self.setPixmap(scaled_pixmap)
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
@@ -339,17 +358,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
event.accept() event.accept()
def show_fullscreen(self): def show_fullscreen(self):
# Скрываем стрелки карусели перед открытием FullscreenDialog
if self.carousel: if self.carousel:
self.carousel.prevArrow.hide() self.carousel.prevArrow.hide()
self.carousel.nextArrow.hide() self.carousel.nextArrow.hide()
dialog = FullscreenDialog(self.images_list, current_index=self.index) dialog = FullscreenDialog(self.images_list, current_index=self.index)
dialog.exec() dialog.exec()
# После закрытия диалога обновляем видимость стрелок
if self.carousel: if self.carousel:
self.carousel.update_arrows_visibility() self.carousel.update_arrows_visibility()
class ImageCarousel(QGraphicsView): class ImageCarousel(QGraphicsView):
""" """
Карусель изображений с адаптивностью, возможностью увеличения по клику Карусель изображений с адаптивностью, возможностью увеличения по клику
@@ -357,19 +373,17 @@ class ImageCarousel(QGraphicsView):
""" """
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None): def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
super().__init__(parent) super().__init__(parent)
# Аннотируем тип scene как QGraphicsScene
self.carousel_scene: QGraphicsScene = QGraphicsScene(self) self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
self.setScene(self.carousel_scene) self.setScene(self.carousel_scene)
self.images = images # Список кортежей: (QPixmap, caption) self.images = images # Список кортежей: (QPixmap, caption)
self.image_items = [] self.image_items = []
self._animation = None self._animation = None
self.theme = theme if theme else default_styles self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.max_height = 300 # Default height for images
self.init_ui() self.init_ui()
self.create_arrows() self.create_arrows()
# Переменные для поддержки перетаскивания
self._drag_active = False self._drag_active = False
self._drag_start_position = None self._drag_start_position = None
self._scroll_start_value = None self._scroll_start_value = None
@@ -380,30 +394,38 @@ class ImageCarousel(QGraphicsView):
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setFrameShape(QFrame.Shape.NoFrame) self.setFrameShape(QFrame.Shape.NoFrame)
x_offset = 10 # Отступ между изображениями self.update_scene()
max_height = 300 # Фиксированная высота изображений
def update_scene(self):
"""Update the scene with scaled images based on current size and scale."""
self.carousel_scene.clear()
self.image_items.clear()
x_offset = 10
x = 0 x = 0
device_pixel_ratio = get_device_pixel_ratio()
for i, (pixmap, caption) in enumerate(self.images): for i, (pixmap, caption) in enumerate(self.images):
item = ClickablePixmapItem( item = ClickablePixmapItem(
pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation), pixmap, # Pass original pixmap
caption, caption,
images_list=self.images, images_list=self.images,
index=i, index=i,
carousel=self # Передаем ссылку на карусель carousel=self
) )
item.update_pixmap(self.max_height) # Scale to current height
item.setPos(x, 0) item.setPos(x, 0)
self.carousel_scene.addItem(item) self.carousel_scene.addItem(item)
self.image_items.append(item) self.image_items.append(item)
x += item.pixmap().width() + x_offset x += item.pixmap().width() / device_pixel_ratio + x_offset
self.setSceneRect(0, 0, x, max_height) self.setSceneRect(0, 0, x, self.max_height)
def create_arrows(self): def create_arrows(self):
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки.""" """Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
self.prevArrow = QToolButton(self) self.prevArrow = QToolButton(self)
self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow) self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore self.prevArrow.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
self.prevArrow.setFixedSize(40, 40) self.prevArrow.setFixedSize(40, 40)
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor) self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
self.prevArrow.setAutoRepeat(True) self.prevArrow.setAutoRepeat(True)
@@ -414,7 +436,7 @@ class ImageCarousel(QGraphicsView):
self.nextArrow = QToolButton(self) self.nextArrow = QToolButton(self)
self.nextArrow.setArrowType(Qt.ArrowType.RightArrow) self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore self.nextArrow.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
self.nextArrow.setFixedSize(40, 40) self.nextArrow.setFixedSize(40, 40)
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor) self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
self.nextArrow.setAutoRepeat(True) self.nextArrow.setAutoRepeat(True)
@@ -423,14 +445,9 @@ class ImageCarousel(QGraphicsView):
self.nextArrow.clicked.connect(self.scroll_right) self.nextArrow.clicked.connect(self.scroll_right)
self.nextArrow.raise_() self.nextArrow.raise_()
# Проверяем видимость стрелок при создании
self.update_arrows_visibility() self.update_arrows_visibility()
def update_arrows_visibility(self): def update_arrows_visibility(self):
"""
Показывает стрелки, если контент шире видимой области.
Иначе скрывает их.
"""
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"): if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
if self.horizontalScrollBar().maximum() == 0: if self.horizontalScrollBar().maximum() == 0:
self.prevArrow.hide() self.prevArrow.hide()
@@ -444,7 +461,8 @@ class ImageCarousel(QGraphicsView):
margin = 10 margin = 10
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2) self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
self.nextArrow.move(self.width() - self.nextArrow.width() - margin, self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
(self.height() - self.nextArrow.height()) // 2) (self.height() - self.nextArrow.height()) // 2)
self.update_scene() # Re-scale images on resize
self.update_arrows_visibility() self.update_arrows_visibility()
def animate_scroll(self, end_value): def animate_scroll(self, end_value):
@@ -469,19 +487,15 @@ class ImageCarousel(QGraphicsView):
self.animate_scroll(new_value) self.animate_scroll(new_value)
def update_images(self, new_images): def update_images(self, new_images):
self.carousel_scene.clear()
self.images = new_images self.images = new_images
self.image_items.clear() self.update_scene()
self.init_ui()
self.update_arrows_visibility() self.update_arrows_visibility()
# Обработка событий мыши для перетаскивания
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self._drag_active = True self._drag_active = True
self._drag_start_position = event.pos() self._drag_start_position = event.pos()
self._scroll_start_value = self.horizontalScrollBar().value() self._scroll_start_value = self.horizontalScrollBar().value()
# Скрываем стрелки при начале перетаскивания
if hasattr(self, "prevArrow"): if hasattr(self, "prevArrow"):
self.prevArrow.hide() self.prevArrow.hide()
if hasattr(self, "nextArrow"): if hasattr(self, "nextArrow"):
@@ -497,6 +511,5 @@ class ImageCarousel(QGraphicsView):
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
self._drag_active = False self._drag_active = False
# Показываем стрелки после завершения перетаскивания (с проверкой видимости)
self.update_arrows_visibility() self.update_arrows_visibility()
super().mouseReleaseEvent(event) super().mouseReleaseEvent(event)

View File

@@ -3,10 +3,11 @@ import threading
import os import os
from typing import Protocol, cast from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from enum import Enum
from pyudev import Context, Monitor, MonitorObserver, Device from pyudev import Context, Monitor, MonitorObserver, Device
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
from PySide6.QtGui import QKeyEvent from PySide6.QtGui import QKeyEvent, QMouseEvent
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.image_utils import FullscreenDialog from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
@@ -31,6 +32,8 @@ class MainWindowProtocol(Protocol):
... ...
def on_slider_released(self) -> None: def on_slider_released(self) -> None:
... ...
def isActiveWindow(self) -> bool:
...
stackedWidget: QStackedWidget stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget] tabButtons: dict[int, QWidget]
gamesListWidget: QWidget gamesListWidget: QWidget
@@ -38,23 +41,29 @@ class MainWindowProtocol(Protocol):
current_exec_line: str | None current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None current_add_game_dialog: AddGameDialog | None
# Mapping of actions to evdev button codes, includes Xbox and PlayStation controllers # Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch controllers
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c # https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c # https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo
BUTTONS = { BUTTONS = {
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS) 'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS) / B (Switch)
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS) 'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS) / A (Switch)
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS) 'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS) / Y (Switch)
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS) 'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS) / X (Switch)
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS) 'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS) / L (Switch)
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS) 'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS) / R (Switch)
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS) 'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS) / + (Switch)
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS) 'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS) / - (Switch)
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button 'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button / Home (Switch)
'increase_size': {ecodes.ABS_RZ}, # RT (Xbox) / R2 (PS) 'increase_size': {ecodes.ABS_RZ}, # RT (Xbox) / R2 (PS) / ZR (Switch)
'decrease_size': {ecodes.ABS_Z}, # LT (Xbox) / L2 (PS) 'decrease_size': {ecodes.ABS_Z}, # LT (Xbox) / L2 (PS) / ZL (Switch)
} }
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
class InputManager(QObject): class InputManager(QObject):
""" """
Manages input from gamepads and keyboards for navigating the application interface. Manages input from gamepads and keyboards for navigating the application interface.
@@ -76,6 +85,7 @@ class InputManager(QObject):
super().__init__(cast(QObject, main_window)) super().__init__(cast(QObject, main_window))
self._parent = main_window self._parent = main_window
self._gamepad_handling_enabled = True self._gamepad_handling_enabled = True
self.gamepad_type = GamepadType.UNKNOWN
# Ensure attributes exist on main_window # Ensure attributes exist on main_window
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None) self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None) self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
@@ -132,6 +142,38 @@ class InputManager(QObject):
# Initialize evdev + hotplug # Initialize evdev + hotplug
self.init_gamepad() self.init_gamepad()
def detect_gamepad_type(self, device: InputDevice) -> GamepadType:
"""
Определяет тип геймпада по capabilities
"""
caps = device.capabilities()
keys = set(caps.get(ecodes.EV_KEY, []))
# Для EV_ABS вытаскиваем только коды (первый элемент кортежа)
abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])}
# Xbox layout
if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys):
if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes):
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
# PlayStation layout
if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys):
self.gamepad_type = GamepadType.PLAYSTATION
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.PLAYSTATION
# Steam Controller / Deck (трекпады)
if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT):
self.gamepad_type = GamepadType.XBOX
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.XBOX
# Fallback
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
def enable_file_explorer_mode(self, file_explorer): def enable_file_explorer_mode(self, file_explorer):
"""Настройка обработки геймпада для FileExplorer""" """Настройка обработки геймпада для FileExplorer"""
try: try:
@@ -161,7 +203,20 @@ class InputManager(QObject):
def handle_file_explorer_button(self, button_code): def handle_file_explorer_button(self, button_code):
try: try:
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']: # A button (BTN_SOUTH)
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
return
elif button_code in BUTTONS['back']: # B button
popup.close()
return
return # Skip other handling if menu is open
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'): if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
logger.debug("No file explorer or file_list available")
return return
focused_widget = QApplication.focusWidget() focused_widget = QApplication.focusWidget()
@@ -169,27 +224,37 @@ class InputManager(QObject):
if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons: if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons:
self.file_explorer.select_drive() # Select the focused drive self.file_explorer.select_drive() # Select the focused drive
elif self.file_explorer.file_list.count() == 0: elif self.file_explorer.file_list.count() == 0:
logger.debug("File list is empty")
return return
else: else:
selected = self.file_explorer.file_list.currentItem().text() selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected) full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path): if os.path.isdir(full_path):
# Открываем директорию
self.file_explorer.current_path = os.path.normpath(full_path) self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list() self.file_explorer.update_file_list()
elif not self.file_explorer.directory_only: elif not self.file_explorer.directory_only:
# Выбираем файл, если directory_only=False
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept() self.file_explorer.accept()
else: else:
logger.debug("Selected item is not a directory, cannot select: %s", full_path) logger.debug("Selected item is not a directory, cannot select: %s", full_path)
elif button_code in BUTTONS['context_menu']: # Start button (BTN_START)
if self.file_explorer.file_list.count() == 0:
logger.debug("File list is empty, cannot show context menu")
return
current_item = self.file_explorer.file_list.currentItem()
if current_item:
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
pos = item_rect.center() # Use local coordinates for itemAt check
self.file_explorer.show_folder_context_menu(pos)
else:
logger.debug("No item selected for context menu")
elif button_code in BUTTONS['add_game']: # X button elif button_code in BUTTONS['add_game']: # X button
if self.file_explorer.file_list.count() == 0: if self.file_explorer.file_list.count() == 0:
logger.debug("File list is empty")
return return
selected = self.file_explorer.file_list.currentItem().text() selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected) full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path): if os.path.isdir(full_path):
# Подтверждаем выбор директории
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept() self.file_explorer.accept()
else: else:
@@ -202,12 +267,29 @@ class InputManager(QObject):
if self.original_button_handler: if self.original_button_handler:
self.original_button_handler(button_code) self.original_button_handler(button_code)
except Exception as e: except Exception as e:
logger.error(f"Error in FileExplorer button handler: {e}") logger.error("Error in FileExplorer button handler: %s", e)
def handle_file_explorer_dpad(self, code, value, current_time): def handle_file_explorer_dpad(self, code, value, current_time):
"""Обработка движения D-pad и левого стика для FileExplorer""" """Обработка движения D-pad и левого стика для FileExplorer"""
try: try:
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if code == ecodes.ABS_HAT0Y and value != 0:
actions = popup.actions()
if not actions:
return
current_action = popup.activeAction()
current_idx = actions.index(current_action) if current_action in actions else -1
if value > 0: # Down
next_idx = (current_idx + 1) % len(actions) if current_idx != -1 else 0
popup.setActiveAction(actions[next_idx])
elif value < 0: # Up
next_idx = (current_idx - 1) % len(actions) if current_idx != -1 else len(actions) - 1
popup.setActiveAction(actions[next_idx])
return # Skip other handling if menu is open
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list: if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
logger.debug("No file explorer or file_list available")
return return
focused_widget = QApplication.focusWidget() focused_widget = QApplication.focusWidget()
@@ -216,14 +298,17 @@ class InputManager(QObject):
if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons: if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons:
# If not focused on a drive button, focus the first one # If not focused on a drive button, focus the first one
self.file_explorer.drive_buttons[0].setFocus() self.file_explorer.drive_buttons[0].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
return return
current_idx = self.file_explorer.drive_buttons.index(focused_widget) current_idx = self.file_explorer.drive_buttons.index(focused_widget)
if value < 0: # Left if value < 0: # Left
next_idx = max(current_idx - 1, 0) next_idx = max(current_idx - 1, 0)
self.file_explorer.drive_buttons[next_idx].setFocus() self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
elif value > 0: # Right elif value > 0: # Right
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1) next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
self.file_explorer.drive_buttons[next_idx].setFocus() self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y): elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons: if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
# Move focus to file list if navigating down from drive buttons # Move focus to file list if navigating down from drive buttons
@@ -264,7 +349,7 @@ class InputManager(QObject):
elif self.original_dpad_handler: elif self.original_dpad_handler:
self.original_dpad_handler(code, value, current_time) self.original_dpad_handler(code, value, current_time)
except Exception as e: except Exception as e:
logger.error(f"Error in FileExplorer dpad handler: {e}") logger.error("Error in FileExplorer dpad handler: %s", e)
def handle_navigation_repeat(self): def handle_navigation_repeat(self):
"""Плавное повторение движения с переменной скоростью для FileExplorer""" """Плавное повторение движения с переменной скоростью для FileExplorer"""
@@ -361,17 +446,14 @@ class InputManager(QObject):
if not self._gamepad_handling_enabled: if not self._gamepad_handling_enabled:
return return
try: try:
# Ignore gamepad events if a game is launched
if getattr(self._parent, '_gameLaunched', False):
return
app = QApplication.instance() app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget() popup = QApplication.activePopupWidget()
modal_dialog = QApplication.activeModalWidget() modal_dialog = QApplication.activeModalWidget()
if not app or not active:
return
# Handle Guide button to open system overlay # Handle Guide button to open system overlay
if button_code in BUTTONS['guide']: if button_code in BUTTONS['guide']:
@@ -516,16 +598,13 @@ class InputManager(QObject):
if not self._gamepad_handling_enabled: if not self._gamepad_handling_enabled:
return return
try: try:
# Ignore gamepad events if a game is launched
if getattr(self._parent, '_gameLaunched', False):
return
app = QApplication.instance() app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget() popup = QApplication.activePopupWidget()
if not app or not active:
return
# Update D-pad state # Update D-pad state
if value != 0: if value != 0:
@@ -630,87 +709,107 @@ class InputManager(QObject):
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
return return
# Group cards by rows based on y-coordinate cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
if not cards:
return
# Group cards by rows with tolerance for y-position
rows = {} rows = {}
for card in game_cards: y_tolerance = 10 # Allow slight variations in y-position
for card in cards:
y = card.pos().y() y = card.pos().y()
if y not in rows: matched = False
rows[y] = [] for row_y in rows:
rows[y].append(card) if abs(y - row_y) <= y_tolerance:
# Sort cards in each row by x-coordinate rows[row_y].append(card)
for y in rows: matched = True
rows[y].sort(key=lambda c: c.pos().x()) break
# Sort rows by y-coordinate if not matched:
rows[y] = [card]
sorted_rows = sorted(rows.items(), key=lambda x: x[0]) sorted_rows = sorted(rows.items(), key=lambda x: x[0])
if not sorted_rows:
return
current_row_idx = None
current_col_idx = None
for row_idx, (_y, row_cards) in enumerate(sorted_rows):
for idx, card in enumerate(row_cards):
if card == focused:
current_row_idx = row_idx
current_col_idx = idx
break
if current_row_idx is not None:
break
# Fallback: if focused card not found, select closest row by y-position
if current_row_idx is None:
if not sorted_rows: # Additional safety check
return
focused_y = focused.pos().y()
current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y))
if current_row_idx >= len(sorted_rows): # Safety check
return
current_row = sorted_rows[current_row_idx][1]
focused_x = focused.pos().x() + focused.width() / 2
current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore
# Add null checks before using current_row_idx and current_col_idx
if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
return
# Find current row and column
current_y = focused.pos().y()
current_row_idx = next(i for i, (y, _) in enumerate(sorted_rows) if y == current_y)
current_row = sorted_rows[current_row_idx][1] current_row = sorted_rows[current_row_idx][1]
current_col_idx = current_row.index(focused) if code == ecodes.ABS_HAT0X and value != 0:
if code == ecodes.ABS_HAT0X and value != 0: # Left/Right
if value < 0: # Left if value < 0: # Left
next_col_idx = current_col_idx - 1 if current_col_idx > 0:
if next_col_idx >= 0: next_card = current_row[current_col_idx - 1]
next_card = current_row[next_col_idx] next_card.setFocus(Qt.FocusReason.OtherFocusReason)
next_card.setFocus()
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
else: else:
# Move to the last card of the previous row if available
if current_row_idx > 0: if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1] prev_row = sorted_rows[current_row_idx - 1][1]
next_card = prev_row[-1] if prev_row else None next_card = prev_row[-1] if prev_row else None
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value > 0: # Right elif value > 0: # Right
next_col_idx = current_col_idx + 1 if current_col_idx < len(current_row) - 1:
if next_col_idx < len(current_row): next_card = current_row[current_col_idx + 1]
next_card = current_row[next_col_idx] next_card.setFocus(Qt.FocusReason.OtherFocusReason)
next_card.setFocus()
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
else: else:
# Move to the first card of the next row if available
if current_row_idx < len(sorted_rows) - 1: if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1] next_row = sorted_rows[current_row_idx + 1][1]
next_card = next_row[0] if next_row else None next_card = next_row[0] if next_row else None
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down elif code == ecodes.ABS_HAT0Y and value != 0:
if value > 0: # Down if value > 0: # Down
next_row_idx = current_row_idx + 1 if current_row_idx < len(sorted_rows) - 1:
if next_row_idx < len(sorted_rows): next_row = sorted_rows[current_row_idx + 1][1]
next_row = sorted_rows[next_row_idx][1] current_x = focused.pos().x() + focused.width() / 2
# Find card in same column or closest
target_x = focused.pos().x()
next_card = min( next_card = min(
next_row, next_row,
key=lambda c: abs(c.pos().x() - target_x), key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None default=None
) )
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value < 0: # Up elif value < 0: # Up
next_row_idx = current_row_idx - 1 if current_row_idx > 0:
if next_row_idx >= 0: prev_row = sorted_rows[current_row_idx - 1][1]
next_row = sorted_rows[next_row_idx][1] current_x = focused.pos().x() + focused.width() / 2
# Find card in same column or closest
target_x = focused.pos().x()
next_card = min( next_card = min(
next_row, prev_row,
key=lambda c: abs(c.pos().x() - target_x), key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None default=None
) )
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif current_row_idx == 0: elif current_row_idx == 0:
@@ -742,6 +841,25 @@ class InputManager(QObject):
if not app: if not app:
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
if event.type() == QEvent.Type.MouseButtonPress:
mouse_event = cast(QMouseEvent, event)
if mouse_event.button() == Qt.MouseButton.ExtraButton1:
# Handle ExtraButton1 as "back" action, similar to Escape
active_win = QApplication.activeWindow()
focused = QApplication.focusWidget()
if isinstance(focused, QLineEdit):
return False # Skip if in QLineEdit
if isinstance(active_win, QDialog):
active_win.reject()
return True
self._parent.goBackDetailPage(self._parent.currentDetailPage)
return True
# Ensure obj is a QObject
if not isinstance(obj, QObject):
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
return False
# Handle key press and release events # Handle key press and release events
if not isinstance(event, QKeyEvent): if not isinstance(event, QKeyEvent):
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
@@ -754,6 +872,62 @@ class InputManager(QObject):
# Handle key press events # Handle key press events
if event.type() == QEvent.Type.KeyPress: if event.type() == QEvent.Type.KeyPress:
# Handle FileExplorer specific logic
if self.file_explorer:
# Handle drive buttons in FileExplorer
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if isinstance(focused, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused in self.file_explorer.drive_buttons:
self.file_explorer.select_drive()
return True
elif isinstance(focused, QListWidget) and focused == self.file_explorer.file_list:
current_item = focused.currentItem()
if current_item:
selected = current_item.text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
if selected == "../":
self.file_explorer.previous_dir()
else:
self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list()
elif not self.file_explorer.directory_only:
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
return True
else:
self._parent.activateFocusedWidget()
return True
# Handle FileExplorer navigation with right arrow key
if key == Qt.Key.Key_Right:
try:
if hasattr(self.file_explorer, 'drive_buttons') and self.file_explorer.drive_buttons:
if not isinstance(focused, AutoSizeButton) or focused not in self.file_explorer.drive_buttons:
self.file_explorer.drive_buttons[0].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
else:
current_idx = self.file_explorer.drive_buttons.index(focused)
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
return True
except Exception as e:
logger.error(f"Error handling right arrow in FileExplorer: {e}")
return True
# Handle Backspace for FileExplorer navigation
if key == Qt.Key.Key_Backspace:
self.file_explorer.previous_dir()
return True
# Handle QLineEdit cursor movement with Left/Right arrows
if isinstance(focused, QLineEdit) and key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
if key == Qt.Key.Key_Left:
focused.cursorBackward(False, 1) # Move cursor left by one character
elif key == Qt.Key.Key_Right:
focused.cursorForward(False, 1) # Move cursor right by one character
return True # Consume the event to prevent further processing
# Open system overlay with Insert # Open system overlay with Insert
if key == Qt.Key.Key_Insert: if key == Qt.Key.Key_Insert:
if not popup and not isinstance(active_win, QDialog): if not popup and not isinstance(active_win, QDialog):
@@ -765,11 +939,19 @@ class InputManager(QObject):
app.quit() app.quit()
return True return True
# Close AddGameDialog with Escape # Handle Backspace for FileExplorer navigation (move to parent directory)
if key == Qt.Key.Key_Escape and isinstance(popup, QDialog): if key == Qt.Key.Key_Backspace and self.file_explorer:
popup.reject() self.file_explorer.previous_dir()
return True return True
# Close Dialogs with Escape
if key == Qt.Key.Key_Escape:
if isinstance(focused, QLineEdit):
return False
if isinstance(active_win, QDialog):
active_win.reject()
return True
# FullscreenDialog navigation # FullscreenDialog navigation
if isinstance(active_win, FullscreenDialog): if isinstance(active_win, FullscreenDialog):
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace): if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
@@ -783,8 +965,8 @@ class InputManager(QObject):
active_win.show_next() active_win.show_next()
return True # Consume event to prevent tab switching return True # Consume event to prevent tab switching
# Handle tab switching with Left/Right arrow keys when not in GameCard focus # Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard) or focused is None): if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and not isinstance(focused, GameCard | QLineEdit) and not self.file_explorer:
idx = self._parent.stackedWidget.currentIndex() idx = self._parent.stackedWidget.currentIndex()
total = len(self._parent.tabButtons) total = len(self._parent.tabButtons)
if key == Qt.Key.Key_Left: if key == Qt.Key.Key_Left:
@@ -911,6 +1093,8 @@ class InputManager(QObject):
new_gamepad = self.find_gamepad() new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad: if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}") logger.info(f"Gamepad connected: {new_gamepad.name}")
self.detect_gamepad_type(new_gamepad)
logger.info(f"Detected gamepad type: {self.gamepad_type.value}")
self.stop_rumble() self.stop_rumble()
self.gamepad = new_gamepad self.gamepad = new_gamepad
if self.gamepad_thread: if self.gamepad_thread:
@@ -929,6 +1113,10 @@ class InputManager(QObject):
try: try:
devices = [InputDevice(path) for path in list_devices()] devices = [InputDevice(path) for path in list_devices()]
for device in devices: for device in devices:
# Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2)
if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
logger.debug(f"Skipping ASRock LED controller: {device.name}")
continue
caps = device.capabilities() caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
return device return device
@@ -947,6 +1135,13 @@ class InputManager(QObject):
if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS): if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
continue continue
now = time.time() now = time.time()
# Проверка фокуса: игнорируем события, если окно не в фокусе
app = QApplication.instance()
active = QApplication.activeWindow()
if not app or not active:
continue
if event.type == ecodes.EV_KEY and event.value == 1: if event.type == ecodes.EV_KEY and event.value == 1:
if event.code in BUTTONS['menu'] and not self._is_gamescope_session: if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen) self.toggle_fullscreen.emit(not self._is_fullscreen)
@@ -999,5 +1194,7 @@ class InputManager(QObject):
self.gamepad_thread.join() self.gamepad_thread.join()
if self.gamepad: if self.gamepad:
self.gamepad.close() self.gamepad.close()
self.gamepad = None
self.gamepad_type = GamepadType.UNKNOWN
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True) logger.error(f"Error during cleanup: {e}", exc_info=True)

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -23,13 +23,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr "" msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
@@ -38,6 +32,15 @@ msgstr ""
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -65,9 +68,6 @@ msgstr ""
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "" msgstr ""
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -163,25 +163,13 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr ""
#, python-brace-format
msgid "Executable not found: {path}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr ""
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "" msgstr ""
@@ -260,15 +248,26 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Select" #, python-brace-format
msgid "Launching {0}"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game" msgid "Edit Game"
msgstr "" msgstr ""
@@ -365,6 +364,12 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -455,21 +460,6 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -479,28 +469,6 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -554,9 +522,6 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -660,3 +625,24 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -23,13 +23,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr "" msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
@@ -38,6 +32,15 @@ msgstr ""
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -65,9 +68,6 @@ msgstr ""
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "" msgstr ""
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -163,25 +163,13 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr ""
#, python-brace-format
msgid "Executable not found: {path}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr ""
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "" msgstr ""
@@ -260,15 +248,26 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Select" #, python-brace-format
msgid "Launching {0}"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game" msgid "Edit Game"
msgstr "" msgstr ""
@@ -365,6 +364,12 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -455,21 +460,6 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -479,28 +469,6 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -554,9 +522,6 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -660,3 +625,24 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -21,13 +21,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr "" msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
@@ -36,6 +30,15 @@ msgstr ""
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -63,9 +66,6 @@ msgstr ""
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "" msgstr ""
@@ -153,7 +153,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -161,25 +161,13 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr ""
#, python-brace-format
msgid "Executable not found: {path}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr ""
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "" msgstr ""
@@ -258,15 +246,26 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Select" #, python-brace-format
msgid "Launching {0}"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game" msgid "Edit Game"
msgstr "" msgstr ""
@@ -363,6 +362,12 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -453,21 +458,6 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -477,28 +467,6 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -552,9 +520,6 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -658,3 +623,24 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,29 +9,24 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: 2025-07-14 13:16+0500\n" "PO-Revision-Date: 2025-09-23 22:23+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "Language: ru_RU\n"
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 "
"&& (n%100<10 || n%100>=20) ? 1 : 2);\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
"X-Generator: Poedit 3.6\n"
msgid "Error" msgid "Error"
msgstr "Ошибка" msgstr "Ошибка"
msgid "PortProton is not found" msgid "PortProton directory not found"
msgstr "PortProton не найден" msgstr "Не найден каталог PortProton"
msgid "Stop Game"
msgstr "Остановить игру"
msgid "Launch Game"
msgstr "Запустить игру"
msgid "Remove from Favorites" msgid "Remove from Favorites"
msgstr "Удалить из Избранного" msgstr "Удалить из Избранного"
@@ -39,6 +34,15 @@ msgstr "Удалить из Избранного"
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "Добавить в Избранное" msgstr "Добавить в Избранное"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
msgid "Stop Game"
msgstr "Остановить игру"
msgid "Launch Game"
msgstr "Запустить игру"
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "Импортировать игру" msgstr "Импортировать игру"
@@ -66,9 +70,6 @@ msgstr "Добавить в меню"
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "Редактировать" msgstr "Редактировать"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "Остановлен(а) '{game_name}'" msgstr "Остановлен(а) '{game_name}'"
@@ -86,11 +87,11 @@ msgstr "Успешно"
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"'{game_name}' was added to Steam. Please restart Steam for changes to " "'{game_name}' was added to Steam. Please restart Steam for changes to take "
"take effect." "effect."
msgstr "" msgstr ""
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите " "'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, "
"Steam, чтобы изменения вступили в силу." "чтобы изменения вступили в силу."
#, python-brace-format #, python-brace-format
msgid "Executable not found for game: {game_name}" msgid "Executable not found for game: {game_name}"
@@ -158,43 +159,31 @@ msgid "Menu"
msgstr "Меню" msgstr "Меню"
#, python-brace-format #, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'" msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда" msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
#, python-brace-format #, python-brace-format
msgid "Failed to parse .desktop file for '{game_name}'" msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "Не удалось разобрать файл .desktop для '{game_name}'" msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
#, python-brace-format #, python-brace-format
msgid "Failed to read .desktop file: {error}" msgid "Error reading .desktop file: {error}"
msgstr "Не удалось прочитать файл .desktop: {error}" msgstr "Ошибка при чтении файла .desktop: {error}"
#, python-brace-format #, python-brace-format
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "Файл .desktop для '{game_name}' не найден" msgstr "Файл .desktop для '{game_name}' не найден"
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr "Недопустимая исполняемая команда: {exec_line}"
#, python-brace-format
msgid "Executable not found: {path}"
msgstr "Исполняемый файл не найден: {path}"
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr "Не удалось разобрать исполняемый файл: {error}"
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "Подтвердите удаление" msgstr "Подтвердите удаление"
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"Are you sure you want to delete '{game_name}'? This will remove the " "Are you sure you want to delete '{game_name}'? This will remove the .desktop "
".desktop file and custom data." "file and custom data."
msgstr "" msgstr ""
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению " "Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению файла ."
"файла .desktop и пользовательских данных." "desktop и пользовательских данных."
#, python-brace-format #, python-brace-format
msgid "Failed to delete .desktop file: {error}" msgid "Failed to delete .desktop file: {error}"
@@ -236,11 +225,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"'{game_name}' was removed from Steam. Please restart Steam for changes to" "'{game_name}' was removed from Steam. Please restart Steam for changes to take "
" take effect." "effect."
msgstr "" msgstr ""
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam," "'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы "
" чтобы изменения вступили в силу." "изменения вступили в силу."
#, python-brace-format #, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}" msgid "Failed to remove game '{game_name}' from Steam: {error}"
@@ -267,15 +256,26 @@ msgstr "Удалить"
msgid "Select All" msgid "Select All"
msgstr "Выбрать всё" msgstr "Выбрать всё"
msgid "Select" #, python-brace-format
msgstr "Выбрать" msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "Cancel" msgid "Cancel"
msgstr "Отмена" msgstr "Отмена"
msgid "File Explorer"
msgstr "Проводник"
msgid "Select"
msgstr "Выбрать"
msgid "Path: " msgid "Path: "
msgstr "Путь: " msgstr "Путь: "
#, python-format
msgid "Access denied: %s"
msgstr "Доступ запрещен: %s"
msgid "Edit Game" msgid "Edit Game"
msgstr "Редактировать игру" msgstr "Редактировать игру"
@@ -372,6 +372,12 @@ msgstr "Настройки PortProton"
msgid "Themes" msgid "Themes"
msgstr "Темы" msgstr "Темы"
msgid "Back"
msgstr "Назад"
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..." msgstr "Загрузка игр из Steam..."
@@ -462,21 +468,6 @@ msgstr "Тактильная отдача на геймпаде"
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "Тактильная отдача на геймпаде:" msgstr "Тактильная отдача на геймпаде:"
msgid "Open Legendary Login"
msgstr "Открыть браузер для входа в Legendary"
msgid "Legendary Authentication:"
msgstr "Авторизация в Legendary:"
msgid "Enter Legendary Authorization Code"
msgstr "Введите код авторизации Legendary"
msgid "Authorization Code:"
msgstr "Код авторизации:"
msgid "Submit Code"
msgstr "Отправить код"
msgid "Save Settings" msgid "Save Settings"
msgstr "Сохранить настройки" msgstr "Сохранить настройки"
@@ -486,35 +477,12 @@ msgstr "Сбросить настройки"
msgid "Clear Cache" msgid "Clear Cache"
msgstr "Очистить кэш" msgstr "Очистить кэш"
msgid "Opened Legendary login page in browser"
msgstr "Открытие страницы входа в Legendary в браузере"
msgid "Failed to open Legendary login page"
msgstr "Не удалось открыть страницу входа в Legendary"
msgid "Please enter an authorization code"
msgstr "Пожалуйста, введите код авторизации"
msgid "Successfully authenticated with Legendary"
msgstr "Успешная аутентификация в Legendary"
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr "Не удалось выполнить аутентификацию Legendary: {0}"
msgid "Legendary executable not found"
msgstr "Не найден исполняемый файл Legendary"
msgid "Unexpected error during authentication"
msgstr "Неожиданная ошибка при аутентификации"
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "Подтвердите удаление" msgstr "Подтвердите удаление"
msgid "Are you sure you want to reset all settings? This action cannot be undone." msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr "" msgstr ""
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя " "Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
"отменить."
msgid "Settings reset. Restarting..." msgid "Settings reset. Restarting..."
msgstr "Настройки сброшены. Перезапуск..." msgstr "Настройки сброшены. Перезапуск..."
@@ -563,9 +531,6 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'" msgstr "Ошибка при применение темы '{0}'"
msgid "Back"
msgstr "Назад"
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "Последний запуск" msgstr "Последний запуск"
@@ -669,3 +634,23 @@ msgstr "мин."
msgid "sec." msgid "sec."
msgstr "сек." msgstr "сек."
msgid "Show"
msgstr "Показать"
msgid "Favorites"
msgstr "Избранное"
msgid "Recent Games"
msgstr "Недавние"
msgid "Exit"
msgstr "Выход"
msgid "Hide"
msgstr "Скрыть"
msgid "No favorites"
msgstr "Нет избранных"
msgid "No recent games"
msgstr "Нет недавних игр"

View File

@@ -1,16 +1,34 @@
import logging import logging
def setup_logger(): def setup_logger(level='NOTSET'):
"""Настройка базовой конфигурации логирования.""" """Настройка базовой конфигурации логирования."""
logging.basicConfig( # Clear existing handlers to prevent duplicates
level=logging.INFO, root_logger = logging.getLogger()
format='[%(levelname)s] %(message)s', for handler in root_logger.handlers[:]:
handlers=[logging.StreamHandler()] root_logger.removeHandler(handler)
)
# Convert string level to logging level constant, map ALL to DEBUG
if level.upper() == 'ALL':
log_level = logging.DEBUG
else:
log_level = getattr(logging, level.upper(), logging.NOTSET)
# Configure logging with null handler if level is NOTSET
if log_level == logging.NOTSET:
logging.basicConfig(
level=logging.NOTSET,
handlers=[logging.NullHandler()]
)
else:
logging.basicConfig(
level=log_level,
format='[%(levelname)s] %(message)s',
handlers=[logging.StreamHandler()]
)
def get_logger(name): def get_logger(name):
"""Возвращает логгер для указанного модуля.""" """Возвращает логгер для указанного модуля."""
return logging.getLogger(name) return logging.getLogger(name)
# Инициализация логгера при импорте модуля # Инициализация логгера при импорте модуля (без логов по умолчанию)
setup_logger() setup_logger()

View File

@@ -4,22 +4,23 @@ import shutil
import signal import signal
import subprocess import subprocess
import sys import sys
import portprotonqt.themes.standart.styles as default_styles
import psutil import psutil
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.game_card import GameCard from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from portprotonqt.system_overlay import SystemOverlay from portprotonqt.system_overlay import SystemOverlay
from portprotonqt.input_manager import GamepadType
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
from portprotonqt.egs_api import load_egs_games_async, get_egs_executable from portprotonqt.egs_api import load_egs_games_async, get_egs_executable
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
from portprotonqt.config_utils import ( from portprotonqt.config_utils import (
get_portproton_location, read_theme_from_config, save_theme_to_config, parse_desktop_entry, get_portproton_location, read_theme_from_config, save_theme_to_config, parse_desktop_entry,
@@ -30,46 +31,38 @@ from portprotonqt.config_utils import (
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
) )
from portprotonqt.localization import _, get_egs_language, read_metadata_translations from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QGraphicsEffect, QGraphicsOpacityEffect, QApplication, QPushButton, QProgressBar, QCheckBox) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from PySide6.QtCore import Qt, QAbstractAnimation, QPropertyAnimation, QByteArray, QUrl, Signal, QTimer, Slot
from typing import cast from typing import cast
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from PySide6.QtWidgets import QSizePolicy
logger = get_logger(__name__) logger = get_logger(__name__)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Main window of PortProtonQt.""" """Main window of PortProtonQt."""
settings_saved = Signal()
games_loaded = Signal(list) games_loaded = Signal(list)
update_progress = Signal(int) # Signal to update progress bar update_progress = Signal(int) # Signal to update progress bar
update_status_message = Signal(str, int) # Signal to update status message update_status_message = Signal(str, int) # Signal to update status message
def __init__(self): def __init__(self, app_name: str):
super().__init__() super().__init__()
# Создаём менеджер тем и читаем, какая тема выбрана # Создаём менеджер тем и читаем, какая тема выбрана
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.is_exiting = False
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme self.current_theme_name = selected_theme
try: self.theme = self.theme_manager.apply_theme(selected_theme)
self.theme = self.theme_manager.apply_theme(selected_theme) self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
except FileNotFoundError:
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
self.theme = self.theme_manager.apply_theme("standart")
self.current_theme_name = "standart"
save_theme_to_config("standart")
if not self.theme:
self.theme = default_styles
self.card_width = read_card_size() self.card_width = read_card_size()
self.setWindowTitle("PortProtonQt") self.setWindowTitle(app_name)
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
self.games = [] self.games = []
@@ -149,32 +142,26 @@ class MainWindow(QMainWindow):
self.header.setStyleSheet(self.theme.MAIN_WINDOW_HEADER_STYLE) self.header.setStyleSheet(self.theme.MAIN_WINDOW_HEADER_STYLE)
headerLayout = QVBoxLayout(self.header) headerLayout = QVBoxLayout(self.header)
headerLayout.setContentsMargins(0, 0, 0, 0) headerLayout.setContentsMargins(0, 0, 0, 0)
# Текст "PortProton" слева
self.titleLabel = QLabel()
pixmap = load_logo()
if pixmap is None:
width, height = self.theme.pixmapsScaledSize
pixmap = QPixmap(width, height)
pixmap.fill(QColor(0, 0, 0, 0))
width, height = self.theme.pixmapsScaledSize
scaled_pixmap = pixmap.scaled(width, height,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
self.titleLabel.setPixmap(scaled_pixmap)
self.titleLabel.setFixedSize(scaled_pixmap.size())
self.titleLabel.setStyleSheet(self.theme.TITLE_LABEL_STYLE)
headerLayout.addStretch() headerLayout.addStretch()
self.input_manager = InputManager(self)
self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК) # 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
self.navWidget = QWidget() self.navWidget = QWidget()
self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE) self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE)
navLayout = QHBoxLayout(self.navWidget) navLayout = QHBoxLayout(self.navWidget)
navLayout.setContentsMargins(10, 0, 10, 0) navLayout.setContentsMargins(10, 0, 10, 0)
navLayout.setSpacing(0) navLayout.setSpacing(10)
navLayout.addWidget(self.titleLabel) # Left navigation button (key_left or button_lb)
self.leftNavButton = QLabel()
self.leftNavButton.setFixedSize(32, 32)
self.leftNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
navLayout.addWidget(self.leftNavButton)
# Вкладки
self.tabButtons = {} self.tabButtons = {}
tabs = [ tabs = [
_("Library"), _("Library"),
@@ -193,6 +180,16 @@ class MainWindow(QMainWindow):
self.tabButtons[i] = btn self.tabButtons[i] = btn
self.tabButtons[0].setChecked(True) self.tabButtons[0].setChecked(True)
# Right navigation button (key_right or button_rb)
self.rightNavButton = QLabel()
self.rightNavButton.setFixedSize(32, 32)
self.rightNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
navLayout.addWidget(self.rightNavButton)
# Initial update of navigation buttons based on input device
self.updateNavButtons()
mainLayout.addWidget(self.navWidget) mainLayout.addWidget(self.navWidget)
# 3. QStackedWidget (ВКЛАДКИ) # 3. QStackedWidget (ВКЛАДКИ)
@@ -207,9 +204,13 @@ class MainWindow(QMainWindow):
self.createPortProtonTab() # вкладка 4 self.createPortProtonTab() # вкладка 4
self.createThemeTab() # вкладка 5 self.createThemeTab() # вкладка 5
# Подсказки управления
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
self.restore_state() self.restore_state()
self.input_manager = InputManager(self) self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames) QTimer.singleShot(0, self.loadGames)
if read_fullscreen_config(): if read_fullscreen_config():
@@ -220,6 +221,212 @@ class MainWindow(QMainWindow):
self.resize(width, height) self.resize(width, height)
else: else:
self.showNormal() self.showNormal()
def get_button_icon(self, action: str, gtype: GamepadType) -> str:
"""Get the icon name for a specific action and gamepad type."""
mappings = {
'confirm': {
GamepadType.XBOX: "xbox_a",
GamepadType.PLAYSTATION: "ps_cross",
},
'back': {
GamepadType.XBOX: "xbox_b",
GamepadType.PLAYSTATION: "ps_circle",
},
'add_game': {
GamepadType.XBOX: "xbox_x",
GamepadType.PLAYSTATION: "ps_triangle",
},
'context_menu': {
GamepadType.XBOX: "xbox_start",
GamepadType.PLAYSTATION: "ps_options",
},
'menu': {
GamepadType.XBOX: "xbox_view",
GamepadType.PLAYSTATION: "ps_share",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def get_nav_icon(self, direction: str, gtype: GamepadType) -> str:
"""Get the icon name for navigation direction and gamepad type."""
if direction == 'left':
action = 'prev_tab'
else:
action = 'next_tab'
mappings = {
'prev_tab': {
GamepadType.XBOX: "xbox_lb",
GamepadType.PLAYSTATION: "ps_l1",
},
'next_tab': {
GamepadType.XBOX: "xbox_rb",
GamepadType.PLAYSTATION: "ps_r1",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def createControlHintsWidget(self) -> QWidget:
from portprotonqt.localization import _
"""Creates a widget displaying control hints for gamepad and keyboard."""
logger.debug("Creating control hints widget")
hintsWidget = QWidget()
hintsWidget.setStyleSheet(self.theme.STATUS_BAR_STYLE)
hintsLayout = QHBoxLayout(hintsWidget)
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
gamepad_actions = [
("confirm", _("Select")),
("back", _("Back")),
("add_game", _("Add Game")),
("context_menu", _("Menu")),
("menu", _("Fullscreen")),
]
keyboard_hints = [
("key_enter", _("Select")),
("key_backspace", _("Back")),
("key_e", _("Add Game")),
("key_context", _("Menu")),
("key_f11", _("Fullscreen")),
]
self.hintsLabels = []
def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
# иконка кнопки
icon_label = QLabel()
icon_label.setFixedSize(32, 32)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image(icon_name, self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and pixmap.load(str(candidate)):
break
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
layout.addWidget(icon_label)
# текст действия
text_label = QLabel(action_text)
text_label.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
layout.addWidget(text_label)
if is_gamepad:
container.setVisible(False)
self.hintsLabels.append((container, icon_label, action)) # Store action for dynamic update
else:
container.setVisible(True)
self.hintsLabels.append((container, icon_label, None)) # Keyboard, no action
hintsLayout.addWidget(container)
# Create gamepad hints
for action, text in gamepad_actions:
makeHint("placeholder", text, True, action) # Initial placeholder
# Create keyboard hints
for icon, text in keyboard_hints:
makeHint(icon, text, False)
hintsLayout.addStretch()
return hintsWidget
def updateNavButtons(self, *args) -> None:
"""Updates navigation buttons based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
gtype = self.input_manager.gamepad_type
logger.debug("Updating nav buttons, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
# Left navigation button
left_pix = QPixmap()
if is_gamepad_connected:
left_icon_name = self.get_nav_icon('left', gtype)
else:
left_icon_name = "key_left"
left_icon = self.theme_manager.get_theme_image(left_icon_name, self.current_theme_name)
if left_icon:
left_pix.load(str(left_icon))
if not left_pix.isNull():
self.leftNavButton.setPixmap(left_pix.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.leftNavButton.setVisible(True) # Always visible, icon changes
# Right navigation button
right_pix = QPixmap()
if is_gamepad_connected:
right_icon_name = self.get_nav_icon('right', gtype)
else:
right_icon_name = "key_right"
right_icon = self.theme_manager.get_theme_image(right_icon_name, self.current_theme_name)
if right_icon:
right_pix.load(str(right_icon))
if not right_pix.isNull():
self.rightNavButton.setPixmap(right_pix.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.rightNavButton.setVisible(True) # Always visible, icon changes
def updateControlHints(self, *args) -> None:
"""Updates control hints based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
gtype = self.input_manager.gamepad_type
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
for container, icon_label, action in self.hintsLabels:
if action in gamepad_actions: # Gamepad hint
if is_gamepad_connected:
container.setVisible(True)
# Update icon based on type
icon_name = self.get_button_icon(action, gtype)
icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
else: # Keyboard hint
container.setVisible(not is_gamepad_connected)
# Update navigation buttons
self.updateNavButtons()
@Slot(list) @Slot(list)
def on_games_loaded(self, games: list[tuple]): def on_games_loaded(self, games: list[tuple]):
self.games = games self.games = games
@@ -669,6 +876,8 @@ class MainWindow(QMainWindow):
sliderLayout = QHBoxLayout() sliderLayout = QHBoxLayout()
sliderLayout.addStretch() sliderLayout.addStretch()
# Слайдер
self.sizeSlider = QSlider(Qt.Orientation.Horizontal) self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200) self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250) self.sizeSlider.setMaximum(250)
@@ -679,6 +888,7 @@ class MainWindow(QMainWindow):
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.on_slider_released) self.sizeSlider.sliderReleased.connect(self.on_slider_released)
sliderLayout.addWidget(self.sizeSlider) sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout) layout.addLayout(sliderLayout)
def calculate_card_width(): def calculate_card_width():
@@ -698,6 +908,15 @@ class MainWindow(QMainWindow):
def resizeEvent(self, event): def resizeEvent(self, event):
super().resizeEvent(event) super().resizeEvent(event)
if hasattr(self, '_animations') and self._animations:
for widget, animation in list(self._animations.items()):
try:
if animation.state() == QAbstractAnimation.State.Running:
animation.stop()
widget.setWindowOpacity(1.0)
del self._animations[widget]
except RuntimeError:
del self._animations[widget]
if not hasattr(self, '_last_width'): if not hasattr(self, '_last_width'):
self._last_width = self.width() self._last_width = self.width()
if abs(self.width() - self._last_width) > 10: if abs(self.width() - self._last_width) > 10:
@@ -1122,36 +1341,36 @@ class MainWindow(QMainWindow):
self.gamepadRumbleCheckBox.setChecked(current_rumble_state) self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox) formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
# 8. Legendary Authentication # # 8. Legendary Authentication
self.legendaryAuthButton = AutoSizeButton( # self.legendaryAuthButton = AutoSizeButton(
_("Open Legendary Login"), # _("Open Legendary Login"),
icon=self.theme_manager.get_icon("login") # icon=self.theme_manager.get_icon("login")
) # )
self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) # self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin) # self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin)
self.legendaryAuthTitle = QLabel(_("Legendary Authentication:")) # self.legendaryAuthTitle = QLabel(_("Legendary Authentication:"))
self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) # self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) # self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton) # formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton)
#
self.legendaryCodeEdit = CustomLineEdit(self, theme=self.theme) # self.legendaryCodeEdit = CustomLineEdit(self, theme=self.theme)
self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code")) # self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) # self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryCodeTitle = QLabel(_("Authorization Code:")) # self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) # self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) # self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit) # formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
#
self.submitCodeButton = AutoSizeButton( # self.submitCodeButton = AutoSizeButton(
_("Submit Code"), # _("Submit Code"),
icon=self.theme_manager.get_icon("save") # icon=self.theme_manager.get_icon("save")
) # )
self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) # self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.submitCodeButton.clicked.connect(self.submitLegendaryCode) # self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
formLayout.addRow(QLabel(""), self.submitCodeButton) # formLayout.addRow(QLabel(""), self.submitCodeButton)
layout.addLayout(formLayout) layout.addLayout(formLayout)
@@ -1193,46 +1412,46 @@ class MainWindow(QMainWindow):
layout.addStretch(1) layout.addStretch(1)
self.stackedWidget.addWidget(self.portProtonWidget) self.stackedWidget.addWidget(self.portProtonWidget)
def openLegendaryLogin(self): # def openLegendaryLogin(self):
"""Opens the Legendary login page in the default web browser.""" # """Opens the Legendary login page in the default web browser."""
login_url = "https://legendary.gl/epiclogin" # login_url = "https://legendary.gl/epiclogin"
try: # try:
QDesktopServices.openUrl(QUrl(login_url)) # QDesktopServices.openUrl(QUrl(login_url))
self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000) # self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000)
except Exception as e: # except Exception as e:
logger.error(f"Failed to open Legendary login page: {e}") # logger.error(f"Failed to open Legendary login page: {e}")
self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000) # self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
#
def submitLegendaryCode(self): # def submitLegendaryCode(self):
"""Submits the Legendary authorization code using the legendary CLI.""" # """Submits the Legendary authorization code using the legendary CLI."""
auth_code = self.legendaryCodeEdit.text().strip() # auth_code = self.legendaryCodeEdit.text().strip()
if not auth_code: # if not auth_code:
QMessageBox.warning(self, _("Error"), _("Please enter an authorization code")) # QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
return # return
#
try: # try:
# Execute legendary auth command # # Execute legendary auth command
result = subprocess.run( # result = subprocess.run(
[self.legendary_path, "auth", "--code", auth_code], # [self.legendary_path, "auth", "--code", auth_code],
capture_output=True, # capture_output=True,
text=True, # text=True,
check=True # check=True
) # )
logger.info("Legendary authentication successful: %s", result.stdout) # logger.info("Legendary authentication successful: %s", result.stdout)
self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000) # self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
self.legendaryCodeEdit.clear() # self.legendaryCodeEdit.clear()
# Reload Epic Games Store games after successful authentication # # Reload Epic Games Store games after successful authentication
self.games = self.loadGames() # self.games = self.loadGames()
self.updateGameGrid() # self.updateGameGrid()
except subprocess.CalledProcessError as e: # except subprocess.CalledProcessError as e:
logger.error("Legendary authentication failed: %s", e.stderr) # logger.error("Legendary authentication failed: %s", e.stderr)
self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000) # self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
except FileNotFoundError: # except FileNotFoundError:
logger.error("Legendary executable not found at %s", self.legendary_path) # logger.error("Legendary executable not found at %s", self.legendary_path)
self.statusBar().showMessage(_("Legendary executable not found"), 5000) # self.statusBar().showMessage(_("Legendary executable not found"), 5000)
except Exception as e: # except Exception as e:
logger.error("Unexpected error during Legendary authentication: %s", str(e)) # logger.error("Unexpected error during Legendary authentication: %s", str(e))
self.statusBar().showMessage(_("Unexpected error during authentication"), 5000) # self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
def resetSettings(self): def resetSettings(self):
"""Сбрасывает настройки и перезапускает приложение.""" """Сбрасывает настройки и перезапускает приложение."""
@@ -1321,8 +1540,6 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.start() self.settingsDebounceTimer.start()
self.settings_saved.emit()
# Управление полноэкранным режимом # Управление полноэкранным режимом
gamepad_connected = self.input_manager.find_gamepad() is not None gamepad_connected = self.input_manager.find_gamepad() is not None
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected): if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
@@ -1517,27 +1734,48 @@ class MainWindow(QMainWindow):
detailPage = QWidget() detailPage = QWidget()
self._animations = {} self._animations = {}
imageLabel = QLabel() imageLabel = QLabel()
imageLabel.setFixedSize(300, 400) imageLabel.setFixedSize(300, 450)
self._detail_page_active = True self._detail_page_active = True
self._current_detail_page = detailPage self._current_detail_page = detailPage
if cover_path: # Функция загрузки изображения и обновления стилей
def on_pixmap_ready(pixmap): def load_image_and_restore_effect():
rounded = round_corners(pixmap, 10) if not detailPage or detailPage.isHidden():
imageLabel.setPixmap(rounded) logger.warning("Detail page is None or hidden, skipping image load")
return
def on_palette_ready(palette): detailPage.setWindowOpacity(1.0)
dark_palette = [self.darkenColor(color, factor=200) for color in palette]
stops = ",\n".join(
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
)
detailPage.setStyleSheet(self.theme.detail_page_style(stops))
self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready) if cover_path:
def on_pixmap_ready(pixmap):
if not detailPage or detailPage.isHidden():
logger.warning("Detail page is None or hidden, skipping pixmap update")
return
rounded = round_corners(pixmap, 10)
imageLabel.setPixmap(rounded)
logger.debug("Pixmap set for imageLabel")
load_pixmap_async(cover_path, 300, 400, on_pixmap_ready) def on_palette_ready(palette):
else: if not detailPage or detailPage.isHidden():
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE) logger.warning("Detail page is None or hidden, skipping palette update")
return
dark_palette = [self.darkenColor(color, factor=200) for color in palette]
stops = ",\n".join(
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
)
detailPage.setStyleSheet(self.theme.detail_page_style(stops))
detailPage.update()
logger.debug("Stylesheet updated with palette")
self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
else:
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
detailPage.update()
def cleanup_animation():
if detailPage in self._animations:
del self._animations[detailPage]
mainLayout = QVBoxLayout(detailPage) mainLayout = QVBoxLayout(detailPage)
mainLayout.setContentsMargins(30, 30, 30, 30) mainLayout.setContentsMargins(30, 30, 30, 30)
@@ -1558,7 +1796,7 @@ class MainWindow(QMainWindow):
# Обложка (слева) # Обложка (слева)
coverFrame = QFrame() coverFrame = QFrame()
coverFrame.setFixedSize(300, 400) coverFrame.setFixedSize(300, 450)
coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE) coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE)
shadow = QGraphicsDropShadowEffect(coverFrame) shadow = QGraphicsDropShadowEffect(coverFrame)
shadow.setBlurRadius(20) shadow.setBlurRadius(20)
@@ -1645,7 +1883,7 @@ class MainWindow(QMainWindow):
egsLabel.setVisible(egs_visible) egsLabel.setVisible(egs_visible)
# PortProton badge # PortProton badge
portproton_icon = self.theme_manager.get_icon("ppqt-tray") portproton_icon = self.theme_manager.get_icon("portproton")
portprotonLabel = ClickableLabel( portprotonLabel = ClickableLabel(
"PortProton", "PortProton",
icon=portproton_icon, icon=portproton_icon,
@@ -1880,17 +2118,7 @@ class MainWindow(QMainWindow):
self.current_play_button = playButton self.current_play_button = playButton
# Анимация # Анимация
opacityEffect = QGraphicsOpacityEffect(detailPage) self.detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
detailPage.setGraphicsEffect(opacityEffect)
animation = QPropertyAnimation(opacityEffect, QByteArray(b"opacity"))
animation.setDuration(800)
animation.setStartValue(0)
animation.setEndValue(1)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self._animations[detailPage] = animation
animation.finished.connect(
lambda: detailPage.setGraphicsEffect(cast(QGraphicsEffect, None))
)
def toggleFavoriteInDetailPage(self, game_name, label): def toggleFavoriteInDetailPage(self, game_name, label):
favorites = read_favorites() favorites = read_favorites()
@@ -1946,16 +2174,42 @@ class MainWindow(QMainWindow):
parent = parent.parent() parent = parent.parent()
def goBackDetailPage(self, page: QWidget | None) -> None: def goBackDetailPage(self, page: QWidget | None) -> None:
if page is None or page != self.stackedWidget.currentWidget(): if page is None or page != self.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False):
return return
self._exit_animation_in_progress = True
self._detail_page_active = False self._detail_page_active = False
self._current_detail_page = None self._current_detail_page = None
self.stackedWidget.setCurrentIndex(0)
self.stackedWidget.removeWidget(page) def cleanup():
page.deleteLater() """Helper function to clean up after animation."""
self.currentDetailPage = None try:
self.current_exec_line = None if page in self._animations:
self.current_play_button = None animation = self._animations[page]
try:
if animation.state() == QAbstractAnimation.State.Running:
animation.stop()
except RuntimeError:
pass # Animation already deleted
finally:
del self._animations[page]
self.stackedWidget.setCurrentIndex(0)
self.stackedWidget.removeWidget(page)
page.deleteLater()
self.currentDetailPage = None
self.current_exec_line = None
self.current_play_button = None
self._exit_animation_in_progress = False
except Exception as e:
logger.error(f"Error in cleanup: {e}", exc_info=True)
self._exit_animation_in_progress = False
# Start exit animation
try:
self.detail_animations.animate_detail_page_exit(page, cleanup)
except Exception as e:
logger.error(f"Error starting exit animation: {e}", exc_info=True)
self._exit_animation_in_progress = False
cleanup() # Fallback to cleanup if animation fails
def is_target_exe_running(self): def is_target_exe_running(self):
"""Проверяет, запущен ли процесс с именем self.target_exe через psutil.""" """Проверяет, запущен ли процесс с именем self.target_exe через psutil."""
@@ -1984,8 +2238,6 @@ class MainWindow(QMainWindow):
elif not child_running: elif not child_running:
# Игра завершилась сбрасываем флаг, сбрасываем кнопку и останавливаем таймер # Игра завершилась сбрасываем флаг, сбрасываем кнопку и останавливаем таймер
self._gameLaunched = False self._gameLaunched = False
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
self.resetPlayButton() self.resetPlayButton()
#self._uninhibit_screensaver() #self._uninhibit_screensaver()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None: if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
@@ -2041,9 +2293,6 @@ class MainWindow(QMainWindow):
# Проверяем, запущена ли игра # Проверяем, запущена ли игра
if self.game_processes and self.target_exe == current_exe: if self.game_processes and self.target_exe == current_exe:
# Останавливаем игру # Останавливаем игру
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
for proc in self.game_processes: for proc in self.game_processes:
try: try:
parent = psutil.Process(proc.pid) parent = psutil.Process(proc.pid)
@@ -2103,10 +2352,6 @@ class MainWindow(QMainWindow):
icon = QIcon() icon = QIcon()
update_button.setIcon(icon) update_button.setIcon(icon)
# Delay disabling gamepad handling
if hasattr(self, 'input_manager'):
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
self.checkProcessTimer = QTimer(self) self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe) self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(500) self.checkProcessTimer.start(500)
@@ -2144,9 +2389,6 @@ class MainWindow(QMainWindow):
# Если игра уже запущена для этого exe останавливаем её # Если игра уже запущена для этого exe останавливаем её
if self.game_processes and self.target_exe == current_exe: if self.game_processes and self.target_exe == current_exe:
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
for proc in self.game_processes: for proc in self.game_processes:
try: try:
parent = psutil.Process(proc.pid) parent = psutil.Process(proc.pid)
@@ -2194,10 +2436,6 @@ class MainWindow(QMainWindow):
env_vars['START_FROM_STEAM'] = '1' env_vars['START_FROM_STEAM'] = '1'
env_vars['PROCESS_LOG'] = '1' env_vars['PROCESS_LOG'] = '1'
# Delay disabling gamepad handling to allow rumble to complete
if hasattr(self, 'input_manager'):
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
# Запускаем игру # Запускаем игру
try: try:
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid) process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
@@ -2221,46 +2459,51 @@ class MainWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна.""" """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
for proc in self.game_processes: if hasattr(self, 'is_exiting') and self.is_exiting:
try: # Принудительное закрытие: завершаем процессы и приложение
parent = psutil.Process(proc.pid) for proc in self.game_processes:
children = parent.children(recursive=True) try:
for child in children: parent = psutil.Process(proc.pid)
try: children = parent.children(recursive=True)
logger.debug(f"Terminating child process {child.pid}") for child in children:
child.terminate() try:
except psutil.NoSuchProcess: logger.debug(f"Terminating child process {child.pid}")
logger.debug(f"Child process {child.pid} already terminated") child.terminate()
psutil.wait_procs(children, timeout=5) except psutil.NoSuchProcess:
for child in children: logger.debug(f"Child process {child.pid} already terminated")
if child.is_running(): psutil.wait_procs(children, timeout=5)
logger.debug(f"Killing child process {child.pid}") for child in children:
child.kill() if child.is_running():
logger.debug(f"Terminating process group {proc.pid}") logger.debug(f"Killing child process {child.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) child.kill()
except (psutil.NoSuchProcess, ProcessLookupError) as e: logger.debug(f"Terminating process group {proc.pid}")
logger.debug(f"Process {proc.pid} already terminated: {e}") os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {proc.pid} already terminated: {e}")
self.game_processes = [] # Очищаем список процессов self.game_processes = [] # Очищаем список процессов
# Сохраняем настройки окна # Очищаем таймеры
if not read_fullscreen_config(): if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") self.games_load_timer.stop()
save_window_geometry(self.width(), self.height()) if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
save_card_size(self.card_width) self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
# Очищаем таймеры и другие ресурсы # Сохраняем настройки окна
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive(): if not read_fullscreen_config():
self.games_load_timer.stop() logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive(): save_window_geometry(self.width(), self.height())
self.settingsDebounceTimer.stop() save_card_size(self.card_width)
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
QApplication.quit() event.accept()
event.accept() else:
# Сворачиваем в трей вместо закрытия
self.hide()
event.ignore()

View File

@@ -18,6 +18,11 @@ from collections.abc import Callable
import re import re
import shutil import shutil
import zlib import zlib
import websocket
import requests
import random
import base64
import glob
downloader = Downloader() downloader = Downloader()
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -40,14 +45,14 @@ def safe_vdf_load(path: str | Path) -> dict:
def decode_text(text: str) -> str: def decode_text(text: str) -> str:
""" """
Декодирует HTML-сущности в строке. Decodes HTML entities in a string.
Например, "&amp;quot;" преобразуется в '"'. For example, "&amp;quot;" is converted to '"'.
Остальные символы и HTML-теги остаются без изменений. Other characters and HTML tags remain unchanged.
""" """
return html.unescape(text) return html.unescape(text)
def get_cache_dir(): def get_cache_dir():
"""Возвращает путь к каталогу кэша, создаёт его при необходимости.""" """Returns the path to the cache directory, creating it if necessary."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
@@ -60,7 +65,7 @@ STEAM_DATA_DIRS = (
) )
def get_steam_home(): def get_steam_home():
"""Возвращает путь к директории Steam, используя список возможных директорий.""" """Returns the path to the Steam directory using a list of possible directories."""
for dir_path in STEAM_DATA_DIRS: for dir_path in STEAM_DATA_DIRS:
expanded_path = Path(os.path.expanduser(dir_path)) expanded_path = Path(os.path.expanduser(dir_path))
if expanded_path.exists(): if expanded_path.exists():
@@ -68,7 +73,7 @@ def get_steam_home():
return None return None
def get_last_steam_user(steam_home: Path) -> dict | None: def get_last_steam_user(steam_home: Path) -> dict | None:
"""Возвращает данные последнего пользователя Steam из loginusers.vdf.""" """Returns data for the last Steam user from loginusers.vdf."""
loginusers_path = steam_home / "config/loginusers.vdf" loginusers_path = steam_home / "config/loginusers.vdf"
data = safe_vdf_load(loginusers_path) data = safe_vdf_load(loginusers_path)
if not data: if not data:
@@ -79,20 +84,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
try: try:
return {'SteamID': int(user_id)} return {'SteamID': int(user_id)}
except ValueError: except ValueError:
logger.error(f"Неверный формат SteamID: {user_id}") logger.error(f"Invalid SteamID format: {user_id}")
return None return None
logger.info("Не найден пользователь с MostRecent=1") logger.info("No user found with MostRecent=1")
return None return None
def convert_steam_id(steam_id: int) -> int: def convert_steam_id(steam_id: int) -> int:
""" """
Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число. Converts a signed 32-bit integer to an unsigned 32-bit integer.
Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения. Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values.
""" """
return steam_id & 0xFFFFFFFF return steam_id & 0xFFFFFFFF
def get_steam_libs(steam_dir: Path) -> set[Path]: def get_steam_libs(steam_dir: Path) -> set[Path]:
"""Возвращает набор директорий Steam libraryfolders.""" """Returns a set of Steam library folders."""
libs = set() libs = set()
libs_vdf = steam_dir / "steamapps/libraryfolders.vdf" libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
data = safe_vdf_load(libs_vdf) data = safe_vdf_load(libs_vdf)
@@ -108,7 +113,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]:
return libs return libs
def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]: def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]:
"""Возвращает данные о времени игры для последнего пользователя.""" """Returns playtime data for the last user."""
play_data: dict[int, tuple[int, int]] = {} play_data: dict[int, tuple[int, int]] = {}
if steam_home is None: if steam_home is None:
steam_home = get_steam_home() steam_home = get_steam_home()
@@ -128,14 +133,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
return play_data return play_data
if not last_user: if not last_user:
logger.info("Не удалось определить последнего пользователя Steam") logger.info("Could not identify the last Steam user")
return play_data return play_data
user_id = last_user['SteamID'] user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
user_dir = userdata_dir / str(unsigned_id) user_dir = userdata_dir / str(unsigned_id)
if not user_dir.exists(): if not user_dir.exists():
logger.info(f"Директория пользователя {unsigned_id} не найдена") logger.info(f"User directory {unsigned_id} not found")
return play_data return play_data
localconfig = user_dir / "config/localconfig.vdf" localconfig = user_dir / "config/localconfig.vdf"
@@ -149,11 +154,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
playtime = int(info.get('Playtime', 0)) playtime = int(info.get('Playtime', 0))
play_data[appid] = (last_played, playtime) play_data[appid] = (last_played, playtime)
except ValueError: except ValueError:
logger.warning(f"Некорректные данные playtime для app {appid_str}") logger.warning(f"Invalid playtime data for app {appid_str}")
return play_data return play_data
def get_steam_installed_games() -> list[tuple[str, int, int, int]]: def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
"""Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec).""" """Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec)."""
games: list[tuple[str, int, int, int]] = [] games: list[tuple[str, int, int, int]] = []
steam_home = get_steam_home() steam_home = get_steam_home()
if steam_home is None or not steam_home.exists(): if steam_home is None or not steam_home.exists():
@@ -182,13 +187,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
def normalize_name(s): def normalize_name(s):
""" """
Приведение строки к нормальному виду: Normalizes a string by:
- перевод в нижний регистр, - converting to lowercase,
- удаление символов ™ и ®, - removing ™ and ® symbols,
- замена разделителей (-, :, ,) на пробел, - replacing separators (-, :, ,) with spaces,
- удаление лишних пробелов, - removing extra spaces,
- удаление суффиксов 'bin' или 'app' в конце строки, - removing 'bin' or 'app' suffixes,
- удаление ключевых слов типа 'ultimate', 'edition' и т.п. - removing keywords like 'ultimate', 'edition', etc.
""" """
s = s.lower() s = s.lower()
for ch in ["", "®"]: for ch in ["", "®"]:
@@ -206,12 +211,12 @@ def normalize_name(s):
def is_valid_candidate(candidate): def is_valid_candidate(candidate):
""" """
Проверяет, содержит ли кандидат запрещённые подстроки: Checks if a candidate contains forbidden substrings:
- win32 - win32
- win64 - win64
- gamelauncher - gamelauncher
Для проверки дополнительно используется строка без пробелов. Additionally checks the string without spaces.
Возвращает True, если кандидат допустим, иначе False. Returns True if the candidate is valid, otherwise False.
""" """
normalized_candidate = normalize_name(candidate) normalized_candidate = normalize_name(candidate)
normalized_no_space = normalized_candidate.replace(" ", "") normalized_no_space = normalized_candidate.replace(" ", "")
@@ -223,7 +228,7 @@ def is_valid_candidate(candidate):
def filter_candidates(candidates): def filter_candidates(candidates):
""" """
Фильтрует список кандидатов, отбрасывая недопустимые. Filters a list of candidates, discarding invalid ones.
""" """
valid = [] valid = []
dropped = [] dropped = []
@@ -233,18 +238,18 @@ def filter_candidates(candidates):
else: else:
dropped.append(cand) dropped.append(cand)
if dropped: if dropped:
logger.info("Отбрасываю кандидатов: %s", dropped) logger.info("Discarding candidates: %s", dropped)
return valid return valid
def remove_duplicates(candidates): def remove_duplicates(candidates):
""" """
Удаляет дубликаты из списка, сохраняя порядок. Removes duplicates from a list while preserving order.
""" """
return list(dict.fromkeys(candidates)) return list(dict.fromkeys(candidates))
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
def get_exiftool_data(game_exe): def get_exiftool_data(game_exe):
"""Получает метаданные через exiftool""" """Retrieves metadata using exiftool."""
try: try:
proc = subprocess.run( proc = subprocess.run(
["exiftool", "-j", game_exe], ["exiftool", "-j", game_exe],
@@ -253,18 +258,28 @@ def get_exiftool_data(game_exe):
check=False check=False
) )
if proc.returncode != 0: if proc.returncode != 0:
logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}") logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
return {} return {}
meta_data_list = orjson.loads(proc.stdout.encode("utf-8")) meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
return meta_data_list[0] if meta_data_list else {} return meta_data_list[0] if meta_data_list else {}
except Exception as e: except Exception as e:
logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}") logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
return {} return {}
def delete_cached_app_files(cache_dir: str, pattern: str):
"""Deletes cached files matching the given pattern in the cache directory."""
try:
for file_path in glob.glob(os.path.join(cache_dir, pattern)):
os.remove(file_path)
logger.info(f"Deleted cached file: {file_path}")
except Exception as e:
logger.error(f"Failed to delete cached files matching {pattern}: {e}")
def load_steam_apps_async(callback: Callable[[list], None]): def load_steam_apps_async(callback: Callable[[list], None]):
""" """
Asynchronously loads the list of Steam applications, using cache if available. Asynchronously loads the list of Steam applications, using cache if available.
Calls the callback with the list of apps. Calls the callback with the list of apps.
Deletes cached app detail files when downloading a new steam_apps.json.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "games_appid.tar.xz") cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
@@ -290,12 +305,14 @@ def load_steam_apps_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar) logger.info("Deleted archive: %s", cache_tar)
steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or [] # Delete all cached app detail files (steam_app_*.json)
delete_cached_app_files(cache_dir, "steam_app_*.json")
steam_apps = data if isinstance(data, list) else []
logger.info("Loaded %d apps from archive", len(steam_apps)) logger.info("Loaded %d apps from archive", len(steam_apps))
callback(steam_apps) callback(steam_apps)
except Exception as e: except Exception as e:
logger.error("Error extracting Steam apps archive: %s", e) logger.error("Failed to extract Steam apps archive: %s", e)
callback([]) callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -303,26 +320,43 @@ def load_steam_apps_async(callback: Callable[[list], None]):
try: try:
with open(cache_json, "rb") as f: with open(cache_json, "rb") as f:
data = orjson.loads(f.read()) data = orjson.loads(f.read())
steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or [] # Validate JSON structure
if not isinstance(data, list):
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure")
# Validate each app entry
for app in data:
if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json)
raise ValueError("Invalid app entry structure")
steam_apps = data
logger.info("Loaded %d apps from cache", len(steam_apps)) logger.info("Loaded %d apps from cache", len(steam_apps))
callback(steam_apps) callback(steam_apps)
except Exception as e: except Exception as e:
logger.error("Error reading cached JSON: %s", e) logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e)
callback([]) # Attempt to re-download if cache is invalid or corrupted
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
)
# Delete cached app detail files before re-downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else: else:
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
) )
# Delete cached app detail files before downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_index(steam_apps): def build_index(steam_apps):
""" """
Строит индекс приложений по полю normalized_name. Builds an index of applications by normalized_name field.
""" """
steam_apps_index = {} steam_apps_index = {}
if not steam_apps: if not steam_apps:
return steam_apps_index return steam_apps_index
logger.info("Построение индекса Steam приложений:") logger.info("Building Steam apps index")
for app in steam_apps: for app in steam_apps:
normalized = app["normalized_name"] normalized = app["normalized_name"]
steam_apps_index[normalized] = app steam_apps_index[normalized] = app
@@ -330,25 +364,24 @@ def build_index(steam_apps):
def search_app(candidate, steam_apps_index): def search_app(candidate, steam_apps_index):
""" """
Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку. Searches for an application by candidate: tries exact match first, then substring match.
""" """
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm) logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in steam_apps_index: if candidate_norm in steam_apps_index:
logger.info(" Найдено точное совпадение: '%s'", candidate_norm) logger.info("Found exact match: '%s'", candidate_norm)
return steam_apps_index[candidate_norm] return steam_apps_index[candidate_norm]
for name_norm, app in steam_apps_index.items(): for name_norm, app in steam_apps_index.items():
if candidate_norm in name_norm: if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm) ratio = len(candidate_norm) / len(name_norm)
if ratio > 0.8: if ratio > 0.8:
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)", logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
candidate_norm, name_norm, ratio)
return app return app
logger.info(" Приложение для кандидата '%s' не найдено", candidate_norm) logger.info("No app found for candidate '%s'", candidate_norm)
return None return None
def load_app_details(app_id): def load_app_details(app_id):
"""Загружает кэшированные данные для игры по appid, если они не устарели.""" """Loads cached game data by appid if not outdated."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
if os.path.exists(cache_file): if os.path.exists(cache_file):
@@ -358,7 +391,7 @@ def load_app_details(app_id):
return None return None
def save_app_details(app_id, data): def save_app_details(app_id, data):
"""Сохраняет данные по appid в файл кэша.""" """Saves appid data to a cache file."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
with open(cache_file, "wb") as f: with open(cache_file, "wb") as f:
@@ -401,7 +434,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
save_app_details(app_id, app_data) save_app_details(app_id, app_data)
callback(app_data) callback(app_data)
except Exception as e: except Exception as e:
logger.error("Error processing Steam app info for appid %s: %s", app_id, e) logger.error("Failed to process Steam app info for appid %s: %s", app_id, e)
callback(None) callback(None)
downloader.download_async(url, cache_file, timeout=5, callback=process_response) downloader.download_async(url, cache_file, timeout=5, callback=process_response)
@@ -410,6 +443,7 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
""" """
Asynchronously loads the list of WeAntiCheatYet data, using cache if available. Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
Calls the callback with the list of anti-cheat data. Calls the callback with the list of anti-cheat data.
Deletes cached anti-cheat files when downloading a new anticheat_games.json.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz") cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
@@ -435,12 +469,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar) logger.info("Deleted archive: %s", cache_tar)
anti_cheat_data = data or [] anti_cheat_data = data or []
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data)) logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
callback(anti_cheat_data) callback(anti_cheat_data)
except Exception as e: except Exception as e:
logger.error("Error extracting WeAntiCheatYet archive: %s", e) logger.error("Failed to extract WeAntiCheatYet archive: %s", e)
callback([]) callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -448,12 +482,25 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
try: try:
with open(cache_json, "rb") as f: with open(cache_json, "rb") as f:
data = orjson.loads(f.read()) data = orjson.loads(f.read())
anti_cheat_data = data or [] # Validate JSON structure
if not isinstance(data, list):
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure")
# Validate each anti-cheat entry
for entry in data:
if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json)
raise ValueError("Invalid anti-cheat entry structure")
anti_cheat_data = data
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data)) logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
callback(anti_cheat_data) callback(anti_cheat_data)
except Exception as e: except Exception as e:
logger.error("Error reading cached WeAntiCheatYet JSON: %s", e) logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e)
callback([]) # Attempt to re-download if cache is invalid or corrupted
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else: else:
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
@@ -462,12 +509,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
def build_weanticheatyet_index(anti_cheat_data): def build_weanticheatyet_index(anti_cheat_data):
""" """
Строит индекс античит-данных по полю normalized_name. Builds an index of anti-cheat data by normalized_name field.
""" """
anti_cheat_index = {} anti_cheat_index = {}
if not anti_cheat_data: if not anti_cheat_data:
return anti_cheat_index return anti_cheat_index
logger.info("Построение индекса WeAntiCheatYet данных:") logger.info("Building WeAntiCheatYet data index")
for entry in anti_cheat_data: for entry in anti_cheat_data:
normalized = entry["normalized_name"] normalized = entry["normalized_name"]
anti_cheat_index[normalized] = entry anti_cheat_index[normalized] = entry
@@ -475,20 +522,19 @@ def build_weanticheatyet_index(anti_cheat_data):
def search_anticheat_status(candidate, anti_cheat_index): def search_anticheat_status(candidate, anti_cheat_index):
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm) logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in anti_cheat_index: if candidate_norm in anti_cheat_index:
status = anti_cheat_index[candidate_norm]["status"] status = anti_cheat_index[candidate_norm]["status"]
logger.info(" Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status) logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
return status return status
for name_norm, entry in anti_cheat_index.items(): for name_norm, entry in anti_cheat_index.items():
if candidate_norm in name_norm: if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm) ratio = len(candidate_norm) / len(name_norm)
if ratio > 0.8: if ratio > 0.8:
status = entry["status"] status = entry["status"]
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'", logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
candidate_norm, name_norm, ratio, status)
return status return status
logger.info(" Античит-статус для кандидата '%s' не найден", candidate_norm) logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
return "" return ""
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]): def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
@@ -504,7 +550,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No
load_weanticheatyet_data_async(on_anticheat_data) load_weanticheatyet_data_async(on_anticheat_data)
def load_protondb_status(appid): def load_protondb_status(appid):
"""Загружает закешированные данные ProtonDB для игры по appid, если они не устарели.""" """Loads cached ProtonDB data for a game by appid if not outdated."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
if os.path.exists(cache_file): if os.path.exists(cache_file):
@@ -513,18 +559,18 @@ def load_protondb_status(appid):
with open(cache_file, "rb") as f: with open(cache_file, "rb") as f:
return orjson.loads(f.read()) return orjson.loads(f.read())
except Exception as e: except Exception as e:
logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e) logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e)
return None return None
def save_protondb_status(appid, data): def save_protondb_status(appid, data):
"""Сохраняет данные ProtonDB для игры по appid в файл кэша.""" """Saves ProtonDB data for a game by appid to a cache file."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
try: try:
with open(cache_file, "wb") as f: with open(cache_file, "wb") as f:
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
except Exception as e: except Exception as e:
logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e) logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e)
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]): def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
""" """
@@ -612,7 +658,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
if game_exe.lower().endswith('.exe'): if game_exe.lower().endswith('.exe'):
break break
except Exception as e: except Exception as e:
logger.error("Error processing bat file %s: %s", game_exe, e) logger.error("Failed to process bat file %s: %s", game_exe, e)
else: else:
logger.error("Bat file not found: %s", game_exe) logger.error("Bat file not found: %s", game_exe)
@@ -745,6 +791,126 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
load_steam_apps_async(on_steam_apps) load_steam_apps_async(on_steam_apps)
def enable_steam_cef() -> tuple[bool, str]:
"""
Checks and enables Steam CEF remote debugging if necessary.
Creates a .cef-enable-remote-debugging file in the Steam directory.
Steam must be restarted after the file is first created.
Returns a tuple:
- (True, "already_enabled") if already enabled.
- (True, "restart_needed") if just enabled and Steam restart is needed.
- (False, "steam_not_found") if Steam directory is not found.
"""
steam_home = get_steam_home()
if not steam_home:
return (False, "steam_not_found")
cef_flag_file = steam_home / ".cef-enable-remote-debugging"
logger.info(f"Checking CEF flag: {cef_flag_file}")
if cef_flag_file.exists():
logger.info("CEF Remote Debugging is already enabled")
return (True, "already_enabled")
else:
try:
os.makedirs(cef_flag_file.parent, exist_ok=True)
cef_flag_file.touch()
logger.info("Enabled CEF Remote Debugging. Steam restart required")
return (True, "restart_needed")
except Exception as e:
logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}")
return (False, str(e))
def call_steam_api(js_cmd: str, *args) -> dict | None:
"""
Executes a JavaScript function in the Steam context via CEF Remote Debugging.
Args:
js_cmd: Name of the JS function to call (e.g., 'createShortcut').
*args: Arguments to pass to the JS function.
Returns:
Dictionary with the result or None if an error occurs.
"""
status, message = enable_steam_cef()
if not (status is True and message == "already_enabled"):
if message == "restart_needed":
logger.warning("Steam CEF API is available but requires Steam restart for full activation")
elif message == "steam_not_found":
logger.error("Could not find Steam directory to check CEF API")
else:
logger.error(f"Steam CEF API is unavailable or not ready: {message}")
return None
steam_debug_url = "http://localhost:8080/json"
try:
response = requests.get(steam_debug_url, timeout=2)
response.raise_for_status()
contexts = response.json()
ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
if not ws_url:
logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?")
return None
except Exception as e:
logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}")
return None
js_code = """
async function createShortcut(name, exe, dir, icon, args) {
const id = await SteamClient.Apps.AddShortcut(name, exe, dir, args);
console.log("Shortcut created with ID:", id);
await SteamClient.Apps.SetShortcutName(id, name);
if (icon)
await SteamClient.Apps.SetShortcutIcon(id, icon);
if (args)
await SteamClient.Apps.SetAppLaunchOptions(id, args);
return { id };
};
async function setGrid(id, i, ext, image) {
await SteamClient.Apps.SetCustomArtworkForApp(id, image, ext, i);
return true;
};
async function removeShortcut(id) {
await SteamClient.Apps.RemoveShortcut(+id);
return true;
};
"""
try:
ws = websocket.create_connection(ws_url, timeout=5)
js_args = ", ".join(orjson.dumps(arg).decode('utf-8') for arg in args)
expression = f"{js_code} {js_cmd}({js_args});"
payload = {
"id": random.randint(0, 32767),
"method": "Runtime.evaluate",
"params": {
"expression": expression,
"awaitPromise": True,
"returnByValue": True
}
}
ws.send(orjson.dumps(payload))
response_str = ws.recv()
ws.close()
response_data = orjson.loads(response_str)
if "error" in response_data:
logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}")
return None
result = response_data.get('result', {}).get('result', {})
if result.get('type') == 'object' and result.get('subtype') == 'error':
logger.error(f"JavaScript execution error in Steam: {result.get('description')}")
return None
return result.get('value')
except Exception as e:
logger.error(f"WebSocket interaction error with Steam: {e}")
return None
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]: def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
""" """
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag, Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
@@ -819,24 +985,24 @@ export START_FROM_STEAM=1
else: else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True) success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path): if not success or not os.path.exists(generated_icon_path):
logger.warning(f"generate_thumbnail failed to create icon for {exe_path}") logger.warning(f"Failed to generate thumbnail for {exe_path}")
icon_path = "" icon_path = ""
else: else:
logger.info(f"Generated thumbnail: {generated_icon_path}") logger.info(f"Generated thumbnail: {generated_icon_path}")
icon_path = generated_icon_path icon_path = generated_icon_path
except Exception as e: except Exception as e:
logger.error(f"Error generating thumbnail for {exe_path}: {e}") logger.error(f"Failed to generate thumbnail for {exe_path}: {e}")
icon_path = "" icon_path = ""
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
logger.error("Steam home directory not found") logger.error("Steam home directory not found")
return (False, "Steam directory not found.") return (False, "Steam directory not found")
last_user = get_last_steam_user(steam_home) last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user: if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID") logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID.") return (False, "Failed to get Steam user ID")
userdata_dir = steam_home / "userdata" userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID'] user_id = last_user['SteamID']
@@ -846,45 +1012,42 @@ export START_FROM_STEAM=1
grid_dir = user_dir / "config" / "grid" grid_dir = user_dir / "config" / "grid"
os.makedirs(grid_dir, exist_ok=True) os.makedirs(grid_dir, exist_ok=True)
backup_path = f"{steam_shortcuts_path}.backup" appid = None
if os.path.exists(steam_shortcuts_path): was_api_used = False
try:
shutil.copy2(steam_shortcuts_path, backup_path)
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
return (False, f"Failed to create backup of shortcuts.vdf: {e}")
unique_string = f"{script_path}{game_name}" logger.info("Attempting to add shortcut via Steam CEF API")
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff api_response = call_steam_api(
appid = baseid | 0x80000000 "createShortcut",
if appid > 0x7FFFFFFF: game_name,
aidvdf = appid - 0x100000000 script_path,
str(Path(script_path).parent),
icon_path,
""
)
if api_response and isinstance(api_response, dict) and 'id' in api_response:
appid = api_response['id']
was_api_used = True
logger.info(f"Shortcut successfully added via API. AppID: {appid}")
else: else:
aidvdf = appid logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
backup_path = f"{steam_shortcuts_path}.backup"
if os.path.exists(steam_shortcuts_path):
try:
shutil.copy2(steam_shortcuts_path, backup_path)
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
return (False, f"Failed to create backup of shortcuts.vdf: {e}")
steam_appid = None unique_string = f"{script_path}{game_name}"
downloaded_count = 0 baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
total_covers = 4 # количество обложек appid = baseid | 0x80000000
if appid > 0x7FFFFFFF:
aidvdf = appid - 0x100000000
else:
aidvdf = appid
download_lock = threading.Lock()
def on_cover_download(cover_file: str, cover_type: str):
nonlocal downloaded_count
try:
if cover_file and os.path.exists(cover_file):
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
else:
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
except Exception as e:
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
finalize_shortcut()
def finalize_shortcut():
tags_dict = {'0': 'PortProton'}
shortcut = { shortcut = {
"appid": aidvdf, "appid": aidvdf,
"AppName": game_name, "AppName": game_name,
@@ -899,7 +1062,7 @@ export START_FROM_STEAM=1
"Devkit": 0, "Devkit": 0,
"DevkitGameID": "", "DevkitGameID": "",
"LastPlayTime": 0, "LastPlayTime": 0,
"tags": tags_dict "tags": {'0': 'PortProton'}
} }
logger.info(f"Shortcut entry to be written: {shortcut}") logger.info(f"Shortcut entry to be written: {shortcut}")
@@ -929,6 +1092,7 @@ export START_FROM_STEAM=1
with open(steam_shortcuts_path, 'wb') as f: with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": shortcuts}, f) vdf.binary_dump({"shortcuts": shortcuts}, f)
logger.info(f"Game '{game_name}' successfully added to Steam with covers")
except Exception as e: except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}") logger.error(f"Failed to update shortcuts.vdf: {e}")
if os.path.exists(backup_path): if os.path.exists(backup_path):
@@ -937,34 +1101,54 @@ export START_FROM_STEAM=1
logger.info("Restored shortcuts.vdf from backup due to update failure") logger.info("Restored shortcuts.vdf from backup due to update failure")
except Exception as restore_err: except Exception as restore_err:
logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}") logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
return (False, f"Failed to update shortcuts.vdf: {e}") appid = None
logger.info(f"Game '{game_name}' successfully added to Steam with covers") if not appid:
return (True, f"Game '{game_name}' added to Steam with covers") return (False, "Failed to create shortcut using any method")
steam_appid = None
def on_game_info(game_info: dict): def on_game_info(game_info: dict):
nonlocal steam_appid nonlocal steam_appid
steam_appid = game_info.get("appid") steam_appid = game_info.get("appid")
if not steam_appid or not isinstance(steam_appid, int): if not steam_appid or not isinstance(steam_appid, int):
logger.info("No valid Steam appid found, skipping cover download") logger.info("No valid Steam appid found, skipping cover download")
return finalize_shortcut() return
logger.info(f"Found Steam AppID {steam_appid} for cover download")
# Обложки и имена, соответствующие bash-скрипту и твоим размерам
cover_types = [ cover_types = [
(".jpg", "header.jpg"), # базовый, сохранится как AppId.jpg ("p.jpg", "library_600x900_2x.jpg"),
("p.jpg", "library_600x900_2x.jpg"), # сохранится как AppIdp.jpg ("_hero.jpg", "library_hero.jpg"),
("_hero.jpg", "library_hero.jpg"), # AppId_hero.jpg ("_logo.png", "logo.png"),
("_logo.png", "logo.png") # AppId_logo.png (".jpg", "header.jpg")
] ]
for suffix, cover_type in cover_types: def on_cover_download(result_path: str | None, steam_name: str, index: int):
try:
if result_path and os.path.exists(result_path):
logger.info(f"Downloaded cover {steam_name} to {result_path}")
if was_api_used:
try:
with open(result_path, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
ext = Path(steam_name).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e:
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
else:
logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
except Exception as e:
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
for i, (suffix, steam_name) in enumerate(cover_types):
cover_file = os.path.join(grid_dir, f"{appid}{suffix}") cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}" cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
downloader.download_async( downloader.download_async(
cover_url, cover_url,
cover_file, cover_file,
timeout=5, timeout=5,
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype) callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
) )
get_steam_game_info_async(game_name, exec_line, on_game_info) get_steam_game_info_async(game_name, exec_line, on_game_info)
@@ -996,13 +1180,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
logger.error("Steam home directory not found") logger.error("Steam home directory not found")
return (False, "Steam directory not found.") return (False, "Steam directory not found")
# Get current Steam user ID # Get current Steam user ID
last_user = get_last_steam_user(steam_home) last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user: if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID") logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID.") return (False, "Failed to get Steam user ID")
userdata_dir = steam_home / "userdata" userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID'] user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
@@ -1017,19 +1201,7 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
logger.info(f"shortcuts.vdf not found at {steam_shortcuts_path}") logger.info(f"shortcuts.vdf not found at {steam_shortcuts_path}")
return (False, f"Game '{game_name}' not found in Steam") return (False, f"Game '{game_name}' not found in Steam")
# Generate appid for identifying cover files appid = None
unique_string = f"{script_path}{game_name}"
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
appid = baseid | 0x80000000
# Create backup of shortcuts.vdf
backup_path = f"{steam_shortcuts_path}.backup"
try:
shutil.copy2(steam_shortcuts_path, backup_path)
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
return (False, f"Failed to create backup of shortcuts.vdf: {e}")
# Load and modify shortcuts.vdf # Load and modify shortcuts.vdf
try: try:
@@ -1043,37 +1215,51 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
return (False, f"Failed to load shortcuts.vdf: {load_err}") return (False, f"Failed to load shortcuts.vdf: {load_err}")
shortcuts = shortcuts_data.get("shortcuts", {}) shortcuts = shortcuts_data.get("shortcuts", {})
found = False
new_shortcuts = {} new_shortcuts = {}
index = 0 index = 0
# Filter out the matching shortcut # Filter out the matching shortcut
for _key, entry in shortcuts.items(): for _key, entry in shortcuts.items():
if entry.get("AppName") == game_name and entry.get("Exe") == f'"{script_path}"': if entry.get("AppName") == game_name and entry.get("Exe") == f'"{script_path}"':
found = True appid = convert_steam_id(int(entry.get("appid")))
logger.info(f"Found matching shortcut for '{game_name}' to remove") logger.info(f"Found matching shortcut for '{game_name}' to remove")
continue continue
new_shortcuts[str(index)] = entry new_shortcuts[str(index)] = entry
index += 1 index += 1
if not found: if not appid:
logger.info(f"Game '{game_name}' not found in Steam shortcuts") logger.info(f"Game '{game_name}' not found in Steam shortcuts")
return (False, f"Game '{game_name}' not found in Steam") return (False, f"Game '{game_name}' not found in Steam")
# Save updated shortcuts.vdf api_response = call_steam_api("removeShortcut", appid)
try: if api_response is not None: # API responded, even if response is empty
with open(steam_shortcuts_path, 'wb') as f: logger.info(f"Shortcut for AppID {appid} successfully removed via API")
vdf.binary_dump({"shortcuts": new_shortcuts}, f) else:
logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'") logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf")
except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}") # Create backup of shortcuts.vdf
if os.path.exists(backup_path): backup_path = f"{steam_shortcuts_path}.backup"
try: try:
shutil.copy2(backup_path, steam_shortcuts_path) shutil.copy2(steam_shortcuts_path, backup_path)
logger.info("Restored shortcuts.vdf from backup due to update failure") logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as restore_err: except Exception as e:
logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}") logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
return (False, f"Failed to update shortcuts.vdf: {e}") return (False, f"Failed to create backup of shortcuts.vdf: {e}")
# Save updated shortcuts.vdf
try:
with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}")
if os.path.exists(backup_path):
try:
shutil.copy2(backup_path, steam_shortcuts_path)
logger.info("Restored shortcuts.vdf from backup due to update failure")
except Exception as restore_err:
logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
return (False, f"Failed to update shortcuts.vdf: {e}")
# Delete cover files # Delete cover files
cover_files = [ cover_files = [
@@ -1128,5 +1314,5 @@ def is_game_in_steam(game_name: str) -> bool:
if entry.get("AppName") == game_name: if entry.get("AppName") == game_name:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error checking if game {game_name} is in Steam: {e}") logger.error(f"Failed to check if game {game_name} is in Steam: {e}")
return False return False

View File

@@ -1,9 +1,8 @@
import importlib.util import importlib.util
import os import os
import ast
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from PySide6.QtSvg import QSvgRenderer from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -14,6 +13,59 @@ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]
_loaded_theme = None
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_theme_safety(theme_file: str) -> bool:
"""
Проверяет файл темы на наличие запрещённых модулей и функций.
Возвращает True, если файл безопасен, иначе False.
"""
has_errors = False
try:
with open(theme_file) as f:
content = f.read()
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, ast.Import | ast.ImportFrom):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
has_errors = True
except SyntaxError as e:
logger.error(f"Syntax error in file {theme_file}: {e}")
has_errors = True
except Exception as e:
logger.error(f"Failed to check theme safety for {theme_file}: {e}")
has_errors = True
return not has_errors
def list_themes(): def list_themes():
""" """
@@ -49,9 +101,13 @@ def load_theme_screenshots(theme_name):
def load_theme_fonts(theme_name): def load_theme_fonts(theme_name):
""" """
Загружает все шрифты выбранной темы. Загружает все шрифты выбранной темы, если они ещё не были загружены.
:param theme_name: Имя темы.
""" """
global _loaded_theme
if _loaded_theme == theme_name:
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return
QFontDatabase.removeAllApplicationFonts() QFontDatabase.removeAllApplicationFonts()
fonts_folder = None fonts_folder = None
if theme_name == "standart": if theme_name == "standart":
@@ -66,7 +122,7 @@ def load_theme_fonts(theme_name):
break break
if not fonts_folder or not os.path.exists(fonts_folder): if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Папка fonts не найдена для темы '{theme_name}'") logger.error(f"Fonts folder not found for theme '{theme_name}'")
return return
for filename in os.listdir(fonts_folder): for filename in os.listdir(fonts_folder):
@@ -75,29 +131,11 @@ def load_theme_fonts(theme_name):
font_id = QFontDatabase.addApplicationFont(font_path) font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1: if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id) families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Шрифт {filename} успешно загружен: {families}") logger.info(f"Font {filename} successfully loaded: {families}")
else: else:
logger.error(f"Ошибка загрузки шрифта: {filename}") logger.error(f"Error loading font: {filename}")
def load_logo(): _loaded_theme = theme_name
logo_path = None
base_dir = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
file_extension = os.path.splitext(logo_path)[1].lower()
if file_extension == ".svg":
renderer = QSvgRenderer(logo_path)
if not renderer.isValid():
logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
return None
pixmap = QPixmap(128, 128)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return pixmap
class ThemeWrapper: class ThemeWrapper:
""" """
@@ -109,69 +147,83 @@ class ThemeWrapper:
self.custom_theme = custom_theme self.custom_theme = custom_theme
self.metainfo = metainfo or {} self.metainfo = metainfo or {}
self.screenshots = load_theme_screenshots(self.metainfo.get("name", "")) self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
self._default_theme = None # Lazy-loaded default theme
def __getattr__(self, name): def __getattr__(self, name):
if hasattr(self.custom_theme, name): if hasattr(self.custom_theme, name):
return getattr(self.custom_theme, name) return getattr(self.custom_theme, name)
import portprotonqt.themes.standart.styles as default_styles if self._default_theme is None:
return getattr(default_styles, name) self._default_theme = load_theme("standart") # Dynamically load standard theme
return getattr(self._default_theme, name)
def load_theme(theme_name): def load_theme(theme_name):
""" """
Динамически загружает модуль стилей выбранной темы и метаинформацию. Динамически загружает модуль стилей выбранной темы и метаинформацию.
Если выбрана стандартная тема, импортируется оригинальный styles.py. Все темы, включая стандартную, проходят проверку безопасности.
Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты. Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
""" """
if theme_name == "standart":
import portprotonqt.themes.standart.styles as default_styles
return default_styles
for themes_dir in THEMES_DIRS: for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name) theme_folder = os.path.join(themes_dir, theme_name)
styles_file = os.path.join(theme_folder, "styles.py") styles_file = os.path.join(theme_folder, "styles.py")
if os.path.exists(styles_file): if os.path.exists(styles_file):
# Проверяем безопасность темы перед загрузкой
if not check_theme_safety(styles_file):
logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
spec = importlib.util.spec_from_file_location("theme_styles", styles_file) spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
continue continue
custom_theme = importlib.util.module_from_spec(spec) custom_theme = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_theme) spec.loader.exec_module(custom_theme)
if theme_name == "standart":
return custom_theme
meta = load_theme_metainfo(theme_name) meta = load_theme_metainfo(theme_name)
wrapper = ThemeWrapper(custom_theme, metainfo=meta) wrapper = ThemeWrapper(custom_theme, metainfo=meta)
wrapper.screenshots = load_theme_screenshots(theme_name) wrapper.screenshots = load_theme_screenshots(theme_name)
return wrapper return wrapper
raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'") raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
class ThemeManager: class ThemeManager:
""" """
Класс для управления темами приложения. Класс для управления темами приложения.
Реализует паттерн Singleton для единого экземпляра.
Позволяет получить список доступных тем, загрузить и применить выбранную тему.
""" """
def __init__(self): _instance = None
self.current_theme_name = None
self.current_theme_module = None
def get_available_themes(self): def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.current_theme_name = None
cls._instance.current_theme_module = None
return cls._instance
def get_available_themes(self) -> list:
"""Возвращает список доступных тем.""" """Возвращает список доступных тем."""
return list_themes() return list_themes()
def get_theme_logo(self): def apply_theme(self, theme_name: str):
"""Возвращает логотип для текущей или указанной темы.""" """
return load_logo() Применяет указанную тему, если она ещё не применена.
Возвращает модуль темы или обёртку.
"""
if self.current_theme_name == theme_name and self.current_theme_module is not None:
logger.debug(f"Theme '{theme_name}' is already applied, skipping")
return self.current_theme_module
try:
theme_module = load_theme(theme_name)
except FileNotFoundError:
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
theme_module = load_theme("standart")
theme_name = "standart"
save_theme_to_config("standart")
def apply_theme(self, theme_name):
"""
Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
:param theme_name: Имя темы.
:return: Загруженный модуль темы (или обёртка).
"""
theme_module = load_theme(theme_name)
load_theme_fonts(theme_name) load_theme_fonts(theme_name)
self.current_theme_name = theme_name self.current_theme_name = theme_name
self.current_theme_module = theme_module self.current_theme_module = theme_module
save_theme_to_config(theme_name) save_theme_to_config(theme_name)
logger.info(f"Тема '{theme_name}' успешно применена") logger.info(f"Theme '{theme_name}' successfully applied")
return theme_module return theme_module
def get_icon(self, icon_name, theme_name=None, as_path=False): def get_icon(self, icon_name, theme_name=None, as_path=False):
@@ -226,7 +278,7 @@ class ThemeManager:
# Если иконка всё равно не найдена # Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path): if not icon_path or not os.path.exists(icon_path):
logger.error(f"Предупреждение: иконка '{icon_name}' не найдена") logger.error(f"Warning: icon '{icon_name}' not found")
return QIcon() if not as_path else None return QIcon() if not as_path else None
if as_path: if as_path:

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 734 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 213 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>

Before

Width:  |  Height:  |  Size: 622 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 164 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>

Before

Width:  |  Height:  |  Size: 570 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 367 B

View File

@@ -1 +0,0 @@
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z"/></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 660 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 208 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 165 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848t-1.5848 3.8596q-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z"/></svg>

Before

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -1,5 +0,0 @@
[Metainfo]
author = BlackSnaker
author_link =
description = Стандартная тема PortProtonQt (светлый вариант)
name = Light

View File

@@ -1,699 +0,0 @@
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
# КОНСТАНТЫ
favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60
GAME_CARD_ANIMATION = {
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях.
"default_border_width": 2,
# Ширина обводки при наведении курсора.
# Увеличивает толщину рамки, когда курсор находится над карточкой.
# Значение в пикселях.
"hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
# Увеличивает толщину рамки, когда карточка в фокусе.
# Значение в пикселях.
"focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации.
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
# Значение в пикселях.
"pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации.
# Определяет максимальную толщину рамки при пульсации.
# Значение в пикселях.
"pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
# Влияет на скорость перехода от одной ширины обводки к другой.
# Значение в миллисекундах.
"thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации.
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
# Значение в миллисекундах.
"pulse_anim_duration": 800,
# Длительность анимации вращения градиента.
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
# Значение в миллисекундах.
"gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах).
# Определяет начальную точку вращения градиента при старте анимации.
"gradient_start_angle": 360,
# Конечный угол градиента (в градусах).
# Определяет конечную точку вращения градиента.
# Значение 0 означает полный поворот на 360 градусов.
"gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
"thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
# Влияет на "чувство" возврата к исходной ширине обводки.
"thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки.
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex.
# Влияет на внешний вид обводки при наведении или фокусе.
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
]
}
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
MAIN_WINDOW_HEADER_STYLE = """
QFrame {
background: transparent;
border: 10px solid rgba(255, 255, 255, 0.10);
border-bottom: 0px solid rgba(255, 255, 255, 0.15);
border-top-left-radius: 30px;
border-top-right-radius: 30px;
border: none;
}
"""
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
TITLE_LABEL_STYLE = """
QLabel {
font-family: 'RASKHAL';
font-size: 38px;
margin: 0 0 0 0;
color: #007AFF;
}
"""
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
NAV_WIDGET_STYLE = """
QWidget {
background: #ffffff;
border-bottom: 0px solid rgba(0, 0, 0, 0.10);
}
"""
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
NAV_BUTTON_STYLE = """
NavLabel {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(242, 242, 242, 0.5),
stop:1 rgba(232, 232, 232, 0.5));
padding: 10px 10px;
margin: 10px 0 10px 10px;
color: #333333;
font-size: 16px;
font-family: 'Poppins';
text-transform: uppercase;
border: 1px solid rgba(179, 179, 179, 0.4);
border-radius: 15px;
}
NavLabel[checked = true] {
background: rgba(0,122,255,0.25);
color: #002244;
font-weight: bold;
border-radius: 15px;
}
NavLabel:hover {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(0,122,255,0.12),
stop:1 rgba(0,122,255,0.08));
color: #002244;
}
"""
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
MAIN_WINDOW_STYLE = """
QMainWindow {
background: none;
}
QLabel {
color: #333333;
}
"""
# СТИЛЬ ПОЛЯ ПОИСКА
SEARCH_EDIT_STYLE = """
QLineEdit {
background-color: rgba(30, 30, 30, 0.50);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
padding: 7px 14px;
font-family: 'Poppins';
font-size: 16px;
color: #ffffff;
}
QLineEdit:focus {
border: 1px solid rgba(0,122,255,0.25);
}
"""
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
SCROLL_AREA_STYLE = """
QWidget {
background: transparent;
}
QScrollBar:vertical {
width: 10px;
border: 0px solid;
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}
QScrollBar::handle:vertical {
background: rgba(255, 255, 255, 0.7);
border: 0px solid;
border-radius: 5px;
}
QScrollBar::add-line:vertical {
border: 0px solid;
background: none;
}
QScrollBar::sub-line:vertical {
border: 0px solid;
background: none;
}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
border: 0px solid;
width: 3px;
height: 3px;
background: none;
}
QScrollBar:horizontal {
height: 10px;
border: 0px solid;
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}
QScrollBar::handle:horizontal {
background: #bebebe;
border: 0px solid;
border-radius: 5px;
}
QScrollBar::add-line:horizontal {
border: 0px solid;
background: none;
}
QScrollBar::sub-line:horizontal {
border: 0px solid;
background: none;
}
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {
border: 0px solid;
width: 3px;
height: 3px;
background: none;
}
"""
# SLIDER_SIZE_STYLE
SLIDER_SIZE_STYLE= """
QWidget {
background: transparent;
height: 25px;
}
QSlider::groove:horizontal {
border: 0px solid;
border-radius: 3px;
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
background: rgba(20, 20, 20, 0.30);
margin: 6px 0;
}
QSlider::handle:horizontal {
background: #bebebe;
border: 0px solid;
width: 18px;
height: 18px;
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
border-radius: 9px;
}
"""
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
LIST_WIDGET_STYLE = """
QWidget {
background: none;
border: 0px solid rgba(255, 255, 255, 0.10);
border-radius: 25px;
}
"""
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
INSTALLED_TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627;"
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
ACTION_BUTTON_STYLE = """
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(242, 242, 242, 0.5),
stop:1 rgba(232, 232, 232, 0.5));
border: 1px solid rgba(179, 179, 179, 0.4);
border-radius: 10px;
color: #232627;
font-size: 16px;
font-family: 'Poppins';
padding: 8px 16px;
}
QPushButton:hover {
background: rgba(0,122,255,0.25);
}
QPushButton:pressed {
background: rgba(0,122,255,0.25);
}
"""
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627; background-color: none;"
CONTENT_STYLE = """
QLabel {
font-family: 'Poppins';
font-size: 16px;
color: #232627;
background-color: none;
border-bottom: 1px solid rgba(165, 165, 165, 0.7);
padding-bottom: 15px;
}
"""
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
# LIBRARY_WIDGET_STYLE
LIBRARY_WIDGET_STYLE= """
QWidget {
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
border-radius: 0px;
}
"""
# CONTAINER_STYLE
CONTAINER_STYLE= """
QWidget {
background-color: none;
}
"""
# OTHER_PAGES_WIDGET_STYLE
OTHER_PAGES_WIDGET_STYLE= """
QWidget {
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
border-radius: 0px;
}
"""
# CAROUSEL_WIDGET_STYLE
CAROUSEL_WIDGET_STYLE= """
QWidget {
background: qlineargradient(spread:pad, x1:0.099, y1:0.119, x2:0.917, y2:0.936149, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(217, 193, 255, 255));
border-radius: 0px;
}
"""
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
DETAIL_PAGE_NO_COVER_STYLE = "background: rgba(20,20,20,0.95); border-radius: 15px;"
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
ADDGAME_BACK_BUTTON_STYLE = """
QPushButton {
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
color: #ffffff;
font-size: 16px;
font-family: 'Poppins';
padding: 4px 16px;
}
QPushButton:hover {
background: rgba(0,122,255,0.25);
}
QPushButton:pressed {
background: rgba(0,122,255,0.25);
}
"""
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
DETAIL_CONTENT_FRAME_STYLE = """
QFrame {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(20, 20, 20, 0.40),
stop:1 rgba(20, 20, 20, 0.35));
border: 0px solid rgba(255, 255, 255, 0.10);
border-radius: 15px;
}
"""
# ФРЕЙМ ПОД ОБЛОЖКОЙ
COVER_FRAME_STYLE = """
QFrame {
background: rgba(30, 30, 30, 0.80);
border-radius: 15px;
border: 0px solid rgba(255, 255, 255, 0.15);
}
"""
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
COVER_LABEL_STYLE = "border-radius: 100px;"
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
DETAILS_WIDGET_STYLE = "background: rgba(20,20,20,0.40); border-radius: 15px; padding: 10px;"
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
DETAIL_PAGE_TITLE_STYLE = "font-family: 'Orbitron'; font-size: 32px; color: #007AFF;"
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
# ТЕКСТ ОПИСАНИЯ
DETAIL_PAGE_DESC_STYLE = "font-family: 'Poppins'; font-size: 16px; color: #ffffff; line-height: 1.5;"
# СТИЛЬ КНОПКИ "ИГРАТЬ"
PLAY_BUTTON_STYLE = """
QPushButton {
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
font-size: 18px;
color: #ffffff;
font-weight: bold;
font-family: 'Orbitron';
padding: 8px 16px;
min-width: 120px;
min-height: 40px;
}
QPushButton:hover {
background: rgba(0,122,255,0.25);
}
QPushButton:pressed {
background: rgba(0,122,255,0.25);
}
"""
# СТИЛЬ КНОПКИ "ОБЗОР..." В ДИАЛОГЕ "ДОБАВИТЬ ИГРУ"
DIALOG_BROWSE_BUTTON_STYLE = """
QPushButton {
background: rgba(20, 20, 20, 0.40);
border: 0px solid rgba(255, 255, 255, 0.20);
border-radius: 15px;
color: #ffffff;
font-size: 16px;
padding: 5px 10px;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(0,122,255,0.20),
stop:1 rgba(0,122,255,0.15));
}
QPushButton:pressed {
background: rgba(20, 20, 20, 0.60);
border: 0px solid rgba(255, 255, 255, 0.25);
}
"""
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
GAME_CARD_WINDOW_STYLE = """
QFrame {
border-radius: 20px;
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(255, 255, 255, 0.3),
stop:1 rgba(249, 249, 249, 0.3));
border: 0px solid rgba(255, 255, 255, 0.4);
}
"""
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
GAME_CARD_NAME_LABEL_STYLE = """
QLabel {
color: #333333;
font-family: 'Orbitron';
font-size: 16px;
font-weight: bold;
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(242, 242, 242, 0.5),
stop:1 rgba(232, 232, 232, 0.5));
border-radius: 20px;
padding: 7px;
qproperty-wordWrap: true;
}
"""
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
LAST_LAUNCH_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
LAST_LAUNCH_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
PLAY_TIME_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
PLAY_TIME_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
GAMEPAD_SUPPORT_VALUE_STYLE = """
font-family: 'Poppins'; font-size: 12px; color: #00ff00;
font-weight: bold; background: rgba(0, 0, 0, 0.3);
border-radius: 5px; padding: 4px 8px;
"""
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
CAPTION_LABEL_STYLE="color: white; font-size: 16px;"
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
def get_protondb_badge_style(tier):
tier = tier.lower()
tier_colors = {
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
}
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
return f"""
qproperty-alignment: AlignCenter;
background-color: {colors["background"]};
color: {colors["color"]};
border-radius: 5px;
font-family: 'Poppins';
font-weight: bold;
"""
def get_anticheat_badge_style(status):
status = status.lower()
status_colors = {
"supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
"running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
"planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
"broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
"denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
}
colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
return f"""
qproperty-alignment: AlignCenter;
background-color: {colors["background"]};
color: {colors["color"]};
border-radius: 5px;
font-family: 'Poppins';
font-weight: bold;
"""
# СТИЛИ БЕЙДЖА STEAM
STEAM_BADGE_STYLE= """
qproperty-alignment: AlignCenter;
background: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 5px;
font-family: 'Poppins';
font-weight: bold;
"""
# Favorite Star
FAVORITE_LABEL_STYLE = "color: gold; font-size: 32px; background: transparent; border: none;"
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
MESSAGE_BOX_STYLE = """
QMessageBox {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(40, 40, 40, 0.95),
stop:1 rgba(25, 25, 25, 0.95));
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
}
QMessageBox QLabel {
color: #ffffff;
font-family: 'Poppins';
font-size: 16px;
}
QMessageBox QPushButton {
background: rgba(30, 30, 30, 0.6);
border: 1px solid rgba(165, 165, 165, 0.7);
border-radius: 8px;
color: #ffffff;
font-family: 'Poppins';
padding: 8px 20px;
min-width: 80px;
}
QMessageBox QPushButton:hover {
background: #09bec8;
border-color: rgba(255, 255, 255, 0.3);
}
"""
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
# PARAMS_TITLE_STYLE
PARAMS_TITLE_STYLE = "color: #232627; font-family: 'Poppins'; font-size: 16px; padding: 10px; background: transparent;"
PROXY_INPUT_STYLE = """
QLineEdit {
background: rgba(20, 20, 20, 0.40);
border: 0px solid rgba(165, 165, 165, 0.7);
border-radius: 10px;
height: 34px;
padding-left: 12px;
color: #ffffff;
font-family: 'Poppins';
font-size: 16px;
}
QLineEdit:focus {
border: 1px solid rgba(0,122,255,0.25);
}
QMenu {
border: 1px solid rgba(255, 255, 255, 0.5);
padding: 5px 10px;
background: #c7c7c7;
}
QMenu::item {
padding: 0px 10px;
border: 10px solid transparent; /* reserve space for selection border */
}
QMenu::item:selected {
background: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
}
"""
SETTINGS_COMBO_STYLE = f"""
QComboBox {{
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
height: 34px;
padding-left: 12px;
color: #ffffff;
font-family: 'Poppins';
font-size: 16px;
min-width: 120px;
combobox-popup: 0;
}}
QComboBox:on {{
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(165, 165, 165, 0.7);
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}}
QComboBox:hover {{
border: 1px solid rgba(165, 165, 165, 0.7);
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
border-left: 1px solid rgba(255, 255, 255, 0.5);
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow {{
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow:on {{
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox QAbstractItemView {{
outline: none;
border: 1px solid rgba(165, 165, 165, 0.7);
border-top-style: none;
}}
QListView {{
background: #ffffff;
}}
QListView::item {{
padding: 7px 7px 7px 12px;
border-radius: 0px;
color: #232627;
}}
QListView::item:hover {{
background: rgba(0,122,255,0.25);
}}
QListView::item:selected {{
background: rgba(0,122,255,0.25);
}}
"""
class FileExplorerStyles:
WINDOW_STYLE = """
QDialog {
background-color: #2d2d2d;
color: #ffffff;
font-family: "Arial";
font-size: 14px;
}
"""
PATH_LABEL_STYLE = """
QLabel {
color: #3daee9;
font-size: 16px;
padding: 5px;
}
"""
LIST_STYLE = """
QListWidget {
font-size: 16px;
background-color: #353535;
color: #eee;
border: 1px solid #444;
border-radius: 4px;
}
QListWidget::item {
padding: 8px;
border-bottom: 1px solid #444;
}
QListWidget::item:selected {
background-color: #3daee9;
color: white;
border-radius: 2px;
}
"""
BUTTON_STYLE = """
QPushButton {
background-color: #3daee9;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
}
QPushButton:hover {
background-color: #2c9fd8;
}
QPushButton:pressed {
background-color: #1a8fc7;
}
"""

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Some files were not shown because too many files have changed in this diff Show More