diff --git a/cartridges/ui/cover.blp b/cartridges/ui/cover.blp new file mode 100644 index 0000000..580bb05 --- /dev/null +++ b/cartridges/ui/cover.blp @@ -0,0 +1,28 @@ +using Gtk 4.0; +using Adw 1; + +template $Cover: Adw.Bin { + child: Adw.Clamp { + orientation: vertical; + unit: px; + maximum-size: bind picture.height-request; + tightening-threshold: bind picture.height-request; + + Adw.Clamp { + unit: px; + maximum-size: bind picture.width-request; + tightening-threshold: bind picture.width-request; + + Picture picture { + paintable: bind template.paintable; + width-request: 200; + height-request: 300; + content-fit: cover; + + styles [ + "card", + ] + } + } + }; +} diff --git a/cartridges/ui/cover.py b/cartridges/ui/cover.py new file mode 100644 index 0000000..8947a0e --- /dev/null +++ b/cartridges/ui/cover.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2025 kramo + +from gi.repository import Adw, Gdk, GObject, Gtk + +from cartridges.config import PREFIX + + +@Gtk.Template.from_resource(f"{PREFIX}/cover.ui") +class Cover(Adw.Bin): + """Displays a game's cover art.""" + + __gtype_name__ = __qualname__ + + paintable = GObject.Property(type=Gdk.Paintable) diff --git a/cartridges/ui/meson.build b/cartridges/ui/meson.build index 4abb0a5..72c469a 100644 --- a/cartridges/ui/meson.build +++ b/cartridges/ui/meson.build @@ -1,10 +1,10 @@ python.install_sources( - files('__init__.py', 'window.py'), + files('__init__.py', 'cover.py', 'window.py'), subdir: 'cartridges' / 'ui', ) blueprints = custom_target( - input: files('window.blp'), + input: files('cover.blp', 'window.blp'), output: '.', command: [ blueprint_compiler, diff --git a/cartridges/ui/style.css b/cartridges/ui/style.css index 80ecc3f..2b24060 100644 --- a/cartridges/ui/style.css +++ b/cartridges/ui/style.css @@ -10,3 +10,13 @@ padding: 12px; border-radius: 24px; } + +#background { + filter: saturate(300%) opacity(0.5); +} + +@media (prefers-contrast: more) { + #background { + filter: saturate(300%) opacity(0.2); + } +} diff --git a/cartridges/ui/ui.gresource.xml.in b/cartridges/ui/ui.gresource.xml.in index b57587a..f15d90d 100644 --- a/cartridges/ui/ui.gresource.xml.in +++ b/cartridges/ui/ui.gresource.xml.in @@ -1,6 +1,7 @@ + cover.ui window.ui style.css diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 633cc80..2d7d955 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -1,4 +1,5 @@ using Gtk 4.0; +using Gdk 4.0; using Adw 1; template $Window: Adw.ApplicationWindow { @@ -23,155 +24,267 @@ template $Window: Adw.ApplicationWindow { } } - content: Adw.ToolbarView { - [top] - Adw.HeaderBar { - title-widget: Adw.Clamp clamp { - tightening-threshold: bind clamp.maximum-size; + Adw.Breakpoint { + condition ("max-width: 700px") - child: CenterBox { - hexpand: true; - - center-widget: SearchEntry search_entry { - hexpand: true; - placeholder-text: _("Search games"); - search-started => $_search_started(); - search-changed => $_search_changed(); - stop-search => $_stop_search(); - }; - - end-widget: MenuButton { - icon-name: "filter-symbolic"; - tooltip-text: _("Sort & Filter"); - margin-start: 6; - - menu-model: menu { - section { - label: _("Sort"); - - item { - label: _("Last Played"); - action: "win.sort"; - target: "last_played"; - } - - item { - label: _("A-Z"); - action: "win.sort"; - target: "a-z"; - } - - item { - label: _("Z-A"); - action: "win.sort"; - target: "z-a"; - } - - item { - label: _("Newest"); - action: "win.sort"; - target: "newest"; - } - - item { - label: _("Oldest"); - action: "win.sort"; - target: "oldest"; - } - } - - section { - item (_("Show Hidden Games"), "win.show-hidden") - } - }; - }; - }; - }; - - [end] - MenuButton { - icon-name: "open-menu-symbolic"; - tooltip-text: _("Main Menu"); - primary: true; - - menu-model: menu { - item (_("About Cartridges"), "app.about") - }; - } + setters { + details_box.orientation: vertical; + name_label.halign: center; + name_label.justify: center; + developer_label.halign: center; + developer_label.justify: center; + date_labels.orientation: vertical; + date_labels.halign: center; + last_played_label.halign: center; + last_played_label.justify: center; + added_label.halign: center; + added_label.justify: center; } + } - content: ScrolledWindow { - child: GridView grid { - name: "grid"; + content: Adw.NavigationView navigation_view { + Adw.NavigationPage { + title: bind template.title; - model: NoSelection { - model: SortListModel { - sorter: CustomSorter sorter {}; + child: Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.Clamp clamp { + tightening-threshold: bind clamp.maximum-size; - model: FilterListModel { - filter: EveryFilter { - AnyFilter { - StringFilter { - expression: expr item as <$Game>.name; - search: bind template.search-text; - } + child: CenterBox { + hexpand: true; - 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; - } + center-widget: SearchEntry search_entry { + hexpand: true; + placeholder-text: _("Search games"); + search-started => $_search_started(); + search-changed => $_search_changed(); + stop-search => $_stop_search(); }; - model: bind template.games; + end-widget: MenuButton { + icon-name: "filter-symbolic"; + tooltip-text: _("Sort & Filter"); + margin-start: 6; + + menu-model: menu { + section { + label: _("Sort"); + + item { + label: _("Last Played"); + action: "win.sort"; + target: "last_played"; + } + + item { + label: _("A-Z"); + action: "win.sort"; + target: "a-z"; + } + + item { + label: _("Z-A"); + action: "win.sort"; + target: "z-a"; + } + + item { + label: _("Newest"); + action: "win.sort"; + target: "newest"; + } + + item { + label: _("Oldest"); + action: "win.sort"; + target: "oldest"; + } + } + + section { + item (_("Show Hidden Games"), "win.show-hidden") + } + }; + }; + }; + }; + + [end] + MenuButton { + icon-name: "open-menu-symbolic"; + tooltip-text: _("Main Menu"); + primary: true; + + menu-model: menu { + item (_("About Cartridges"), "app.about") + }; + } + } + + content: ScrolledWindow { + hscrollbar-policy: never; + + child: GridView grid { + name: "grid"; + single-click-activate: true; + activate => $_activate_game(); + + model: NoSelection { + model: SortListModel { + sorter: CustomSorter sorter {}; + + model: FilterListModel { + 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: Box { + orientation: vertical; + spacing: 12; + + $Cover { + paintable: bind template.item as <$Game>.cover; + } + + Label { + label: bind template.item as <$Game>.name; + ellipsize: middle; + } + }; + } }; }; }; - factory: BuilderListItemFactory { - template ListItem { - child: Box { - orientation: vertical; - spacing: 12; + styles [ + "view", + ] + }; + } - Picture { - paintable: bind template.item as <$Game>.cover; - width-request: 200; - height-request: 300; - halign: center; + Adw.NavigationPage { + title: bind template.active-game as <$Game>.name; + tag: "details"; - styles [ - "card", - ] + child: Overlay { + child: Picture { + name: "background"; + paintable: bind $_downscale_image(template.active-game as <$Game>.cover) as ; + content-fit: cover; + }; + + [overlay] + Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-title: false; + } + + content: ScrolledWindow { + hscrollbar-policy: never; + + child: Box details_box { + halign: center; + valign: center; + margin-top: 12; + margin-bottom: 48; + margin-start: 24; + margin-end: 24; + spacing: 36; + + $Cover { + paintable: bind template.active-game as <$Game>.cover; } - Label { - label: bind template.item as <$Game>.name; - ellipsize: middle; + Box { + orientation: vertical; + valign: center; + spacing: 3; + + Label name_label { + label: bind template.active-game as <$Game>.name; + halign: start; + max-width-chars: 24; + wrap: true; + wrap-mode: word_char; + + styles [ + "title-1", + ] + } + + Label developer_label { + label: bind template.active-game as <$Game>.developer; + visible: bind $_bool(template.active-game as <$Game>.developer) as ; + halign: start; + max-width-chars: 36; + wrap: true; + wrap-mode: word_char; + + styles [ + "heading", + ] + } + + Box date_labels { + halign: start; + valign: start; + margin-top: 9; + spacing: 9; + + Label last_played_label { + label: bind $_date_label(_("Last Played: {}"), template.active-game as <$Game>.last_played) as ; + halign: start; + wrap: true; + wrap-mode: word_char; + } + + Label added_label { + label: bind $_date_label(_("Added: {}"), template.active-game as <$Game>.added) as ; + halign: start; + wrap: true; + wrap-mode: word_char; + } + } } }; - } - }; + }; + } }; - }; - - styles [ - "view", - ] + } }; } diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index 1058c4e..54c16d9 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -1,17 +1,21 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed -# SPDX-FileCopyrightText: Copyright 2025 kramo +# SPDX-FileCopyrightText: Copyright 2022-2025 kramo import locale from collections.abc import Generator +from datetime import UTC, datetime +from gettext import gettext as _ from typing import Any -from gi.repository import Adw, Gio, GLib, GObject, Gtk +from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk from cartridges import games from cartridges.config import PREFIX, PROFILE from cartridges.games import Game +from .cover import Cover # noqa: F401 + SORT_MODES = { "last_played": ("last-played", True), "a-z": ("name", False), @@ -27,10 +31,12 @@ class Window(Adw.ApplicationWindow): __gtype_name__ = __qualname__ + navigation_view: Adw.NavigationView = Gtk.Template.Child() search_entry: Gtk.SearchEntry = Gtk.Template.Child() grid: Gtk.GridView = Gtk.Template.Child() sorter: Gtk.CustomSorter = Gtk.Template.Child() + active_game = GObject.Property(type=Game) search_text = GObject.Property(type=str) show_hidden = GObject.Property(type=bool, default=False) @@ -58,6 +64,51 @@ class Window(Adw.ApplicationWindow): ("sort", self._sort, "s", "'last_played'"), )) + @Gtk.Template.Callback() + def _activate_game(self, grid: Gtk.GridView, position: int): + if isinstance(model := grid.props.model, Gio.ListModel): + self.active_game = model.get_item(position) + self.navigation_view.push_by_tag("details") + + @Gtk.Template.Callback() + def _downscale_image(self, _obj, cover: Gdk.Texture | None) -> Gdk.Texture | None: + if cover and (renderer := self.get_renderer()): + cover.snapshot(snapshot := Gtk.Snapshot.new(), 3, 3) + if node := snapshot.to_node(): + return renderer.render_texture(node) + + return None + + @Gtk.Template.Callback() + def _date_label(self, _obj, label: str, timestamp: int) -> str: + date = datetime.fromtimestamp(timestamp, UTC) + now = datetime.now(UTC) + return label.format( + _("Never") + if not timestamp + else _("Today") + if (n_days := (now - date).days) == 0 + else _("Yesterday") + if n_days == 1 + else date.strftime("%A") + if n_days <= (day_of_week := now.weekday()) + else _("Last Week") + if n_days <= day_of_week + 7 + else _("This Month") + if n_days <= (day_of_month := now.day) + else _("Last Month") + if n_days <= day_of_month + 30 + else date.strftime("%B") + if n_days < (day_of_year := now.timetuple().tm_yday) + else _("Last Year") + if n_days <= day_of_year + 365 + else date.strftime("%Y") + ) + + @Gtk.Template.Callback() + def _bool(self, _obj, o: object) -> bool: + return bool(o) + @Gtk.Template.Callback() def _search_started(self, entry: Gtk.SearchEntry): entry.grab_focus() diff --git a/po/POTFILES.in b/po/POTFILES.in index 0dadddd..f291c7e 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,5 +1,7 @@ cartridges/application.py cartridges/games.py +cartridges/ui/cover.blp +cartridges/ui/cover.py cartridges/ui/window.blp cartridges/ui/window.py data/page.kramo.Cartridges.desktop.in