14 Commits

Author SHA1 Message Date
Renovate Bot
92572bf5a1 chore(deps): update archlinux:base-devel docker digest to 87a967f 2025-10-19 00:01:20 +00:00
438e9737ea chore(release): drop sha256 sums
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 21:07:37 +05:00
2d39a4c740 fix: fix CloseEvent on native package
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 21:06:23 +05:00
567203b0b0 chore: bump to 0.1.8
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:22:32 +05:00
502cbc5030 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:20:50 +05:00
9b61215152 chore(theme): update screenshots
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:17:47 +05:00
10d3fe8ab4 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 13:40:56 +05:00
a568ad9ef8 fix(add_game_dialog): prevent overwriting manually entered game name
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 13:09:58 +05:00
f074843fc8 fix: prevent udev monitor hang by using non-blocking poll with timeout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 12:53:47 +05:00
4ab078b93e fix: sync card_width between GameLibraryManager and MainWindow to prevent config overwrite
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 12:17:17 +05:00
7df6ad3b80 feat(autoinstalls): added slider
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:55:17 +05:00
464ad0fe9c chore: optimize and clean code
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:09:02 +05:00
cde92885d4 feat(virtual_keybord): added gamepad hint
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 2025-10-16 23:20:48 +05:00
29 changed files with 441 additions and 114 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:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host

View File

@@ -8,7 +8,7 @@ on:
env: env:
# Common version, will be used for tagging the release # Common version, will be used for tagging the release
VERSION: 0.1.7 VERSION: 0.1.8
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -188,4 +188,4 @@ jobs:
tag_name: v${{ env.VERSION }} tag_name: v${{ env.VERSION }}
prerelease: true prerelease: true
files: release/**/* files: release/**/*
sha256sum: true sha256sum: false

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

View File

@@ -3,16 +3,24 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [0.1.8] - 2025-10-18
### Added ### Added
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению - В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
### Changed ### Changed
- При завершении автоустановки приложение больше не перезапускается - При завершении автоустановки приложение больше не перезапускается
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
- Обновлены и дополнены скриншоты темы
### Fixed ### Fixed
- Исправлено наложение карточек при смене фильтра игр - Исправлено наложение карточек при смене фильтра игр
- Исправлена невозможность запуска приложения без подключёного геймпада
- Исправлена невозможность установки компонентов Winetricks через геймпад
- Ресиверы и виртуальные устройства больше не считаются за геймпад
### Contributors ### Contributors

View File

@@ -36,7 +36,7 @@ AppDir:
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.7 version: 0.1.8
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:

View File

@@ -1,5 +1,5 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.7 pkgver=0.1.8
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')

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.7 %global pypi_version 0.1.8
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1

View File

@@ -11,7 +11,7 @@ from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.7" __app_version__ = "0.1.8"
def get_version(): def get_version():
try: try:

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.

View File

@@ -1034,8 +1034,8 @@ class AddGameDialog(QDialog):
"""Обработчик выбора файла в FileExplorer""" """Обработчик выбора файла в FileExplorer"""
self.exeEdit.setText(file_path) self.exeEdit.setText(file_path)
self.last_exe_path = file_path # Update last selected exe path self.last_exe_path = file_path # Update last selected exe path
if not self.edit_mode: if not self.edit_mode and not self.nameEdit.text().strip():
# Автоматически заполняем имя игры, если не в режиме редактирования # Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную
game_name = os.path.splitext(os.path.basename(file_path))[0] game_name = os.path.splitext(os.path.basename(file_path))[0]
self.nameEdit.setText(game_name) self.nameEdit.setText(game_name)

View File

@@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol):
# Required attributes # Required attributes
searchEdit: CustomLineEdit searchEdit: CustomLineEdit
_last_card_width: int _last_card_width: int
card_width: int
current_hovered_card: GameCard | None current_hovered_card: GameCard | None
current_focused_card: GameCard | None current_focused_card: GameCard | None
gamesListWidget: QWidget | None gamesListWidget: QWidget | None
@@ -128,6 +129,8 @@ class GameLibraryManager:
self.card_width = self.sizeSlider.value() self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px") self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width) save_card_size(self.card_width)
self.main_window.card_width = self.card_width
self.main_window._last_card_width = self.card_width
for card in self.game_card_cache.values(): for card in self.game_card_cache.values():
card.update_card_size(self.card_width) card.update_card_size(self.card_width)
self.update_game_grid() self.update_game_grid()

View File

