21 Commits
v0.1.7 ... main

Author SHA1 Message Date
7df6ad3b80 feat(autoinstalls): added slider
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:55:17 +05:00
464ad0fe9c chore: optimize and clean code
All checks were successful
Code check / Check code (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:09:02 +05:00
cde92885d4 feat(virtual_keybord): added gamepad hint
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 00:04:47 +05:00
120c7b319c fix: improve gamepad detection using udev ID_INPUT_JOYSTICK property
All checks were successful
Code check / Check code (push) Successful in 1m18s
2025-10-16 23:20:48 +05:00
596aed0077 chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m3s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:54:30 +05:00
6fc6cb1e02 feat: added minimize to tray
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:53:08 +05:00
186e28a19b fix(gamepad): resolve MonitorObserver blocking issue causing application hang
All checks were successful
Code check / Check code (push) Successful in 1m23s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:43:49 +05:00
28e4d1e77c Revert "chore: broke autorelease for tasting purpose"
This reverts commit fff1f888c4.
2025-10-16 14:11:36 +05:00
fff1f888c4 chore: broke autorelease for tasting purpose
All checks were successful
Code check / Check code (push) Successful in 1m16s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 12:48:21 +05:00
fdd5a0a3d5 chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m50s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 10:44:30 +05:00
792e52d981 feat(dialogs): added controller hints
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 10:39:24 +05:00
84d5e46a74 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 22:53:08 +05:00
4bc764d568 partially revert b1047ba18e
All checks were successful
Code check / Check code (push) Successful in 1m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 22:31:35 +05:00
9a18aa037e feat(autoinstall): no restart on autoinstall finished
All checks were successful
Code check / Check code (push) Successful in 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 21:58:40 +05:00
ed62d2d1c4 fix: resolve lambda variable capture issue in switchTab method
All checks were successful
Code check / Check code (push) Successful in 1m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 21:47:14 +05:00
accc9b18b6 chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m23s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 15:31:56 +05:00
82249d7eab feat(settings): Added Gamepad type settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 15:30:31 +05:00
476c896940 chore(TODO): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 12:44:01 +05:00
b1047ba18e fix: fix card overlap on display_filter change
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-13 12:14:54 +05:00
987199d8e6 chore(release): enable node experimental-fetch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-13 11:52:43 +05:00
Renovate Bot
ef1acd4581 chore(deps): update archlinux:base-devel docker digest to 06ab929
All checks were successful
Code check / Check code (push) Successful in 1m15s
2025-10-12 17:46:27 +00:00
20 changed files with 1011 additions and 191 deletions

View File

@@ -94,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@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166 image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host

View File

@@ -180,6 +180,8 @@ jobs:
- name: Release - name: Release
uses: https://gitea.com/actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with: with:
body_path: changelog.txt body_path: changelog.txt
token: ${{ env.GITEA_TOKEN }} token: ${{ env.GITEA_TOKEN }}

View File

@@ -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@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166 image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host

View File

@@ -3,6 +3,23 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [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
- @Vector_null
---
## [0.1.7] - 2025-10-12 ## [0.1.7] - 2025-10-12
### Added ### Added

15
TODO.md
View File

@@ -1,6 +1,6 @@
- [X] Адаптировать структуру проекта для поддержки инструментов сборки - [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [X] Добавить возможность управления с геймпада - [X] Добавить возможность управления с геймпада
- [ ] Добавить возможность управления с тачскрина - [X] Добавить возможность управления с тачскрина (Формально и так есть)
- [X] Добавить возможность управления с мыши и клавиатуры - [X] Добавить возможность управления с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide) - [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено) - [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
@@ -11,18 +11,18 @@
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800) - [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots) - [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Получать описания и названия игр из базы данных Steam - [X] Получать описания и названия игр из базы данных Steam
- [X] Получать обложки для игр из SteamGridDB или CDN Steam - [X] Получать обложки для игр из CDN Steam
- [X] Оптимизировать работу со Steam API для ускорения времени запуска - [X] Оптимизировать работу со Steam API для ускорения времени запуска
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley) - [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода - [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Избавиться от вызовов yad - [X] Избавиться от вызовов yad
- [X] Реализовать собственный системный трей вместо использования трея PortProton - [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) - [X] Добавить экранную клавиатуру в поиск
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
- [X] Добавить индикацию запуска приложения - [X] Добавить индикацию запуска приложения
- [X] Достигнуть паритета функциональности с Ingame - [X] Достигнуть паритета функциональности с Ingame
- [ ] Достигнуть паритета функциональности с PortProton - [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов)
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}` - [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/) - [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
- [X] Добавить переводы в переопределения - [X] Добавить переводы в переопределения
@@ -49,7 +49,7 @@
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter) - [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
- [X] Добавить систему избранного для карточек - [X] Добавить систему избранного для карточек
- [X] Заменить все `print` на `logging` - [X] Заменить все `print` на `logging`
- [ ] Привести все логи к единому языку - [X] Привести все логи к единому языку
- [X] Уменьшить количество подстановок в переводах - [X] Уменьшить количество подстановок в переводах
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog) - [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py` - [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
@@ -62,7 +62,6 @@
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры - [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [ ] Доделать светлую тему - [X] Добавить подсказки к управлению с геймпада
- [ ] Добавить подсказки к управлению с геймпада
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры - [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры - [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры

View File

@@ -21,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 240 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 240 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 240 of 240 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 |
--- ---

View File

@@ -21,9 +21,9 @@
| Локаль | Прогресс | Переведено | | Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 240 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 240 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 240 из 240 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
--- ---

View File

@@ -177,6 +177,26 @@ def save_card_size(card_width):
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_auto_card_size():
"""Reads the card size (width) for Auto Install from the [Cards] section.
Returns 250 if the parameter is not set.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
save_auto_card_size(250)
return 250
return cp.getint("Cards", "auto_card_width", fallback=250)
def save_auto_card_size(card_width):
"""Saves the card size (width) for Auto Install to the [Cards] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Cards" not in cp:
cp["Cards"] = {}
cp["Cards"]["auto_card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_sort_method(): def read_sort_method():
"""Reads the sort method from the [Games] section. """Reads the sort method from the [Games] section.
Returns 'last_launch' if the parameter is not set. Returns 'last_launch' if the parameter is not set.
@@ -259,6 +279,25 @@ def save_rumble_config(rumble_enabled):
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_gamepad_type():
"""Reads the gamepad type from the [Gamepad] section.
Returns 'xbox' if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
save_gamepad_type("xbox")
return "xbox"
return cp.get("Gamepad", "type", fallback="xbox").lower()
def save_gamepad_type(gpad_type):
"""Saves the gamepad type to the [Gamepad] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Gamepad" not in cp:
cp["Gamepad"] = {}
cp["Gamepad"]["type"] = gpad_type
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def ensure_default_proxy_config(): def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file. """Ensures the [Proxy] section exists in the configuration file.
Creates it with empty values if missing. Creates it with empty values if missing.
@@ -408,3 +447,22 @@ def save_favorite_folders(folders):
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"' cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
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_minimize_to_tray():
"""Reads the minimize-to-tray setting from the [Display] section.
Returns True if the parameter is missing (default: minimize to tray).
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
save_minimize_to_tray(True)
return True
return cp.getboolean("Display", "minimize_to_tray", fallback=True)
def save_minimize_to_tray(minimize_to_tray):
"""Saves the minimize-to-tray setting to the [Display] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Display" not in cp:
cp["Display"] = {}
cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)

View File

@@ -91,6 +91,130 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
logger.error(f"Ошибка при сохранении миниатюры: {e}") logger.error(f"Ошибка при сохранении миниатюры: {e}")
return False return False
def create_dialog_hints_widget(theme, main_window, input_manager, context='default'):
"""
Common function to create hints widget for all dialogs.
Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection.
"""
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
hintsWidget = QWidget()
hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE)
hintsLayout = QHBoxLayout(hintsWidget)
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
dialog_actions = []
# Context-specific actions (gamepad only, no keyboard)
if context == 'file_explorer':
dialog_actions = [
("confirm", _("Open")), # A / Cross
("add_game", _("Select Dir")), # X / Triangle
("prev_dir", _("Prev Dir")), # Y / Square
("back", _("Cancel")), # B / Circle
("context_menu", _("Menu")), # Start / Options
]
elif context == 'winetricks':
dialog_actions = [
("confirm", _("Toggle")), # A / Cross
("add_game", _("Install")), # X / Triangle
("prev_dir", _("Force Install")), # Y / Square
("back", _("Cancel")), # B / Circle
("prev_tab", _("Prev Tab")), # LB / L1
("next_tab", _("Next Tab")), # RB / R1
]
hints_labels = [] # Store for updates (returned for class storage)
def make_hint(icon_name, text, action=None):
container = QWidget()
hlayout = QHBoxLayout(container)
hlayout.setContentsMargins(0, 5, 0, 0)
hlayout.setSpacing(6)
icon_label = QLabel()
icon_label.setFixedSize(26, 26)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
hlayout.addWidget(icon_label)
text_label = QLabel(text)
text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
hlayout.addWidget(text_label)
# Initially hidden; show only if gamepad connected
container.setVisible(False)
hints_labels.append((container, icon_label, action))
hintsLayout.addWidget(container)
# Add gamepad hints only
for action, text in dialog_actions:
make_hint("placeholder", text, action)
hintsLayout.addStretch()
# Return widget and labels for class storage
return hintsWidget, hints_labels
def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name):
"""
Common function to update hints for any dialog.
"""
if not input_manager or not main_window:
# Hide all if no input_manager or main_window
for container, _, _ in hints_labels:
container.setVisible(False)
return
is_gamepad = input_manager.gamepad is not None
if not is_gamepad:
# Hide all hints if no gamepad
for container, _, _ in hints_labels:
container.setVisible(False)
return
gtype = input_manager.gamepad_type
gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab']
for container, icon_label, action in hints_labels:
if action and action in gamepad_actions:
container.setVisible(True)
# Update icon using main_window methods
if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']:
icon_name = main_window.get_button_icon(action, gtype)
else: # only prev_tab/next_tab (treat as nav)
direction = 'left' if action == 'prev_tab' else 'right'
icon_name = main_window.get_nav_icon(direction, gtype)
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
class FileSelectedSignal(QObject): class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу file_selected = Signal(str) # Сигнал с путем к выбранному файлу
@@ -185,6 +309,7 @@ class FileExplorer(QDialog):
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.thumbnail_cache = {} # Cache for loaded thumbnails
self.pending_thumbnails = set() # Track files pending thumbnail loading self.pending_thumbnails = set() # Track files pending thumbnail loading
self.main_window = None # Add reference to MainWindow
self.setup_ui() self.setup_ui()
# Window settings # Window settings
@@ -198,6 +323,7 @@ class FileExplorer(QDialog):
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
self.main_window = parent
if hasattr(parent, 'context_menu_manager'): if hasattr(parent, 'context_menu_manager'):
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
parent = parent.parent() parent = parent.parent()
@@ -214,6 +340,17 @@ 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()
# Create hints widget using common function
self.current_theme_name = read_theme_from_config()
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer')
self.main_layout.addWidget(self.hints_widget)
# Connect signals
if self.input_manager:
self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
class ThumbnailLoader(QRunnable): class ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread.""" """Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject): class Signals(QObject):
@@ -1037,8 +1174,6 @@ Icon={icon_path}
return desktop_entry, desktop_path return desktop_entry, desktop_path
class WinetricksDialog(QDialog): class WinetricksDialog(QDialog):
"""Dialog for managing Winetricks components in a prefix."""
def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None): def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
@@ -1071,6 +1206,36 @@ class WinetricksDialog(QDialog):
self.setup_ui() self.setup_ui()
self.load_lists() self.load_lists()
# Find input_manager and main_window
self.input_manager = None
self.main_window = None
parent = self.parent()
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
parent = parent.parent()
self.current_theme_name = read_theme_from_config()
# Enable Winetricks-specific mode
if self.input_manager:
self.input_manager.enable_winetricks_mode(self)
# Create hints widget using common function
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks')
self.main_layout.addWidget(self.hints_widget)
# Connect signals (use self.theme_manager)
if self.input_manager:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def update_winetricks(self): def update_winetricks(self):
"""Update the winetricks script.""" """Update the winetricks script."""
if not self.downloader.has_internet(): if not self.downloader.has_internet():
@@ -1143,15 +1308,15 @@ class WinetricksDialog(QDialog):
def setup_ui(self): def setup_ui(self):
"""Set up the user interface with tabs and tables.""" """Set up the user interface with tabs and tables."""
main_layout = QVBoxLayout(self) self.main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10) self.main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10) self.main_layout.setSpacing(10)
# Log output # Log output
self.log_output = QTextEdit() self.log_output = QTextEdit()
self.log_output.setReadOnly(True) self.log_output.setReadOnly(True)
self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
main_layout.addWidget(self.log_output) self.main_layout.addWidget(self.log_output)
# Tab widget # Tab widget
self.tab_widget = QTabWidget() self.tab_widget = QTabWidget()
@@ -1258,7 +1423,7 @@ class WinetricksDialog(QDialog):
"settings": self.settings_container "settings": self.settings_container
} }
main_layout.addWidget(self.tab_widget) self.main_layout.addWidget(self.tab_widget)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -1272,7 +1437,7 @@ class WinetricksDialog(QDialog):
button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.force_button) button_layout.addWidget(self.force_button)
button_layout.addWidget(self.install_button) button_layout.addWidget(self.install_button)
main_layout.addLayout(button_layout) self.main_layout.addLayout(button_layout)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
self.force_button.clicked.connect(lambda: self.install_selected(force=True)) self.force_button.clicked.connect(lambda: self.install_selected(force=True))
@@ -1497,3 +1662,15 @@ class WinetricksDialog(QDialog):
"""Добавляет в лог.""" """Добавляет в лог."""
self.log_output.append(message) self.log_output.append(message)
self.log_output.moveCursor(QTextCursor.MoveOperation.End) self.log_output.moveCursor(QTextCursor.MoveOperation.End)
def closeEvent(self, event):
"""Disable mode on close."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().closeEvent(event)
def reject(self):
"""Disable mode on reject."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().reject()

View File

@@ -217,6 +217,16 @@ class GameLibraryManager:
else: else:
self._update_game_grid_immediate() self._update_game_grid_immediate()
def force_update_cards_library(self):
if self.gamesListWidget and self.gamesListLayout:
self.gamesListLayout.invalidate()
self.gamesListWidget.updateGeometry()
widget = self.gamesListWidget
QTimer.singleShot(0, lambda: (
widget.adjustSize(),
widget.updateGeometry()
))
def _update_game_grid_immediate(self): def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list.""" """Updates the game grid with the provided or current game list."""
if self.gamesListLayout is None or self.gamesListWidget is None: if self.gamesListLayout is None or self.gamesListWidget is None:
@@ -346,6 +356,8 @@ class GameLibraryManager:
self.gamesListWidget.updateGeometry() self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width self.main_window._last_card_width = self.card_width
self.force_update_cards_library()
self.is_filtering = False # Reset flag in any case self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str): def _apply_filter_visibility(self, search_text: str):
@@ -453,11 +465,3 @@ class GameLibraryManager:
def filter_games_delayed(self): def filter_games_delayed(self):
"""Filters games based on search text and updates the grid.""" """Filters games based on search text and updates the grid."""
self.update_game_grid(is_filter=True) self.update_game_grid(is_filter=True)
def calculate_columns(self, card_width: int) -> int:
"""Calculate the number of columns based on card width and assumed container width."""
# Assuming a typical container width; adjust as needed
available_width = 1200 # Example width, can be dynamic if widget access is added
spacing = 15 # Assumed spacing between cards
columns = max(1, (available_width - spacing) // (card_width + spacing))
return min(columns, 8) # Cap at reasonable max

View File

@@ -4,16 +4,16 @@ 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 enum import Enum
from pyudev import Context, Monitor, MonitorObserver, Device from pyudev import Context, Monitor, Device, Devices
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem
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, QMouseEvent 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
from portprotonqt.game_card import GameCard from portprotonqt.game_card import GameCard
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog from portprotonqt.dialogs import AddGameDialog
from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -76,6 +76,7 @@ class InputManager(QObject):
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release) button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
dpad_moved = Signal(int, int, float) # Signal for D-pad movements dpad_moved = Signal(int, int, float) # Signal for D-pad movements
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal) toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
gamepad_hotplug = Signal(str) # 'add' or 'remove'
def __init__( def __init__(
self, self,
@@ -87,8 +88,13 @@ 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 type_str = read_gamepad_type()
# Ensure attributes exist on main_window if type_str == "playstation":
self.gamepad_type = GamepadType.PLAYSTATION
elif type_str == "xbox":
self.gamepad_type = GamepadType.XBOX
else:
self.gamepad_type = GamepadType.UNKNOWN
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)
self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None) self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None)
@@ -271,38 +277,6 @@ class InputManager(QObject):
elif current_row_idx == 0: elif current_row_idx == 0:
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason) self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
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:
@@ -482,6 +456,171 @@ class InputManager(QObject):
except Exception as e: except Exception as e:
logger.error("Error in FileExplorer dpad handler: %s", e) logger.error("Error in FileExplorer dpad handler: %s", e)
def enable_winetricks_mode(self, winetricks_dialog):
"""Setup gamepad handling for WinetricksDialog"""
try:
self.winetricks_dialog = winetricks_dialog
self.original_button_handler = self.handle_button_slot
self.original_dpad_handler = self.handle_dpad_slot
self.original_gamepad_state = self._gamepad_handling_enabled
self.handle_button_slot = self.handle_winetricks_button
self.handle_dpad_slot = self.handle_winetricks_dpad
self._gamepad_handling_enabled = True
# Reset dpad timer for table nav
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
logger.debug("Gamepad handling successfully connected for WinetricksDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for Winetricks: {e}")
def disable_winetricks_mode(self):
"""Restore original main window handlers"""
try:
if self.winetricks_dialog:
self.handle_button_slot = self.original_button_handler
self.handle_dpad_slot = self.original_dpad_handler
self._gamepad_handling_enabled = self.original_gamepad_state
self.winetricks_dialog = None
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
logger.debug("Gamepad handling successfully restored from Winetricks")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from Winetricks: {e}")
def handle_winetricks_button(self, button_code, value):
if self.winetricks_dialog is None:
return
if value == 0: # Ignore releases
return
try:
# Always check for popups first, including QMessageBox
popup = QApplication.activePopupWidget()
if popup:
if isinstance(popup, QMessageBox):
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']:
popup.accept() # Close QMessageBox with A or B
return
elif isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']: # A: Select menu item
focused = popup.activeAction()
if focused:
focused.trigger()
return
elif button_code in BUTTONS['back']: # B: Close menu
popup.close()
return
# Additional check for top-level QMessageBox (in case not active popup yet)
for widget in QApplication.topLevelWidgets():
if isinstance(widget, QMessageBox) and widget.isVisible():
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']:
widget.accept()
return
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
if current_row >= 0:
checkbox_item = focused.item(current_row, 0)
if checkbox_item and isinstance(checkbox_item, QTableWidgetItem):
new_state = Qt.CheckState.Checked if checkbox_item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
checkbox_item.setCheckState(new_state)
return
elif button_code in BUTTONS['add_game']: # X: Install (no force)
self.winetricks_dialog.install_selected(force=False)
return
elif button_code in BUTTONS['prev_dir']: # Y: Force Install
self.winetricks_dialog.install_selected(force=True)
return
elif button_code in BUTTONS['back']: # B: Cancel
self.winetricks_dialog.reject()
return
elif button_code in BUTTONS['prev_tab']: # LB: Prev Tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
self._focus_first_row_in_current_table()
return
elif button_code in BUTTONS['next_tab']: # RB: Next Tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
self._focus_first_row_in_current_table()
return
# Fallback: Activate focused widget (e.g., buttons)
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_winetricks_button: {e}")
def handle_winetricks_dpad(self, code, value, now):
if self.winetricks_dialog is None:
return
try:
if value == 0: # Release: Stop repeat
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Start/update repeat timer for hold navigation
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300) # Initial slower, then faster repeat
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
table = self._get_current_table()
if not table or table.rowCount() == 0:
return
current_row = table.currentRow()
if code == ecodes.ABS_HAT0Y: # Up/Down: Navigate rows
if value < 0: # Up
new_row = max(0, current_row - 1)
elif value > 0: # Down
new_row = min(table.rowCount() - 1, current_row + 1)
else:
return
if new_row != current_row:
table.setCurrentCell(new_row, 0) # Focus checkbox column
table.setFocus(Qt.FocusReason.OtherFocusReason)
elif code == ecodes.ABS_HAT0X: # Left/Right: Switch tabs
if value < 0: # Left: Prev tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
elif value > 0: # Right: Next tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
self._focus_first_row_in_current_table()
except Exception as e:
logger.error(f"Error in handle_winetricks_dpad: {e}")
def _get_current_table(self):
"""Get the current visible table from the tab widget's stacked container."""
if self.winetricks_dialog is None:
return None
current_container = self.winetricks_dialog.tab_widget.currentWidget()
if current_container and isinstance(current_container, QStackedWidget):
current_table = current_container.widget(1) # Table is at index 1 (after preloader)
if isinstance(current_table, QTableWidget):
return current_table
return None
def _focus_first_row_in_current_table(self):
"""Focus the first row in the current table after tab switch."""
if self.winetricks_dialog is None:
return
table = self._get_current_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def handle_navigation_repeat(self): def handle_navigation_repeat(self):
"""Плавное повторение движения с переменной скоростью для FileExplorer""" """Плавное повторение движения с переменной скоростью для FileExplorer"""
try: try:
@@ -732,39 +871,6 @@ class InputManager(QObject):
self._parent.toggleGame(self._parent.current_exec_line, None) self._parent.toggleGame(self._parent.current_exec_line, None)
return return
if isinstance(active, WinetricksDialog):
if button_code in BUTTONS['confirm']: # A button - toggle checkbox
current_table = active.tab_widget.currentWidget()
if isinstance(current_table, QTableWidget):
current_row = current_table.currentRow()
if current_row >= 0:
checkbox = current_table.item(current_row, 0)
if checkbox:
checkbox.setCheckState(
Qt.CheckState.Unchecked if checkbox.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked
)
return
elif button_code in BUTTONS['add_game']: # X button - install
active.install_selected(force=False)
return
elif button_code in BUTTONS['prev_dir']: # Y button - force install
active.install_selected(force=True)
return
elif button_code in BUTTONS['back']: # B button - close dialog
active.reject()
return
elif button_code in BUTTONS['prev_tab']: # LB - previous tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx - 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
elif button_code in BUTTONS['next_tab']: # RB - next tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx + 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
# Standard navigation # Standard navigation
if button_code in BUTTONS['confirm']: if button_code in BUTTONS['confirm']:
self._parent.activateFocusedWidget() self._parent.activateFocusedWidget()
@@ -1331,76 +1437,241 @@ class InputManager(QObject):
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
def init_gamepad(self) -> None: def init_gamepad(self) -> None:
self.udev_context = Context()
self.Devices = Devices
self.monitor_ready = False
# Подключаем сигнал hotplug к обработчику в главном потоке
self.gamepad_hotplug.connect(self._on_gamepad_hotplug)
# Debounce timer для отложенной проверки геймпада (в главном потоке Qt)
self.gamepad_check_timer = QTimer()
self.gamepad_check_timer.setSingleShot(True)
self.gamepad_check_timer.timeout.connect(self.check_gamepad)
# Первоначальная проверка
self.check_gamepad() self.check_gamepad()
# Запускаем udev monitor в отдельном потоке
threading.Thread(target=self.run_udev_monitor, daemon=True).start() threading.Thread(target=self.run_udev_monitor, daemon=True).start()
logger.info("Gamepad support initialized with hotplug (evdev + pyudev)") logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
def run_udev_monitor(self) -> None: def run_udev_monitor(self) -> None:
"""
Неблокирующий опрос udev событий без MonitorObserver.
Использует monitor.poll() с таймаутом для корректного завершения.
"""
try: try:
context = Context() logger.info("Starting udev monitor...")
monitor = Monitor.from_netlink(context) monitor = Monitor.from_netlink(self.udev_context)
monitor.filter_by(subsystem='input') monitor.filter_by(subsystem='input')
observer = MonitorObserver(monitor, self.handle_udev_event) monitor.start()
observer.start() logger.info("Monitor started, draining initial events...")
# КРИТИЧНО: При старте udev отправляет события о ВСЕХ существующих устройствах
# Это может быть 10-50+ событий, которые блокируют инициализацию
# Решение: дренируем (игнорируем) все события за первые 500ms
drain_start = time.time()
drained_count = 0
while time.time() - drain_start < 0.5:
device = monitor.poll(timeout=0.1)
if device is not None:
drained_count += 1
self.monitor_ready = True
logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...")
# Основной цикл опроса с таймаутом 1 секунда
while self.running: while self.running:
time.sleep(1) # poll() возвращает None при таймауте - не блокирует навсегда
device = monitor.poll(timeout=1.0)
if device is not None:
action = device.action
# Фильтруем только джойстики на уровне callback
# Это предотвращает обработку мышей/клавиатур/и т.д.
if action and self._is_joystick_device(device):
logger.info(f"Joystick hotplug event: {action} for {device.sys_name}")
self.handle_udev_event(action, device)
logger.info("udev monitor stopped gracefully")
except Exception as e: except Exception as e:
logger.error(f"Error in udev monitor: {e}", exc_info=True) logger.error(f"Error in udev monitor: {e}", exc_info=True)
def _is_joystick_device(self, device: Device) -> bool:
"""
Быстрая проверка: является ли устройство джойстиком.
Проверяет ID_INPUT_JOYSTICK из udev базы данных.
"""
try:
# Проверяем свойство ID_INPUT_JOYSTICK
if device.get('ID_INPUT_JOYSTICK') == '1':
return True
# Дополнительно: проверяем родительские устройства
# (некоторые контроллеры имеют свойство только у родителя)
parent = device.parent
if parent and parent.get('ID_INPUT_JOYSTICK') == '1':
return True
return False
except Exception as e:
logger.debug(f"Error checking joystick device: {e}")
return False
def handle_udev_event(self, action: str, device: Device) -> None: def handle_udev_event(self, action: str, device: Device) -> None:
"""
Обработчик udev событий для джойстиков.
Отправляет сигнал в главный поток Qt вместо прямого вызова QTimer.
"""
try: try:
if action == 'add': if action == 'add':
time.sleep(0.1) # Отправляем сигнал в главный поток Qt
self.check_gamepad() # QTimer будет запущен там безопасно
logger.debug("Emitting gamepad add signal")
self.gamepad_hotplug.emit('add')
elif action == 'remove' and self.gamepad: elif action == 'remove' and self.gamepad:
if not any(self.gamepad.path == path for path in list_devices()): # Проверяем конкретно наш геймпад по пути устройства
logger.info("Gamepad disconnected") device_node = device.device_node # например, /dev/input/event3
self.stop_rumble()
self.gamepad = None if device_node and self.gamepad.path == device_node:
if self.gamepad_thread: logger.info(f"Connected gamepad disconnected: {device_node}")
self.gamepad_thread.join() # Отправляем сигнал в главный поток
if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): self.gamepad_hotplug.emit('remove')
self.toggle_fullscreen.emit(False)
except Exception as e: except Exception as e:
logger.error(f"Error handling udev event: {e}", exc_info=True) logger.error(f"Error handling udev event: {e}", exc_info=True)
def _on_gamepad_hotplug(self, action: str) -> None:
"""
Обработчик сигнала hotplug, выполняется в главном потоке Qt.
Безопасно работает с QTimer.
"""
try:
if action == 'add':
# Debounce: откладываем проверку на 200ms
# Множественные события за короткое время объединяются в один вызов
logger.debug("Scheduling gamepad check (debounced)")
self.gamepad_check_timer.start(200)
elif action == 'remove':
# Немедленная обработка отключения
self.stop_rumble()
self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join(timeout=2.0)
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(False)
except Exception as e:
logger.error(f"Error in hotplug handler: {e}", exc_info=True)
def check_gamepad(self) -> None: def check_gamepad(self) -> None:
"""
Проверка и подключение геймпада.
Вызывается из главного потока Qt через QTimer (debounced).
"""
try: try:
new_gamepad = self.find_gamepad() new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}") # Проверяем, действительно ли это новый геймпад
self.detect_gamepad_type(new_gamepad) if new_gamepad:
logger.info(f"Detected gamepad type: {self.gamepad_type.value}") if not self.gamepad or new_gamepad.path != self.gamepad.path:
logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
self.stop_rumble()
self.gamepad = new_gamepad
if self.gamepad_thread:
self.gamepad_thread.join(timeout=2.0)
self.gamepad_thread = threading.Thread(
target=self.monitor_gamepad,
daemon=True
)
self.gamepad_thread.start()
# Автоматический фуллскрин при подключении геймпада
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(True)
elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()):
# Геймпад был подключён, но теперь его нет в системе
logger.info("Gamepad no longer detected")
self.stop_rumble() self.stop_rumble()
self.gamepad = new_gamepad self.gamepad = None
if self.gamepad_thread: if self.gamepad_thread:
self.gamepad_thread.join() self.gamepad_thread.join(timeout=2.0)
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
self.gamepad_thread.start()
# Send signal for fullscreen mode only if:
# 1. auto_fullscreen_gamepad is enabled
# 2. fullscreen is not already enabled (to avoid conflict)
if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(True) self.toggle_fullscreen.emit(False)
except Exception as e: except Exception as e:
logger.error(f"Error checking gamepad: {e}", exc_info=True) logger.error(f"Error checking gamepad: {e}", exc_info=True)
def find_gamepad(self) -> InputDevice | None: def find_gamepad(self) -> InputDevice | None:
"""
Находит первый доступный геймпад.
Оптимизирован: предварительная фильтрация по capabilities перед udev-запросами.
"""
try: try:
devices = [InputDevice(path) for path in list_devices()] devices = [InputDevice(path) for path in list_devices()]
if not devices:
return None
logger.debug(f"Checking {len(devices)} devices for gamepad...")
for device in devices: for device in devices:
# Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2) # Skip ASRock LED controller (известная проблема)
if device.info.vendor == 0x26ce and device.info.product == 0x01a2: if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
logger.debug(f"Skipping ASRock LED controller: {device.name}")
continue continue
caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: # Предварительная фильтрация: проверяем capabilities
return device # Джойстик должен иметь хотя бы оси (ABS) или кнопки (KEY)
# Это избегает udev-запросов для явно не-джойстиков
caps = device.capabilities(verbose=False)
has_abs_axes = ecodes.EV_ABS in caps
has_buttons = ecodes.EV_KEY in caps
if not (has_abs_axes or has_buttons):
continue
# Только для потенциальных джойстиков делаем udev-запрос
try:
udev_device = self.Devices.from_device_file(
self.udev_context,
device.path
)
is_joystick = udev_device.get('ID_INPUT_JOYSTICK')
if is_joystick == '1':
logger.info(f"Found gamepad: {device.name}")
return device
except Exception as e:
logger.debug(f"Could not check udev properties for {device.path}: {e}")
continue
logger.debug("No gamepad found")
return None return None
except Exception as e: except Exception as e:
logger.error(f"Error finding gamepad: {e}", exc_info=True) logger.error(f"Error finding gamepad: {e}", exc_info=True)
return None return None
def monitor_gamepad(self) -> None: def monitor_gamepad(self) -> None:
try: try:
if not self.gamepad: if not self.gamepad:
@@ -1464,16 +1735,32 @@ class InputManager(QObject):
self.gamepad = None self.gamepad = None
def cleanup(self) -> None: def cleanup(self) -> None:
"""
Корректное завершение работы с геймпадом и udev монитором.
"""
try: try:
# Флаг для остановки udev monitor loop
self.running = False self.running = False
# Останавливаем все таймеры
if hasattr(self, 'gamepad_check_timer'):
self.gamepad_check_timer.stop()
self.dpad_timer.stop() self.dpad_timer.stop()
self.nav_timer.stop() self.nav_timer.stop()
# Очистка геймпада
self.stop_rumble() self.stop_rumble()
if self.gamepad_thread: if self.gamepad_thread:
self.gamepad_thread.join() self.gamepad_thread.join(timeout=2.0)
if self.gamepad: if self.gamepad:
self.gamepad.close() self.gamepad.close()
self.gamepad = None self.gamepad = None
self.gamepad_type = GamepadType.UNKNOWN self.gamepad_type = GamepadType.UNKNOWN
logger.info("Gamepad cleanup completed")
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-10-12 17:14+0500\n" "POT-Creation-Date: 2025-10-16 14:54+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"
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings" msgid "Settings"
msgstr "" msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -579,6 +597,9 @@ msgstr ""
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "" msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL" msgid "Proxy URL"
msgstr "" msgstr ""
@@ -603,6 +624,12 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" 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-10-12 17:14+0500\n" "POT-Creation-Date: 2025-10-16 14:54+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"
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings" msgid "Settings"
msgstr "" msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -579,6 +597,9 @@ msgstr ""
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "" msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL" msgid "Proxy URL"
msgstr "" msgstr ""
@@ -603,6 +624,12 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" 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-10-12 17:14+0500\n" "POT-Creation-Date: 2025-10-16 14:54+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"
@@ -250,13 +250,37 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
@@ -324,12 +348,6 @@ msgstr ""
msgid "Settings" msgid "Settings"
msgstr "" msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -577,6 +595,9 @@ msgstr ""
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "" msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL" msgid "Proxy URL"
msgstr "" msgstr ""
@@ -601,6 +622,12 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" msgstr ""

View File

@@ -9,8 +9,8 @@ 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-10-12 17:14+0500\n" "POT-Creation-Date: 2025-10-16 14:54+0500\n"
"PO-Revision-Date: 2025-10-12 17:13+0500\n" "PO-Revision-Date: 2025-10-16 14:54+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -259,13 +259,37 @@ msgstr "Удалить"
msgid "Select All" msgid "Select All"
msgstr "Выбрать всё" msgstr "Выбрать всё"
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr "Открыть"
msgstr "Идёт запуск {0}"
msgid "Select Dir"
msgstr "Выбрать папку"
msgid "Prev Dir"
msgstr "Предыдущий каталог"
msgid "Cancel" msgid "Cancel"
msgstr "Отмена" msgstr "Отмена"
msgid "Toggle"
msgstr "Переключить"
msgid "Install"
msgstr "Установить"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Prev Tab"
msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "File Explorer" msgid "File Explorer"
msgstr "Проводник" msgstr "Проводник"
@@ -333,12 +357,6 @@ msgstr "Шрифты"
msgid "Settings" msgid "Settings"
msgstr "Настройки" msgstr "Настройки"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Install"
msgstr "Установить"
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку." msgstr "Winetricks не найден. Повторите попытку."
@@ -588,6 +606,9 @@ msgstr "все"
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "Фильтр игр:" msgstr "Фильтр игр:"
msgid "Gamepad Type:"
msgstr "Тип геймпада:"
msgid "Proxy URL" msgid "Proxy URL"
msgstr "Адрес прокси" msgstr "Адрес прокси"
@@ -612,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "Режим полноэкранного отображения приложения:" msgstr "Режим полноэкранного отображения приложения:"
msgid "Minimize to tray on close"
msgstr "Сворачивать в трей при закрытии"
msgid "Application Close Mode:"
msgstr "Режим закрытия приложения:"
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "Режим полноэкранного отображения приложения при подключении геймпада" msgstr "Режим полноэкранного отображения приложения при подключении геймпада"

View File

@@ -29,7 +29,8 @@ from portprotonqt.config_utils import (
read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method, read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method,
save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config, save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config, save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
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, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray,
read_auto_card_size, save_auto_card_size
) )
from portprotonqt.localization import _, get_egs_language, read_metadata_translations from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.howlongtobeat_api import HowLongToBeat
@@ -39,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.virtual_keyboard import VirtualKeyboard
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from typing import cast from typing import cast
@@ -63,6 +64,7 @@ class MainWindow(QMainWindow):
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) self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size() self.card_width = read_card_size()
self.auto_card_width = read_auto_card_size()
self._last_card_width = self.card_width self._last_card_width = self.card_width
self.setWindowTitle(f"{app_name} {version}") self.setWindowTitle(f"{app_name} {version}")
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
@@ -260,6 +262,10 @@ class MainWindow(QMainWindow):
GamepadType.XBOX: "xbox_y", GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square", GamepadType.PLAYSTATION: "ps_square",
}, },
'prev_dir': {
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
} }
return mappings.get(action, {}).get(gtype, "placeholder") return mappings.get(action, {}).get(gtype, "placeholder")
@@ -516,12 +522,26 @@ class MainWindow(QMainWindow):
self.install_monitor_timer = None self.install_monitor_timer = None
self.progress_bar.setRange(0, 100) self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(100) self.progress_bar.setValue(100)
if exit_code == 0: if exit_code == 0:
self.update_status_message.emit(_("Installation completed successfully."), 5000) self.update_status_message.emit(_("Installation completed successfully."), 5000)
QTimer.singleShot(500, lambda: self.restart_application())
desktop_dir = self.portproton_location or ""
new_desktops = [e.path for e in os.scandir(desktop_dir) if e.name.endswith(".desktop")]
if new_desktops:
latest = max(new_desktops, key=os.path.getmtime)
self._process_desktop_file_async(
latest,
lambda result: (
self.game_library_manager.add_game_incremental(result)
if result else None
)
)
else: else:
self.update_status_message.emit(_("Installation failed."), 5000) self.update_status_message.emit(_("Installation failed."), 5000)
QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).") QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).")
self.progress_bar.setVisible(False) self.progress_bar.setVisible(False)
self.current_install_script = None self.current_install_script = None
if self.install_process: if self.install_process:
@@ -820,6 +840,25 @@ class MainWindow(QMainWindow):
for i, btn in self.tabButtons.items(): for i, btn in self.tabButtons.items():
btn.setChecked(i == index) btn.setChecked(i == index)
self.stackedWidget.setCurrentIndex(index) self.stackedWidget.setCurrentIndex(index)
if hasattr(self, "game_library_manager"):
mgr = self.game_library_manager
if mgr.gamesListWidget and mgr.gamesListLayout:
games_layout = mgr.gamesListLayout
games_widget = mgr.gamesListWidget
QTimer.singleShot(0, lambda: (
games_layout.invalidate(),
games_widget.adjustSize(),
games_widget.updateGeometry()
))
if hasattr(self, "autoInstallContainer") and hasattr(self, "autoInstallContainerLayout"):
auto_layout = self.autoInstallContainerLayout
auto_widget = self.autoInstallContainer
QTimer.singleShot(0, lambda: (
auto_layout.invalidate(),
auto_widget.adjustSize(),
auto_widget.updateGeometry()
))
def openSystemOverlay(self): def openSystemOverlay(self):
"""Opens the system overlay dialog.""" """Opens the system overlay dialog."""
@@ -1063,8 +1102,7 @@ class MainWindow(QMainWindow):
autoInstallPage = QWidget() autoInstallPage = QWidget()
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
autoInstallLayout = QVBoxLayout(autoInstallPage) autoInstallLayout = QVBoxLayout(autoInstallPage)
autoInstallLayout.setContentsMargins(20, 0, 20, 0) autoInstallLayout.setSpacing(15)
autoInstallLayout.setSpacing(0)
# Верхняя панель с заголовком и поиском # Верхняя панель с заголовком и поиском
headerWidget = QWidget() headerWidget = QWidget()
@@ -1113,6 +1151,25 @@ class MainWindow(QMainWindow):
autoInstallLayout.addWidget(self.autoInstallScrollArea) autoInstallLayout.addWidget(self.autoInstallScrollArea)
# Slider for card size
sliderLayout = QHBoxLayout()
sliderLayout.setSpacing(0)
sliderLayout.setContentsMargins(0, 0, 0, 0)
sliderLayout.addStretch()
self.auto_size_slider = QSlider(Qt.Orientation.Horizontal)
self.auto_size_slider.setMinimum(200)
self.auto_size_slider.setMaximum(250)
self.auto_size_slider.setValue(self.auto_card_width)
self.auto_size_slider.setTickInterval(10)
self.auto_size_slider.setFixedWidth(150)
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
self.auto_size_slider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.auto_size_slider.sliderReleased.connect(self.on_auto_slider_released)
sliderLayout.addWidget(self.auto_size_slider)
autoInstallLayout.addLayout(sliderLayout)
# Хранение карточек # Хранение карточек
self.autoInstallGameCards = {} self.autoInstallGameCards = {}
self.allAutoInstallCards = [] self.allAutoInstallCards = []
@@ -1122,7 +1179,7 @@ class MainWindow(QMainWindow):
if exe_name in self.autoInstallGameCards and local_path: if exe_name in self.autoInstallGameCards and local_path:
card = self.autoInstallGameCards[exe_name] card = self.autoInstallGameCards[exe_name]
card.cover_path = local_path card.cover_path = local_path
load_pixmap_async(local_path, self.card_width, int(self.card_width * 1.5), card.on_cover_loaded) load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded)
# Загрузка игр # Загрузка игр
def on_autoinstall_games_loaded(games: list[tuple]): def on_autoinstall_games_loaded(games: list[tuple]):
@@ -1158,7 +1215,7 @@ class MainWindow(QMainWindow):
None, None, None, game_source, None, None, None, game_source,
select_callback=select_callback, select_callback=select_callback,
theme=self.theme, theme=self.theme,
card_width=self.card_width, card_width=self.auto_card_width,
parent=self.autoInstallContainer, parent=self.autoInstallContainer,
) )
@@ -1200,6 +1257,18 @@ class MainWindow(QMainWindow):
self.stackedWidget.addWidget(autoInstallPage) self.stackedWidget.addWidget(autoInstallPage)
def on_auto_slider_released(self):
"""Handles auto-install slider release to update card size."""
if hasattr(self, 'auto_size_slider') and self.auto_size_slider:
self.auto_card_width = self.auto_size_slider.value()
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
save_auto_card_size(self.auto_card_width)
for card in self.allAutoInstallCards:
card.update_card_size(self.auto_card_width)
self.autoInstallContainerLayout.invalidate()
self.autoInstallContainer.updateGeometry()
self.autoInstallScrollArea.updateGeometry()
def filterAutoInstallGames(self): def filterAutoInstallGames(self):
"""Filter auto install game cards based on search text.""" """Filter auto install game cards based on search text."""
search_text = self.autoInstallSearchLineEdit.text().lower().strip() search_text = self.autoInstallSearchLineEdit.text().lower().strip()
@@ -1765,7 +1834,22 @@ class MainWindow(QMainWindow):
self.gamesDisplayCombo.setCurrentIndex(idx) self.gamesDisplayCombo.setCurrentIndex(idx)
formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo) formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo)
# 4. Proxy settings # 4 Gamepad Type
self.gamepadTypeCombo = QComboBox()
self.gamepadTypeCombo.addItems(["Xbox", "PlayStation"])
self.gamepadTypeCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.gamepadTypeCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.gamepadTypeTitle = QLabel(_("Gamepad Type:"))
self.gamepadTypeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.gamepadTypeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
current_type_str = read_gamepad_type()
if current_type_str == "playstation":
self.gamepadTypeCombo.setCurrentText("PlayStation")
else:
self.gamepadTypeCombo.setCurrentText("Xbox")
formLayout.addRow(self.gamepadTypeTitle, self.gamepadTypeCombo)
# 5. Proxy settings
self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme) self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme)
self.proxyUrlEdit.setPlaceholderText(_("Proxy URL")) self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
@@ -1797,7 +1881,7 @@ class MainWindow(QMainWindow):
self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit) formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit)
# 5. Fullscreen setting for application # 6. Fullscreen setting for application
self.fullscreenCheckBox = QCheckBox(_("Launch Application in Fullscreen")) self.fullscreenCheckBox = QCheckBox(_("Launch Application in Fullscreen"))
self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1808,7 +1892,19 @@ class MainWindow(QMainWindow):
self.fullscreenCheckBox.setChecked(current_fullscreen) self.fullscreenCheckBox.setChecked(current_fullscreen)
formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox) formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox)
# 6. Automatic fullscreen on gamepad connection # 7. Minimize to tray setting
self.minimizeToTrayCheckBox = QCheckBox(_("Minimize to tray on close"))
self.minimizeToTrayCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.minimizeToTrayTitle = QLabel(_("Application Close Mode:"))
self.minimizeToTrayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.minimizeToTrayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
current_minimize_to_tray = read_minimize_to_tray()
self.minimizeToTrayCheckBox.setChecked(current_minimize_to_tray)
self.minimizeToTrayCheckBox.toggled.connect(lambda checked: save_minimize_to_tray(checked))
formLayout.addRow(self.minimizeToTrayTitle, self.minimizeToTrayCheckBox)
# 8. Automatic fullscreen on gamepad connection
self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected")) self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1820,7 +1916,7 @@ class MainWindow(QMainWindow):
self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen) self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox) formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
# 7. Gamepad haptic feedback config # 9. Gamepad haptic feedback config
self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback")) self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
@@ -1831,7 +1927,7 @@ 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 # # 9. Legendary Authentication
# self.legendaryAuthButton = AutoSizeButton( # self.legendaryAuthButton = AutoSizeButton(
# _("Open Legendary Login"), # _("Open Legendary Login"),
# icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login") # icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login")
@@ -2011,6 +2107,19 @@ class MainWindow(QMainWindow):
rumble_enabled = self.gamepadRumbleCheckBox.isChecked() rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
save_rumble_config(rumble_enabled) save_rumble_config(rumble_enabled)
gamepad_type_text = self.gamepadTypeCombo.currentText()
gpad_type = "playstation" if gamepad_type_text == "PlayStation" else "xbox"
save_gamepad_type(gpad_type)
if hasattr(self, 'input_manager'):
if gpad_type == "playstation":
self.input_manager.gamepad_type = GamepadType.PLAYSTATION
elif gpad_type == "xbox":
self.input_manager.gamepad_type = GamepadType.XBOX
else:
self.input_manager.gamepad_type = GamepadType.UNKNOWN
self.updateControlHints()
for card in self.game_library_manager.game_card_cache.values(): for card in self.game_library_manager.game_card_cache.values():
card.update_badge_visibility(filter_key) card.update_badge_visibility(filter_key)
@@ -2943,10 +3052,12 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to launch game {exe_name}: {e}") logger.error(f"Failed to launch game {exe_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e))) QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
def closeEvent(self, event): def closeEvent(self, event):
"""Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход.""" """Обработчик закрытия окна: проверяет настройку minimize_to_tray.
if hasattr(self, 'is_exiting') and self.is_exiting: Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
"""
minimize_to_tray = read_minimize_to_tray() # Импорт read_minimize_to_tray из config_utils
if hasattr(self, 'is_exiting') and self.is_exiting or not minimize_to_tray:
# Принудительное закрытие: завершаем процессы и приложение # Принудительное закрытие: завершаем процессы и приложение
for proc in self.game_processes: for proc in self.game_processes:
try: try:
@@ -2991,6 +3102,7 @@ class MainWindow(QMainWindow):
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
save_window_geometry(self.width(), self.height()) save_window_geometry(self.width(), self.height())
save_card_size(self.card_width) save_card_size(self.card_width)
save_auto_card_size(self.auto_card_width)
event.accept() event.accept()
else: else:

