"""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("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") gi.require_version("Gtk", "4.0") from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402 from .models import TargetType from .shells import Shell _ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" _ICON_BUTTON_CSS_CLASS = "fenigma-icon-button" _css_loaded = False NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png" STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png" # TargetType -> the shared basename suffix, after 'Enemy_'/'Friendly_', # for whichever of the game's own unit icons fits best. Best-effort # guesses (flagged the same way the Shell descriptions were): the game # doesn't have a dedicated icon for every one of our types, TANK reuses # the Armor_Mechanized artwork, and FDC/PILLBOX/ENEMY only exist on the # enemy side at all (see target_icon_path()'s fallback). No entry means # no icon exists worth drawing, the caller falls back to a plain dot # (only UNKNOWN now, STRIKE has its own crosshair below). _TARGET_ICON_BASENAME = { TargetType.SUPPLY_CACHE: "Ammunition Cache.png", TargetType.FDC: "Fire Direction Center.png", TargetType.INFANTRY: "Infantry.png", TargetType.MECHANIZED: "Armor_Mechanized.png", TargetType.ARTILLERY: "Field Artillery.png", TargetType.TANK: "Armor_Mechanized.png", TargetType.PILLBOX: "Heavy_Gun_Bunker.png", TargetType.MARINE_GARRISON: "Marine.png", TargetType.ENEMY: "Base.png", } def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None: """Icon file for a Target or Ally's type, or None if there isn't a good one. `is_ally` picks the Friendly_ set over the Enemy_ one, falling back to Enemy_ if that particular basename has no friendly version (the two sets aren't the same size, see assets/icons/ README.md). STRIKE (a planned impact point, not a unit) gets its own crosshair rather than a unit icon, it doesn't fit the Enemy_/ Friendly_ naming scheme at all.""" if target_type is TargetType.STRIKE: return STRIKE_ICON_PATH basename = _TARGET_ICON_BASENAME.get(target_type) if basename is None: return None if is_ally: friendly = _ICONS_DIR / "targets" / "friendly" / f"Friendly_{basename}" if friendly.exists(): return friendly enemy = _ICONS_DIR / "targets" / "enemy" / f"Enemy_{basename}" return enemy if enemy.exists() else None 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. The border is reserved at a fixed 2px, transparent, on every one of these buttons all the time, not just the checked one, same reasoning as the firing card's own selection border (see app.py's _FIRING_CARD_CSS): without a border reserved on the unchecked state too, toggling a Gtk.ToggleButton's :checked state would shift its content inward by however wide the border is instead of just changing its color.""" global _css_loaded if _css_loaded: return display = Gdk.Display.get_default() if display is None: return # GTK's CSS has no !important (tried it, GTK's own parser rejects it # outright, 'Junk at end of value'), the only way to beat # libadwaita's own padding/min-size rules is a more specific # selector, not a stronger declaration, a bare '.' wasn't # enough on its own. Both type selectors are needed, not just # 'button': Gtk.Button's and Gtk.ToggleButton's CSS node is actually # named 'button' (so that part did work), but Gtk.MenuButton's is # its own distinct 'menubutton' node, a 'button.' selector # silently never matches it at all, which is why the icon-only # MenuButton face specifically kept its full padding even after # adding the type selector (verified: identical extra width/height # before and after, because the rule was matching zero elements). provider = Gtk.CssProvider() provider.load_from_string(f""" button.{_ICON_BUTTON_CSS_CLASS}, menubutton.{_ICON_BUTTON_CSS_CLASS}, menubutton.{_ICON_BUTTON_CSS_CLASS} > button {{ padding: 2px; min-width: 0; min-height: 0; border: 2px solid transparent; }} button.{_ICON_BUTTON_CSS_CLASS}:checked, menubutton.{_ICON_BUTTON_CSS_CLASS}:checked {{ border-color: @accent_bg_color; }} """) 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.Widget: """A widget showing a Shell enum member's icon, scaled to `width` px wide (the source art, after cropping out its built-in padding, is roughly 2.5:1, height follows proportionally). Two real bugs got fixed here in turn, both about GTK not sizing the widget the way it looks like it should from the code: - Gtk.Image caps displayed size to GTK's icon-size classes (built for symbolic 16/32px icons), rendering tiny regardless of the source file's actual resolution. - Gtk.Picture avoids that, but loading the full-resolution file and only *hinting* a size via set_size_request() doesn't work either: Picture's own natural-size request is the source image's full native resolution (512x256) no matter what size_request says, so depending on the surrounding layout it could end up either way too large (a container honoring that huge natural request) or inconsistently small (one clamping it back down). Pre-scaling the actual pixel data with GdkPixbuf first, then wrapping *that* already-correctly-sized image, makes the natural size request correct in the first place, nothing left to fight the layout about. 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, -1, True) picture = Gtk.Picture.new_for_pixbuf(pixbuf) picture.set_content_fit(Gtk.ContentFit.CONTAIN) picture.set_can_shrink(True) picture.set_size_request(pixbuf.get_width(), pixbuf.get_height()) return picture image = Gtk.Image.new_from_icon_name("image-missing-symbolic") image.set_pixel_size(width // 2) return image _GRID_ICON_WIDTH = 88 # per-cell icon in the picker grid, large enough to actually read _GRID_COLUMNS = 3 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_grid(make_button) -> Gtk.Widget: """Shared grid layout: rows of up to _GRID_COLUMNS buttons, one per Shell, each built by `make_button(shell) -> Gtk.Widget`. Used by both the popover picker and the inline radio-style grid below. A plain nested Gtk.Box grid, not a Gtk.FlowBox, on purpose, after two FlowBox attempts both broke in different ways: a ScrolledWindow sizes to its content's *minimum* size unless told otherwise (a first pass squeezed to a near-unreadable width because of that), and separately, FlowBox's own reported natural width (queried with no fixed allocation yet) turned out to mean 'fit every child on one line', ignoring max_children_per_line entirely, so min/max-content- width on a ScrolledWindow around it never actually took effect (verified directly: it kept ballooning out to fit every shell in a single row regardless of what those properties were set to). Shell is a small, fixed, known set, there's no real need for FlowBox's dynamic reflow-to-fewer-columns behavior here, a manual grid of fixed-size rows has a fully deterministic natural width (columns * cell width, nothing else involved) and sidesteps the whole class of bug.""" shells = list(Shell) grid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4, margin_top=8, margin_bottom=8, margin_start=8, margin_end=8) for start in range(0, len(shells), _GRID_COLUMNS): row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True) for s in shells[start:start + _GRID_COLUMNS]: row_box.append(make_button(s)) grid.append(row_box) return grid def build_shell_popover(on_pick) -> Gtk.Popover: """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. hscrollbar_policy=NEVER is a backstop against a horizontal scrollbar ever appearing, not everyone has a horizontal scroll wheel. Calls `on_pick(shell)` and closes itself when a cell is clicked. Meant for a context tight on space (a firing card, see build_shell_button below), where hiding the options behind a click is worth it, for a dialog with room to spare, build_shell_grid() below shows them all up front instead.""" popover = Gtk.Popover() _ensure_icon_button_css() def make_button(s: Shell) -> Gtk.Widget: 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))) return btn scroller = Gtk.ScrolledWindow( max_content_height=440, propagate_natural_height=True, hscrollbar_policy=Gtk.PolicyType.NEVER, ) scroller.set_child(_build_shell_grid(make_button)) popover.set_child(scroller) return popover def build_shell_grid(selected: Shell, on_pick) -> Gtk.Widget: """Inline radio-style grid of every Shell, for a 'pick one before proceeding' context (the Add Strike dialog) with room to just show every option up front rather than hiding them behind a submenu click. Exactly one cell is ever highlighted (Gtk.ToggleButton. set_group() makes them mutually exclusive), `on_pick(shell)` fires whenever the active one changes.""" _ensure_icon_button_css() leader: Gtk.ToggleButton | None = None def make_button(s: Shell) -> Gtk.Widget: nonlocal leader btn = Gtk.ToggleButton(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)") if leader is None: leader = btn else: btn.set_group(leader) if s is selected: btn.set_active(True) btn.connect("toggled", lambda b, s=s: on_pick(s) if b.get_active() else None) return btn return _build_shell_grid(make_button) 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: 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: render(s) on_pick(s) render(selected) btn.set_popover(build_shell_popover(handle_pick)) return btn