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>
324 lines
14 KiB
Python
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
|