gamepad: Add initial controller navigation (#406)

Co-authored-by: kramo <git@kramo.page>
Co-authored-by: Zoey Ahmed <zoethetransrat@gmail.com>
Reviewed-on: https://codeberg.org/kramo/cartridges/pulls/406
Reviewed-by: Laura Kramolis <git@kramo.page>
Reviewed-by: Jamie Gravendeel <me@jamie.garden>
Co-authored-by: Zoey Ahmed <zoeyahmed10@proton.me>
Co-committed-by: Zoey Ahmed <zoeyahmed10@proton.me>
This commit is contained in:
Zoey Ahmed
2025-12-17 21:24:34 +01:00
committed by Laura Kramolis
parent 6b735c8cf6
commit eef38f73f5
9 changed files with 386 additions and 16 deletions

View File

@@ -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

342
cartridges/gamepads.py Normal file
View File

@@ -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))

View File

@@ -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',
)

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -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 <NoSelection>.n-items,
"grid",
$_if_else(
template.search-text,
"empty-search",
$_if_else(template.show-hidden, "empty-hidden", "empty") as <string>
) as <string>
) as <string>;
visible-child-name: bind $_if_else(grid.model as <NoSelection>.n-items, "grid", $_if_else(template.search-text, "empty-search", $_if_else(template.show-hidden, "empty-hidden", "empty") as <string>) as <string>) as <string>;
Adw.ViewStackPage {
name: "grid";

View File

@@ -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

View File

@@ -8,6 +8,7 @@
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--device=input",
"--socket=wayland",
"--talk-name=org.freedesktop.Flatpak",
"--filesystem=host:ro",

View File

@@ -1,4 +1,5 @@
cartridges/application.py
cartridges/gamepads.py
cartridges/games.py
cartridges/sources/__init__.py
cartridges/sources/steam.py