diff --git a/cartridges/__init__.py b/cartridges/__init__.py index 9708cb2..2ef55ab 100644 --- a/cartridges/__init__.py +++ b/cartridges/__init__.py @@ -15,6 +15,10 @@ gi.require_versions({ "Adw": "1", }) +if sys.platform.startswith("linux"): + gi.require_version("Manette", "0.2") + + from gi.repository import Gio, GLib from .config import APP_ID, LOCALEDIR, PKGDATADIR diff --git a/cartridges/gamepads.py b/cartridges/gamepads.py new file mode 100644 index 0000000..f59c162 --- /dev/null +++ b/cartridges/gamepads.py @@ -0,0 +1,342 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed + +import math +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, cast + +from gi.repository import Adw, Gio, GLib, GObject, Gtk, Manette + +from .ui.game_item import GameItem + +if TYPE_CHECKING: + from .ui.window import Window + +STICK_DEADZONE = 0.5 +REPEAT_DELAY = 280 + + +class Gamepad(GObject.Object): + """Data class for gamepad, including UI navigation.""" + + window: "Window" + device: Manette.Device + + def __init__(self, device: Manette.Device, **kwargs: Any): + super().__init__(**kwargs) + + self.device = device + self._allowed_inputs = { + Gtk.DirectionType.UP, + Gtk.DirectionType.DOWN, + Gtk.DirectionType.LEFT, + Gtk.DirectionType.RIGHT, + } + + self.device.connect("button-press-event", self._on_button_press_event) + self.device.connect("absolute-axis-event", self._on_analog_axis_event) + + @staticmethod + def _get_rtl_direction( + a: Gtk.DirectionType, b: Gtk.DirectionType + ) -> Gtk.DirectionType: + return a if Gtk.Widget.get_default_direction() == Gtk.TextDirection.RTL else b + + def _lock_input(self, direction: Gtk.DirectionType): + self._allowed_inputs.remove(direction) + GLib.timeout_add(REPEAT_DELAY, lambda *_: self._allowed_inputs.add(direction)) + + def _on_button_press_event(self, _device: Manette.Device, event: Manette.Event): + _success, button = event.get_button() + match button: # Xbox / Nintendo / PlayStation + case 304: # A / B / Circle + self._on_activate_button_pressed() + case 305: # B / A / Cross + self._on_return_button_pressed() + case 307: # Y / X / Triangle + self.window.search_entry.grab_focus() + case 308: # X / Y / Square + pass + case 310: # Left Shoulder Button + pass + case 311: # Right Shoulder Button + pass + case 314: # Back / - / Options + pass + case 315: # Start / + / Share + pass + case 544: + self._move_vertically(Gtk.DirectionType.UP) + case 545: + self._move_vertically(Gtk.DirectionType.DOWN) + case 546: + self._move_horizontally(Gtk.DirectionType.LEFT) + case 547: + self._move_horizontally(Gtk.DirectionType.RIGHT) + + def _on_analog_axis_event(self, _device: Manette.Device, event: Manette.Event): + _, axis, value = event.get_absolute() + if abs(value) < STICK_DEADZONE: + return + + match axis: + case 0: + direction = ( + Gtk.DirectionType.LEFT if value < 0 else Gtk.DirectionType.RIGHT + ) + + if direction in self._allowed_inputs: + self._lock_input(direction) + self._move_horizontally(direction) + case 1: + direction = ( + Gtk.DirectionType.UP if value < 0 else Gtk.DirectionType.DOWN + ) + if direction in self._allowed_inputs: + self._lock_input(direction) + self._move_vertically(direction) + + def _on_activate_button_pressed(self): + if self.window.navigation_view.props.visible_page_tag == "details": + if focus_widget := self.window.props.focus_widget: + focus_widget.activate() + return + + if self._is_focused_on_top_bar() and ( + focus_widget := self.window.props.focus_widget + ): + if isinstance(focus_widget, Gtk.ToggleButton): + focus_widget.props.active = True + return + + focus_widget.activate() + return + + self.window.grid.activate_action( + "list.activate-item", + GLib.Variant.new_uint32(self._get_current_position()), + ) + + def _on_return_button_pressed(self): + if self.window.navigation_view.props.visible_page_tag == "details": + if self.window.details.stack.props.visible_child_name == "edit": + self.window.details.activate_action("details.cancel") + return + + if self._is_focused_on_top_bar(): + self.window.sort_button.props.active = False + return + + self.window.navigation_view.pop_to_tag("games") + + open_menu = self._get_active_menu_button() + + if open_menu: + open_menu.set_active(False) + open_menu.grab_focus() + return + + self.window.grid.grab_focus() + self.window.props.focus_visible = True + + def _navigate_to_game_position(self, new_pos: int): + if new_pos >= 0 and new_pos <= self._n_grid_games() - 1: + self.window.grid.scroll_to(new_pos, Gtk.ListScrollFlags.FOCUS, None) + else: + self.window.props.display.beep() + + def _move_horizontally(self, direction: Gtk.DirectionType): + if self._is_focused_on_top_bar(): + if not self.window.header_bar.child_focus(direction): + self.window.header_bar.keynav_failed(direction) + + self.window.props.focus_visible = True + return + + if self._can_navigate_games_page(): + self._navigate_to_game_position( + self._get_current_position() + + ( + -1 + if direction + == self._get_rtl_direction( + Gtk.DirectionType.RIGHT, Gtk.DirectionType.LEFT + ) + else 1 + ) + ) + return + + if self.window.navigation_view.props.visible_page_tag == "details": + if self.window.details.stack.props.visible_child_name == "details": + self._navigate_action_buttons(direction) + return + + if ( + (focus_widget := self.window.props.focus_widget) + and (parent := focus_widget.props.parent) + and not parent.child_focus(direction) + ): + parent.keynav_failed(direction) + + def _move_vertically(self, direction: Gtk.DirectionType): + if self._is_focused_on_top_bar() and direction == Gtk.DirectionType.DOWN: + self.window.grid.grab_focus() + return + + if self._can_navigate_games_page(): + if not (game := self._get_focused_game()): + return + + current_grid_columns = math.floor( + self.window.grid.get_width() / game.get_width() + ) + + new_pos = self._get_current_position() + ( + -current_grid_columns + if direction == Gtk.DirectionType.UP + else current_grid_columns + ) + if new_pos < 0 and direction == Gtk.DirectionType.UP: + self.window.search_entry.grab_focus() + return + + self._navigate_to_game_position(new_pos) + return + + if self.window.navigation_view.props.visible_page_tag != "details": + return + + if self.window.details.stack.props.visible_child_name == "details": + self._navigate_action_buttons(direction) + return + + if not (focus_widget := self.window.props.focus_widget): + return + + if not ( + current_row := ( + focus_widget.get_ancestor(Adw.EntryRow) + or focus_widget.get_ancestor(Adw.WrapBox) + ) + ): + self.window.header_bar.grab_focus() + if not (focus_widget := self.window.get_focus_child()): + return + + if focus_widget.child_focus(direction): + self.window.props.focus_visible = True + return + + focus_widget.keynav_failed(direction) + return + + if not (current_box := current_row.get_ancestor(Gtk.Box)): + return + + if not current_box.child_focus(direction): + current_box.keynav_failed(direction) + + self.window.props.focus_visible = True + + def _navigate_action_buttons(self, direction: Gtk.DirectionType): + if not (focus_widget := self.window.props.focus_widget): + return + + widget = focus_widget + for _ in range(2): # Try to focus the actions, then try the play button + if not (widget := widget.props.parent): + break + + if widget.child_focus(direction): + self.window.props.focus_visible = True + return + + # Focus on header bar if the user goes up/down + self.window.header_bar.grab_focus() + if not (focus_widget := self.window.get_focus_child()): + return + + if focus_widget.child_focus(direction): + self.window.props.focus_visible = True + return + + focus_widget.keynav_failed(direction) + + def _get_active_menu_button(self) -> Gtk.MenuButton | None: + for button in self.window.main_menu_button, self.window.sort_button: + if button.props.active: + return button + return None + + def _get_focused_game(self) -> Gtk.Widget | None: + self.window.grid.grab_focus() + if ( + focused_game := self.window.props.focus_widget + ) and focused_game.get_ancestor(Gtk.GridView): + return focused_game + return None + + def _get_current_position(self) -> int: + if (game_widget := self._get_focused_game()) and isinstance( + item := game_widget.get_first_child(), GameItem + ): + return item.position + return 0 + + def _can_navigate_games_page(self) -> bool: + return bool( + self.window.navigation_view.props.visible_page_tag == "games" + and self._n_grid_games() + and not self._get_active_menu_button() + ) + + def _is_focused_on_top_bar(self) -> bool: + return bool( + self.window.header_bar.get_focus_child() + and not self._get_active_menu_button() + ) + + def _n_grid_games(self) -> int: + return cast(Gtk.SingleSelection, self.window.grid.props.model).props.n_items + + +def _iterate_devices() -> Generator[Gamepad]: + monitor_iter = monitor.iterate() + has_next = True + while has_next: + has_next, device = monitor_iter.next() + if device: + yield Gamepad(device) + + +def _remove_device(device: Manette.Device): + model.remove( + next( + pos + for pos, gamepad in enumerate(model) + if cast(Gamepad, gamepad).device == device + ) + ) + + +def _update_window_style(model: Gio.ListStore): + if model.props.n_items > 0: + Gamepad.window.add_css_class("controller-connected") + else: + Gamepad.window.remove_css_class("controller-connected") + + +def setup_monitor(): + """Connect monitor to device connect/disconnect signals.""" + monitor.connect( + "device-connected", + lambda _, device: model.append(Gamepad(device)), + ) + monitor.connect("device-disconnected", lambda _, device: _remove_device(device)) + model.splice(0, 0, tuple(_iterate_devices())) + + +monitor = Manette.Monitor() +model = Gio.ListStore.new(Gamepad) +model.connect("items-changed", lambda model, *_: _update_window_style(model)) diff --git a/cartridges/meson.build b/cartridges/meson.build index aa2fd8c..575a348 100644 --- a/cartridges/meson.build +++ b/cartridges/meson.build @@ -1,5 +1,11 @@ python.install_sources( - files('__init__.py', '__main__.py', 'application.py', 'games.py'), + files( + '__init__.py', + '__main__.py', + 'application.py', + 'games.py', + 'gamepads.py', + ), subdir: 'cartridges', ) diff --git a/cartridges/ui/game_details.py b/cartridges/ui/game_details.py index d49d0c8..f1f29c7 100644 --- a/cartridges/ui/game_details.py +++ b/cartridges/ui/game_details.py @@ -34,6 +34,7 @@ class GameDetails(Adw.NavigationPage): __gtype_name__ = __qualname__ stack: Adw.ViewStack = Gtk.Template.Child() + actions: Gtk.Box = Gtk.Template.Child() name_entry: Adw.EntryRow = Gtk.Template.Child() developer_entry: Adw.EntryRow = Gtk.Template.Child() executable_entry: Adw.EntryRow = Gtk.Template.Child() diff --git a/cartridges/ui/style.css b/cartridges/ui/style.css index 847e89a..27cc367 100644 --- a/cartridges/ui/style.css +++ b/cartridges/ui/style.css @@ -15,6 +15,7 @@ } /* https://gitlab.gnome.org/World/highscore/-/blob/cea3c7492d0b3c78a8b79ca60e9a86e8cdd4ceaf/src/library/library.css#L86 */ +.controller-connected #grid > child:focus-within #cover, #grid > child:focus-within:focus-visible #cover { outline: 5px solid rgb(from var(--accent-color) r g b / 50%); } @@ -73,6 +74,7 @@ } @media (prefers-contrast: more) { + .controller-connected #grid > child:focus-within #cover, #grid > child:focus-within:focus-visible #cover { outline: 5px solid var(--accent-color); } diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 7cb3ca6..cc65fd5 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -2,6 +2,7 @@ using Gtk 4.0; using Adw 1; template $Window: Adw.ApplicationWindow { + realize => $_setup_gamepad_monitor(); title: _("Cartridges"); ShortcutController { @@ -34,6 +35,7 @@ template $Window: Adw.ApplicationWindow { content: Adw.NavigationView navigation_view { Adw.NavigationPage { title: bind template.title; + tag: "games"; styles [ "view", @@ -41,11 +43,11 @@ template $Window: Adw.ApplicationWindow { child: Adw.ToolbarView { [top] - Adw.HeaderBar { + Adw.HeaderBar header_bar { title-widget: Adw.Clamp clamp { tightening-threshold: bind clamp.maximum-size; - child: CenterBox { + child: CenterBox title_box { hexpand: true; center-widget: SearchEntry search_entry { @@ -57,7 +59,7 @@ template $Window: Adw.ApplicationWindow { stop-search => $_stop_search(); }; - end-widget: MenuButton { + end-widget: MenuButton sort_button { icon-name: "filter-symbolic"; tooltip-text: _("Sort & Filter"); margin-start: 6; @@ -113,7 +115,7 @@ template $Window: Adw.ApplicationWindow { } [end] - MenuButton { + MenuButton main_menu_button { icon-name: "open-menu-symbolic"; tooltip-text: _("Main Menu"); primary: true; @@ -128,15 +130,7 @@ template $Window: Adw.ApplicationWindow { content: Adw.ToastOverlay toast_overlay { child: Adw.ViewStack { enable-transitions: true; - visible-child-name: bind $_if_else( - grid.model as .n-items, - "grid", - $_if_else( - template.search-text, - "empty-search", - $_if_else(template.show-hidden, "empty-hidden", "empty") as - ) as - ) as ; + visible-child-name: bind $_if_else(grid.model as .n-items, "grid", $_if_else(template.search-text, "empty-search", $_if_else(template.show-hidden, "empty-hidden", "empty") as ) as ) as ; Adw.ViewStackPage { name: "grid"; diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index d82f8a0..34a6f4a 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -3,6 +3,7 @@ # SPDX-FileCopyrightText: Copyright 2022-2025 kramo # SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel +import sys from collections.abc import Callable from gettext import gettext as _ from typing import Any, TypeVar, cast @@ -16,6 +17,10 @@ from cartridges.games import Game, GameSorter from .game_details import GameDetails from .game_item import GameItem # noqa: F401 +if sys.platform.startswith("linux"): + from cartridges import gamepads + from cartridges.gamepads import Gamepad + SORT_MODES = { "last_played": ("last-played", True), "a-z": ("name", False), @@ -35,8 +40,12 @@ class Window(Adw.ApplicationWindow): __gtype_name__ = __qualname__ navigation_view: Adw.NavigationView = Gtk.Template.Child() - toast_overlay: Adw.ToastOverlay = Gtk.Template.Child() + header_bar: Adw.HeaderBar = Gtk.Template.Child() + title_box: Gtk.CenterBox = Gtk.Template.Child() search_entry: Gtk.SearchEntry = Gtk.Template.Child() + sort_button: Gtk.MenuButton = Gtk.Template.Child() + main_menu_button: Gtk.MenuButton = Gtk.Template.Child() + toast_overlay: Adw.ToastOverlay = Gtk.Template.Child() grid: Gtk.GridView = Gtk.Template.Child() sorter: GameSorter = Gtk.Template.Child() details: GameDetails = Gtk.Template.Child() @@ -67,7 +76,11 @@ class Window(Adw.ApplicationWindow): self.add_action(Gio.PropertyAction.new("show-hidden", self, "show-hidden")) self.add_action_entries(( ("search", lambda *_: self.search_entry.grab_focus()), - ("edit", lambda _action, param, *_: self._edit(param.get_uint32()), "u"), + ( + "edit", + lambda _action, param, *_: self._edit(param.get_uint32()), + "u", + ), ("add", lambda *_: self._add()), ("undo", lambda *_: self._undo()), )) @@ -88,6 +101,12 @@ class Window(Adw.ApplicationWindow): self.toast_overlay.add_toast(toast) + @Gtk.Template.Callback() + def _setup_gamepad_monitor(self, *_args): + if sys.platform.startswith("linux"): + Gamepad.window = self # pyright: ignore[reportPossiblyUnboundVariable] + gamepads.setup_monitor() # pyright: ignore[reportPossiblyUnboundVariable] + @Gtk.Template.Callback() def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T: return first if condition else second diff --git a/flatpak/page.kramo.Cartridges.Devel.json b/flatpak/page.kramo.Cartridges.Devel.json index 5dfe228..9df337f 100644 --- a/flatpak/page.kramo.Cartridges.Devel.json +++ b/flatpak/page.kramo.Cartridges.Devel.json @@ -8,6 +8,7 @@ "--share=ipc", "--socket=fallback-x11", "--device=dri", + "--device=input", "--socket=wayland", "--talk-name=org.freedesktop.Flatpak", "--filesystem=host:ro", diff --git a/po/POTFILES.in b/po/POTFILES.in index 8f4600d..81003fa 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,4 +1,5 @@ cartridges/application.py +cartridges/gamepads.py cartridges/games.py cartridges/sources/__init__.py cartridges/sources/steam.py