closures: Add a module for commonly used closures

This commit is contained in:
Jamie Gravendeel
2026-01-09 15:28:10 +01:00
parent c5cfa476ff
commit 94830393b4
11 changed files with 90 additions and 52 deletions

46
cartridges/ui/closures.py Normal file
View File

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

View File

@@ -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 <string>;
title: bind $either(template.collection as <$Collection>.name, _("New Collection")) as <string>;
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 <string>;
tooltip-text: bind $if_else(template.collection as <$Collection>.in-model, _("Apply"), _("Add")) as <string>;
styles [
"suggested-action",

View File

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

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

View File

@@ -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 <string>;
title: bind $either(template.game as <$Game>.name, _("Add Game")) as <string>;
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 <bool>;
visible: bind $boolean(template.game as <$Game>.developer) as <bool>;
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 <string>;
label: bind $format_string(
_("Last played: {}"),
$_pretty_date(template.game as <$Game>.last_played) as <string>
) as <string>;
halign: start;
wrap: true;
wrap-mode: word_char;
}
Label added_label {
label: bind $_date_label(_("Added: {}"), template.game as <$Game>.added) as <string>;
label: bind $format_string(
_("Added: {}"),
$_pretty_date(template.game as <$Game>.added) as <string>
) as <string>;
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 <string>;
label: bind $if_else(template.game as <$Game>.added, _("Apply"), _("Add")) as <string>;
styles [
"pill",

View File

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

View File

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

View File

@@ -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 <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,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 <string>;
title: bind $format_string(
_("No Games in {}"),
template.collection as <$Collection>.name,
) as <string>;
};
}

View File

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

View File

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