FEnigma/src/fenigma/icons.py
Dominik Roth 3c80f93203 Import map screenshots into the board, and edit entities from the map
The existing clipboard button now routes images: a map-table shot goes to the
vision pipeline, anything else to the text OCR path as before. The decision
runs in a worker, since even the cheap pre-filter costs ~0.3s and solve()
takes 10-20s. solve() rejecting counts as "not a map" and falls through to
OCR, because it is the reliable verdict (0 false positives over 122 text
screenshots) where the pre-filter lets ~6% through; reporting a failure there
would mean a text screenshot never got read at all.

Grid first, units second. The one modal confirms or fixes the geometry only:
the screenshot with the reconstructed lattice drawn over it, plus four
draggable handles on one cell's corners. Four corners pin a homography
exactly (8 DOF, 2 equations each), and dragging any of them refits the whole
grid live. Detection deliberately does not run until this is accepted --
every unit position is expressed in grid coordinates, so detecting against a
grid about to be dragged would only be thrown away.

Once accepted the screenshot is rectified into board space and drawn as the
map's backdrop. Pre-warping is what makes it drawable at all: cairo has no
projective transform, but a rectified image places with a plain scale and
translate. Detected units then appear as proposals ON the map, drawn hollow
-- the same shape the map already uses for "this might be where it is", which
is exactly what a proposal is. Clicking one offers accept (with the detected
type or a corrected one) or reject; the header gains accept-all and
remove-screenshot, and removing the screenshot drops every proposal never
accepted, since they were only ever readings of it.

Separately, right-clicking any entity now opens an edit menu: change type,
change id, change position, delete. Which actions appear follows what the
entity actually has -- only Target/Ally carry a TargetType, Spotter's id is
an int, and the Nest is singular so it cannot be deleted. Changing an
existing target's type or id had no UI at all before this.

Also fixes warp_to_map, which composed only the lattice homography and
dropped the discrete (si,sj,du,dv) mapping that pins lattice indices to named
cells, so every automatically solved screenshot landed in the wrong place. It
happened to test fine because manual solutions have an identity mapping.
While there, the same routine had an off-by-one for a negative axis sign
(si*u+du runs from col+1 down to col across a cell, so floor() named the
neighbour); both now go through one shared GridSolution.grid_of.

Verified end to end through the real widgets on a fixture: grid phase yields
no proposals, four handles, a drag refits and still names cells correctly,
reset restores, a degenerate drag survives, accept warps to a 2000x1000
overlay, detection then yields proposals that hit-test, accept and reject
correctly, and removing the screenshot keeps accepted units only.

Completes the FEnigma rename in app.py (APP_ID, window title, class).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:41:56 +02:00

324 lines
14 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_type_from_icon(basename: str | None) -> TargetType | None:
"""Inverse of _TARGET_ICON_BASENAME, for the map-vision marker classifier,
which names what it matched by icon file rather than by TargetType.
Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one
resolves to MECHANIZED and the user retypes it if it was a Tank (map
right-click -> Change type). Icons with no TargetType at all give None,
which callers treat as UNKNOWN.
"""
if not basename:
return None
name = basename if basename.lower().endswith(".png") else f"{basename}.png"
for prefix in ("Enemy_", "Friendly_"):
if name.startswith(prefix):
name = name[len(prefix):]
for type_, base in _TARGET_ICON_BASENAME.items():
if base == name:
return type_
return None
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