FEnigma/src/fenigma/icons.py
Dominik Roth e352dff531 Map: render entity game icons instead of plain dots once zoomed in
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.
2026-08-09 20:45:15 +02:00

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