Below ICON_MIN_CELL_PX (42px cell width) everything still draws as the plain colored dot it always has, a game icon at that size would just be an illegible smudge. Past that threshold, Nest/Target/Ally markers switch to the game's own unit icon for their type, drawn as a Cairo surface (raw Cairo draw_func, not GTK widgets, so this loads PNGs directly via cairo.ImageSurface.create_from_png(), cached per path so a repeat draw doesn't re-hit disk). icons.py's target_icon_path(type, is_ally) is the TargetType -> icon file mapping, best-effort guesses the same way the Shell descriptions were (flagged for correction): most types map onto the game's own Enemy_*/Friendly_* unit icons (is_ally picks which set, falling back to Enemy_ if a given type has no Friendly_ counterpart), TANK reuses the Armor_Mechanized artwork (no dedicated tank icon exists), and STRIKE gets its own crosshair (assets/icons/misc/Crosshair.png, not a unit icon at all, a planned impact point) rather than a unit icon. An ambiguous candidate (hollow, dashed-ring marker) never switches to the icon regardless of zoom, an icon there would look more confident about an unconfirmed position than the dashed ring is supposed to convey. Verified with rendered screenshots at both zoom levels: plain dots below the threshold, real icons above it (Tank/Infantry/MarineGarrison/ Nest all confirmed showing their correct icons), and the Strike crosshair specifically.
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""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 '.<class>' 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.<class>' 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
|