diff --git a/cartridges/ui/closures.py b/cartridges/ui/closures.py new file mode 100644 index 0000000..e39a1f2 --- /dev/null +++ b/cartridges/ui/closures.py @@ -0,0 +1,46 @@ +# 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 diff --git a/cartridges/ui/collection-details.blp b/cartridges/ui/collection-details.blp index 4ec74f7..6d64ce5 100644 --- a/cartridges/ui/collection-details.blp +++ b/cartridges/ui/collection-details.blp @@ -2,7 +2,7 @@ using Gtk 4.0; using Adw 1; template $CollectionDetails: Adw.Dialog { - title: bind $_or(template.collection as <$Collection>.name, _("New Collection")) as ; + title: bind $either(template.collection as <$Collection>.name, _("New Collection")) as ; content-width: 360; default-widget: apply_button; focus-widget: name_entry; @@ -31,7 +31,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 ; + tooltip-text: bind $if_else(template.collection as <$Collection>.in-model, _("Apply"), _("Add")) as ; styles [ "suggested-action", diff --git a/cartridges/ui/collection_details.py b/cartridges/ui/collection_details.py index 8f338ac..02b197b 100644 --- a/cartridges/ui/collection_details.py +++ b/cartridges/ui/collection_details.py @@ -9,6 +9,7 @@ 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): @@ -54,6 +55,9 @@ class CollectionDetails(Adw.Dialog): _selected_icon: str + either = closures.either + if_else = closures.if_else + @GObject.Property(type=Collection) def collection(self) -> Collection: """The collection that `self` represents.""" @@ -128,11 +132,3 @@ 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 diff --git a/cartridges/ui/cover.blp b/cartridges/ui/cover.blp index c0e1045..3a4c300 100644 --- a/cartridges/ui/cover.blp +++ b/cartridges/ui/cover.blp @@ -15,7 +15,7 @@ template $Cover: Adw.Bin { child: Adw.ViewStack { name: "cover"; - visible-child-name: bind $_if_else(template.paintable, "cover", "icon") as ; + visible-child-name: bind $if_else(template.paintable, "cover", "icon") as ; 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 .application as .application-id, "-symbolic" ) as ; diff --git a/cartridges/ui/cover.py b/cartridges/ui/cover.py index 0178ebc..57c171e 100644 --- a/cartridges/ui/cover.py +++ b/cartridges/ui/cover.py @@ -4,6 +4,7 @@ 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") @@ -16,10 +17,5 @@ class Cover(Adw.Bin): width = GObject.Property(type=int, default=200) height = GObject.Property(type=int, default=300) - @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) + concat = closures.concat + if_else = closures.if_else diff --git a/cartridges/ui/game-details.blp b/cartridges/ui/game-details.blp index b56800b..2da6eba 100644 --- a/cartridges/ui/game-details.blp +++ b/cartridges/ui/game-details.blp @@ -5,7 +5,7 @@ using Adw 1; template $GameDetails: Adw.NavigationPage { name: "details"; tag: "details"; - title: bind $_or(template.game as <$Game>.name, _("Add Game")) as ; + title: bind $either(template.game as <$Game>.name, _("Add Game")) as ; hidden => $_cancel(); ShortcutController { @@ -101,7 +101,7 @@ template $GameDetails: Adw.NavigationPage { Label developer_label { label: bind template.game as <$Game>.developer; - visible: bind $_bool(template.game as <$Game>.developer) as ; + visible: bind $boolean(template.game as <$Game>.developer) as ; halign: start; max-width-chars: 36; wrap: true; @@ -119,14 +119,20 @@ template $GameDetails: Adw.NavigationPage { spacing: 9; Label last_played_label { - label: bind $_date_label(_("Last played: {}"), template.game as <$Game>.last_played) as ; + label: bind $format_string( + _("Last played: {}"), + $_pretty_date(template.game as <$Game>.last_played) as + ) as ; halign: start; wrap: true; wrap-mode: word_char; } Label added_label { - label: bind $_date_label(_("Added: {}"), template.game as <$Game>.added) as ; + label: bind $format_string( + _("Added: {}"), + $_pretty_date(template.game as <$Game>.added) as + ) as ; halign: start; wrap: true; wrap-mode: word_char; @@ -376,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 ; + label: bind $if_else(template.game as <$Game>.added, _("Apply"), _("Add")) as ; styles [ "pill", diff --git a/cartridges/ui/game_details.py b/cartridges/ui/game_details.py index 314ee2e..e1c01c4 100644 --- a/cartridges/ui/game_details.py +++ b/cartridges/ui/game_details.py @@ -16,6 +16,7 @@ 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 @@ -42,6 +43,11 @@ class GameDetails(Adw.NavigationPage): 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.""" @@ -145,14 +151,6 @@ 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()): @@ -163,10 +161,10 @@ class GameDetails(Adw.NavigationPage): return None @Gtk.Template.Callback() - def _date_label(self, _obj, label: str, timestamp: int) -> str: + def _pretty_date(self, _obj, timestamp: int) -> str: date = datetime.fromtimestamp(timestamp, UTC) now = datetime.now(UTC) - return label.format( + return ( _("Never") if not timestamp else _("Today") @@ -188,10 +186,6 @@ 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") diff --git a/cartridges/ui/meson.build b/cartridges/ui/meson.build index b9eb6de..24ebe06 100644 --- a/cartridges/ui/meson.build +++ b/cartridges/ui/meson.build @@ -1,6 +1,7 @@ python.install_sources( files( '__init__.py', + 'closures.py', 'collection_details.py', 'collections.py', 'cover.py', diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index a872c7b..ddc06cc 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -213,16 +213,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 .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 + $if_else(template.show-hidden, "empty-hidden", "empty") as ) as ) as ) as ; @@ -310,7 +310,10 @@ template $Window: Adw.ApplicationWindow { child: Adw.StatusPage { icon-name: bind template.collection as <$Collection>.icon-name; - title: bind $_format(_("No Games in {}"), template.collection as <$Collection>.name) as ; + title: bind $format_string( + _("No Games in {}"), + template.collection as <$Collection>.name, + ) as ; }; } diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index 3df39ed..9b6eccd 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -14,7 +14,7 @@ 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 collections, games, sources +from cartridges.ui import closures, collections, games, sources from .collection_details import CollectionDetails from .collections import CollectionFilter, CollectionSidebarItem @@ -73,6 +73,9 @@ class Window(Adw.ApplicationWindow): _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.""" @@ -232,14 +235,6 @@ 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) diff --git a/po/POTFILES.in b/po/POTFILES.in index 661a261..cea44a7 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -6,6 +6,7 @@ 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