View File

@@ -1,7 +1,8 @@
from typing import cast from typing import cast, Any
from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout, from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
QSizePolicy, QWidget, QLineEdit) QSizePolicy, QWidget, QLineEdit)
from PySide6.QtCore import Qt, Signal, QProcess from PySide6.QtCore import Qt, Signal, QProcess, QSize
from PySide6.QtGui import QPixmap, QIcon
from portprotonqt.keyboard_layouts import keyboard_layouts from portprotonqt.keyboard_layouts import keyboard_layouts
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config from portprotonqt.config_utils import read_theme_from_config
@@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame):
self.margins = 10 self.margins = 10
self.num_cols = 14 self.num_cols = 14
# Find input_manager and main_window
self.input_manager: Any = None
self.main_window: Any = None
parent_widget: QWidget | None = self._parent
while parent_widget:
if hasattr(parent_widget, 'input_manager'):
self.input_manager = cast(Any, parent_widget).input_manager
self.main_window = cast(Any, parent_widget)
parent_widget = cast(QWidget | None, parent_widget.parent())
self.current_theme_name = read_theme_from_config()
self.initUI() self.initUI()
self.hide() self.hide()
@@ -119,6 +132,34 @@ class VirtualKeyboard(QFrame):
self.buttons: dict[str, QPushButton] = {} self.buttons: dict[str, QPushButton] = {}
self.update_keyboard() self.update_keyboard()
def set_gamepad_icon(self, button, icon_type, gtype=''):
"""Set gamepad icon on button based on type"""
if icon_type in ['back', 'add_game']:
icon_name = self.main_window.get_button_icon(icon_type, gtype)
else: # nav left/right
if icon_type in ['left', 'right']:
direction = icon_type
icon_name = self.main_window.get_nav_icon(direction, gtype)
else:
direction = 'left' if icon_type == 'left' else 'right'
icon_name = self.main_window.get_nav_icon(direction, gtype)
icon_path = 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():
button.setIcon(QIcon(pixmap))
button.setIconSize(QSize(20, 20))
return
else:
# Fallback to placeholder
placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
button.setIcon(QIcon(placeholder))
button.setIconSize(QSize(20, 20))
return
def update_keyboard(self): def update_keyboard(self):
coords = self._save_focused_coords() coords = self._save_focused_coords()
@@ -151,6 +192,9 @@ class VirtualKeyboard(QFrame):
button.setCheckable(True) button.setCheckable(True)
button.setChecked(self.shift_pressed) button.setChecked(self.shift_pressed)
button.clicked.connect(lambda checked: self.on_shift_click(checked)) button.clicked.connect(lambda checked: self.on_shift_click(checked))
# Add gamepad icon for Shift (RB/R)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(button, 'right', gtype)
else: else:
button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k)) button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
@@ -163,6 +207,9 @@ class VirtualKeyboard(QFrame):
shift.setCheckable(True) shift.setCheckable(True)
shift.setChecked(self.shift_pressed) shift.setChecked(self.shift_pressed)
shift.clicked.connect(lambda checked: self.on_shift_click(checked)) shift.clicked.connect(lambda checked: self.on_shift_click(checked))
# Add gamepad icon for Shift (RB/R)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(shift, 'right', gtype)
self.keyboard_layout.addWidget(shift, 3, 11, 1, 3) self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
button = QPushButton('CAPS') button = QPushButton('CAPS')
@@ -179,6 +226,9 @@ class VirtualKeyboard(QFrame):
backspace.setFixedSize(fixed_w, fixed_h) backspace.setFixedSize(fixed_w, fixed_h)
backspace.pressed.connect(self.on_backspace_pressed) backspace.pressed.connect(self.on_backspace_pressed)
backspace.released.connect(self.stop_backspace_repeat) backspace.released.connect(self.stop_backspace_repeat)
# Add gamepad icon for Backspace (X/Triangle)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(backspace, 'add_game', gtype)
self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1) self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
enter = QPushButton('Enter') enter = QPushButton('Enter')
@@ -189,6 +239,9 @@ class VirtualKeyboard(QFrame):
lang = QPushButton('🌐') lang = QPushButton('🌐')
lang.setFixedSize(fixed_w, fixed_h) lang.setFixedSize(fixed_w, fixed_h)
lang.clicked.connect(self.on_lang_click) lang.clicked.connect(self.on_lang_click)
# Add gamepad icon for Lang (LB/L)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(lang, 'left', gtype)
self.keyboard_layout.addWidget(lang, 4, 0, 1, 1) self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
clear = QPushButton('Clear') clear = QPushButton('Clear')
@@ -219,6 +272,9 @@ class VirtualKeyboard(QFrame):
hide_button = QPushButton('Hide') hide_button = QPushButton('Hide')
hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
hide_button.clicked.connect(self.hide) hide_button.clicked.connect(self.hide)
# Add gamepad icon for Hide (B/Circle)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(hide_button, 'back', gtype)
self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2) self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
if coords: if coords: