window: Support undo

This commit is contained in:
Jamie Gravendeel
2025-12-05 14:19:35 +01:00
parent c1e3c987c1
commit f1a59d402d
5 changed files with 264 additions and 180 deletions

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,11 @@ template $Window: Adw.ApplicationWindow {
action: "action(win.add)";
}
Shortcut {
trigger: "<Control>z";
action: "action(win.undo)";
}
Shortcut {
trigger: "<Control>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 <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>;
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 <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>;
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>.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>.application-id;
title: _("No Games");
description: _("Use the + button to add games");
};
}
};
};
};
}

View File

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