details: Implement edit action

This commit is contained in:
kramo
2025-12-01 20:53:39 +01:00
parent b6927a796f
commit 8c14f47189
10 changed files with 530 additions and 294 deletions

View File

@@ -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 <Widget>.width-request;
height-request: bind template.root as <Widget>.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 <Gdk.Texture>;
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 <bool>;
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 <string>;
halign: start;
wrap: true;
wrap-mode: word_char;
}
Label added_label {
label: bind $_date_label(_("Added: {}"), template.game as <$Game>.added) as <string>;
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<tt>\"{}\"</tt>\n\nTo open the file \"{}\" with the default application, use:\n\n<tt>{} \"{}\"</tt>\n\nIf the path contains spaces, make sure to wrap it in double quotes!")) as <string>;
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",
]
}
};
}
}
};
};
}
};
};
}

View File

@@ -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";
}

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,10 @@
);
}
#details list {
background: rgb(from var(--card-bg-color) r g b / 20%);
}
#background {
filter: saturate(300%) opacity(50%);
}

View File

@@ -2,6 +2,7 @@
<gresources>
<gresource prefix="@PREFIX@">
<file>cover.ui</file>
<file>game-details.ui</file>
<file>game-item.ui</file>
<file>window.ui</file>
<file>style.css</file>

View File

@@ -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 <Gdk.Texture>;
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 <bool>;
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 <string>;
halign: start;
wrap: true;
wrap-mode: word_char;
}
Label added_label {
label: bind $_date_label(_("Added: {}"), template.active-game as <$Game>.added) as <string>;
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();
}
};
}

View File

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

View File

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