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,6 +39,7 @@ template $Window: Adw.ApplicationWindow {
"view",
]
child: Adw.ToastOverlay toast_overlay {
child: Adw.ToolbarView {
[top]
Adw.HeaderBar {
@@ -219,6 +225,7 @@ template $Window: Adw.ApplicationWindow {
}
};
};
};
}
$GameDetails details {

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