@@ -4,13 +4,13 @@ from typing import Protocol, cast
from evdev import InputDevice , ecodes , list_devices
from evdev import InputDevice , ecodes , list_devices
import pyudev
import pyudev
from PySide6 . QtWidgets import QWidget , QStackedWidget , QApplication , QScrollArea , QLineEdit
from PySide6 . QtWidgets import QWidget , QStackedWidget , QApplication , QScrollArea , QLineEdit
from PySide6 . QtCore import Qt , QObject , QEvent , QPoint
from PySide6 . QtCore import Qt , QObject , QEvent , QPoint , Signal , Slot
from PySide6 . QtGui import QKeyEvent
from PySide6 . QtGui import QKeyEvent
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
from portprotonqt . custom_widgets import NavLabel
from portprotonqt . game_card import GameCard
from portprotonqt . game_card import GameCard
from portprotonqt . config_utils import read_fullscreen_config
from portprotonqt . config_utils import read_fullscreen_config , read_window_geometry , save_window_geometry
logger = get_logger ( __name__ )
logger = get_logger ( __name__ )
@@ -31,23 +31,15 @@ class MainWindowProtocol(Protocol):
currentDetailPage : QWidget | None
currentDetailPage : QWidget | None
current_exec_line : str | None
current_exec_line : str | None
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers (https://www.kernel.org/doc/html/v4.12/input/gamepad.html)
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers
BUTTONS = {
BUTTONS = {
# South button: X (PlayStation), A (Xbox), B (Switch Joy-Con south)
' confirm ' : { ecodes . BTN_A } ,
' confirm ' : { ecodes . BTN_SOUTH , ecodes . BTN_A } ,
' back ' : { ecodes . BTN_B } ,
# East button: Circle (PS), B (Xbox), A (Switch Joy-Con east)
' add_game ' : { ecodes . BTN_Y } ,
' back ' : { ecodes . BTN_EAST , ecodes . BTN_B } ,
# North button: Triangle (PS), Y (Xbox), X (Switch Joy-Con north)
' add_game ' : { ecodes . BTN_NORTH , ecodes . BTN_Y } ,
# Shoulder buttons: L1/L2 (PS), LB (Xbox), L (Switch): BTN_TL, BTN_TL2
' prev_tab ' : { ecodes . BTN_TL , ecodes . BTN_TL2 } ,
' prev_tab ' : { ecodes . BTN_TL , ecodes . BTN_TL2 } ,
# Shoulder buttons: R1/R2 (PS), RB (Xbox), R (Switch): BTN_TR, BTN_TR2
' next_tab ' : { ecodes . BTN_TR , ecodes . BTN_TR2 } ,
' next_tab ' : { ecodes . BTN_TR , ecodes . BTN_TR2 } ,
# Optional: stick presses on Switch Joy-Con
' confirm_stick ' : { ecodes . BTN_THUMBL , ecodes . BTN_THUMBR } ,
' confirm_stick ' : { ecodes . BTN_THUMBL , ecodes . BTN_THUMBR } ,
# Start button for context menu
' context_menu ' : { ecodes . BTN_START } ,
' context_menu ' : { ecodes . BTN_START } ,
# Select/home for back/menu
' menu ' : { ecodes . BTN_SELECT , ecodes . BTN_MODE } ,
' menu ' : { ecodes . BTN_SELECT , ecodes . BTN_MODE } ,
}
}
@@ -55,8 +47,14 @@ class InputManager(QObject):
"""
"""
Manages input from gamepads and keyboards for navigating the application interface.
Manages input from gamepads and keyboards for navigating the application interface.
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
for seamless UI interaction.
for seamless UI interaction. Enables fullscreen mode when a gamepad is connected
and restores normal mode when disconnected.
"""
"""
# Signals for gamepad events
button_pressed = Signal ( int ) # Signal for button presses
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)
def __init__ (
def __init__ (
self ,
self ,
main_window : MainWindowProtocol ,
main_window : MainWindowProtocol ,
@@ -81,22 +79,48 @@ class InputManager(QObject):
self . running = True
self . running = True
self . _is_fullscreen = read_fullscreen_config ( )
self . _is_fullscreen = read_fullscreen_config ( )
# Connect signals to slots
self . button_pressed . connect ( self . handle_button_slot )
self . dpad_moved . connect ( self . handle_dpad_slot )
self . toggle_fullscreen . connect ( self . handle_fullscreen_slot )
# Install keyboard event filter
# Install keyboard event filter
app = QApplication . instance ( )
app = QApplication . instance ( )
if app is not None :
if app is not None :
app . installEventFilter ( self )
app . installEventFilter ( self )
else :
logger . error ( " QApplication instance is None, cannot install event filter " )
# Initialize evdev + hotplug
# Initialize evdev + hotplug
self . init_gamepad ( )
self . init_gamepad ( )
@Slot ( bool )
def handle_fullscreen_slot ( self , enable : bool ) - > None :
try :
if read_fullscreen_config ( ) :
return
window = self . _parent
if not isinstance ( window , QWidget ) :
return
if enable and not self . _is_fullscreen :
if not window . isFullScreen ( ) :
save_window_geometry ( window . width ( ) , window . height ( ) )
window . showFullScreen ( )
self . _is_fullscreen = True
elif not enable and self . _is_fullscreen :
window . showNormal ( )
width , height = read_window_geometry ( )
if width > 0 and height > 0 :
window . resize ( width , height )
self . _is_fullscreen = False
save_window_geometry ( width , height )
except Exception as e :
logger . error ( f " Error in handle_fullscreen_slot: { e } " , exc_info = True )
def eventFilter ( self , obj : QObject , event : QEvent ) - > bool :
def eventFilter ( self , obj : QObject , event : QEvent ) - > bool :
app = QApplication . instance ( )
app = QApplication . instance ( )
if not app :
if not app :
return super ( ) . eventFilter ( obj , event )
return super ( ) . eventFilter ( obj , event )
# 1) Интересуют только нажатия клавиш
# Handle only key press events
if not ( isinstance ( event , QKeyEvent ) and event . type ( ) == QEvent . Type . KeyPress ) :
if not ( isinstance ( event , QKeyEvent ) and event . type ( ) == QEvent . Type . KeyPress ) :
return super ( ) . eventFilter ( obj , event )
return super ( ) . eventFilter ( obj , event )
@@ -105,17 +129,16 @@ class InputManager(QObject):
focused = QApplication . focusWidget ( )
focused = QApplication . focusWidget ( )
popup = QApplication . activePopupWidget ( )
popup = QApplication . activePopupWidget ( )
# 2) Закрытие приложения по Ctrl+Q
# Close application with Ctrl+Q
if key == Qt . Key . Key_Q and modifiers & Qt . KeyboardModifier . ControlModifier :
if key == Qt . Key . Key_Q and modifiers & Qt . KeyboardModifier . ControlModifier :
app . quit ( )
app . quit ( )
return True
return True
# 3) Если открыт любой popup — не перехватываем ENTER, ESC и стрелки
# Skip navigation keys if a popup is open
if popup :
if popup :
# возвращаем False, чтобы событие пошло дальше в Qt и закрыло popup как нужно
return False
return False
# 4) Навигация в полноэкранном просмотре
# FullscreenDialog navigation
active_win = QApplication . activeWindow ( )
active_win = QApplication . activeWindow ( )
if isinstance ( active_win , FullscreenDialog ) :
if isinstance ( active_win , FullscreenDialog ) :
if key == Qt . Key . Key_Right :
if key == Qt . Key . Key_Right :
@@ -128,27 +151,25 @@ class InputManager(QObject):
active_win . close ( )
active_win . close ( )
return True
return True
# 5) Н а странице деталей Enter запускает/останавливает игру
# Launch/stop game on detail page
if self . _parent . currentDetailPage and key in ( Qt . Key . Key_Return , Qt . Key . Key_Enter ) :
if self . _parent . currentDetailPage and key in ( Qt . Key . Key_Return , Qt . Key . Key_Enter ) :
if self . _parent . current_exec_line :
if self . _parent . current_exec_line :
self . _parent . toggleGame ( self . _parent . current_exec_line , None )
self . _parent . toggleGame ( self . _parent . current_exec_line , None )
return True
return True
# 6) Открытие контекстного меню для GameCard
# Context menu for GameCard
if isinstance ( focused , GameCard ) :
if isinstance ( focused , GameCard ) :
if key == Qt . Key . Key_F10 and Qt . KeyboardModifier . ShiftModifier :
if key == Qt . Key . Key_F10 and Qt . KeyboardModifier . ShiftModifier :
pos = QPoint ( focused . width ( ) / / 2 , focused . height ( ) / / 2 )
pos = QPoint ( focused . width ( ) / / 2 , focused . height ( ) / / 2 )
focused . _show_context_menu ( pos )
focused . _show_context_menu ( pos )
return True
return True
# 7) Навигация по карточкам в Library
# Navigation in Library tab
if self . _parent . stackedWidget . currentIndex ( ) == 0 :
if self . _parent . stackedWidget . currentIndex ( ) == 0 :
game_cards = self . _parent . gamesListWidget . findChildren ( GameCard )
game_cards = self . _parent . gamesListWidget . findChildren ( GameCard )
scroll_area = self . _parent . gamesListWidget . parentWidget ( )
scroll_area = self . _parent . gamesListWidget . parentWidget ( )
while scroll_area and not isinstance ( scroll_area , QScrollArea ) :
while scroll_area and not isinstance ( scroll_area , QScrollArea ) :
scroll_area = scroll_area . parentWidget ( )
scroll_area = scroll_area . parentWidget ( )
if not scroll_area :
logger . warning ( " No QScrollArea found for gamesListWidget " )
if isinstance ( focused , GameCard ) :
if isinstance ( focused , GameCard ) :
current_index = game_cards . index ( focused ) if focused in game_cards else - 1
current_index = game_cards . index ( focused ) if focused in game_cards else - 1
@@ -184,7 +205,7 @@ class InputManager(QObject):
scroll_area . ensureWidgetVisible ( next_card , 50 , 50 )
scroll_area . ensureWidgetVisible ( next_card , 50 , 50 )
return True
return True
# 8) Переключение вкладок ←/→
# Tab switching with Left/Right keys
idx = self . _parent . stackedWidget . currentIndex ( )
idx = self . _parent . stackedWidget . currentIndex ( )
total = len ( self . _parent . tabButtons )
total = len ( self . _parent . tabButtons )
if key == Qt . Key . Key_Left and not isinstance ( focused , GameCard ) :
if key == Qt . Key . Key_Left and not isinstance ( focused , GameCard ) :
@@ -198,7 +219,7 @@ class InputManager(QObject):
self . _parent . tabButtons [ new ] . setFocus ( )
self . _parent . tabButtons [ new ] . setFocus ( )
return True
return True
# 9) Спуск в содержимое вкладки ↓
# Navigate down into tab content
if key == Qt . Key . Key_Down :
if key == Qt . Key . Key_Down :
if isinstance ( focused , NavLabel ) :
if isinstance ( focused , NavLabel ) :
page = self . _parent . stackedWidget . currentWidget ( )
page = self . _parent . stackedWidget . currentWidget ( )
@@ -212,15 +233,15 @@ class InputManager(QObject):
focused . focusNextChild ( )
focused . focusNextChild ( )
return True
return True
# 10) Подъём по содержимому вкладки ↑
# Navigate up through tab content
if key == Qt . Key . Key_Up :
if key == Qt . Key . Key_Up :
if isinstance ( focused , NavLabel ) :
if isinstance ( focused , NavLabel ) :
return True # Н е даём уйти выше NavLabel
return True
if focused is not None :
if focused is not None :
focused . focusPreviousChild ( )
focused . focusPreviousChild ( )
return True
return True
# 11) Общие : Activate, Back, Add
# General actions : Activate, Back, Add
if key in ( Qt . Key . Key_Return , Qt . Key . Key_Enter ) :
if key in ( Qt . Key . Key_Return , Qt . Key . Key_Enter ) :
self . _parent . activateFocusedWidget ( )
self . _parent . activateFocusedWidget ( )
return True
return True
@@ -235,18 +256,11 @@ class InputManager(QObject):
self . _parent . openAddGameDialog ( )
self . _parent . openAddGameDialog ( )
return True
return True
# 12) Переключение полноэкранного режима по F11
# Toggle fullscreen with F11
if key == Qt . Key . Key_F11 :
if key == Qt . Key . Key_F11 :
if read_fullscreen_config ( ) :
if read_fullscreen_config ( ) :
return True
return True
window = self . _parent
self . toggle_fullscreen . emit ( not self . _is_fullscreen )
if isinstance ( window , QWidget ) :
if self . _is_fullscreen :
window . showNormal ( )
self . _is_fullscreen = False
else :
window . showFullScreen ( )
self . _is_fullscreen = True
return True
return True
return super ( ) . eventFilter ( obj , event )
return super ( ) . eventFilter ( obj , event )
@@ -254,9 +268,10 @@ class InputManager(QObject):
def init_gamepad ( self ) - > None :
def init_gamepad ( self ) - > None :
self . check_gamepad ( )
self . check_gamepad ( )
threading . Thread ( target = self . run_udev_monitor , daemon = True ) . start ( )
threading . Thread ( target = self . run_udev_monitor , daemon = True ) . start ( )
logger . info ( " Input 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 :
try :
context = pyudev . Context ( )
context = pyudev . Context ( )
monitor = pyudev . Monitor . from_netlink ( context )
monitor = pyudev . Monitor . from_netlink ( context )
monitor . filter_by ( subsystem = ' input ' )
monitor . filter_by ( subsystem = ' input ' )
@@ -264,8 +279,11 @@ class InputManager(QObject):
observer . start ( )
observer . start ( )
while self . running :
while self . running :
time . sleep ( 1 )
time . sleep ( 1 )
except Exception as e :
logger . error ( f " Error in udev monitor: { e } " , exc_info = True )
def handle_udev_event ( self , action : str , device : pyudev . Device ) - > None :
def handle_udev_event ( self , action : str , device : pyudev . Device ) - > None :
try :
if action == ' add ' :
if action == ' add ' :
time . sleep ( 0.1 )
time . sleep ( 0.1 )
self . check_gamepad ( )
self . check_gamepad ( )
@@ -275,8 +293,13 @@ class InputManager(QObject):
self . gamepad = None
self . gamepad = None
if self . gamepad_thread :
if self . gamepad_thread :
self . gamepad_thread . join ( )
self . gamepad_thread . join ( )
# Signal to exit fullscreen mode
self . toggle_fullscreen . emit ( False )
except Exception as e :
logger . error ( f " Error handling udev event: { e } " , exc_info = True )
def check_gamepad ( self ) - > None :
def check_gamepad ( self ) - > None :
try :
new_gamepad = self . find_gamepad ( )
new_gamepad = self . find_gamepad ( )
if new_gamepad and new_gamepad != self . gamepad :
if new_gamepad and new_gamepad != self . gamepad :
logger . info ( f " Gamepad connected: { new_gamepad . name } " )
logger . info ( f " Gamepad connected: { new_gamepad . name } " )
@@ -285,14 +308,22 @@ class InputManager(QObject):
self . gamepad_thread . join ( )
self . gamepad_thread . join ( )
self . gamepad_thread = threading . Thread ( target = self . monitor_gamepad , daemon = True )
self . gamepad_thread = threading . Thread ( target = self . monitor_gamepad , daemon = True )
self . gamepad_thread . start ( )
self . gamepad_thread . start ( )
# Signal to enter fullscreen mode
self . toggle_fullscreen . emit ( True )
except Exception as e :
logger . error ( f " Error checking gamepad: { e } " , exc_info = True )
def find_gamepad ( self ) - > InputDevice | None :
def find_gamepad ( self ) - > InputDevice | None :
try :
devices = [ InputDevice ( path ) for path in list_devices ( ) ]
devices = [ InputDevice ( path ) for path in list_devices ( ) ]
for device in devices :
for device in devices :
caps = device . capabilities ( )
caps = device . capabilities ( )
if ecodes . EV_KEY in caps or ecodes . EV_ABS in caps :
if ecodes . EV_KEY in caps or ecodes . EV_ABS in caps :
return device
return device
return None
return None
except Exception as e :
logger . error ( f " Error finding gamepad: { e } " , exc_info = True )
return None
def monitor_gamepad ( self ) - > None :
def monitor_gamepad ( self ) - > None :
try :
try :
@@ -305,16 +336,29 @@ class InputManager(QObject):
continue
continue
now = time . time ( )
now = time . time ( )
if event . type == ecodes . EV_KEY and event . value == 1 :
if event . type == ecodes . EV_KEY and event . value == 1 :
self . handle_button ( event . code )
self . button_pressed . emit ( event . code )
elif event . type == ecodes . EV_ABS :
elif event . type == ecodes . EV_ABS :
self . handle_dpad ( event . code , event . value , now )
self . dpad_moved . emit ( event . code , event . value , now )
except OSError as e :
if e . errno == 19 : # ENODEV: No such device
logger . info ( " Gamepad disconnected during event loop " )
else :
logger . error ( f " OSError in gamepad monitoring: { e } " , exc_info = True )
except Exception as e :
except Exception as e :
logger . error ( f " Error access ing gamepad: { e } " )
logger . error ( f " Error in gamepad monitoring : { e } " , exc_info = True )
finally :
if self . gamepad :
try :
self . gamepad . close ( )
except Exception :
pass
self . gamepad = None
def handle_button ( self , button_code : int ) - > None :
@Slot ( int )
def handle_button_slot ( self , button_code : int ) - > None :
try :
app = QApplication . instance ( )
app = QApplication . instance ( )
if app is None :
if not app :
logger . error ( " QApplication instance is None " )
return
return
active = QApplication . activeWindow ( )
active = QApplication . activeWindow ( )
focused = QApplication . focusWidget ( )
focused = QApplication . focusWidget ( )
@@ -357,11 +401,14 @@ class InputManager(QObject):
idx = ( self . _parent . stackedWidget . currentIndex ( ) + 1 ) % len ( self . _parent . tabButtons )
idx = ( self . _parent . stackedWidget . currentIndex ( ) + 1 ) % len ( self . _parent . tabButtons )
self . _parent . switchTab ( idx )
self . _parent . switchTab ( idx )
self . _parent . tabButtons [ idx ] . setFocus ( Qt . FocusReason . OtherFocusReason )
self . _parent . tabButtons [ idx ] . setFocus ( Qt . FocusReason . OtherFocusReason )
except Exception as e :
logger . error ( f " Error in handle_button_slot: { e } " , exc_info = True )
def handle_dpad ( self , code : int , value : int , current_time : float ) - > None :
@Slot ( int , int , float )
def handle_dpad_slot ( self , code : int , value : int , current_time : float ) - > None :
try :
app = QApplication . instance ( )
app = QApplication . instance ( )
if app is None :
if not app :
logger . error ( " QApplication instance is None " )
return
return
active = QApplication . activeWindow ( )
active = QApplication . activeWindow ( )
@@ -375,13 +422,11 @@ class InputManager(QObject):
# Vertical navigation (DPAD up/down)
# Vertical navigation (DPAD up/down)
if code == ecodes . ABS_HAT0Y :
if code == ecodes . ABS_HAT0Y :
# ignore release
if value == 0 :
if value == 0 :
return
return
focused = QApplication . focusWidget ( )
focused = QApplication . focusWidget ( )
page = self . _parent . stackedWidget . currentWidget ( )
page = self . _parent . stackedWidget . currentWidget ( )
if value > 0 :
if value > 0 :
# down
if isinstance ( focused , NavLabel ) :
if isinstance ( focused , NavLabel ) :
focusables = page . findChildren ( QWidget , options = Qt . FindChildOption . FindChildrenRecursively )
focusables = page . findChildren ( QWidget , options = Qt . FindChildOption . FindChildrenRecursively )
focusables = [ w for w in focusables if w . focusPolicy ( ) & Qt . FocusPolicy . StrongFocus ]
focusables = [ w for w in focusables if w . focusPolicy ( ) & Qt . FocusPolicy . StrongFocus ]
@@ -392,7 +437,6 @@ class InputManager(QObject):
focused . focusNextChild ( )
focused . focusNextChild ( )
return
return
elif value < 0 and focused :
elif value < 0 and focused :
# up
focused . focusPreviousChild ( )
focused . focusPreviousChild ( )
return
return
@@ -411,8 +455,11 @@ class InputManager(QObject):
self . trigger_dpad_movement ( code , value )
self . trigger_dpad_movement ( code , value )
self . last_move_time = current_time
self . last_move_time = current_time
self . current_axis_delay = self . repeat_axis_move_delay
self . current_axis_delay = self . repeat_axis_move_delay
except Exception as e :
logger . error ( f " Error in handle_dpad_slot: { e } " , exc_info = True )
def trigger_dpad_movement ( self , code : int , value : int ) - > None :
def trigger_dpad_movement ( self , code : int , value : int ) - > None :
try :
if code != ecodes . ABS_HAT0X :
if code != ecodes . ABS_HAT0X :
return
return
idx = self . _parent . stackedWidget . currentIndex ( )
idx = self . _parent . stackedWidget . currentIndex ( )
@@ -422,9 +469,15 @@ class InputManager(QObject):
new = ( idx + 1 ) % len ( self . _parent . tabButtons )
new = ( idx + 1 ) % len ( self . _parent . tabButtons )
self . _parent . switchTab ( new )
self . _parent . switchTab ( new )
self . _parent . tabButtons [ new ] . setFocus ( Qt . FocusReason . OtherFocusReason )
self . _parent . tabButtons [ new ] . setFocus ( Qt . FocusReason . OtherFocusReason )
except Exception as e :
logger . error ( f " Error in trigger_dpad_movement: { e } " , exc_info = True )
def cleanup ( self ) - > None :
def cleanup ( self ) - > None :
try :
self . running = False
self . running = False
if self . gamepad_thread :
self . gamepad_thread . join ( )
if self . gamepad :
if self . gamepad :
self . gamepad . close ( )
self . gamepad . close ( )
logger . info ( " Input support cleaned up " )
except Exception as e :
logger . error ( f " Error during cleanup: { e } " , exc_info = True )