Compare commits

..

7 Commits

Author SHA1 Message Date
Jamie Gravendeel
94830393b4 closures: Add a module for commonly used closures 2026-01-10 01:38:55 +01:00
Jamie Gravendeel
c5cfa476ff sources: Add initial UI 2026-01-05 20:34:32 +01:00
Jamie Gravendeel
7bc9d6aee9 window: Move game filters to ui.games 2026-01-05 20:34:32 +01:00
Jamie Gravendeel
00795b83fd sources: Fix icon name 2026-01-05 20:34:32 +01:00
Jamie Gravendeel
f9cb794394 cover: Add width and height properties 2026-01-05 19:43:38 +01:00
Jamie Gravendeel
1aee234cbf cartridges: Use generic methods in favor of TypeVar 2026-01-05 19:34:02 +01:00
Jamie Gravendeel
21588fe92b application: Only use GTK after startup 2026-01-05 19:31:11 +01:00
21 changed files with 239 additions and 191 deletions

View File

@@ -19,6 +19,11 @@ class Application(Adw.Application):
def __init__(self): def __init__(self):
super().__init__(application_id=APP_ID) super().__init__(application_id=APP_ID)
@override
def do_startup(self):
Adw.Application.do_startup(self)
self.props.style_manager.props.color_scheme = Adw.ColorScheme.PREFER_DARK
self.add_action_entries(( self.add_action_entries((
("quit", lambda *_: self.quit()), ("quit", lambda *_: self.quit()),
("about", lambda *_: self._present_about_dialog()), ("about", lambda *_: self._present_about_dialog()),
@@ -28,11 +33,6 @@ class Application(Adw.Application):
sources.load() sources.load()
collections.load() collections.load()
@override
def do_startup(self):
Adw.Application.do_startup(self)
Adw.StyleManager.get_default().props.color_scheme = Adw.ColorScheme.PREFER_DARK
@override @override
def do_activate(self): def do_activate(self):
window = self.props.active_window or Window(application=self) window = self.props.active_window or Window(application=self)

View File

@@ -112,11 +112,6 @@ class Gamepad(GObject.Object):
focus_widget.activate() focus_widget.activate()
return return
if self.window.sidebar.get_focus_child():
selected_item = self.window.sidebar.get_selected_item()
self.window.navigate_sidebar(self.window.sidebar, item=selected_item)
return
self.window.grid.activate_action( self.window.grid.activate_action(
"list.activate-item", "list.activate-item",
GLib.Variant.new_uint32(self._get_current_position()), GLib.Variant.new_uint32(self._get_current_position()),
@@ -141,20 +136,7 @@ class Gamepad(GObject.Object):
open_menu.grab_focus() open_menu.grab_focus()
return return
grid_visible = self.window.view_stack.props.visible_child_name == "grid" self.window.grid.grab_focus()
if self._is_focused_on_top_bar():
focus_widget = self.window.grid if grid_visible else self.window.sidebar
# If the grid is not visible (i.e no search results or imports)
# the searchbar is focused as a fallback.
fallback_child = (
self.window.grid
if self.window.sidebar.get_focus_child()
else self.window.sidebar
)
focus_widget = fallback_child if grid_visible else self.window.search_entry
focus_widget.grab_focus()
self.window.props.focus_visible = True self.window.props.focus_visible = True
def _navigate_to_game_position(self, new_pos: int): def _navigate_to_game_position(self, new_pos: int):
@@ -165,67 +147,24 @@ class Gamepad(GObject.Object):
def _move_horizontally(self, direction: Gtk.DirectionType): def _move_horizontally(self, direction: Gtk.DirectionType):
if self._is_focused_on_top_bar(): if self._is_focused_on_top_bar():
if self.window.header_bar.child_focus(direction): if not self.window.header_bar.child_focus(direction):
self.window.props.focus_visible = True
return
# The usual behaviour of child_focus() on the header bar on the
# left will result in the above child focus to fail, so
# we need to manually check the user is going left to then focus the
# sidebar.
if direction is not self._get_rtl_direction(
Gtk.DirectionType.RIGHT, Gtk.DirectionType.LEFT
):
self.window.header_bar.keynav_failed(direction) self.window.header_bar.keynav_failed(direction)
return
self.window.sidebar.grab_focus()
self.window.props.focus_visible = True self.window.props.focus_visible = True
return return
if self.window.sidebar.get_focus_child():
# The usual behaviour of child_focus() on the sidebar
# would result in the + button being focused, instead of the grid
# so we need to grab the focus of the grid if the user inputs the
# corresponding direction to the grid.
grid_direction = self._get_rtl_direction(
Gtk.DirectionType.LEFT, Gtk.DirectionType.RIGHT
)
# Focus the first game when re-entering from sidebar
if direction is grid_direction:
self.window.grid.scroll_to(0, Gtk.ListScrollFlags.FOCUS, None)
self.window.grid.grab_focus()
return
self.window.sidebar.keynav_failed(direction)
return
if self._can_navigate_games_page(): if self._can_navigate_games_page():
if not self._get_focused_game(): self._navigate_to_game_position(
return self._get_current_position()
+ (
new_pos = self._get_current_position() + ( -1
-1 if direction
if direction == self._get_rtl_direction(
== self._get_rtl_direction( Gtk.DirectionType.RIGHT, Gtk.DirectionType.LEFT
Gtk.DirectionType.RIGHT, Gtk.DirectionType.LEFT )
else 1
) )
else 1
) )
# If the user is focused on the first game and tries to go
# back another game, instead of failing, the focus should
# change to the sidebar.
if new_pos < 0:
self.window.sidebar.grab_focus()
self.window.props.focus_visible = True
return
self._navigate_to_game_position(new_pos)
return return
if self.window.navigation_view.props.visible_page_tag == "details": if self.window.navigation_view.props.visible_page_tag == "details":
@@ -245,14 +184,6 @@ class Gamepad(GObject.Object):
self.window.grid.grab_focus() self.window.grid.grab_focus()
return return
if self.window.sidebar.get_focus_child():
if self.window.sidebar.child_focus(direction):
self.window.props.focus_visible = True
return
self.window.sidebar.keynav_failed(direction)
return
if self._can_navigate_games_page(): if self._can_navigate_games_page():
if not (game := self._get_focused_game()): if not (game := self._get_focused_game()):
return return

View File

@@ -54,6 +54,8 @@ class _SourceModule(Protocol):
class Source(GObject.Object, Gio.ListModel): # pyright: ignore[reportIncompatibleMethodOverride] class Source(GObject.Object, Gio.ListModel): # pyright: ignore[reportIncompatibleMethodOverride]
"""A source of games to import.""" """A source of games to import."""
__gtype_name__ = __qualname__
id = GObject.Property(type=str) id = GObject.Property(type=str)
name = GObject.Property(type=str) name = GObject.Property(type=str)
icon_name = GObject.Property(type=str) icon_name = GObject.Property(type=str)
@@ -65,11 +67,11 @@ class Source(GObject.Object, Gio.ListModel): # pyright: ignore[reportIncompatib
self.id, self.name, self._module = module.ID, module.NAME, module self.id, self.name, self._module = module.ID, module.NAME, module
self.bind_property( self.bind_property(
"name", "id",
self, self,
"icon-name", "icon-name",
GObject.BindingFlags.SYNC_CREATE, GObject.BindingFlags.SYNC_CREATE,
lambda _, name: f"{name}-symbolic", lambda _, ident: f"{ident}-symbolic",
) )
try: try:

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; using Adw 1;
template $CollectionDetails: Adw.Dialog { 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; content-width: 360;
default-widget: apply_button; default-widget: apply_button;
focus-widget: name_entry; focus-widget: name_entry;
@@ -31,7 +31,7 @@ template $CollectionDetails: Adw.Dialog {
Button apply_button { Button apply_button {
action-name: "details.apply"; action-name: "details.apply";
icon-name: "apply-symbolic"; 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 [ styles [
"suggested-action", "suggested-action",

View File

@@ -2,13 +2,14 @@
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel # SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from itertools import product from itertools import product
from typing import Any, NamedTuple, TypeVar, cast from typing import Any, NamedTuple, cast
from gi.repository import Adw, Gio, GObject, Gtk from gi.repository import Adw, Gio, GObject, Gtk
from cartridges import collections from cartridges import collections
from cartridges.collections import Collection from cartridges.collections import Collection
from cartridges.config import PREFIX from cartridges.config import PREFIX
from cartridges.ui import closures
class _Icon(NamedTuple): class _Icon(NamedTuple):
@@ -40,8 +41,6 @@ _ICONS = (
_Icon("fist", ""), _Icon("fist", ""),
) )
_T = TypeVar("_T")
@Gtk.Template.from_resource(f"{PREFIX}/collection-details.ui") @Gtk.Template.from_resource(f"{PREFIX}/collection-details.ui")
class CollectionDetails(Adw.Dialog): class CollectionDetails(Adw.Dialog):
@@ -56,6 +55,9 @@ class CollectionDetails(Adw.Dialog):
_selected_icon: str _selected_icon: str
either = closures.either
if_else = closures.if_else
@GObject.Property(type=Collection) @GObject.Property(type=Collection)
def collection(self) -> Collection: def collection(self) -> Collection:
"""The collection that `self` represents.""" """The collection that `self` represents."""
@@ -130,11 +132,3 @@ class CollectionDetails(Adw.Dialog):
collections.save() collections.save()
self.close() self.close()
@Gtk.Template.Callback()
def _or(self, _obj, first: _T, second: _T) -> _T:
return first or second
@Gtk.Template.Callback()
def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T:
return first if condition else second

View File

@@ -5,27 +5,27 @@ template $Cover: Adw.Bin {
child: Adw.Clamp { child: Adw.Clamp {
orientation: vertical; orientation: vertical;
unit: px; unit: px;
maximum-size: bind _picture.height-request; maximum-size: bind template.height;
tightening-threshold: bind _picture.height-request; tightening-threshold: bind template.height;
child: Adw.Clamp { child: Adw.Clamp {
unit: px; unit: px;
maximum-size: bind _picture.width-request; maximum-size: bind template.width;
tightening-threshold: bind _picture.width-request; tightening-threshold: bind template.width;
child: Adw.ViewStack { child: Adw.ViewStack {
name: "cover"; name: "cover";
visible-child-name: bind $_get_stack_child(template.paintable) as <string>; visible-child-name: bind $if_else(template.paintable, "cover", "icon") as <string>;
overflow: hidden; overflow: hidden;
enable-transitions: true; enable-transitions: true;
Adw.ViewStackPage { Adw.ViewStackPage {
name: "cover"; name: "cover";
child: Picture _picture { child: Picture {
paintable: bind template.paintable; paintable: bind template.paintable;
width-request: 200; width-request: bind template.width;
height-request: 300; height-request: bind template.height;
content-fit: cover; content-fit: cover;
}; };
} }
@@ -34,7 +34,10 @@ template $Cover: Adw.Bin {
name: "icon"; name: "icon";
child: Image { child: Image {
icon-name: bind template.app-icon-name; icon-name: bind $concat(
template.root as <Window>.application as <Application>.application-id,
"-symbolic"
) as <string>;
pixel-size: 80; pixel-size: 80;
styles [ styles [

View File

@@ -3,7 +3,8 @@
from gi.repository import Adw, Gdk, GObject, Gtk from gi.repository import Adw, Gdk, GObject, Gtk
from cartridges.config import APP_ID, PREFIX from cartridges.config import PREFIX
from cartridges.ui import closures
@Gtk.Template.from_resource(f"{PREFIX}/cover.ui") @Gtk.Template.from_resource(f"{PREFIX}/cover.ui")
@@ -12,12 +13,9 @@ class Cover(Adw.Bin):
__gtype_name__ = __qualname__ __gtype_name__ = __qualname__
picture = GObject.Property(lambda self: self._picture, type=Gtk.Picture)
paintable = GObject.Property(type=Gdk.Paintable) paintable = GObject.Property(type=Gdk.Paintable)
app_icon_name = GObject.Property(type=str, default=f"{APP_ID}-symbolic") width = GObject.Property(type=int, default=200)
height = GObject.Property(type=int, default=300)
_picture = Gtk.Template.Child() concat = closures.concat
if_else = closures.if_else
@Gtk.Template.Callback()
def _get_stack_child(self, _obj, paintable: Gdk.Paintable | None) -> str:
return "cover" if paintable else "icon"

View File

@@ -5,7 +5,7 @@ using Adw 1;
template $GameDetails: Adw.NavigationPage { template $GameDetails: Adw.NavigationPage {
name: "details"; name: "details";
tag: "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(); hidden => $_cancel();
ShortcutController { ShortcutController {
@@ -101,7 +101,7 @@ template $GameDetails: Adw.NavigationPage {
Label developer_label { Label developer_label {
label: bind template.game as <$Game>.developer; 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; halign: start;
max-width-chars: 36; max-width-chars: 36;
wrap: true; wrap: true;
@@ -119,14 +119,20 @@ template $GameDetails: Adw.NavigationPage {
spacing: 9; spacing: 9;
Label last_played_label { 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; halign: start;
wrap: true; wrap: true;
wrap-mode: word_char; wrap-mode: word_char;
} }
Label added_label { 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; halign: start;
wrap: true; wrap: true;
wrap-mode: word_char; wrap-mode: word_char;
@@ -376,7 +382,7 @@ template $GameDetails: Adw.NavigationPage {
Button apply_button { Button apply_button {
action-name: "details.apply"; 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 [ styles [
"pill", "pill",

View File

@@ -12,8 +12,8 @@ template $GameItem: Box {
Adw.Clamp { Adw.Clamp {
unit: px; unit: px;
maximum-size: bind cover.picture as <Picture>.width-request; maximum-size: bind cover.width;
tightening-threshold: bind cover.picture as <Picture>.width-request; tightening-threshold: bind cover.width;
child: Overlay { child: Overlay {
child: $Cover cover { child: $Cover cover {

View File

@@ -7,7 +7,7 @@ import sys
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from gettext import gettext as _ from gettext import gettext as _
from typing import Any, TypeVar, cast from typing import Any, cast
from urllib.parse import quote from urllib.parse import quote
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk
@@ -16,6 +16,7 @@ from cartridges import games, sources
from cartridges.config import PREFIX from cartridges.config import PREFIX
from cartridges.games import Game from cartridges.games import Game
from cartridges.sources import imported from cartridges.sources import imported
from cartridges.ui import closures
from .collections import CollectionsBox from .collections import CollectionsBox
from .cover import Cover # noqa: F401 from .cover import Cover # noqa: F401
@@ -26,8 +27,6 @@ _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
} }
_T = TypeVar("_T")
@Gtk.Template.from_resource(f"{PREFIX}/game-details.ui") @Gtk.Template.from_resource(f"{PREFIX}/game-details.ui")
class GameDetails(Adw.NavigationPage): class GameDetails(Adw.NavigationPage):
@@ -44,6 +43,11 @@ class GameDetails(Adw.NavigationPage):
sort_changed = GObject.Signal() sort_changed = GObject.Signal()
boolean = closures.boolean
either = closures.either
format_string = closures.format_string
if_else = closures.if_else
@GObject.Property(type=Game) @GObject.Property(type=Game)
def game(self) -> Game: def game(self) -> Game:
"""The game whose details to show.""" """The game whose details to show."""
@@ -147,14 +151,6 @@ class GameDetails(Adw.NavigationPage):
else: else:
self.collections_box.finish() self.collections_box.finish()
@Gtk.Template.Callback()
def _or(self, _obj, first: _T, second: _T) -> _T:
return first or second
@Gtk.Template.Callback()
def _if_else(self, _obj, condition: object, first: _T, second: _T) -> _T:
return first if condition else second
@Gtk.Template.Callback() @Gtk.Template.Callback()
def _downscale_image(self, _obj, cover: Gdk.Texture | None) -> Gdk.Texture | None: def _downscale_image(self, _obj, cover: Gdk.Texture | None) -> Gdk.Texture | None:
if cover and (renderer := cast(Gtk.Native, self.props.root).get_renderer()): if cover and (renderer := cast(Gtk.Native, self.props.root).get_renderer()):
@@ -165,10 +161,10 @@ class GameDetails(Adw.NavigationPage):
return None return None
@Gtk.Template.Callback() @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) date = datetime.fromtimestamp(timestamp, UTC)
now = datetime.now(UTC) now = datetime.now(UTC)
return label.format( return (
_("Never") _("Never")
if not timestamp if not timestamp
else _("Today") else _("Today")
@@ -190,10 +186,6 @@ class GameDetails(Adw.NavigationPage):
else date.strftime("%Y") else date.strftime("%Y")
) )
@Gtk.Template.Callback()
def _bool(self, _obj, o: object) -> bool:
return bool(o)
@Gtk.Template.Callback() @Gtk.Template.Callback()
def _format_more_info(self, _obj, label: str) -> str: def _format_more_info(self, _obj, label: str) -> str:
executable = _("program") executable = _("program")

View File

@@ -19,7 +19,24 @@ _SORT_MODES = {
"oldest": ("added", False), "oldest": ("added", False),
} }
model = Gtk.FlattenListModel.new(sources.model) filter_ = Gtk.EveryFilter()
filter_.append(
Gtk.BoolFilter(
expression=Gtk.PropertyExpression.new(Game, None, "removed"),
invert=True,
)
)
filter_.append(
Gtk.BoolFilter(
expression=Gtk.PropertyExpression.new(Game, None, "blacklisted"),
invert=True,
)
)
model = Gtk.FilterListModel(
model=Gtk.FlattenListModel.new(sources.model),
filter=filter_,
watch_items=True, # pyright: ignore[reportCallIssue]
)
class GameSorter(Gtk.Sorter): class GameSorter(Gtk.Sorter):

View File

@@ -1,12 +1,14 @@
python.install_sources( python.install_sources(
files( files(
'__init__.py', '__init__.py',
'closures.py',
'collection_details.py', 'collection_details.py',
'collections.py', 'collections.py',
'cover.py', 'cover.py',
'game_details.py', 'game_details.py',
'game_item.py', 'game_item.py',
'games.py', 'games.py',
'sources.py',
'window.py', 'window.py',
), ),
subdir: 'cartridges' / 'ui', subdir: 'cartridges' / 'ui',

44
cartridges/ui/sources.py Normal file
View File

@@ -0,0 +1,44 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: Copyright 2025 Jamie Gravendeel
from gi.repository import Adw, Gio, GObject, Gtk
from cartridges import sources
from cartridges.sources import Source
from cartridges.ui import games
class SourceSidebarItem(Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue]
"""A sidebar item representing a source."""
model = GObject.Property(type=Gio.ListModel)
@GObject.Property(type=Source)
def source(self) -> Source:
"""The source that `self` represents."""
return self._source
@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(
"items-changed",
lambda *_: self.set_property("visible", self.model.props.n_items),
)
self.props.visible = self.model.props.n_items
model = Gtk.SortListModel.new(
sources.model,
Gtk.StringSorter.new(Gtk.PropertyExpression.new(Source, None, "name")),
)

View File

@@ -94,6 +94,10 @@ template $Window: Adw.ApplicationWindow {
} }
} }
Adw.SidebarSection sources {
title: _("Sources");
}
Adw.SidebarSection collections { Adw.SidebarSection collections {
title: _("Collections"); title: _("Collections");
@@ -208,17 +212,17 @@ template $Window: Adw.ApplicationWindow {
} }
content: Adw.ToastOverlay toast_overlay { content: Adw.ToastOverlay toast_overlay {
child: Adw.ViewStack view_stack { child: Adw.ViewStack {
visible-child-name: bind $_if_else( visible-child-name: bind $if_else(
grid.model as <NoSelection>.n-items, grid.model as <NoSelection>.n-items,
"grid", "grid",
$_if_else( $if_else(
template.search-text, template.search-text,
"empty-search", "empty-search",
$_if_else( $if_else(
template.collection, template.collection,
"empty-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> ) as <string>
) as <string>; ) as <string>;
@@ -262,16 +266,6 @@ template $Window: Adw.ApplicationWindow {
expression: expr item as <$Game>.hidden; expression: expr item as <$Game>.hidden;
invert: bind template.show-hidden inverted; 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.model; model: bind template.model;
@@ -316,7 +310,10 @@ template $Window: Adw.ApplicationWindow {
child: Adw.StatusPage { child: Adw.StatusPage {
icon-name: bind template.collection as <$Collection>.icon-name; 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

@@ -6,21 +6,22 @@
import sys import sys
from collections.abc import Callable from collections.abc import Callable
from gettext import gettext as _ from gettext import gettext as _
from typing import Any, TypeVar, cast from typing import Any, cast
from gi.repository import Adw, Gio, GLib, GObject, Gtk from gi.repository import Adw, Gio, GLib, GObject, Gtk
from cartridges import STATE_SETTINGS from cartridges import STATE_SETTINGS
from cartridges.collections import Collection from cartridges.collections import Collection
from cartridges.config import PREFIX, PROFILE from cartridges.config import PREFIX, PROFILE
from cartridges.sources import imported from cartridges.sources import Source, imported
from cartridges.ui import collections, games from cartridges.ui import closures, collections, games, sources
from .collection_details import CollectionDetails from .collection_details import CollectionDetails
from .collections import CollectionFilter, CollectionSidebarItem from .collections import CollectionFilter, CollectionSidebarItem
from .game_details import GameDetails from .game_details import GameDetails
from .game_item import GameItem # noqa: F401 from .game_item import GameItem # noqa: F401
from .games import GameSorter from .games import GameSorter
from .sources import SourceSidebarItem
if sys.platform.startswith("linux"): if sys.platform.startswith("linux"):
from cartridges import gamepads from cartridges import gamepads
@@ -34,7 +35,6 @@ SORT_MODES = {
"oldest": ("added", True), "oldest": ("added", True),
} }
_T = TypeVar("_T")
type _UndoFunc = Callable[[], Any] type _UndoFunc = Callable[[], Any]
@@ -46,6 +46,7 @@ class Window(Adw.ApplicationWindow):
split_view: Adw.OverlaySplitView = Gtk.Template.Child() split_view: Adw.OverlaySplitView = Gtk.Template.Child()
sidebar: Adw.Sidebar = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue] sidebar: Adw.Sidebar = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
sources: Adw.SidebarSection = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
collections: Adw.SidebarSection = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue] collections: Adw.SidebarSection = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
new_collection_item: Adw.SidebarItem = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue] new_collection_item: Adw.SidebarItem = Gtk.Template.Child() # pyright: ignore[reportAttributeAccessIssue]
collection_menu: Gio.Menu = Gtk.Template.Child() collection_menu: Gio.Menu = Gtk.Template.Child()
@@ -56,7 +57,6 @@ class Window(Adw.ApplicationWindow):
sort_button: Gtk.MenuButton = Gtk.Template.Child() sort_button: Gtk.MenuButton = Gtk.Template.Child()
main_menu_button: Gtk.MenuButton = Gtk.Template.Child() main_menu_button: Gtk.MenuButton = Gtk.Template.Child()
toast_overlay: Adw.ToastOverlay = Gtk.Template.Child() toast_overlay: Adw.ToastOverlay = Gtk.Template.Child()
view_stack: Adw.ViewStack = 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()
collection_filter: CollectionFilter = Gtk.Template.Child() collection_filter: CollectionFilter = Gtk.Template.Child()
@@ -73,6 +73,9 @@ class Window(Adw.ApplicationWindow):
_collection_removed_signal: int | None = None _collection_removed_signal: int | None = None
_selected_sidebar_item = 0 _selected_sidebar_item = 0
format_string = closures.format_string
if_else = closures.if_else
@GObject.Property(type=Collection) @GObject.Property(type=Collection)
def collection(self) -> Collection | None: def collection(self) -> Collection | None:
"""The currently selected collection.""" """The currently selected collection."""
@@ -110,6 +113,7 @@ class Window(Adw.ApplicationWindow):
# https://gitlab.gnome.org/GNOME/gtk/-/issues/7901 # https://gitlab.gnome.org/GNOME/gtk/-/issues/7901
self.search_entry.set_key_capture_widget(self) self.search_entry.set_key_capture_widget(self)
self.sources.bind_model(sources.model, self._create_source_item)
self.collections.bind_model( self.collections.bind_model(
collections.model, collections.model,
lambda collection: CollectionSidebarItem(collection=collection), lambda collection: CollectionSidebarItem(collection=collection),
@@ -164,26 +168,17 @@ class Window(Adw.ApplicationWindow):
self.toast_overlay.add_toast(toast) self.toast_overlay.add_toast(toast)
def navigate_sidebar(self, sidebar: Adw.Sidebar, item: Adw.SidebarItem): # pyright: ignore[reportAttributeAccessIssue] def _create_source_item(self, source: Source) -> SourceSidebarItem:
"""Select given item in the sidebar. item = SourceSidebarItem(source=source)
item.connect(
"notify::visible",
lambda item, _: self._source_empty() if not item.props.visible else None,
)
return item
Item should correspond to a collection, or the all game category/ def _source_empty(self):
new collection buttons. self.model = games.model
""" self.sidebar.props.selected = 0
match item:
case self.new_collection_item:
self._add_collection()
sidebar.props.selected = self._selected_sidebar_item
case CollectionSidebarItem():
self.collection = item.collection
case _:
self.collection = None
if item is not self.new_collection_item:
self._selected_sidebar_item = sidebar.props.selected
if self.split_view.props.collapsed:
self.split_view.props.show_sidebar = False
@Gtk.Template.Callback() @Gtk.Template.Callback()
def _show_sidebar_title(self, _obj, layout: str) -> bool: def _show_sidebar_title(self, _obj, layout: str) -> bool:
@@ -192,7 +187,27 @@ class Window(Adw.ApplicationWindow):
@Gtk.Template.Callback() @Gtk.Template.Callback()
def _navigate(self, sidebar: Adw.Sidebar, index: int): # pyright: ignore[reportAttributeAccessIssue] def _navigate(self, sidebar: Adw.Sidebar, index: int): # pyright: ignore[reportAttributeAccessIssue]
self.navigate_sidebar(sidebar, sidebar.get_item(index)) item = sidebar.get_item(index)
match item:
case self.new_collection_item:
self._add_collection()
sidebar.props.selected = self._selected_sidebar_item
case SourceSidebarItem():
self.collection = None
self.model = item.model
case CollectionSidebarItem():
self.collection = item.collection
self.model = games.model
case _:
self.collection = None
self.model = games.model
if item is not self.new_collection_item:
self._selected_sidebar_item = index
if self.split_view.props.collapsed:
self.split_view.props.show_sidebar = False
@Gtk.Template.Callback() @Gtk.Template.Callback()
def _update_selection(self, sidebar: Adw.Sidebar, *_args): # pyright: ignore[reportAttributeAccessIssue] def _update_selection(self, sidebar: Adw.Sidebar, *_args): # pyright: ignore[reportAttributeAccessIssue]
@@ -220,14 +235,6 @@ class Window(Adw.ApplicationWindow):
Gamepad.window = self # pyright: ignore[reportPossiblyUnboundVariable] Gamepad.window = self # pyright: ignore[reportPossiblyUnboundVariable]
gamepads.setup_monitor() # pyright: ignore[reportPossiblyUnboundVariable] gamepads.setup_monitor() # pyright: ignore[reportPossiblyUnboundVariable]
@Gtk.Template.Callback()
def _if_else(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() @Gtk.Template.Callback()
def _show_details(self, grid: Gtk.GridView, position: int): def _show_details(self, grid: Gtk.GridView, position: int):
self.details.game = cast(Gio.ListModel, grid.props.model).get_item(position) self.details.game = cast(Gio.ListModel, grid.props.model).get_item(position)

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="#000" fill-rule="evenodd" d="m7.872 16-3.817-3.083L2 2.79 7.872 0l5.871 2.789-2.055 10.128zm0-4.257-.294-.293-.88-7.927 1.1-1.908 1.174 1.908-.807 7.927zm-.294.367-.147.367-1.761.294-.294-.66.294-.662 1.761.294zm-.073.734-.22 1.541.587.294.587-.294-.22-1.541-.367-.22zm.807-.367-.147-.367.147-.367 1.761-.293.294.66-.294.66z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@@ -4,6 +4,10 @@
<file>apply-symbolic.svg</file> <file>apply-symbolic.svg</file>
<file>cancel-symbolic.svg</file> <file>cancel-symbolic.svg</file>
<file>filter-symbolic.svg</file> <file>filter-symbolic.svg</file>
<!-- Sources -->
<file>heroic-symbolic.svg</file>
<file>imported-symbolic.svg</file>
<file>steam-symbolic.svg</file>
<!-- Categories --> <!-- Categories -->
<file>collection-symbolic.svg</file> <file>collection-symbolic.svg</file>
<file>ball-symbolic.svg</file> <file>ball-symbolic.svg</file>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#222" d="M7 1v6H1v2h6v6h2V9h6V7H9V1zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 144 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M9.352 5.1a1.509 1.509 0 1 0 2.51 1.675A1.509 1.509 0 0 0 9.352 5.1m2.923-.277a2.009 2.009 0 1 1-3.34 2.231 2.009 2.009 0 0 1 3.34-2.23ZM5.01 12.131l-.983-.407a1.7 1.7 0 0 0 3.108-.103 1.696 1.696 0 0 0-1.213-2.29 1.7 1.7 0 0 0-.966.07l1.015.421a1.249 1.249 0 0 1-.96 2.307zM2.546 2.121A8 8 0 0 1 7.966 0l.003.013a7.99 7.99 0 0 1 7.159 4.432 7.996 7.996 0 0 1-4.277 11.018 7.99 7.99 0 0 1-8.274-1.558A8 8 0 0 1 .279 10.18l3.064 1.267A2.264 2.264 0 0 0 7.823 11v-.107l2.718-1.938h.063A3.016 3.016 0 1 0 7.589 5.94v.031l-1.906 2.76h-.126c-.454 0-.898.138-1.273.395L0 7.354A8 8 0 0 1 2.546 2.12Z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 842 B

View File

@@ -6,6 +6,7 @@ cartridges/sources/__init__.py
cartridges/sources/heroic.py cartridges/sources/heroic.py
cartridges/sources/imported.py cartridges/sources/imported.py
cartridges/sources/steam.py cartridges/sources/steam.py
cartridges/ui/closures.py
cartridges/ui/collection-details.blp cartridges/ui/collection-details.blp
cartridges/ui/collection_details.py cartridges/ui/collection_details.py
cartridges/ui/collections.py cartridges/ui/collections.py
@@ -17,6 +18,7 @@ cartridges/ui/game_details.py
cartridges/ui/game_item.py cartridges/ui/game_item.py
cartridges/ui/games.py cartridges/ui/games.py
cartridges/ui/shortcuts-dialog.blp cartridges/ui/shortcuts-dialog.blp
cartridges/ui/sources.py
cartridges/ui/window.blp cartridges/ui/window.blp
cartridges/ui/window.py cartridges/ui/window.py
data/page.kramo.Cartridges.desktop.in data/page.kramo.Cartridges.desktop.in