From 2b73a96c7a61d3c1329800b01ae197e64167dd8d Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 9 Aug 2026 18:59:50 +0200 Subject: [PATCH] Replace the plain-text shell dropdown/list with an icon picker Both places the app asks for a shell (the Add Strike dialog's Adw.ComboRow, and the firing panel's per-target shell popover) used to show just the bare enum name in a plain list, nothing conveying what the shell actually is. New icons.py builds a shared MenuButton + popover from the game's own shell icon set (assets/icons/shells/, already named to match Shell.name exactly): each row shows the shell's icon, name, description, and blast radius, and the button face updates to match whichever one gets picked. Verified via a GTK smoke test that the button and its popover build and open without crashing. --- src/fenigma/app.py | 18 ++++--- src/fenigma/firing_panel.py | 20 ++------ src/fenigma/icons.py | 98 +++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 src/fenigma/icons.py diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 245e2b2..1dbbaaa 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -24,7 +24,7 @@ gi.require_version("Gdk", "4.0") from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 from PIL import Image # noqa: E402 -from . import ballistics, ocr, solver # noqa: E402 +from . import ballistics, icons, ocr, solver # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402 from .firing_panel import FiringPanel # noqa: E402 from .grid_widget import GridCanvas # noqa: E402 @@ -972,12 +972,14 @@ class MainWindow(Adw.ApplicationWindow): outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16, margin_top=16, margin_bottom=16, margin_start=16, margin_end=16) - group = Adw.PreferencesGroup(title="Shell (blast radius)") - shells = list(Shell) - shell_row = Adw.ComboRow(title="Shell", model=Gtk.StringList.new([s.name for s in shells])) - shell_row.set_selected(shells.index(Shell.HCHE)) - group.add(shell_row) - outer.append(group) + label = Gtk.Label(label="Shell (blast radius)", xalign=0) + label.add_css_class("heading") + outer.append(label) + + chosen = [Shell.HCHE] + shell_btn = icons.build_shell_button(chosen[0], lambda s: chosen.__setitem__(0, s)) + shell_btn.set_halign(Gtk.Align.START) + outer.append(shell_btn) next_btn = Gtk.Button(label="Next: click the map to place it") next_btn.add_css_class("suggested-action") @@ -985,7 +987,7 @@ class MainWindow(Adw.ApplicationWindow): next_btn.set_halign(Gtk.Align.CENTER) def on_next(_b): - chosen_shell = shells[shell_row.get_selected()] + chosen_shell = chosen[0] dialog.close() self.canvas.start_placement( lambda coord: self._add_strike_at(coord, chosen_shell), diff --git a/src/fenigma/firing_panel.py b/src/fenigma/firing_panel.py index ebabfbd..35a1288 100644 --- a/src/fenigma/firing_panel.py +++ b/src/fenigma/firing_panel.py @@ -29,7 +29,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gdk, GObject, Gtk # noqa: E402 -from . import ballistics +from . import ballistics, icons from .models import Board, Target, TargetType from .shells import Shell @@ -381,21 +381,8 @@ class FiringPanel(Gtk.Box): charges: int, elev_value_label: Gtk.Label) -> Gtk.Widget: row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - shell_btn = Gtk.MenuButton(label=target.effective_shell.name) - shell_btn.add_css_class("flat") + shell_btn = icons.build_shell_button(target.effective_shell, lambda s: self._pick_shell(target, s)) shell_btn.set_tooltip_text("Change shell (blast radius)") - popover = Gtk.Popover() - shell_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, - margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) - for s in Shell: - radius = f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius" - btn = Gtk.Button(label=f"{s.name}: {s.description} ({radius})") - btn.add_css_class("flat") - btn.get_child().set_xalign(0) - btn.connect("clicked", lambda _b, s=s: self._pick_shell(target, s, popover)) - shell_list.append(btn) - popover.set_child(shell_list) - shell_btn.set_popover(popover) row.append(shell_btn) segments: list[Gtk.Button] = [] @@ -437,7 +424,6 @@ class FiringPanel(Gtk.Box): target.alive = not target.alive self.on_change() - def _pick_shell(self, target: Target, shell: Shell, popover: Gtk.Popover) -> None: + def _pick_shell(self, target: Target, shell: Shell) -> None: target.shell = shell - popover.popdown() self.on_change() diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py new file mode 100644 index 0000000..1c5b170 --- /dev/null +++ b/src/fenigma/icons.py @@ -0,0 +1,98 @@ +"""Bundled game-icon lookup and the shared shell picker built from them. + +Icon files live in the repo's assets/icons/ (see its README.md for where +they came from and which source-filename typos got corrected on copy), +outside the src/ package, resolved relative to this file rather than the +process's cwd so it works no matter where the app was launched from. +""" + +from __future__ import annotations + +from pathlib import Path + +import gi + +gi.require_version("GdkPixbuf", "2.0") +gi.require_version("Gtk", "4.0") +from gi.repository import GdkPixbuf, Gtk # noqa: E402 + +from .shells import Shell + +_ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" + + +def shell_icon_path(shell_name: str) -> Path: + return _ICONS_DIR / "shells" / f"{shell_name}.png" + + +def shell_icon_image(shell_name: str, width: int = 64) -> Gtk.Image: + """A Gtk.Image for a Shell enum member's icon, scaled to `width` px + wide (the source art is a fixed 2:1 rectangle, height follows). + Falls back to a generic missing-image icon rather than raising, a + gap in the icon set shouldn't crash the shell picker.""" + path = shell_icon_path(shell_name) + if path.exists(): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, width // 2, False) + return Gtk.Image.new_from_pixbuf(pixbuf) + image = Gtk.Image.new_from_icon_name("image-missing-symbolic") + image.set_pixel_size(width // 2) + return image + + +def _shell_row(s: Shell) -> Gtk.Widget: + radius = f"{s.blast_radius_km}km blast radius" if s.blast_radius_km is not None else "blast radius unknown" + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + content.append(shell_icon_image(s.name, width=56)) + label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True) + name_label = Gtk.Label(label=s.name, xalign=0) + name_label.add_css_class("heading") + desc_label = Gtk.Label(label=f"{s.description} ยท {radius}", xalign=0, wrap=True) + desc_label.add_css_class("caption") + desc_label.add_css_class("dim-label") + label_box.append(name_label) + label_box.append(desc_label) + content.append(label_box) + return content + + +def build_shell_popover(on_pick) -> Gtk.Popover: + """Popover listing every Shell with its icon, name, description, and + blast radius, replacing a plain text dropdown/list with something + that actually shows what each shell looks like. Calls `on_pick(shell)` + and closes itself when a row is clicked.""" + popover = Gtk.Popover() + scroller = Gtk.ScrolledWindow(max_content_height=420, propagate_natural_height=True) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) + for s in Shell: + row = Gtk.Button(child=_shell_row(s)) + row.add_css_class("flat") + row.connect("clicked", lambda _b, s=s: (popover.popdown(), on_pick(s))) + box.append(row) + scroller.set_child(box) + popover.set_child(scroller) + return popover + + +def build_shell_button(selected: Shell, on_pick) -> Gtk.MenuButton: + """A flat MenuButton showing the currently selected shell's icon and + name, opening build_shell_popover() to change it. `on_pick(shell)` + fires on selection, after this button's own face has already been + updated to match, the caller only needs to react to the new value + (persist it, refresh dependents), not maintain the button's display.""" + btn = Gtk.MenuButton() + btn.add_css_class("flat") + + def render(s: Shell) -> None: + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + content.append(shell_icon_image(s.name, width=28)) + content.append(Gtk.Label(label=s.name)) + btn.set_child(content) + + def handle_pick(s: Shell) -> None: + render(s) + on_pick(s) + + render(selected) + btn.set_popover(build_shell_popover(handle_pick)) + return btn