feat(input_manager): added mouse emulation

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-11-03 12:34:27 +05:00
parent 06e55db54d
commit a448ba29b0

View File

@@ -1,8 +1,9 @@
import time import time
import threading import threading
import os import os
import math
from typing import Protocol, cast from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff from evdev import InputDevice, InputEvent, UInput, ecodes, list_devices, ff
from enum import Enum from enum import Enum
from pyudev import Context, Monitor, Device, Devices 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
@@ -15,6 +16,7 @@ from portprotonqt.game_card import GameCard
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type
from portprotonqt.dialogs import AddGameDialog from portprotonqt.dialogs import AddGameDialog
from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.virtual_keyboard import VirtualKeyboard
import select
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -115,6 +117,31 @@ class InputManager(QObject):
self.last_trigger_time = 0.0 self.last_trigger_time = 0.0
self.trigger_cooldown = 0.2 self.trigger_cooldown = 0.2
# Mouse emulation attributes
self.mouse_emulation_enabled = True # Enable by default as crutch for external apps
self.ui = None # UInput for virtual mouse
self.stick_x_raw = 0
self.stick_y_raw = 0
self.deadzone = 8000 # Deadzone for sticks
self.max_value = 32767 # Max stick value
self.sensitivity = 8.0 # Cursor sensitivity
self.scroll_accumulator = 0.0
self.scroll_sensitivity = 0.15 # Scroll sensitivity
self.scroll_threshold = 0.2 # Scroll threshold
self.last_update = time.time()
self.update_interval = 0.016 # ~60 FPS
self.emulation_active = False # Flag for external focus (updated in main thread)
# Focus check timer for emulation flag (runs in main thread)
self.focus_check_timer = QTimer(self)
self.focus_check_timer.timeout.connect(self._update_emulation_flag)
self.focus_check_timer.start(100) # Check every 100ms
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled:
self.enable_mouse_emulation()
# FileExplorer specific attributes # FileExplorer specific attributes
self.file_explorer = None self.file_explorer = None
self.original_button_handler = None self.original_button_handler = None
@@ -151,6 +178,11 @@ class InputManager(QObject):
# Initialize evdev + hotplug # Initialize evdev + hotplug
self.init_gamepad() self.init_gamepad()
def _update_emulation_flag(self):
"""Update emulation_active flag based on Qt app focus (main thread only)."""
active = QApplication.activeWindow()
self.emulation_active = (active is None) # True for external windows (e.g., winefile)
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None: def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
"""Common navigation logic for game cards in a container.""" """Common navigation logic for game cards in a container."""
if container is None: if container is None:
@@ -637,6 +669,116 @@ class InputManager(QObject):
except Exception as e: except Exception as e:
logger.error(f"Error in navigation repeat: {e}") logger.error(f"Error in navigation repeat: {e}")
def enable_mouse_emulation(self):
"""Enable mouse emulation mode (creates virtual mouse device)."""
if self.mouse_emulation_enabled and self.ui is not None:
logger.debug("EMUL: Mouse emulation already enabled, skipping")
return
try:
logger.info("EMUL: Attempting to create UInput virtual mouse...")
if not os.path.exists('/dev/uinput'):
logger.error("EMUL: /dev/uinput does not exist")
self.mouse_emulation_enabled = False
return
if not os.access('/dev/uinput', os.W_OK):
logger.error("EMUL: No write access to /dev/uinput")
self.mouse_emulation_enabled = False
return
self.ui = UInput({
ecodes.EV_KEY: [ecodes.BTN_LEFT, ecodes.BTN_RIGHT],
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
}, name="Virtual DPad Mouse")
self.mouse_emulation_enabled = True
logger.info("EMUL: Virtual mouse created successfully")
except PermissionError as e:
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
self.mouse_emulation_enabled = False
except Exception as ex:
logger.error(f"EMUL: Error creating virtual mouse: {ex}", exc_info=True)
self.mouse_emulation_enabled = False
def disable_mouse_emulation(self):
"""Disable mouse emulation mode (closes virtual mouse device)."""
logger.info("EMUL: Disabling mouse emulation...")
if self.ui:
try:
self.ui.close()
logger.info("EMUL: Virtual mouse closed")
except Exception as e:
logger.error("EMUL: Error closing virtual mouse: %s", e)
self.ui = None
self.mouse_emulation_enabled = False
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
def handle_scroll(self, raw_value):
"""Обработка прокрутки с правого стика Y"""
if not self.mouse_emulation_enabled or not self.emulation_active or not self.ui:
return
if abs(raw_value) < self.deadzone:
self.scroll_accumulator = 0.0
return
normalized = raw_value / self.max_value
self.scroll_accumulator += normalized * self.scroll_sensitivity
while abs(self.scroll_accumulator) >= self.scroll_threshold:
scroll_step = 1 if self.scroll_accumulator > 0 else -1
self.scroll_wheel(-scroll_step)
self.scroll_accumulator -= scroll_step * self.scroll_threshold
def update_mouse_position(self):
"""Постоянное обновление позиции мыши на основе состояния стика"""
if not self.ui or not self.emulation_active:
return
x = self.stick_x_raw
y = self.stick_y_raw
magnitude = math.sqrt(x * x + y * y)
if magnitude < self.deadzone:
return
norm_x = x / magnitude
norm_y = y / magnitude
adjusted_magnitude = max(0.0, min(1.0, (magnitude - self.deadzone) / (self.max_value - self.deadzone)))
adjusted_magnitude = math.pow(adjusted_magnitude, 1.5)
speed = adjusted_magnitude * self.sensitivity
dx = int(norm_x * speed)
dy = int(norm_y * speed)
if dx != 0 or dy != 0:
self.move_mouse(dx, dy)
def move_mouse(self, dx, dy):
"""Сдвиг системного курсора"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_X, dx)
self.ui.write(ecodes.EV_REL, ecodes.REL_Y, dy)
self.ui.syn()
def scroll_wheel(self, steps):
"""Прокрутка колеса мыши"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_WHEEL, steps)
self.ui.syn()
def click_left(self):
"""Клик левой кнопкой мыши"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 0)
self.ui.syn()
def click_right(self):
"""Клик правой кнопкой мыши"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 0)
self.ui.syn()
@Slot(bool) @Slot(bool)
def handle_fullscreen_slot(self, enable: bool) -> None: def handle_fullscreen_slot(self, enable: bool) -> None:
try: try:
@@ -1473,7 +1615,6 @@ class InputManager(QObject):
logger.error(f"Failed to start udev monitor: {e}") logger.error(f"Failed to start udev monitor: {e}")
return return
import select
fd = monitor.fileno() fd = monitor.fileno()
poller = select.poll() poller = select.poll()
poller.register(fd, select.POLLIN) poller.register(fd, select.POLLIN)
@@ -1692,60 +1833,103 @@ class InputManager(QObject):
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: while self.running:
return current_time = time.time()
for event in self.gamepad.read_loop():
if not self.running:
break
if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
continue
now = time.time()
# Проверка фокуса: игнорируем события, если окно не в фокусе if self.gamepad:
app = QApplication.instance() try:
active = QApplication.activeWindow() # Non-blocking read with short timeout
if not app or not active: events = []
continue r, w, x = select.select([self.gamepad.fd], [], [], 0.001)
if r:
events = list(self.gamepad.read())
if event.type == ecodes.EV_KEY: # Process events
# Emit on both press (1) and release (0) for event in events:
self.button_event.emit(event.code, event.value) if not self.running:
# Special handling for menu on press only break
if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen) # UI signal handling (always, for internal app)
elif event.type == ecodes.EV_ABS: if event.type == ecodes.EV_KEY:
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}: self.button_event.emit(event.code, event.value)
# Проверяем, достаточно ли времени прошло с последнего срабатывания # Special handling for menu on press only
if now - self.last_trigger_time < self.trigger_cooldown: if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session:
continue self.toggle_fullscreen.emit(not self._is_fullscreen)
if event.code == ecodes.ABS_Z: # LT/L2 elif event.type == ecodes.EV_ABS:
if event.value > 128 and not self.lt_pressed: if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
self.lt_pressed = True # Trigger handling for UI
self.button_event.emit(event.code, 1) # Emit as press if current_time - self.last_trigger_time < self.trigger_cooldown:
self.last_trigger_time = now continue
elif event.value <= 128 and self.lt_pressed: if event.code == ecodes.ABS_Z: # LT/L2
self.lt_pressed = False if event.value > 128 and not self.lt_pressed:
self.button_event.emit(event.code, 0) # Emit as release self.lt_pressed = True
elif event.code == ecodes.ABS_RZ: # RT/R2 self.button_event.emit(event.code, 1)
if event.value > 128 and not self.rt_pressed: self.last_trigger_time = current_time
self.rt_pressed = True elif event.value <= 128 and self.lt_pressed:
self.button_event.emit(event.code, 1) # Emit as press self.lt_pressed = False
self.last_trigger_time = now self.button_event.emit(event.code, 0)
elif event.value <= 128 and self.rt_pressed: elif event.code == ecodes.ABS_RZ: # RT/R2
self.rt_pressed = False if event.value > 128 and not self.rt_pressed:
self.button_event.emit(event.code, 0) # Emit as release self.rt_pressed = True
else: self.button_event.emit(event.code, 1)
self.dpad_moved.emit(event.code, event.value, now) self.last_trigger_time = current_time
except OSError as e: elif event.value <= 128 and self.rt_pressed:
if e.errno == 19: # ENODEV: No such device self.rt_pressed = False
logger.info("Gamepad disconnected during event loop") self.button_event.emit(event.code, 0)
else: else:
logger.error(f"OSError in gamepad monitoring: {e}", exc_info=True) self.dpad_moved.emit(event.code, event.value, current_time)
# Mouse emulation (only for external windows)
if self.mouse_emulation_enabled and self.emulation_active:
if event.type == ecodes.EV_ABS:
if event.code == ecodes.ABS_HAT0X:
if event.value == -1:
self.move_mouse(-10, 0)
elif event.value == 1:
self.move_mouse(10, 0)
elif event.code == ecodes.ABS_HAT0Y:
if event.value == -1:
self.move_mouse(0, -10)
elif event.value == 1:
self.move_mouse(0, 10)
elif event.code == ecodes.ABS_X:
self.stick_x_raw = event.value
elif event.code == ecodes.ABS_Y:
self.stick_y_raw = event.value
elif event.code == ecodes.ABS_RY:
self.handle_scroll(event.value)
elif event.type == ecodes.EV_KEY:
if event.code in (ecodes.BTN_SOUTH, ecodes.BTN_A) and event.value == 1:
self.click_left()
elif event.code in (ecodes.BTN_EAST, ecodes.BTN_B) and event.value == 1:
self.click_right()
# Periodic mouse position update
if current_time - self.last_update >= self.update_interval:
self.update_mouse_position()
self.last_update = current_time
except OSError as e:
if e.errno == 19: # ENODEV
logger.info("Gamepad disconnected during monitoring")
else:
logger.error(f"IOError in gamepad monitoring: {e}")
self.gamepad = None
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
break
except Exception as ex:
logger.error(f"Unexpected error in gamepad monitoring: {ex}")
break
else:
time.sleep(0.1)
if not self.running:
break
except Exception as e: except Exception as e:
logger.error(f"Error in gamepad monitoring: {e}", exc_info=True) logger.error(f"Error in gamepad monitoring thread: {e}", exc_info=True)
finally: finally:
if self.gamepad: if self.gamepad:
try: try:
@@ -1760,6 +1944,12 @@ class InputManager(QObject):
Корректное завершение работы с геймпадом и udev монитором. Корректное завершение работы с геймпадом и udev монитором.
""" """
try: try:
# Mouse emulation cleanup
self.disable_mouse_emulation()
# Stop focus check timer
self.focus_check_timer.stop()
# Флаг для остановки udev monitor loop # Флаг для остановки udev monitor loop
self.running = False self.running = False