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:
committed by
Laura Kramolis
parent
6b735c8cf6
commit
eef38f73f5
@@ -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
342
cartridges/gamepads.py
Normal 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))
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"--share=ipc",
|
||||
"--socket=fallback-x11",
|
||||
"--device=dri",
|
||||
"--device=input",
|
||||
"--socket=wayland",
|
||||
"--talk-name=org.freedesktop.Flatpak",
|
||||
"--filesystem=host:ro",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
cartridges/application.py
|
||||
cartridges/gamepads.py
|
||||
cartridges/games.py
|
||||
cartridges/sources/__init__.py
|
||||
cartridges/sources/steam.py
|
||||
|
||||
Reference in New Issue
Block a user