Compare commits

..

2 Commits

Author SHA1 Message Date
Jamie Gravendeel
dab108ce8b ui: Use GObject.BindingGroup 2026-01-10 01:46:20 +01:00
Jamie Gravendeel
515bafa428 ui: Use GObject.SignalGroup 2026-01-10 01:22:55 +01:00
13 changed files with 157 additions and 175 deletions

View File

@@ -1,46 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from collections.abc import Callable
from typing import Any
from gi.repository import Gtk
def _closure[**P, R](func: Callable[P, R]) -> object: # gi._gtktemplate.CallThing
@Gtk.Template.Callback()
@staticmethod
def wrapper(_obj, *args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@_closure
def boolean(value: object) -> bool:
"""Get a boolean for `value`."""
return bool(value)
@_closure
def concat(*strings: str) -> str:
"""Join `strings`."""
return "".join(strings)
@_closure
def either[T](first: T, second: T) -> T:
"""Return `first` or `second`."""
return first or second
@_closure
def format_string(string: str, *args: Any) -> str:
"""Format `string` with `args`."""
return string.format(*args)
@_closure
def if_else[T](condition: object, first: T, second: T) -> T:
"""Return `first` or `second` depending on `condition`."""
return first if condition else second

View File

@@ -1,8 +1,14 @@
using Gtk 4.0;
using Adw 1;
using GObject 2.0;
GObject.SignalGroup collection_signals {
target: bind template.collection;
target-type: typeof<$Collection>;
}
template $CollectionDetails: Adw.Dialog {
title: bind $either(template.collection as <$Collection>.name, _("New Collection")) as <string>;
title: bind $_or(template.collection as <$Collection>.name, _("New Collection")) as <string>;
content-width: 360;
default-widget: apply_button;
focus-widget: name_entry;
@@ -31,7 +37,7 @@ template $CollectionDetails: Adw.Dialog {
Button apply_button {
action-name: "details.apply";
icon-name: "apply-symbolic";
tooltip-text: bind $if_else(template.collection as <$Collection>.in-model, _("Apply"), _("Add")) as <string>;
tooltip-text: bind $_if_else(template.collection as <$Collection>.in-model, _("Apply"), _("Add")) as <string>;
styles [
"suggested-action",

View File

@@ -2,14 +2,13 @@
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from itertools import product
from typing import Any, NamedTuple, cast
from typing import Any, NamedTuple
from gi.repository import Adw, Gio, GObject, Gtk
from cartridges import collections
from cartridges.collections import Collection
from cartridges.config import PREFIX
from cartridges.ui import closures
class _Icon(NamedTuple):
@@ -51,13 +50,11 @@ class CollectionDetails(Adw.Dialog):
name_entry: Adw.EntryRow = Gtk.Template.Child()
icons_grid: Gtk.Grid = Gtk.Template.Child()
collection_signals: GObject.SignalGroup = Gtk.Template.Child()
sort_changed = GObject.Signal()
_selected_icon: str
either = closures.either
if_else = closures.if_else
@GObject.Property(type=Collection)
def collection(self) -> Collection:
"""The collection that `self` represents."""
@@ -67,10 +64,8 @@ class CollectionDetails(Adw.Dialog):
def collection(self, collection: Collection):
self._collection = collection
self.insert_action_group("collection", collection)
remove_action = cast(Gio.SimpleAction, collection.lookup_action("remove"))
remove_action.connect("activate", lambda *_: self.force_close())
def __init__(self, **kwargs: Any):
def __init__(self, collection: Collection, **kwargs: Any):
super().__init__(**kwargs)
self.insert_action_group("details", group := Gio.SimpleActionGroup())
@@ -85,6 +80,13 @@ class CollectionDetails(Adw.Dialog):
transform_to=lambda _, text: bool(text),
)
self.collection_signals.connect_closure(
"notify::removed",
lambda *_: self.force_close(),
after=True,
)
self.collection = collection
group_button = None
for index, (row, col) in enumerate(product(range(3), range(7))):
icon = _ICONS[index].name
@@ -132,3 +134,11 @@ class CollectionDetails(Adw.Dialog):
collections.save()
self.close()
@Gtk.Template.Callback()
def _or[T](self, _obj, first: T, second: T) -> T:
return first or second
@Gtk.Template.Callback()
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second

View File

@@ -37,19 +37,9 @@ class CollectionFilter(Gtk.Filter):
class CollectionSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue]
"""A sidebar item representing a collection."""
@GObject.Property(type=Collection)
def collection(self) -> Collection:
"""The collection that `self` represents."""
return self._collection
collection = GObject.Property(type=Collection)
@collection.setter
def collection(self, collection: Collection):
self._collection = collection
flags = GObject.BindingFlags.SYNC_CREATE
collection.bind_property("name", self, "title", flags)
collection.bind_property("icon-name", self, "icon-name", flags)
def __init__(self, **kwargs: Any):
def __init__(self, collection: Collection, **kwargs: Any):
super().__init__(**kwargs)
self.bind_property(
@@ -60,6 +50,13 @@ class CollectionSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttribute
lambda _, name: GLib.markup_escape_text(name),
)
self._collection_bindings = GObject.BindingGroup()
flags = GObject.BindingFlags.DEFAULT
self._collection_bindings.bind("name", self, "title", flags)
self._collection_bindings.bind("icon-name", self, "icon-name", flags)
self.bind_property("collection", self._collection_bindings, "source")
self.collection = collection
class CollectionButton(Gtk.ToggleButton):
"""A toggle button representing a collection."""

View File

@@ -15,7 +15,7 @@ template $Cover: Adw.Bin {
child: Adw.ViewStack {
name: "cover";
visible-child-name: bind $if_else(template.paintable, "cover", "icon") as <string>;
visible-child-name: bind $_if_else(template.paintable, "cover", "icon") as <string>;
overflow: hidden;
enable-transitions: true;
@@ -34,7 +34,7 @@ template $Cover: Adw.Bin {
name: "icon";
child: Image {
icon-name: bind $concat(
icon-name: bind $_concat(
template.root as <Window>.application as <Application>.application-id,
"-symbolic"
) as <string>;

View File

@@ -4,7 +4,6 @@
from gi.repository import Adw, Gdk, GObject, Gtk
from cartridges.config import PREFIX
from cartridges.ui import closures
@Gtk.Template.from_resource(f"{PREFIX}/cover.ui")
@@ -17,5 +16,10 @@ class Cover(Adw.Bin):
width = GObject.Property(type=int, default=200)
height = GObject.Property(type=int, default=300)
concat = closures.concat
if_else = closures.if_else
@Gtk.Template.Callback()
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second
@Gtk.Template.Callback()
def _concat(self, _obj, *strings: str) -> str:
return "".join(strings)

View File

@@ -1,11 +1,17 @@
using Gtk 4.0;
using Gdk 4.0;
using Adw 1;
using GObject 2.0;
GObject.SignalGroup game_signals {
target: bind template.game;
target-type: typeof<$Game>;
}
template $GameDetails: Adw.NavigationPage {
name: "details";
tag: "details";
title: bind $either(template.game as <$Game>.name, _("Add Game")) as <string>;
title: bind $_or(template.game as <$Game>.name, _("Add Game")) as <string>;
hidden => $_cancel();
ShortcutController {
@@ -101,7 +107,7 @@ template $GameDetails: Adw.NavigationPage {
Label developer_label {
label: bind template.game as <$Game>.developer;
visible: bind $boolean(template.game as <$Game>.developer) as <bool>;
visible: bind $_bool(template.game as <$Game>.developer) as <bool>;
halign: start;
max-width-chars: 36;
wrap: true;
@@ -119,20 +125,14 @@ template $GameDetails: Adw.NavigationPage {
spacing: 9;
Label last_played_label {
label: bind $format_string(
_("Last played: {}"),
$_pretty_date(template.game as <$Game>.last_played) as <string>
) as <string>;
label: bind $_date_label(_("Last played: {}"), template.game as <$Game>.last_played) as <string>;
halign: start;
wrap: true;
wrap-mode: word_char;
}
Label added_label {
label: bind $format_string(
_("Added: {}"),
$_pretty_date(template.game as <$Game>.added) as <string>
) as <string>;
label: bind $_date_label(_("Added: {}"), template.game as <$Game>.added) as <string>;
halign: start;
wrap: true;
wrap-mode: word_char;
@@ -382,7 +382,7 @@ template $GameDetails: Adw.NavigationPage {
Button apply_button {
action-name: "details.apply";
label: bind $if_else(template.game as <$Game>.added, _("Apply"), _("Add")) as <string>;
label: bind $_if_else(template.game as <$Game>.added, _("Apply"), _("Add")) as <string>;
styles [
"pill",

View File

@@ -16,12 +16,11 @@ from cartridges import games, sources
from cartridges.config import PREFIX
from cartridges.games import Game
from cartridges.sources import imported
from cartridges.ui import closures
from .collections import CollectionsBox
from .cover import Cover # noqa: F401
_POP_ON_ACTION = "hide", "unhide", "remove"
_POP_ON_PROPERTY_NOTIFY = "hidden", "removed"
_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
@@ -41,13 +40,9 @@ class GameDetails(Adw.NavigationPage):
developer_entry: Adw.EntryRow = Gtk.Template.Child()
executable_entry: Adw.EntryRow = Gtk.Template.Child()
game_signals: GObject.SignalGroup = Gtk.Template.Child()
sort_changed = GObject.Signal()
boolean = closures.boolean
either = closures.either
format_string = closures.format_string
if_else = closures.if_else
@GObject.Property(type=Game)
def game(self) -> Game:
"""The game whose details to show."""
@@ -58,21 +53,9 @@ class GameDetails(Adw.NavigationPage):
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()),
@@ -106,6 +89,13 @@ class GameDetails(Adw.NavigationPage):
valid = Gtk.ClosureExpression.new(bool, lambda _, *values: all(values), entries)
valid.bind(apply, "enabled")
for name in _POP_ON_PROPERTY_NOTIFY:
self.game_signals.connect_closure(
f"notify::{name}",
lambda *_: self.activate_action("navigation.pop"),
after=True,
)
def edit(self):
"""Enter edit mode."""
for prop in _EDITABLE_PROPERTIES:
@@ -151,6 +141,14 @@ class GameDetails(Adw.NavigationPage):
else:
self.collections_box.finish()
@Gtk.Template.Callback()
def _or[T](self, _obj, first: T, second: T) -> T:
return first or second
@Gtk.Template.Callback()
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second
@Gtk.Template.Callback()
def _downscale_image(self, _obj, cover: Gdk.Texture | None) -> Gdk.Texture | None:
if cover and (renderer := cast(Gtk.Native, self.props.root).get_renderer()):
@@ -161,10 +159,10 @@ class GameDetails(Adw.NavigationPage):
return None
@Gtk.Template.Callback()
def _pretty_date(self, _obj, timestamp: int) -> str:
def _date_label(self, _obj, label: str, timestamp: int) -> str:
date = datetime.fromtimestamp(timestamp, UTC)
now = datetime.now(UTC)
return (
return label.format(
_("Never")
if not timestamp
else _("Today")
@@ -186,6 +184,10 @@ class GameDetails(Adw.NavigationPage):
else date.strftime("%Y")
)
@Gtk.Template.Callback()
def _bool(self, _obj, o: object) -> bool:
return bool(o)
@Gtk.Template.Callback()
def _format_more_info(self, _obj, label: str) -> str:
executable = _("program")

View File

@@ -1,7 +1,6 @@
python.install_sources(
files(
'__init__.py',
'closures.py',
'collection_details.py',
'collections.py',
'cover.py',

View File

@@ -1,6 +1,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from typing import Any
from gi.repository import Adw, Gio, GObject, Gtk
from cartridges import sources
@@ -11,31 +13,35 @@ from cartridges.ui import games
class SourceSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue]
"""A sidebar item representing a source."""
source = GObject.Property(type=Source)
model = GObject.Property(type=Gio.ListModel)
@GObject.Property(type=Source)
def source(self) -> Source:
"""The source that `self` represents."""
return self._source
def __init__(self, source: Source, **kwargs: Any):
super().__init__(**kwargs)
@source.setter
def source(self, source: Source):
self._source = source
flags = GObject.BindingFlags.SYNC_CREATE
source.bind_property("name", self, "title", flags)
source.bind_property("icon-name", self, "icon-name", flags)
self.model = Gtk.FilterListModel(
model=source,
filter=games.filter_,
watch_items=True, # pyright: ignore[reportCallIssue]
)
# https://gitlab.gnome.org/GNOME/gtk/-/issues/7959
self.model.connect(
self._model_signals = GObject.SignalGroup.new(Gio.ListModel)
self._model_signals.connect_closure(
"items-changed",
lambda *_: self.set_property("visible", self.model.props.n_items),
lambda model, *_: model.notify("n-items"),
after=True,
)
self.props.visible = self.model.props.n_items
self._source_bindings = GObject.BindingGroup()
flags = GObject.BindingFlags.DEFAULT
self._source_bindings.bind("name", self, "title", flags)
self._source_bindings.bind("icon-name", self, "icon-name", flags)
self.bind_property("source", self._source_bindings, "source")
self.model = Gtk.FilterListModel(filter=games.filter_, watch_items=True) # pyright: ignore[reportCallIssue]
self.model.bind_property(
"n-items",
self,
"visible",
GObject.BindingFlags.SYNC_CREATE,
)
self.bind_property("source", self.model, "model")
self.source = source
model = Gtk.SortListModel.new(

View File

@@ -1,5 +1,17 @@
using Gtk 4.0;
using Adw 1;
using GObject 2.0;
using Gio 2.0;
GObject.SignalGroup collection_signals {
target: bind template.collection;
target-type: typeof<$Collection>;
}
GObject.SignalGroup model_signals {
target: bind template.model;
target-type: typeof<Gio.ListModel>;
}
template $Window: Adw.ApplicationWindow {
realize => $_setup_gamepad_monitor();
@@ -213,16 +225,16 @@ template $Window: Adw.ApplicationWindow {
content: Adw.ToastOverlay toast_overlay {
child: Adw.ViewStack {
visible-child-name: bind $if_else(
visible-child-name: bind $_if_else(
grid.model as <NoSelection>.n-items,
"grid",
$if_else(
$_if_else(
template.search-text,
"empty-search",
$if_else(
$_if_else(
template.collection,
"empty-collection",
$if_else(template.show-hidden, "empty-hidden", "empty") as <string>
$_if_else(template.show-hidden, "empty-hidden", "empty") as <string>
) as <string>
) as <string>
) as <string>;
@@ -310,10 +322,7 @@ template $Window: Adw.ApplicationWindow {
child: Adw.StatusPage {
icon-name: bind template.collection as <$Collection>.icon-name;
title: bind $format_string(
_("No Games in {}"),
template.collection as <$Collection>.name,
) as <string>;
title: bind $_format(_("No Games in {}"), template.collection as <$Collection>.name) as <string>;
};
}

View File

@@ -13,8 +13,8 @@ from gi.repository import Adw, Gio, GLib, GObject, Gtk
from cartridges import STATE_SETTINGS
from cartridges.collections import Collection
from cartridges.config import PREFIX, PROFILE
from cartridges.sources import Source, imported
from cartridges.ui import closures, collections, games, sources
from cartridges.sources import imported
from cartridges.ui import collections, games, sources
from .collection_details import CollectionDetails
from .collections import CollectionFilter, CollectionSidebarItem
@@ -62,41 +62,18 @@ class Window(Adw.ApplicationWindow):
collection_filter: CollectionFilter = Gtk.Template.Child()
details: GameDetails = Gtk.Template.Child()
model = GObject.Property(type=Gio.ListModel, default=games.model)
collection = GObject.Property(type=Collection)
collection_signals: GObject.SignalGroup = Gtk.Template.Child()
model = GObject.Property(type=Gio.ListModel)
model_signals: GObject.SignalGroup = Gtk.Template.Child()
search_text = GObject.Property(type=str)
show_hidden = GObject.Property(type=bool, default=False)
settings = GObject.Property(type=Gtk.Settings)
_collection: Collection | None = None
_collection_removed_signal: int | None = None
_selected_sidebar_item = 0
format_string = closures.format_string
if_else = closures.if_else
@GObject.Property(type=Collection)
def collection(self) -> Collection | None:
"""The currently selected collection."""
return self._collection
@collection.setter
def collection(self, collection: Collection | None):
if self._collection and self._collection_removed_signal:
self._collection.disconnect(self._collection_removed_signal)
self._collection = collection
self._collection_removed_signal = (
collection.connect("notify::removed", lambda *_: self._collection_removed())
if collection
else None
)
def _collection_removed(self):
self.collection = None
self.sidebar.props.selected = 0
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
@@ -113,10 +90,13 @@ class Window(Adw.ApplicationWindow):
# https://gitlab.gnome.org/GNOME/gtk/-/issues/7901
self.search_entry.set_key_capture_widget(self)
self.sources.bind_model(sources.model, self._create_source_item)
self.sources.bind_model(
sources.model,
lambda source: SourceSidebarItem(source),
)
self.collections.bind_model(
collections.model,
lambda collection: CollectionSidebarItem(collection=collection),
lambda collection: CollectionSidebarItem(collection),
)
self.add_action(STATE_SETTINGS.create_action("show-sidebar"))
@@ -152,6 +132,18 @@ class Window(Adw.ApplicationWindow):
("undo", lambda *_: self._undo()),
))
self.collection_signals.connect_closure(
"notify::removed",
lambda *_: self._collection_removed(),
after=True,
)
self.model_signals.connect_closure(
"items-changed",
lambda model, *_: None if model else self._model_emptied(),
after=True,
)
self.model = games.model
self._history: dict[Adw.Toast, _UndoFunc] = {}
def send_toast(self, title: str, *, undo: _UndoFunc | None = None):
@@ -168,15 +160,11 @@ class Window(Adw.ApplicationWindow):
self.toast_overlay.add_toast(toast)
def _create_source_item(self, source: Source) -> SourceSidebarItem:
item = SourceSidebarItem(source=source)
item.connect(
"notify::visible",
lambda item, _: self._source_empty() if not item.props.visible else None,
)
return item
def _collection_removed(self):
self.collection = None
self.sidebar.props.selected = 0
def _source_empty(self):
def _model_emptied(self):
self.model = games.model
self.sidebar.props.selected = 0
@@ -235,6 +223,14 @@ class Window(Adw.ApplicationWindow):
Gamepad.window = self # pyright: ignore[reportPossiblyUnboundVariable]
gamepads.setup_monitor() # pyright: ignore[reportPossiblyUnboundVariable]
@Gtk.Template.Callback()
def _if_else[T](self, _obj, condition: object, first: T, second: T) -> T:
return first if condition else second
@Gtk.Template.Callback()
def _format(self, _obj, string: str, *args: Any) -> str:
return string.format(*args)
@Gtk.Template.Callback()
def _show_details(self, grid: Gtk.GridView, position: int):
self.details.game = cast(Gio.ListModel, grid.props.model).get_item(position)
@@ -280,12 +276,12 @@ class Window(Adw.ApplicationWindow):
if game_id:
collection.game_ids.add(game_id)
details = CollectionDetails(collection=collection)
details = CollectionDetails(collection)
details.present(self)
def _edit_collection(self, pos: int):
collection = self.collections.get_item(pos).collection
details = CollectionDetails(collection=collection)
details = CollectionDetails(collection)
details.connect(
"sort-changed",
lambda *_: collections.sorter.changed(Gtk.SorterChange.DIFFERENT),

View File

@@ -6,7 +6,6 @@ cartridges/sources/__init__.py
cartridges/sources/heroic.py
cartridges/sources/imported.py
cartridges/sources/steam.py
cartridges/ui/closures.py
cartridges/ui/collection-details.blp
cartridges/ui/collection_details.py
cartridges/ui/collections.py