diff --git a/flatpak/hu.kramo.Cartridges.Devel.json b/flatpak/hu.kramo.Cartridges.Devel.json index 719deed..138e6ff 100644 --- a/flatpak/hu.kramo.Cartridges.Devel.json +++ b/flatpak/hu.kramo.Cartridges.Devel.json @@ -96,9 +96,75 @@ "sha256": "bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1" } ] + }, + { + "name" : "python3-snegg", + "buildsystem" : "simple", + "build-commands": [ + "cd snegg", + "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} . --no-build-isolation" + ], + "sources": [ + { + "type" : "git", + "url": "https://gitlab.freedesktop.org/libinput/snegg.git", + "tag": "main" + } + ] + }, + { + "name": "python3-attrs", + "buildsystem": "simple", + "build-commands": [ + "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"attrs\" --no-build-isolation" + ], + "sources": [ + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/f0/eb/fcb708c7bf5056045e9e98f62b93bd7467eb718b0202e7698eb11d66416c/attrs-23.1.0-py3-none-any.whl", + "sha256": "1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04" + } + ] + }, + { + "name": "python3-jinja2", + "buildsystem": "simple", + "build-commands": [ + "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"jinja2\" --no-build-isolation" + ], + "sources": [ + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/bc/c3/f068337a370801f372f2f8f6bad74a5c140f6fda3d9de154052708dd3c65/Jinja2-3.1.2-py3-none-any.whl", + "sha256": "6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/6d/7c/59a3248f411813f8ccba92a55feaac4bf360d29e2ff05ee7d8e1ef2d7dbf/MarkupSafe-2.1.3.tar.gz", + "sha256": "af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad" + } + ] } ] }, + { + "name" : "libei", + "buildsystem" : "meson", + "config-opts": [ + "-Ddocumentation=[]", + "-Dtests=disabled" + ], + "sources" : [ + { + "type" : "git", + "url" : "https://gitlab.freedesktop.org/libinput/libei.git", + "tag" : "1.0.0" + } + ], + "cleanup" : [ + "*" + ] + }, { "name" : "blueprint-compiler", "buildsystem" : "meson", diff --git a/src/keyboard_emulator.py b/src/keyboard_emulator.py new file mode 100644 index 0000000..cd0a63f --- /dev/null +++ b/src/keyboard_emulator.py @@ -0,0 +1,99 @@ +import logging +import select +from collections import deque + +from gi.repository import GLib +from snegg.oeffis import DisconnectedError, Oeffis, SessionClosedError +from snegg.ei import Sender, DeviceCapability, EventType, Seat, Device, Event + + +class PortalError(Exception): + """Error raised when a oeffis portal can't be acquired""" + + +class KeyboardEmulator: + """ + A class that triggers keypresses with libei + + Libei docs: https://libinput.pages.freedesktop.org/libei/ + Snegg docs: https://libinput.pages.freedesktop.org/snegg/snegg.ei.html + """ + + app = None + queue: deque = None + + sender: Sender = None + seat: Seat = None + keyboard: Device = None + + def __init__(self, app) -> None: + self.app = app + self.queue = deque() + + self.app.connect("emulate-key", self.on_emulate_key) + GLib.Thread.new(None, self.thread_func) + + def on_emulate_key(self, keyval): + self.queue.append(keyval) + + @staticmethod + def get_eis_portal() -> Oeffis: + """Get a portal to the eis server""" + portal = Oeffis.create() + if portal is None: + raise PortalError() + poll = select.poll() + poll.register(portal.fd) + while poll.poll(): + try: + if portal.dispatch(): + # We need to keep the portal object alive so we don't get disconnected + return portal + except (SessionClosedError, DisconnectedError) as error: + raise PortalError() from error + + def thread_func(self): + """Daemon thread entry point""" + + # Connect to the EIS server + try: + portal = self.get_eis_portal() + except PortalError as error: + logging.error("Can't get EIS portal", exc_info=error) + raise + self.sender = Sender.create_for_fd(fd=portal.eis_fd, name="ei-debug-events") + + # Handle sender events + poll = select.poll() + poll.register(self.sender.fd) + while poll.poll(): + self.sender.dispatch() + for event in self.sender.events: + self.handle_sender_event(event) + + def handle_sender_event(self, event: Event): + """Handle libei sender (input producer) events""" + + match event.event_type: + # The emulated seat is created, we need to specify its capabilities + case EventType.SEAT_ADDED: + if not event.seat: + return + self.seat = event.seat + self.seat.bind(DeviceCapability.KEYBOARD) + + # A device was added to the seat (here, we're only doing a keyboard) + case EventType.DEVICE_ADDED: + if not event.device: + return + self.keyboard = event.device + + # Input can be processed, send keys + case EventType.DEVICE_RESUMED: + self.keyboard.start_emulating() + keyval = self.queue.popleft() + self.keyboard.keyboard_key(keyval, True) + self.keyboard.frame() + self.keyboard.keyboard_key(keyval, False) + self.keyboard.frame() + self.keyboard.stop_emulating() diff --git a/src/main.py b/src/main.py index 288f7e1..5d6516c 100644 --- a/src/main.py +++ b/src/main.py @@ -18,6 +18,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later import json +import logging import lzma import sys @@ -25,9 +26,10 @@ import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") +gi.require_version("Manette", "0.2") # pylint: disable=wrong-import-position -from gi.repository import Adw, Gio, GLib, Gtk +from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Manette from src import shared from src.details_window import DetailsWindow @@ -39,6 +41,7 @@ from src.importer.sources.itch_source import ItchSource from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.lutris_source import LutrisSource from src.importer.sources.steam_source import SteamSource +from src.keyboard_emulator import KeyboardEmulator from src.logging.setup import log_system_info, setup_logging from src.preferences import PreferencesWindow from src.store.managers.display_manager import DisplayManager @@ -53,15 +56,81 @@ from src.window import CartridgesWindow class CartridgesApplication(Adw.Application): win = None + window_controller = None + keyboard_emulator = None def __init__(self): super().__init__( application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE ) + @GObject.Signal(name="emulate-key", arg_types=[int]) + def emulate_key(self, keyval) -> None: + """Signal emitted when the app wants to emulate a keypress""" + + def gamepad_abs_axis(self, _device, event): + logging.debug(event.get_absolute()) + + def gamepad_hat_axis(self, device, event): + device.rumble(1000, 1000, 50) + logging.debug( + "Gamepad: hat axis: %s, value: %s", *(hat := event.get_hat())[1:3] + ) + + if hat[2] != 0: + self.navigate(hat[1] + hat[2]) + + def navigate(self, direction: int): + match direction: + case 16: + self.emit("emulate-key", Gdk.KEY_Up) + print("up") + case 18: + print("down") + case 15: + print("left") + case 17: + print("right") + + def print_args(self, *args): + print(*args) + + def gamepad_button_pressed(self, _device, event): + logging.debug("Gamepad: %s pressed", (button := event.get_button()[1])) + + match button: + case 304: + print("A button pressed") + case 305: + print("B button pressed") + case 307: + print("X button pressed") + case 308: + print("Y button pressed") + case 314: + self.win.on_show_hidden_action() + + def gamepad_listen(self, device, *_args): + device.connect("button-press-event", self.gamepad_button_pressed) + device.connect("hat-axis-event", self.gamepad_hat_axis) + + def log_connected(): + logging.debug("%s connected", device.get_name()) + GLib.timeout_add_seconds(60, log_connected) + + log_connected() + def do_activate(self): # pylint: disable=arguments-differ """Called on app creation""" + # Setup gamepads + manette_monitor = Manette.Monitor.new() + manette_iter = manette_monitor.iterate() + + while (device := manette_iter.next())[0]: + self.gamepad_listen(device[1]) + self.keyboard_emulator = KeyboardEmulator(self) + # Set fallback icon-name Gtk.Window.set_default_icon_name(shared.APP_ID) @@ -70,6 +139,16 @@ class CartridgesApplication(Adw.Application): if not self.win: shared.win = self.win = CartridgesWindow(application=self) + for index in range( + (list_model := self.win.observe_controllers()).get_n_items() + ): + if isinstance((item := list_model.get_item(index)), Gtk.EventControllerKey): + self.window_controller = item + break + + self.window_controller.connect("key-pressed", self.print_args) + self.window_controller.connect("key-released", self.print_args) + # Save window geometry shared.state_schema.bind( "width", self.win, "default-width", Gio.SettingsBindFlags.DEFAULT diff --git a/src/meson.build b/src/meson.build index 5bb4a75..ad95604 100644 --- a/src/meson.build +++ b/src/meson.build @@ -21,6 +21,7 @@ install_data( 'details_window.py', 'game.py', 'game_cover.py', + 'keyboard_emulator.py', configure_file( input: 'shared.py.in', output: 'shared.py',