feat: added ps controllers hint
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
@@ -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)
|
||||
|
@@ -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
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 880 B |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 874 B |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 943 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 933 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 956 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
BIN
portprotonqt/themes/standart/images/ps_circle.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
portprotonqt/themes/standart/images/ps_cross.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/ps_l1.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/ps_options.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
portprotonqt/themes/standart/images/ps_r1.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
portprotonqt/themes/standart/images/ps_share.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
portprotonqt/themes/standart/images/ps_triangle.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 562 KiB After Width: | Height: | Size: 364 KiB |
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 430 KiB |
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 104 KiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
portprotonqt/themes/standart/images/xbox_a.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/xbox_b.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/xbox_lb.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
portprotonqt/themes/standart/images/xbox_rb.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
portprotonqt/themes/standart/images/xbox_start.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
portprotonqt/themes/standart/images/xbox_view.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
portprotonqt/themes/standart/images/xbox_x.png
Normal file
After Width: | Height: | Size: 1.8 KiB |