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.
This commit is contained in:
Dominik Moritz Roth 2026-08-09 18:59:50 +02:00
parent 5077cf4d14
commit 2b73a96c7a
3 changed files with 111 additions and 25 deletions

View File

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

View File

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

98
src/fenigma/icons.py Normal file
View File

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