feat: added ps controllers hint

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-09-13 11:46:48 +05:00
parent a8c4ae6f7b
commit 1c82b34e36
37 changed files with 172 additions and 46 deletions

View File

@@ -3,6 +3,7 @@ import threading
import os
from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from enum import Enum
from pyudev import Context, Monitor, MonitorObserver, Device
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
@@ -38,23 +39,29 @@ class MainWindowProtocol(Protocol):
current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None
# Mapping of actions to evdev button codes, includes Xbox and PlayStation controllers
# Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch controllers
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo
BUTTONS = {
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS)
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS)
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS)
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS)
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS)
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS)
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS)
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS)
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button
'increase_size': {ecodes.BTN_TR2}, # RT (Xbox) / R2 (PS)
'decrease_size': {ecodes.BTN_TL2}, # LT (Xbox) / L2 (PS)
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS) / B (Switch)
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS) / A (Switch)
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS) / Y (Switch)
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS) / X (Switch)
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS) / L (Switch)
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS) / R (Switch)
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS) / + (Switch)
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS) / - (Switch)
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button / Home (Switch)
'increase_size': {ecodes.BTN_TR2}, # RT (Xbox) / R2 (PS) / ZR (Switch)
'decrease_size': {ecodes.BTN_TL2}, # LT (Xbox) / L2 (PS) / ZL (Switch)
}
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
class InputManager(QObject):
"""
Manages input from gamepads and keyboards for navigating the application interface.
@@ -76,6 +83,7 @@ class InputManager(QObject):
super().__init__(cast(QObject, main_window))
self._parent = main_window
self._gamepad_handling_enabled = True
self.gamepad_type = GamepadType.UNKNOWN
# Ensure attributes exist on main_window
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
@@ -132,6 +140,38 @@ class InputManager(QObject):
# Initialize evdev + hotplug
self.init_gamepad()
def detect_gamepad_type(self, device: InputDevice) -> GamepadType:
"""
Определяет тип геймпада по capabilities
"""
caps = device.capabilities()
keys = set(caps.get(ecodes.EV_KEY, []))
# Для EV_ABS вытаскиваем только коды (первый элемент кортежа)
abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])}
# Xbox layout
if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys):
if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes):
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
# PlayStation layout
if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys):
self.gamepad_type = GamepadType.PLAYSTATION
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.PLAYSTATION
# Steam Controller / Deck (трекпады)
if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT):
self.gamepad_type = GamepadType.XBOX
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.XBOX
# Fallback
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
def enable_file_explorer_mode(self, file_explorer):
"""Настройка обработки геймпада для FileExplorer"""
try:
@@ -1043,6 +1083,8 @@ class InputManager(QObject):
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)
logger.info(f"Detected gamepad type: {self.gamepad_type.value}")
self.stop_rumble()
self.gamepad = new_gamepad
if self.gamepad_thread:
@@ -1137,5 +1179,7 @@ class InputManager(QObject):
self.gamepad_thread.join()
if self.gamepad:
self.gamepad.close()
self.gamepad = None
self.gamepad_type = GamepadType.UNKNOWN
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)

View File

@@ -15,6 +15,7 @@ from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from portprotonqt.system_overlay import SystemOverlay
from portprotonqt.input_manager import GamepadType
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
@@ -221,6 +222,50 @@ class MainWindow(QMainWindow):
else:
self.showNormal()
def get_button_icon(self, action: str, gtype: GamepadType) -> str:
"""Get the icon name for a specific action and gamepad type."""
mappings = {
'confirm': {
GamepadType.XBOX: "xbox_a",
GamepadType.PLAYSTATION: "ps_cross",
},
'back': {
GamepadType.XBOX: "xbox_b",
GamepadType.PLAYSTATION: "ps_circle",
},
'add_game': {
GamepadType.XBOX: "xbox_x",
GamepadType.PLAYSTATION: "ps_triangle",
},
'context_menu': {
GamepadType.XBOX: "xbox_start",
GamepadType.PLAYSTATION: "ps_options",
},
'menu': {
GamepadType.XBOX: "xbox_view",
GamepadType.PLAYSTATION: "ps_share",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def get_nav_icon(self, direction: str, gtype: GamepadType) -> str:
"""Get the icon name for navigation direction and gamepad type."""
if direction == 'left':
action = 'prev_tab'
else:
action = 'next_tab'
mappings = {
'prev_tab': {
GamepadType.XBOX: "xbox_lb",
GamepadType.PLAYSTATION: "ps_l1",
},
'next_tab': {
GamepadType.XBOX: "xbox_rb",
GamepadType.PLAYSTATION: "ps_r1",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def createControlHintsWidget(self) -> QWidget:
from portprotonqt.localization import _
"""Creates a widget displaying control hints for gamepad and keyboard."""
@@ -232,12 +277,12 @@ class MainWindow(QMainWindow):
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
gamepad_hints = [
("button_a", _("Select")),
("button_b", _("Back")),
("button_x", _("Add Game")),
("button_start", _("Menu")),
("button_select", _("Fullscreen")),
gamepad_actions = [
("confirm", _("Select")),
("back", _("Back")),
("add_game", _("Add Game")),
("context_menu", _("Menu")),
("menu", _("Fullscreen")),
]
keyboard_hints = [
@@ -250,7 +295,7 @@ class MainWindow(QMainWindow):
self.hintsLabels = []
def makeHint(icon_name: str, action: str, visible: bool):
def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
@@ -262,12 +307,12 @@ class MainWindow(QMainWindow):
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
if not icon_path:
icon_path = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if icon_path:
pixmap.load(str(icon_path))
for candidate in (
self.theme_manager.get_theme_image(icon_name, self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and pixmap.load(str(candidate)):
break
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
@@ -279,33 +324,43 @@ class MainWindow(QMainWindow):
layout.addWidget(icon_label)
# текст действия
text_label = QLabel(action)
text_label = QLabel(action_text)
text_label.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
layout.addWidget(text_label)
container.setVisible(visible)
self.hintsLabels.append((container, icon_name))
if is_gamepad:
container.setVisible(False)
self.hintsLabels.append((container, icon_label, action)) # Store action for dynamic update
else:
container.setVisible(True)
self.hintsLabels.append((container, icon_label, None)) # Keyboard, no action
hintsLayout.addWidget(container)
for icon, action in gamepad_hints:
makeHint(icon, action, visible=False)
# Create gamepad hints
for action, text in gamepad_actions:
makeHint("placeholder", text, True, action) # Initial placeholder
for icon, action in keyboard_hints:
makeHint(icon, action, visible=True)
# Create keyboard hints
for icon, text in keyboard_hints:
makeHint(icon, text, False)
hintsLayout.addStretch() # растянуть вправо
hintsLayout.addStretch()
return hintsWidget
def updateNavButtons(self, *args) -> None:
"""Updates control hints and navigation buttons based on gamepad connection status."""
"""Updates navigation buttons based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
logger.debug("Updating control hints, gamepad connected: %s", is_gamepad_connected)
gtype = self.input_manager.gamepad_type
logger.debug("Updating nav buttons, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
# Left navigation button
left_pix = QPixmap()
left_icon_name = "button_lb" if is_gamepad_connected else "key_left"
if is_gamepad_connected:
left_icon_name = self.get_nav_icon('left', gtype)
else:
left_icon_name = "key_left"
left_icon = self.theme_manager.get_theme_image(left_icon_name, self.current_theme_name)
if left_icon:
left_pix.load(str(left_icon))
@@ -315,11 +370,14 @@ class MainWindow(QMainWindow):
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.leftNavButton.setVisible(is_gamepad_connected or not is_gamepad_connected)
self.leftNavButton.setVisible(True) # Always visible, icon changes
# Right navigation button
right_pix = QPixmap()
right_icon_name = "button_rb" if is_gamepad_connected else "key_right"
if is_gamepad_connected:
right_icon_name = self.get_nav_icon('right', gtype)
else:
right_icon_name = "key_right"
right_icon = self.theme_manager.get_theme_image(right_icon_name, self.current_theme_name)
if right_icon:
right_pix.load(str(right_icon))
@@ -329,17 +387,41 @@ class MainWindow(QMainWindow):
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.rightNavButton.setVisible(is_gamepad_connected or not is_gamepad_connected)
self.rightNavButton.setVisible(True) # Always visible, icon changes
def updateControlHints(self, *args) -> None:
"""Updates control hints and navigation buttons based on gamepad connection status."""
"""Updates control hints based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
logger.debug("Updating control hints, gamepad connected: %s", is_gamepad_connected)
gtype = self.input_manager.gamepad_type
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
for container, icon_name in self.hintsLabels:
if icon_name.startswith("button_"): # геймпад
container.setVisible(is_gamepad_connected)
else: # клавиатура
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
for container, icon_label, action in self.hintsLabels:
if action in gamepad_actions: # Gamepad hint
if is_gamepad_connected:
container.setVisible(True)
# Update icon based on type
icon_name = self.get_button_icon(action, gtype)
icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
else: # Keyboard hint
container.setVisible(not is_gamepad_connected)
# Update navigation buttons

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB