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 locale
import os import os
import subprocess 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 json import JSONDecodeError
from pathlib import Path from pathlib import Path
from shlex import quote from shlex import quote
from types import UnionType 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 gi.repository import Gdk, Gio, GLib, GObject, Gtk
from cartridges import DATA_DIR, state_settings from cartridges import DATA_DIR, state_settings
if TYPE_CHECKING:
from .application import Application
from .ui.window import Window
class _GameProp(NamedTuple): class _GameProp(NamedTuple):
name: str name: str
@@ -79,18 +84,26 @@ class Game(Gio.SimpleActionGroup):
self.add_action_entries(( self.add_action_entries((
("play", lambda *_: self.play()), ("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")) self.add_action(hide_action := Gio.SimpleAction.new("hide"))
hide_action.connect("activate", lambda *_: setattr(self, "hidden", True)) hide_action.connect("activate", lambda *_: self._hide())
not_hidden = Gtk.ClosureExpression.new(bool, lambda _, h: not h, (hidden,)) self.bind_property(
not_hidden.bind(hide_action, "enabled", self) "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 @classmethod
def from_data(cls, data: dict[str, Any]) -> Self: 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: with path.open(encoding="utf-8") as f:
json.dump(properties, f, indent=4) 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): class GameSorter(Gtk.Sorter):
"""A sorter for game objects. """A sorter for game objects.

View File

@@ -16,7 +16,7 @@ template $GameDetails: Adw.NavigationPage {
Shortcut { Shortcut {
trigger: "Delete|KP_Delete"; trigger: "Delete|KP_Delete";
action: "action(details.remove)"; action: "action(game.remove)";
} }
} }
@@ -189,7 +189,7 @@ template $GameDetails: Adw.NavigationPage {
} }
Button { Button {
action-name: "details.remove"; action-name: "game.remove";
icon-name: "user-trash-symbolic"; icon-name: "user-trash-symbolic";
tooltip-text: _("Remove"); tooltip-text: _("Remove");
valign: center; valign: center;

View File

@@ -18,6 +18,7 @@ from cartridges.games import Game
from .cover import Cover # noqa: F401 from .cover import Cover # noqa: F401
_POP_ON_ACTION = "hide", "unhide", "remove"
_EDITABLE_PROPERTIES = {prop.name for prop in games.PROPERTIES if prop.editable} _EDITABLE_PROPERTIES = {prop.name for prop in games.PROPERTIES if prop.editable}
_REQUIRED_PROPERTIES = { _REQUIRED_PROPERTIES = {
prop.name for prop in games.PROPERTIES if prop.editable and prop.required 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() sort_changed = GObject.Signal()
@GObject.Property(type=Game) @GObject.Property(type=Game)
def game(self) -> Game | None: def game(self) -> Game:
"""The game whose details to show.""" """The game whose details to show."""
return self._game return self._game
@game.setter @game.setter
def game(self, game: Game | None): def game(self, game: Game):
self._game = game self._game = game
self.insert_action_group("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): def __init__(self, **kwargs: Any):
super().__init__(**kwargs) super().__init__(**kwargs)
self._signal_ids = dict[Gio.SimpleAction, int]()
self.insert_action_group("details", group := Gio.SimpleActionGroup()) self.insert_action_group("details", group := Gio.SimpleActionGroup())
group.add_action_entries(( group.add_action_entries((
("edit", lambda *_: self.edit()), ("edit", lambda *_: self.edit()),
("cancel", lambda *_: self._cancel()), ("cancel", lambda *_: self._cancel()),
("remove", lambda *_: self._remove()),
( (
"search-on", "search-on",
lambda _action, param, *_: Gio.AppInfo.launch_default_for_uri( lambda _action, param, *_: Gio.AppInfo.launch_default_for_uri(
@@ -118,10 +130,6 @@ class GameDetails(Adw.NavigationPage):
self.stack.props.visible_child_name = "details" self.stack.props.visible_child_name = "details"
def _remove(self):
self.game.removed = True
self.activate_action("navigation.pop")
@Gtk.Template.Callback() @Gtk.Template.Callback()
def _or(self, _obj, first: _T, second: _T) -> _T: def _or(self, _obj, first: _T, second: _T) -> _T:
return first or second return first or second

View File

@@ -20,6 +20,11 @@ template $Window: Adw.ApplicationWindow {
action: "action(win.add)"; action: "action(win.add)";
} }
Shortcut {
trigger: "<Control>z";
action: "action(win.undo)";
}
Shortcut { Shortcut {
trigger: "<Control>w"; trigger: "<Control>w";
action: "action(window.close)"; action: "action(window.close)";
@@ -34,189 +39,191 @@ template $Window: Adw.ApplicationWindow {
"view", "view",
] ]
child: Adw.ToolbarView { child: Adw.ToastOverlay toast_overlay {
[top] child: Adw.ToolbarView {
Adw.HeaderBar { [top]
title-widget: Adw.Clamp clamp { Adw.HeaderBar {
tightening-threshold: bind clamp.maximum-size; title-widget: Adw.Clamp clamp {
tightening-threshold: bind clamp.maximum-size;
child: CenterBox { child: CenterBox {
hexpand: true;
center-widget: SearchEntry search_entry {
hexpand: true; hexpand: true;
placeholder-text: _("Search games");
search-started => $_search_started();
search-changed => $_search_changed();
activate => $_search_activate();
stop-search => $_stop_search();
};
end-widget: MenuButton { center-widget: SearchEntry search_entry {
icon-name: "filter-symbolic"; hexpand: true;
tooltip-text: _("Sort & Filter"); placeholder-text: _("Search games");
margin-start: 6; search-started => $_search_started();
search-changed => $_search_changed();
menu-model: menu { activate => $_search_activate();
section { stop-search => $_stop_search();
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")
}
}; };
};
};
};
[start] end-widget: MenuButton {
Button { icon-name: "filter-symbolic";
icon-name: "list-add-symbolic"; tooltip-text: _("Sort & Filter");
tooltip-text: _("Add Game"); margin-start: 6;
action-name: "win.add";
}
[end] menu-model: menu {
MenuButton { section {
icon-name: "open-menu-symbolic"; label: _("Sort");
tooltip-text: _("Main Menu");
primary: true;
menu-model: menu { item {
item (_("Keyboard Shortcuts"), "app.shortcuts") label: _("Last Played");
item (_("About Cartridges"), "app.about") action: "win.sort-mode";
}; target: "last_played";
} }
}
content: Adw.ViewStack { item {
enable-transitions: true; label: _("A-Z");
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>; action: "win.sort-mode";
target: "a-z";
}
Adw.ViewStackPage { item {
name: "grid"; label: _("Z-A");
action: "win.sort-mode";
target: "z-a";
}
child: ScrolledWindow { item {
hscrollbar-policy: never; label: _("Newest");
action: "win.sort-mode";
target: "newest";
}
child: GridView grid { item {
name: "grid"; label: _("Oldest");
single-click-activate: true; action: "win.sort-mode";
activate => $_show_details(); target: "oldest";
}
}
model: NoSelection { section {
model: SortListModel { item (_("Show Hidden Games"), "win.show-hidden")
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;
};
}; };
}; };
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 { content: Adw.ViewStack {
name: "empty-search"; 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 { Adw.ViewStackPage {
icon-name: "edit-find-symbolic"; name: "grid";
title: _("No Games Found");
description: _("Try a different search");
};
}
Adw.ViewStackPage { child: ScrolledWindow {
name: "empty-hidden"; hscrollbar-policy: never;
child: Adw.StatusPage { child: GridView grid {
icon-name: "view-conceal-symbolic"; name: "grid";
title: _("No Hidden Games"); single-click-activate: true;
description: _("Games you hide will appear here"); activate => $_show_details();
};
}
Adw.ViewStackPage { model: NoSelection {
name: "empty"; model: SortListModel {
sorter: $GameSorter sorter {};
child: Adw.StatusPage { model: FilterListModel {
icon-name: bind template.application as <Application>.application-id; watch-items: true;
title: _("No Games");
description: _("Use the + button to add games"); 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 2022-2025 kramo
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel # SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from collections.abc import Callable
from gettext import gettext as _
from typing import Any, TypeVar, cast from typing import Any, TypeVar, cast
from gi.repository import Adw, Gio, GLib, GObject, Gtk from gi.repository import Adw, Gio, GLib, GObject, Gtk
@@ -23,6 +25,7 @@ SORT_MODES = {
} }
_T = TypeVar("_T") _T = TypeVar("_T")
type _UndoFunc = Callable[[], Any]
@Gtk.Template.from_resource(f"{PREFIX}/window.ui") @Gtk.Template.from_resource(f"{PREFIX}/window.ui")
@@ -32,6 +35,7 @@ class Window(Adw.ApplicationWindow):
__gtype_name__ = __qualname__ __gtype_name__ = __qualname__
navigation_view: Adw.NavigationView = Gtk.Template.Child() navigation_view: Adw.NavigationView = Gtk.Template.Child()
toast_overlay: Adw.ToastOverlay = Gtk.Template.Child()
search_entry: Gtk.SearchEntry = Gtk.Template.Child() search_entry: Gtk.SearchEntry = Gtk.Template.Child()
grid: Gtk.GridView = Gtk.Template.Child() grid: Gtk.GridView = Gtk.Template.Child()
sorter: GameSorter = Gtk.Template.Child() sorter: GameSorter = Gtk.Template.Child()
@@ -65,8 +69,25 @@ class Window(Adw.ApplicationWindow):
("search", lambda *_: self.search_entry.grab_focus()), ("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()), ("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() @Gtk.Template.Callback()
def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T: def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T:
return first if condition else second return first if condition else second
@@ -110,3 +131,12 @@ class Window(Adw.ApplicationWindow):
self.navigation_view.push_by_tag("details") self.navigation_view.push_by_tag("details")
self.details.edit() self.details.edit()
def _undo(self):
try:
toast, undo = self._history.popitem()
except KeyError:
return
toast.dismiss()
undo()