@@ -4,7 +4,7 @@ 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, QTableWidgetItem 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
@@ -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,
@@ -1436,75 +1437,258 @@ class InputManager(QObject):
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
def init_gamepad(self) -> None: def init_gamepad(self) -> None:
self.monitor_observer = None # Добавляем атрибут для хранения observer 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 monitor для геймпадов.
Использует select.poll() вместо блокирующего 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)
self.monitor_observer = observer # Сохраняем ссылку для остановки try:
observer.start() # Это блокирует поток до вызова send_stop() monitor.start()
logger.info("MonitorObserver stopped gracefully") except Exception as e:
logger.error(f"Failed to start udev monitor: {e}")
return
import select
fd = monitor.fileno()
poller = select.poll()
poller.register(fd, select.POLLIN)
# Короткий дренаж событий при запуске (0.5 сек)
drain_start = time.time()
drained_count = 0
while time.time() - drain_start < 0.5:
events = poller.poll(100)
if not events:
continue
try:
_ = monitor.poll(timeout=0) # просто читаем, не обрабатываем
drained_count += 1
except Exception:
break
self.monitor_ready = True
logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...")
# Основной цикл
while self.running:
events = poller.poll(1000) # 1 сек таймаут
if not events:
continue # просто ждём, не блокируем
try:
device = monitor.poll(timeout=0)
except Exception as e:
logger.debug(f"Monitor poll failed: {e}")
continue
if not device:
continue
action = device.action
if action and self._is_joystick_device(device):
logger.info(f"Joystick hotplug event: {action} for {device.sys_name}")
# отправляем сигнал в Qt-поток
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}") # Проверяем, действительно ли это новый геймпад
if new_gamepad:
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:
@@ -1568,26 +1752,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
# Останавливаем udev monitor # Останавливаем все таймеры
if self.monitor_observer: if hasattr(self, 'gamepad_check_timer'):
try: self.gamepad_check_timer.stop()
logger.info("Stopping udev monitor...")
self.monitor_observer.send_stop()
except Exception as e:
logger.warning(f"Error stopping monitor observer: {e}")
self.monitor_observer = None
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(timeout=2.0) # Добавлен таймаут 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

@@ -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, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray 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)
@@ -1100,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()
@@ -1150,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 = []
@@ -1159,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]):
@@ -1195,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,
) )
@@ -1237,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()
@@ -3024,55 +3056,73 @@ class MainWindow(QMainWindow):
"""Обработчик закрытия окна: проверяет настройку minimize_to_tray. """Обработчик закрытия окна: проверяет настройку minimize_to_tray.
Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем. Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
""" """
minimize_to_tray = read_minimize_to_tray() # Импорт read_minimize_to_tray из config_utils minimize_to_tray = read_minimize_to_tray()
if hasattr(self, 'is_exiting') and self.is_exiting or not minimize_to_tray:
# Принудительное закрытие: завершаем процессы и приложение
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
logger.debug(f"Terminating child process {child.pid}")
child.terminate()
except psutil.NoSuchProcess:
logger.debug(f"Child process {child.pid} already terminated")
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
logger.debug(f"Killing child process {child.pid}")
child.kill()
logger.debug(f"Terminating process group {proc.pid}")
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 = [] # Очищаем список процессов if minimize_to_tray:
# Просто сворачиваем в трей
# Очищаем таймеры
if hasattr(self, 'games_load_timer') and self.games_load_timer is not None and self.games_load_timer.isActive():
self.games_load_timer.stop()
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive():
self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
self.wine_monitor_timer.stop()
self.wine_monitor_timer.deleteLater()
self.wine_monitor_timer = None
# Сохраняем настройки окна
if not read_fullscreen_config():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
save_window_geometry(self.width(), self.height())
save_card_size(self.card_width)
event.accept()
else:
# Сворачиваем в трей вместо закрытия
self.hide()
event.ignore() event.ignore()
self.hide()
return
# Полное закрытие приложения
self.is_exiting = True
event.accept()
# Скрываем и удаляем иконку трея
if hasattr(self, "tray_manager") and self.tray_manager.tray_icon:
self.tray_manager.tray_icon.hide()
self.tray_manager.tray_icon.deleteLater()
# Сохраняем размеры карточек
save_card_size(self.card_width)
save_auto_card_size(self.auto_card_width)
# Сохраняем размеры окна (если не в полноэкранном режиме)
if not read_fullscreen_config():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
save_window_geometry(self.width(), self.height())
# Завершаем все игровые процессы
for proc in getattr(self, "game_processes", []):
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
logger.debug(f"Terminating child process {child.pid}")
child.terminate()
except psutil.NoSuchProcess:
logger.debug(f"Child process {child.pid} already terminated")
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
logger.debug(f"Killing child process {child.pid}")
child.kill()
logger.debug(f"Terminating process group {proc.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {getattr(proc, 'pid', '?')} already terminated: {e}")
except Exception as e:
logger.warning(f"Failed to terminate process {getattr(proc, 'pid', '?')}: {e}")
self.game_processes = []
# Универсальная остановка и удаление таймеров
timers = [
"games_load_timer",
"settingsDebounceTimer",
"searchDebounceTimer",
"checkProcessTimer",
"wine_monitor_timer",
]
for tname in timers:
timer = getattr(self, tname, None)
if timer and timer.isActive():
timer.stop()
if timer:
timer.deleteLater()
setattr(self, tname, None)

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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:

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.7" version = "0.1.8"
description = "A project to rewrite PortProton (PortWINE) using PySide" description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md" readme = "README.md"
license = { text = "GPL-3.0" } license = { text = "GPL-3.0" }

2
uv.lock generated
View File

@@ -527,7 +527,7 @@ wheels = [
[[package]] [[package]]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.7" version = "0.1.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "babel" }, { name = "babel" },