diff --git a/cartridges/ui/game-details.blp b/cartridges/ui/game-details.blp new file mode 100644 index 0000000..135ded5 --- /dev/null +++ b/cartridges/ui/game-details.blp @@ -0,0 +1,315 @@ +using Gtk 4.0; +using Gdk 4.0; +using Adw 1; + +template $GameDetails: Adw.NavigationPage { + name: "details"; + tag: "details"; + title: bind template.game as <$Game>.name; + + child: Adw.BreakpointBin { + width-request: bind template.root as .width-request; + height-request: bind template.root as .height-request; + + Adw.Breakpoint { + condition ("max-width: 700px") + + setters { + 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; + actions.orientation: vertical; + actions.halign: center; + actions.spacing: 24; + } + } + + child: Overlay { + child: Picture { + name: "background"; + paintable: bind $_downscale_image(template.game as <$Game>.cover) as ; + content-fit: cover; + }; + + [overlay] + Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-title: false; + } + + content: ScrolledWindow { + hscrollbar-policy: never; + + child: Box box { + halign: center; + valign: center; + margin-top: 12; + margin-bottom: 48; + margin-start: 24; + margin-end: 24; + spacing: 36; + + $Cover { + paintable: bind template.game as <$Game>.cover; + } + + Adw.ViewStack stack { + vhomogeneous: false; + enable-transitions: true; + + Adw.ViewStackPage { + name: "details"; + + child: Box { + orientation: vertical; + valign: center; + spacing: 6; + + Label name_label { + label: bind template.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.game as <$Game>.developer; + visible: bind $_bool(template.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.game as <$Game>.last_played) as ; + halign: start; + wrap: true; + wrap-mode: word_char; + } + + Label added_label { + label: bind $_date_label(_("Added: {}"), template.game as <$Game>.added) as ; + halign: start; + wrap: true; + wrap-mode: word_char; + } + } + + Box actions { + spacing: 12; + margin-top: 15; + + Button { + action-name: "game.play"; + label: _("Play"); + valign: center; + halign: center; + + styles [ + "pill", + "suggested-action", + ] + } + + Box { + spacing: 6; + halign: center; + + Button { + action-name: "details.edit"; + icon-name: "document-edit-symbolic"; + tooltip-text: _("Edit"); + valign: center; + + styles [ + "circular", + ] + } + + Button hide_button { + visible: bind hide_button.sensitive; + action-name: "game.hide"; + icon-name: "view-conceal-symbolic"; + tooltip-text: _("Hide"); + valign: center; + + styles [ + "circular", + ] + } + + Button unhide_button { + visible: bind unhide_button.sensitive; + action-name: "game.unhide"; + icon-name: "view-reveal-symbolic"; + tooltip-text: _("Unhide"); + valign: center; + + styles [ + "circular", + ] + } + + Button { + action-name: "game.remove"; + icon-name: "user-trash-symbolic"; + tooltip-text: _("Remove"); + valign: center; + clicked => $_pop(); + + styles [ + "circular", + ] + } + + MenuButton { + icon-name: "edit-find-symbolic"; + tooltip-text: _("Search On"); + valign: center; + + menu-model: menu { + section { + label: _("Search On"); + + item { + label: _("HowLongToBeat"); + action: "details.search-on"; + target: "https://howlongtobeat.com/?q={}"; + } + + item { + label: _("IGDB"); + action: "details.search-on"; + target: "https://www.igdb.com/search?type=1&q={}"; + } + + item { + label: _("Lutris"); + action: "details.search-on"; + target: "https://lutris.net/games?q={}"; + } + + item { + label: _("PCGamingWiki"); + action: "details.search-on"; + target: "https://www.pcgamingwiki.com/w/index.php?search={}"; + } + + item { + label: _("ProtonDB"); + action: "details.search-on"; + target: "https://www.protondb.com/search?q={}"; + } + + item { + label: _("SteamGridDB"); + action: "details.search-on"; + target: "https://www.steamgriddb.com/search/grids?term={}"; + } + } + }; + + styles [ + "circular", + ] + } + } + } + }; + } + + Adw.ViewStackPage { + name: "edit"; + + child: Box { + orientation: vertical; + valign: center; + width-request: 300; + spacing: 24; + + Adw.PreferencesGroup { + Adw.EntryRow name_entry { + title: _("Title"); + action-name: "details.edit-done"; + } + + Adw.EntryRow developer_entry { + title: _("Developer (optional)"); + action-name: "details.edit-done"; + } + } + + Adw.PreferencesGroup { + Adw.EntryRow executable_entry { + title: _("Executable"); + action-name: "details.edit-done"; + + [suffix] + MenuButton { + valign: center; + tooltip-text: _("More Info"); + icon-name: "info-outline-symbolic"; + + popover: Popover { + child: Label { + label: bind $_format_more_info(_("To launch the executable \"{}\", use the command:\n\n\"{}\"\n\nTo open the file \"{}\" with the default application, use:\n\n{} \"{}\"\n\nIf the path contains spaces, make sure to wrap it in double quotes!")) as ; + use-markup: true; + wrap: true; + wrap-mode: word_char; + margin-top: 12; + margin-bottom: 12; + margin-start: 12; + margin-end: 12; + }; + }; + + styles [ + "flat", + ] + } + } + } + + Button { + action-name: "details.edit-done"; + label: _("Done"); + halign: center; + + styles [ + "pill", + ] + } + }; + } + } + }; + }; + } + }; + }; +} diff --git a/cartridges/ui/game-item.blp b/cartridges/ui/game-item.blp index f0d6ae4..13fc1ba 100644 --- a/cartridges/ui/game-item.blp +++ b/cartridges/ui/game-item.blp @@ -32,15 +32,17 @@ template $GameItem: Box { margin-end: 6; menu-model: menu { + item (_("Edit"), "item.edit") + item { - action: "game.hide"; label: _("Hide"); + action: "game.hide"; hidden-when: "action-disabled"; } item { - action: "game.unhide"; label: _("Unhide"); + action: "game.unhide"; hidden-when: "action-disabled"; } diff --git a/cartridges/ui/game_details.py b/cartridges/ui/game_details.py new file mode 100644 index 0000000..16537fd --- /dev/null +++ b/cartridges/ui/game_details.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2022-2025 kramo + + +import sys +from datetime import UTC, datetime +from gettext import gettext as _ +from typing import Any +from urllib.parse import quote + +from gi.repository import Adw, Gdk, Gio, GObject, Gtk + +from cartridges.config import PREFIX +from cartridges.games import Game + +from .cover import Cover # noqa: F401 + +_EDITABLE_PROPERTIES = "name", "developer", "executable" + + +@Gtk.Template.from_resource(f"{PREFIX}/game-details.ui") +class GameDetails(Adw.NavigationPage): + """The details of a game.""" + + __gtype_name__ = __qualname__ + + stack: Adw.ViewStack = Gtk.Template.Child() + name_entry: Adw.EntryRow = Gtk.Template.Child() + developer_entry: Adw.EntryRow = Gtk.Template.Child() + executable_entry: Adw.EntryRow = Gtk.Template.Child() + + sort_changed = GObject.Signal() + + @GObject.Property(type=Game) + def game(self) -> Game | None: + """The game whose details to show.""" + return self._game + + @game.setter + def game(self, game: Game | None): + self._game = game + self.insert_action_group("game", game) + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + + self.insert_action_group("details", group := Gio.SimpleActionGroup()) + group.add_action_entries(( + ("edit", lambda *_: self.edit()), + ("edit-done", lambda *_: self.edit_done()), + ( + "search-on", + lambda _action, param, *_: Gio.AppInfo.launch_default_for_uri( + param.get_string().format(quote(self.game.name)) + ), + "s", + ), + )) + + def edit(self): + """Enter edit mode.""" + for prop in _EDITABLE_PROPERTIES: + getattr(self, f"{prop}_entry").props.text = getattr(self.game, prop) + + self.stack.props.visible_child_name = "edit" + self.name_entry.grab_focus() + + def edit_done(self): + """Save edits and exit edit mode.""" + if self.stack.props.visible_child_name != "edit": + return + + for prop in _EDITABLE_PROPERTIES: + text = getattr(self, f"{prop}_entry").props.text + if text != getattr(self.game, prop) and (text or prop == "developer"): + setattr(self.game, prop, text) + if prop == "name": + self.emit("sort-changed") + + self.stack.props.visible_child_name = "details" + + @Gtk.Template.Callback() + def _downscale_image(self, _obj, cover: Gdk.Texture | None) -> Gdk.Texture | None: + if ( + cover + and isinstance(root := self.props.root, Gtk.Native) + and (renderer := root.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 _pop(self, _obj): + self.activate_action("navigation.pop") + + @Gtk.Template.Callback() + def _format_more_info(self, _obj, label: str) -> str: + executable = _("program") + filename = _("file.txt") + path = _("/path/to/{}") + command = "xdg-open" + + if sys.platform.startswith("darwin"): + command = "open" + elif sys.platform.startswith("win32"): + executable += ".exe" + path = _(r"C:\path\to\{}") + command = "start" + + return label.format( + executable, + path.format(executable), + filename, + command, + path.format(filename), + ) diff --git a/cartridges/ui/game_item.py b/cartridges/ui/game_item.py index 6970bbc..6ab26c9 100644 --- a/cartridges/ui/game_item.py +++ b/cartridges/ui/game_item.py @@ -1,7 +1,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright 2025 kramo -from gi.repository import GObject, Gtk +from typing import Any + +from gi.repository import Gio, GLib, GObject, Gtk from cartridges.config import PREFIX from cartridges.games import Game @@ -15,6 +17,8 @@ class GameItem(Gtk.Box): __gtype_name__ = __qualname__ + position = GObject.Property(type=int) + @GObject.Property(type=Game) def game(self) -> Game | None: """The game that `self` represents.""" @@ -25,6 +29,19 @@ class GameItem(Gtk.Box): self._game = game self.insert_action_group("game", game) + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + + self.insert_action_group("item", group := Gio.SimpleActionGroup()) + group.add_action_entries(( + ( + "edit", + lambda *_: self.activate_action( + "win.edit", GLib.Variant.new_uint32(self.position) + ), + ), + )) + @Gtk.Template.Callback() def _any(self, _obj, *values: bool) -> bool: return any(values) diff --git a/cartridges/ui/meson.build b/cartridges/ui/meson.build index 03a29ea..0c7f0d2 100644 --- a/cartridges/ui/meson.build +++ b/cartridges/ui/meson.build @@ -1,10 +1,16 @@ python.install_sources( - files('__init__.py', 'cover.py', 'game_item.py', 'window.py'), + files( + '__init__.py', + 'cover.py', + 'game_details.py', + 'game_item.py', + 'window.py', + ), subdir: 'cartridges' / 'ui', ) blueprints = custom_target( - input: files('cover.blp', 'game-item.blp', 'window.blp'), + input: files('cover.blp', 'game-details.blp', 'game-item.blp', 'window.blp'), output: '.', command: [ blueprint_compiler, diff --git a/cartridges/ui/style.css b/cartridges/ui/style.css index f08e2c7..0e187b0 100644 --- a/cartridges/ui/style.css +++ b/cartridges/ui/style.css @@ -27,6 +27,10 @@ ); } +#details list { + background: rgb(from var(--card-bg-color) r g b / 20%); +} + #background { filter: saturate(300%) opacity(50%); } diff --git a/cartridges/ui/ui.gresource.xml.in b/cartridges/ui/ui.gresource.xml.in index 2c88eba..1c6a6b9 100644 --- a/cartridges/ui/ui.gresource.xml.in +++ b/cartridges/ui/ui.gresource.xml.in @@ -2,6 +2,7 @@ cover.ui + game-details.ui game-item.ui window.ui style.css diff --git a/cartridges/ui/window.blp b/cartridges/ui/window.blp index 7f42da0..46de10a 100644 --- a/cartridges/ui/window.blp +++ b/cartridges/ui/window.blp @@ -1,5 +1,4 @@ using Gtk 4.0; -using Gdk 4.0; using Adw 1; template $Window: Adw.ApplicationWindow { @@ -22,28 +21,9 @@ template $Window: Adw.ApplicationWindow { } } - Adw.Breakpoint { - condition ("max-width: 700px") - - 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; - actions.orientation: vertical; - actions.halign: center; - actions.spacing: 24; - } - } - content: Adw.NavigationView navigation_view { + popped => $_edit_done(); + Adw.NavigationPage { title: bind template.title; @@ -142,7 +122,7 @@ template $Window: Adw.ApplicationWindow { child: GridView grid { name: "grid"; single-click-activate: true; - activate => $_activate_game(); + activate => $_show_details(); model: NoSelection { model: SortListModel { @@ -189,6 +169,7 @@ template $Window: Adw.ApplicationWindow { template ListItem { child: $GameItem { game: bind template.item; + position: bind template.position; }; } }; @@ -228,205 +209,8 @@ template $Window: Adw.ApplicationWindow { }; } - Adw.NavigationPage { - name: "details"; - tag: "details"; - title: bind template.active-game as <$Game>.name; - - 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; - } - - Box { - orientation: vertical; - valign: center; - spacing: 6; - - 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; - } - } - - Box actions { - spacing: 12; - margin-top: 15; - - Button { - action-name: "game.play"; - label: _("Play"); - valign: center; - - styles [ - "pill", - "suggested-action", - ] - } - - Box { - spacing: 6; - halign: center; - - Button hide_button { - visible: bind hide_button.sensitive; - action-name: "game.hide"; - icon-name: "view-conceal-symbolic"; - tooltip-text: _("Hide"); - valign: center; - - styles [ - "circular", - ] - } - - Button unhide_button { - visible: bind unhide_button.sensitive; - action-name: "game.unhide"; - icon-name: "view-reveal-symbolic"; - tooltip-text: _("Unhide"); - valign: center; - - styles [ - "circular", - ] - } - - Button { - action-name: "game.remove"; - icon-name: "user-trash-symbolic"; - tooltip-text: _("Remove"); - valign: center; - clicked => $_pop(); - - styles [ - "circular", - ] - } - - MenuButton { - icon-name: "edit-find-symbolic"; - tooltip-text: _("Search On"); - valign: center; - - menu-model: menu { - section { - label: _("Search On"); - - item { - label: _("HowLongToBeat"); - action: "win.search-on"; - target: "https://howlongtobeat.com/?q={}"; - } - - item { - label: _("IGDB"); - action: "win.search-on"; - target: "https://www.igdb.com/search?type=1&q={}"; - } - - item { - label: _("Lutris"); - action: "win.search-on"; - target: "https://lutris.net/games?q={}"; - } - - item { - label: _("PCGamingWiki"); - action: "win.search-on"; - target: "https://www.pcgamingwiki.com/w/index.php?search={}"; - } - - item { - label: _("ProtonDB"); - action: "win.search-on"; - target: "https://www.protondb.com/search?q={}"; - } - - item { - label: _("SteamGridDB"); - action: "win.search-on"; - target: "https://www.steamgriddb.com/search/grids?term={}"; - } - } - }; - - styles [ - "circular", - ] - } - } - } - } - }; - }; - } - }; + $GameDetails details { + sort-changed => $_sort_changed(); } }; } diff --git a/cartridges/ui/window.py b/cartridges/ui/window.py index f350b45..5e9a174 100644 --- a/cartridges/ui/window.py +++ b/cartridges/ui/window.py @@ -4,18 +4,15 @@ import locale from collections.abc import Generator -from datetime import UTC, datetime -from gettext import gettext as _ from typing import Any, TypeVar -from urllib.parse import quote -from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk +from gi.repository import Adw, Gio, GLib, GObject, Gtk from cartridges import games, state_settings from cartridges.config import PREFIX, PROFILE from cartridges.games import Game -from .cover import Cover # noqa: F401 +from .game_details import GameDetails from .game_item import GameItem # noqa: F401 SORT_MODES = { @@ -39,6 +36,7 @@ class Window(Adw.ApplicationWindow): search_entry: Gtk.SearchEntry = Gtk.Template.Child() grid: Gtk.GridView = Gtk.Template.Child() sorter: Gtk.CustomSorter = Gtk.Template.Child() + details: GameDetails = Gtk.Template.Child() search_text = GObject.Property(type=str) show_hidden = GObject.Property(type=bool, default=False) @@ -48,16 +46,6 @@ class Window(Adw.ApplicationWindow): """Model of the user's games.""" return games.model - @GObject.Property(type=Game) - def active_game(self) -> Game | None: - """The game whose details to show.""" - return self._active_game - - @active_game.setter - def active_game(self, active_game: Game | None): - self._active_game = active_game - self.insert_action_group("game", active_game) - def __init__(self, **kwargs: Any): super().__init__(**kwargs) @@ -84,13 +72,7 @@ class Window(Adw.ApplicationWindow): "s", state_settings.get_value("sort-mode").print_(False), ), - ( - "search-on", - lambda _action, param, *_: Gio.AppInfo.launch_default_for_uri( - param.get_string().format(quote(self.active_game.name)) - ), - "s", - ), + ("edit", lambda _action, param, *_: self._edit(param.get_uint32()), "u"), )) @Gtk.Template.Callback() @@ -98,54 +80,11 @@ class Window(Adw.ApplicationWindow): return first if condition else second @Gtk.Template.Callback() - def _activate_game(self, grid: Gtk.GridView, position: int): + def _show_details(self, grid: Gtk.GridView, position: int): if isinstance(model := grid.props.model, Gio.ListModel): - self.active_game = model.get_item(position) + self.details.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 _pop(self, _obj): - self.navigation_view.pop() - @Gtk.Template.Callback() def _search_started(self, entry: Gtk.SearchEntry): entry.grab_focus() @@ -192,3 +131,20 @@ class Window(Adw.ApplicationWindow): def _sortable(*strings: str) -> Generator[str]: for string in strings: yield string.lower().removeprefix("the ") + + @Gtk.Template.Callback() + def _sort_changed(self, *_args): + self.sorter.changed(Gtk.SorterChange.DIFFERENT) + + def _edit(self, pos: int): + if isinstance(self.grid.props.model, Gio.ListModel) and ( + game := self.grid.props.model.get_item(pos) + ): + self.details.game = game + + self.navigation_view.push_by_tag("details") + self.details.edit() + + @Gtk.Template.Callback() + def _edit_done(self, *_args): + self.details.edit_done() diff --git a/po/POTFILES.in b/po/POTFILES.in index 1912b2c..e7d9ef2 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -2,6 +2,8 @@ cartridges/application.py cartridges/games.py cartridges/ui/cover.blp cartridges/ui/cover.py +cartridges/ui/game-details.blp +cartridges/ui/game_details.py cartridges/ui/game-item.blp cartridges/ui/game_item.py cartridges/ui/window.blp