diff --git a/cartridges/games.py b/cartridges/games.py index 74ec128..537d527 100644 --- a/cartridges/games.py +++ b/cartridges/games.py @@ -8,17 +8,22 @@ import json import locale import os import subprocess -from collections.abc import Generator, Iterable +from collections.abc import Callable, Generator, Iterable +from gettext import gettext as _ from json import JSONDecodeError from pathlib import Path from shlex import quote from types import UnionType -from typing import Any, NamedTuple, Self, cast, override +from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast, override from gi.repository import Gdk, Gio, GLib, GObject, Gtk from cartridges import DATA_DIR, state_settings +if TYPE_CHECKING: + from .application import Application + from .ui.window import Window + class _GameProp(NamedTuple): name: str @@ -79,18 +84,26 @@ class Game(Gio.SimpleActionGroup): self.add_action_entries(( ("play", lambda *_: self.play()), - ("remove", lambda *_: setattr(self, "removed", True)), + ("remove", lambda *_: self._remove()), )) - self.add_action(unhide_action := Gio.SimpleAction.new("unhide")) - unhide_action.connect("activate", lambda *_: setattr(self, "hidden", False)) - hidden = Gtk.PropertyExpression.new(Game, None, "hidden") - hidden.bind(unhide_action, "enabled", self) - self.add_action(hide_action := Gio.SimpleAction.new("hide")) - hide_action.connect("activate", lambda *_: setattr(self, "hidden", True)) - not_hidden = Gtk.ClosureExpression.new(bool, lambda _, h: not h, (hidden,)) - not_hidden.bind(hide_action, "enabled", self) + hide_action.connect("activate", lambda *_: self._hide()) + self.bind_property( + "hidden", + hide_action, + "enabled", + GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.INVERT_BOOLEAN, + ) + + self.add_action(unhide_action := Gio.SimpleAction.new("unhide")) + unhide_action.connect("activate", lambda *_: self._unhide()) + self.bind_property( + "hidden", + unhide_action, + "enabled", + GObject.BindingFlags.SYNC_CREATE, + ) @classmethod def from_data(cls, data: dict[str, Any]) -> Self: @@ -150,6 +163,32 @@ class Game(Gio.SimpleActionGroup): with path.open(encoding="utf-8") as f: json.dump(properties, f, indent=4) + def _remove(self): + self.removed = True + self._send( + _("{} removed").format(self.name), + undo=lambda: setattr(self, "removed", False), + ) + + def _hide(self): + self.hidden = True + self._send( + _("{} hidden").format(self.name), + undo=lambda: setattr(self, "hidden", False), + ) + + def _unhide(self): + self.hidden = False + self._send( + _("{} unhidden").format(self.name), + undo=lambda: setattr(self, "hidden", True), + ) + + def _send(self, title: str, *, undo: Callable[[], Any]): + app = cast("Application", Gio.Application.get_default()) + window = cast("Window", app.props.active_window) + window.send_toast(title, undo=undo) + class GameSorter(Gtk.Sorter): """A sorter for game objects. diff --git a/cartridges/ui/game-details.blp b/cartridges/ui/game-details.blp index 7e991b6..c32876b 100644 --- a/cartridges/ui/game-details.blp +++ b/cartridges/ui/game-details.blp @@ -16,7 +16,7 @@ template $GameDetails: Adw.NavigationPage { Shortcut { trigger: "Delete|KP_Delete"; - action: "action(details.remove)"; + action: "action(game.remove)"; } } @@ -189,7 +189,7 @@ template $GameDetails: Adw.NavigationPage { } Button { - action-name: "details.remove"; + action-name: "game.remove"; icon-name: "user-trash-symbolic"; tooltip-text: _("Remove"); valign: center; diff --git a/cartridges/ui/game_details.py b/cartridges/ui/game_details.py index 74db3a6..d49d0c8 100644 --- a/cartridges/ui/game_details.py +++ b/cartridges/ui/game_details.py @@ -18,6 +18,7 @@ from cartridges.games import Game from .cover import Cover # noqa: F401 +_POP_ON_ACTION = "hide", "unhide", "remove" _EDITABLE_PROPERTIES = {prop.name for prop in games.PROPERTIES if prop.editable} _REQUIRED_PROPERTIES = { prop.name for prop in games.PROPERTIES if prop.editable and prop.required @@ -40,23 +41,34 @@ class GameDetails(Adw.NavigationPage): sort_changed = GObject.Signal() @GObject.Property(type=Game) - def game(self) -> Game | None: + def game(self) -> Game: """The game whose details to show.""" return self._game @game.setter - def game(self, game: Game | None): + def game(self, game: Game): self._game = game self.insert_action_group("game", game) + for action, ident in self._signal_ids.copy().items(): + action.disconnect(ident) + del self._signal_ids[action] + + for name in _POP_ON_ACTION: + action = cast(Gio.SimpleAction, game.lookup_action(name)) + self._signal_ids[action] = action.connect( + "activate", lambda *_: self.activate_action("navigation.pop") + ) + def __init__(self, **kwargs: Any): super().__init__(**kwargs) + self._signal_ids = dict[Gio.SimpleAction, int]() + self.insert_action_group("details", group := Gio.SimpleActionGroup()) group.add_action_entries(( ("edit", lambda *_: self.edit()), ("cancel", lambda *_: self._cancel()), - ("remove", lambda *_: self._remove()), ( "search-on", lambda _action, param, *_: Gio.AppInfo.launch_default_for_uri( @@ -118,10 +130,6 @@ class GameDetails(Adw.NavigationPage): self.stack.props.visible_child_name = "details" - def _remove(self): - self.game.removed = True - self.activate_action("navigation.pop") - @Gtk.Template.Callback() def _or(self, _obj, first: _T, second: _T) -> _T: return first or second diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 55615a5..922c271 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -20,6 +20,11 @@ template $Window: Adw.ApplicationWindow { action: "action(win.add)"; } + Shortcut { + trigger: "z"; + action: "action(win.undo)"; + } + Shortcut { trigger: "w"; action: "action(window.close)"; @@ -34,189 +39,191 @@ template $Window: Adw.ApplicationWindow { "view", ] - child: Adw.ToolbarView { - [top] - Adw.HeaderBar { - title-widget: Adw.Clamp clamp { - tightening-threshold: bind clamp.maximum-size; + child: Adw.ToastOverlay toast_overlay { + child: Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.Clamp clamp { + tightening-threshold: bind clamp.maximum-size; - child: CenterBox { - hexpand: true; - - center-widget: SearchEntry search_entry { + child: CenterBox { hexpand: true; - placeholder-text: _("Search games"); - search-started => $_search_started(); - search-changed => $_search_changed(); - activate => $_search_activate(); - stop-search => $_stop_search(); - }; - end-widget: MenuButton { - icon-name: "filter-symbolic"; - tooltip-text: _("Sort & Filter"); - margin-start: 6; - - menu-model: menu { - section { - label: _("Sort"); - - item { - label: _("Last Played"); - action: "win.sort-mode"; - target: "last_played"; - } - - item { - label: _("A-Z"); - action: "win.sort-mode"; - target: "a-z"; - } - - item { - label: _("Z-A"); - action: "win.sort-mode"; - target: "z-a"; - } - - item { - label: _("Newest"); - action: "win.sort-mode"; - target: "newest"; - } - - item { - label: _("Oldest"); - action: "win.sort-mode"; - target: "oldest"; - } - } - - section { - item (_("Show Hidden Games"), "win.show-hidden") - } + center-widget: SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search games"); + search-started => $_search_started(); + search-changed => $_search_changed(); + activate => $_search_activate(); + stop-search => $_stop_search(); }; - }; - }; - }; - [start] - Button { - icon-name: "list-add-symbolic"; - tooltip-text: _("Add Game"); - action-name: "win.add"; - } + end-widget: MenuButton { + icon-name: "filter-symbolic"; + tooltip-text: _("Sort & Filter"); + margin-start: 6; - [end] - MenuButton { - icon-name: "open-menu-symbolic"; - tooltip-text: _("Main Menu"); - primary: true; + menu-model: menu { + section { + label: _("Sort"); - menu-model: menu { - item (_("Keyboard Shortcuts"), "app.shortcuts") - item (_("About Cartridges"), "app.about") - }; - } - } + item { + label: _("Last Played"); + action: "win.sort-mode"; + target: "last_played"; + } - content: 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 ; + item { + label: _("A-Z"); + action: "win.sort-mode"; + target: "a-z"; + } - Adw.ViewStackPage { - name: "grid"; + item { + label: _("Z-A"); + action: "win.sort-mode"; + target: "z-a"; + } - child: ScrolledWindow { - hscrollbar-policy: never; + item { + label: _("Newest"); + action: "win.sort-mode"; + target: "newest"; + } - child: GridView grid { - name: "grid"; - single-click-activate: true; - activate => $_show_details(); + item { + label: _("Oldest"); + action: "win.sort-mode"; + target: "oldest"; + } + } - model: NoSelection { - model: SortListModel { - sorter: $GameSorter sorter {}; - - model: FilterListModel { - watch-items: true; - - filter: EveryFilter { - AnyFilter { - StringFilter { - expression: expr item as <$Game>.name; - search: bind template.search-text; - } - - StringFilter { - expression: expr item as <$Game>.developer; - search: bind template.search-text; - } - } - - BoolFilter { - expression: expr item as <$Game>.hidden; - invert: bind template.show-hidden inverted; - } - - BoolFilter { - expression: expr item as <$Game>.removed; - invert: true; - } - - BoolFilter { - expression: expr item as <$Game>.blacklisted; - invert: true; - } - }; - - model: bind template.games; - }; + section { + item (_("Show Hidden Games"), "win.show-hidden") + } }; }; - - factory: BuilderListItemFactory { - template ListItem { - child: $GameItem { - game: bind template.item; - position: bind template.position; - }; - } - }; }; }; + + [start] + Button { + icon-name: "list-add-symbolic"; + tooltip-text: _("Add Game"); + action-name: "win.add"; + } + + [end] + MenuButton { + icon-name: "open-menu-symbolic"; + tooltip-text: _("Main Menu"); + primary: true; + + menu-model: menu { + item (_("Keyboard Shortcuts"), "app.shortcuts") + item (_("About Cartridges"), "app.about") + }; + } } - Adw.ViewStackPage { - name: "empty-search"; + content: 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 ; - child: Adw.StatusPage { - icon-name: "edit-find-symbolic"; - title: _("No Games Found"); - description: _("Try a different search"); - }; - } + Adw.ViewStackPage { + name: "grid"; - Adw.ViewStackPage { - name: "empty-hidden"; + child: ScrolledWindow { + hscrollbar-policy: never; - child: Adw.StatusPage { - icon-name: "view-conceal-symbolic"; - title: _("No Hidden Games"); - description: _("Games you hide will appear here"); - }; - } + child: GridView grid { + name: "grid"; + single-click-activate: true; + activate => $_show_details(); - Adw.ViewStackPage { - name: "empty"; + model: NoSelection { + model: SortListModel { + sorter: $GameSorter sorter {}; - child: Adw.StatusPage { - icon-name: bind template.application as .application-id; - title: _("No Games"); - description: _("Use the + button to add games"); - }; - } + model: FilterListModel { + watch-items: true; + + filter: EveryFilter { + AnyFilter { + StringFilter { + expression: expr item as <$Game>.name; + search: bind template.search-text; + } + + StringFilter { + expression: expr item as <$Game>.developer; + search: bind template.search-text; + } + } + + BoolFilter { + expression: expr item as <$Game>.hidden; + invert: bind template.show-hidden inverted; + } + + BoolFilter { + expression: expr item as <$Game>.removed; + invert: true; + } + + BoolFilter { + expression: expr item as <$Game>.blacklisted; + invert: true; + } + }; + + model: bind template.games; + }; + }; + }; + + factory: BuilderListItemFactory { + template ListItem { + child: $GameItem { + game: bind template.item; + position: bind template.position; + }; + } + }; + }; + }; + } + + Adw.ViewStackPage { + name: "empty-search"; + + child: Adw.StatusPage { + icon-name: "edit-find-symbolic"; + title: _("No Games Found"); + description: _("Try a different search"); + }; + } + + Adw.ViewStackPage { + name: "empty-hidden"; + + child: Adw.StatusPage { + icon-name: "view-conceal-symbolic"; + title: _("No Hidden Games"); + description: _("Games you hide will appear here"); + }; + } + + Adw.ViewStackPage { + name: "empty"; + + child: Adw.StatusPage { + icon-name: bind template.application as .application-id; + title: _("No Games"); + description: _("Use the + button to add games"); + }; + } + }; }; }; } diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index b6b3f24..a9fd8a7 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -3,6 +3,8 @@ # SPDX-FileCopyrightText: Copyright 2022-2025 kramo # SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel +from collections.abc import Callable +from gettext import gettext as _ from typing import Any, TypeVar, cast from gi.repository import Adw, Gio, GLib, GObject, Gtk @@ -23,6 +25,7 @@ SORT_MODES = { } _T = TypeVar("_T") +type _UndoFunc = Callable[[], Any] @Gtk.Template.from_resource(f"{PREFIX}/window.ui") @@ -32,6 +35,7 @@ class Window(Adw.ApplicationWindow): __gtype_name__ = __qualname__ navigation_view: Adw.NavigationView = Gtk.Template.Child() + toast_overlay: Adw.ToastOverlay = Gtk.Template.Child() search_entry: Gtk.SearchEntry = Gtk.Template.Child() grid: Gtk.GridView = Gtk.Template.Child() sorter: GameSorter = Gtk.Template.Child() @@ -65,8 +69,25 @@ class Window(Adw.ApplicationWindow): ("search", lambda *_: self.search_entry.grab_focus()), ("edit", lambda _action, param, *_: self._edit(param.get_uint32()), "u"), ("add", lambda *_: self._add()), + ("undo", lambda *_: self._undo()), )) + self._history: dict[Adw.Toast, _UndoFunc] = {} + + def send_toast(self, title: str, *, undo: _UndoFunc | None = None): + """Notify the user with a toast. + + Optionally display a button allowing the user to `undo` an operation. + """ + toast = Adw.Toast.new(title) + if undo: + toast.props.action_name = "win.undo" + toast.props.button_label = _("Undo") + toast.props.priority = Adw.ToastPriority.HIGH + self._history[toast] = undo + + self.toast_overlay.add_toast(toast) + @Gtk.Template.Callback() def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T: return first if condition else second @@ -110,3 +131,12 @@ class Window(Adw.ApplicationWindow): self.navigation_view.push_by_tag("details") self.details.edit() + + def _undo(self): + try: + toast, undo = self._history.popitem() + except KeyError: + return + + toast.dismiss() + undo()