diff --git a/src/fenigma/firing_panel.py b/src/fenigma/firing_panel.py index 35a1288..d84ed4f 100644 --- a/src/fenigma/firing_panel.py +++ b/src/fenigma/firing_panel.py @@ -381,8 +381,16 @@ class FiringPanel(Gtk.Box): charges: int, elev_value_label: Gtk.Label) -> Gtk.Widget: row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - 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)") + # Icon only, not name-plus-icon: the icon already has the shell's + # short code baked in (see assets/icons/README.md), a redundant + # text label would just eat width on an already-tight card row. + # 64px wide (the source art is a fixed 2:1 rectangle, so 32 tall) + # is what it actually takes to read the baked-in code at this + # size, anything smaller and it blurs into an unreadable smear. + shell_btn = icons.build_shell_button( + target.effective_shell, lambda s: self._pick_shell(target, s), show_label=False, icon_width=64 + ) + shell_btn.set_tooltip_text(f"{target.effective_shell.name}: change shell (blast radius)") row.append(shell_btn) segments: list[Gtk.Button] = [] diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py index 1c5b170..9ca03af 100644 --- a/src/fenigma/icons.py +++ b/src/fenigma/icons.py @@ -12,81 +12,148 @@ from pathlib import Path import gi -gi.require_version("GdkPixbuf", "2.0") +gi.require_version("Gdk", "4.0") gi.require_version("Gtk", "4.0") -from gi.repository import GdkPixbuf, Gtk # noqa: E402 +from gi.repository import Gdk, Gtk # noqa: E402 from .shells import Shell _ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" +_ICON_BUTTON_CSS_CLASS = "fenigma-icon-button" +_css_loaded = False + + +def _ensure_icon_button_css() -> None: + """A plain 'flat' Gtk.Button still carries libadwaita's normal button + padding/min-size, fine for a text label, way too much empty chrome + around a single icon (the button ends up visibly larger than the + icon it holds). Loaded lazily (not at import time) and only once, a + headless import (e.g. from a test) shouldn't need a live display.""" + global _css_loaded + if _css_loaded: + return + display = Gdk.Display.get_default() + if display is None: + return + provider = Gtk.CssProvider() + provider.load_from_string(f".{_ICON_BUTTON_CSS_CLASS} {{ padding: 2px; min-width: 0; min-height: 0; }}") + Gtk.StyleContext.add_provider_for_display(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + _css_loaded = True + 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). +def shell_icon_image(shell_name: str, width: int = 64) -> Gtk.Widget: + """A widget showing a Shell enum member's icon at `width` px wide + (the source art is a fixed 2:1 rectangle, height follows). Gtk.Image + looked right in code but rendered tiny regardless of the source + file's real resolution, it caps displayed size to GTK's icon-size + classes (meant for symbolic 16/32px icons) no matter what you load + into it. Gtk.Picture doesn't do that, it sizes by the image's actual + dimensions, which is what a real multi-hundred-pixel icon needs. 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) + height = width // 2 if path.exists(): - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, width // 2, False) - return Gtk.Image.new_from_pixbuf(pixbuf) + picture = Gtk.Picture.new_for_filename(str(path)) + picture.set_content_fit(Gtk.ContentFit.CONTAIN) + picture.set_can_shrink(True) + picture.set_size_request(width, height) + return picture image = Gtk.Image.new_from_icon_name("image-missing-symbolic") - image.set_pixel_size(width // 2) + image.set_pixel_size(min(width, height)) 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 +_GRID_ICON_WIDTH = 88 # per-cell icon in the picker grid, large enough to actually read +_GRID_COLUMNS = 4 + + +def _shell_radius_text(s: Shell) -> str: + return f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius" + + +def _shell_cell(s: Shell) -> Gtk.Widget: + cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + margin_top=4, margin_bottom=4, margin_start=4, margin_end=4) + cell.append(shell_icon_image(s.name, width=_GRID_ICON_WIDTH)) + radius_label = Gtk.Label(label=_shell_radius_text(s)) + radius_label.add_css_class("caption") + radius_label.add_css_class("dim-label") + cell.append(radius_label) + return cell 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 with a grid of every Shell (icon + blast radius under + it, full description as a tooltip), replacing a plain text dropdown/ + list with something that actually shows what each shell looks like. + A plain vertical box of rows here previously ended up squeezed down + to a near-unreadable width, ScrolledWindow sizes to its content's + *minimum*, not natural, size unless told otherwise, and a wrapping + description label's minimum width can shrink to almost nothing. The + grid avoids that by giving the FlowBox a sensible natural width up + front (cell width * column count) instead of hoping the content asks + for enough space on its own. min_children_per_line is deliberately + NOT pinned to the same column count: a fixed minimum forces that + many columns even when the popover doesn't actually have the room + (near a screen edge, a narrow window), which used to overflow + sideways and need a horizontal scrollbar to reach. Letting it reflow + down to fewer columns instead, plus hscrollbar_policy=NEVER as a + hard backstop, means scrolling here is always vertical only, no + horizontal scroll wheel required (plenty of mice don't have one). + Calls `on_pick(shell)` and closes itself when a cell 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) + flow = Gtk.FlowBox( + min_children_per_line=1, max_children_per_line=_GRID_COLUMNS, + row_spacing=4, column_spacing=4, homogeneous=True, + selection_mode=Gtk.SelectionMode.NONE, + margin_top=8, margin_bottom=8, margin_start=8, margin_end=8, + ) + _ensure_icon_button_css() 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) + btn = Gtk.Button(child=_shell_cell(s)) + btn.add_css_class("flat") + btn.add_css_class(_ICON_BUTTON_CSS_CLASS) + btn.set_tooltip_text(f"{s.name}: {s.description} ({_shell_radius_text(s)} blast radius)") + btn.connect("clicked", lambda _b, s=s: (popover.popdown(), on_pick(s))) + flow.append(btn) + scroller = Gtk.ScrolledWindow( + max_content_height=440, propagate_natural_height=True, propagate_natural_width=True, + hscrollbar_policy=Gtk.PolicyType.NEVER, + ) + scroller.set_min_content_width(_GRID_COLUMNS * (_GRID_ICON_WIDTH + 16)) + scroller.set_child(flow) 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.""" +def build_shell_button(selected: Shell, on_pick, *, show_label: bool = True, icon_width: int = 28) -> Gtk.MenuButton: + """A flat MenuButton showing the currently selected shell's icon + (plus its name, unless `show_label` is False, the icon already has + the shell's short code baked in, redundant next to a firing card + that's tight on space), 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") + if not show_label: + _ensure_icon_button_css() + btn.add_css_class(_ICON_BUTTON_CSS_CLASS) 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)) + if show_label: + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + content.append(shell_icon_image(s.name, width=icon_width)) + content.append(Gtk.Label(label=s.name)) + else: + content = shell_icon_image(s.name, width=icon_width) btn.set_child(content) def handle_pick(s: Shell) -> None: