FEnigma/src/fenigma/icons.py
Dominik Roth 23615a8c92 Fix id-namespace regression, add StrikeRequest type, kill full-panel
rerender on assign/alive/shell, add Windows build tooling

- Board.add_target/add_ally's id auto-assignment used a bare
  next(c for c in string.ascii_uppercase if c not in used), which
  raises StopIteration once 26 entities of a group exist -- a real
  crash confirmed via a live traceback, and a direct regression from
  moving that sequence from per-type to per-group. This was the actual
  cause of "Accept as"/"Accept all" silently doing nothing. Fixed with
  _next_free_id(), which rolls over to two-letter ids instead of
  raising.
- New TargetType.STRIKE_REQUEST: the bearing/distance-offset "taking
  fire" fire-support request (see the earlier two-entity split) now
  creates this instead of reusing STRIKE, so a radioed-in request is
  never confused with a strike the player placed themselves. Same
  crosshair icon, excluded from type pickers/dedupe like STRIKE.
- The "Accept as..." popover on a detected map marker now uses the
  same icon grid the entity-edit "Change type" popover does (was a
  plain unfiltered text list of every TargetType, which also wrongly
  offered STRIKE/STRIKE_REQUEST as pickable).
- Firing panel: _cycle_assignment/_toggle_alive/_pick_shell no longer
  route through app.py's full solver+dedupe+canvas+panel refresh --
  none of the three can affect the solver or dedupe, and none change
  which cards exist or their order (except _toggle_alive in
  hide/sort_later mode). New FiringPanel._rebuild_one() rebuilds just
  the one changed card; on_visual_change is a new, lighter callback
  (just a map redraw) for the two of these three that actually affect
  it. This was a real, confirmed lag source with many units on the
  board: every click on any of these was previously rebuilding every
  card of every target.
- Map right-click entity menu: added "Mark destroyed"/"Mark alive",
  reusing the same cheap-refresh path (new
  FiringPanel.refresh_after_alive_change).
- packaging/windows/: a from-scratch (untested against a real boot)
  MSYS2 + WiX .msi build pipeline for Windows, driven from Linux via
  dockur/windows (KVM-in-container), no Windows machine or GitHub
  required. See its own README for status/caveats.
- New/updated tests: id-namespace sharing + the 26-entity overflow
  regression (tests/test_models.py), StrikeRequest split
  (tests/test_ocr.py), warp_to_map's img_scale param
  (tests/test_map_vision_warp.py). 44/44 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 20:48:57 +02:00

575 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, Pango # noqa: E402
import cairo
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 -> (enemy_basename, friendly_basename), after 'Enemy_'/
# 'Friendly_'. Either half is None where the game draws no icon for that
# type on that side at all -- that's not rare enough on either side to
# treat as an exception list bolted onto a shared-name table (the earlier
# shape of this code: one basename table plus two separate patch dicts for
# "actually the friendly filename differs" and "actually this side has
# none at all", which was easy to update inconsistently and silently do
# the wrong thing for one side). One explicit pair per type, covering
# EVERY TargetType, is the actual shape of the data: a basename shared by
# both sides, a basename that differs (a genuine filename mismatch in the
# source assets, not a difference in what's drawn, see assets/icons/
# README.md), or a basename that exists on only one side.
#
# UNKNOWN/ENEMY are deliberately (None, None): generic/ad-hoc, not a unit
# the game draws specific art for (see their comments on TargetType).
# STRIKE isn't in this table at all: its crosshair isn't an Enemy_/
# Friendly_ file, it's handled as a special case in target_icon_path().
# completeness of this table (every TargetType except STRIKE has a row) is
# asserted below, not just hoped for.
_TARGET_ICON = {
TargetType.UNKNOWN: (None, None),
TargetType.ENEMY: (None, None),
TargetType.ANTI_AIR: ("AA.png", "AA.png"),
TargetType.ANTI_TANK: ("AntiTank.png", "AntiTank.png"),
TargetType.ARTILLERY: ("Field Artillery.png", "Field Artillery.png"),
TargetType.ARTILLERY_OBSERVER: ("Field Artillery Observer.png", "Field Artillery Observer.png"),
TargetType.HEAVY_GUN_TURRET: ("Heavy_Gun_Turret.png", None),
TargetType.INFANTRY: ("Infantry.png", "Infantry.png"),
TargetType.INFANTRY_MECHANIZED: ("Infantry_mechanized.png", "Infantry_Mechanized.png"), # case differs
TargetType.MECH_ANTI_TANK: (None, "Mech_AntiTank.png"), # friendly-only
TargetType.MECHANIZED: ("Armor_Mechanized.png", "Armor_Mechanized.png"),
TargetType.PILLBOX: ("Heavy_Gun_Bunker.png", None),
TargetType.TANK: ("Armor_Mechanized.png", "Armor_Mechanized.png"), # shares MECHANIZED's art, see TargetType
TargetType.BASE: ("Base.png", "Military Base.png"), # name differs
TargetType.COMMANDER: ("Commander.png", "Commander.png"),
TargetType.FDC: ("Fire Direction Center.png", None),
TargetType.FORT: (None, "Fort.png"), # friendly-only
TargetType.GENERAL: (None, "General.png"), # friendly-only
TargetType.KING: (None, "King.png"), # friendly-only
TargetType.MARINE_GARRISON: ("Marine.png", "Marine.png"),
TargetType.POLICE: (None, "Police.png"), # friendly-only
TargetType.SUPPLY_CACHE: ("Ammunition Cache.png", "Ammunition Cache.png"),
TargetType.UNDERGROUND_FORT: ("Underground Fort.png", None),
TargetType.EMERGENCY_MEDICAL: ("Emergency Medical Operation.png", "Emergency Medical Operation.png"),
TargetType.HOSPITAL: (None, "Hospital.png"), # friendly-only
TargetType.MEDICAL: ("Medical.png", "Medical.png"),
TargetType.MEDICAL_FACILITY: ("Medical Treatment Facility.png", "Medical Treatment Facility.png"),
TargetType.CIVIL_MILITARY: (None, "CivilMilitary.png"), # friendly-only
TargetType.CIVILIAN: ("Civ.png", "Civilian.png"), # name differs
TargetType.CIVIL_RIOTING: ("Civil Rioting.png", "Civil Rioting.png"),
TargetType.RIOTING: ("Rioting.png", None),
TargetType.TV_RADIO_PROPAGANDA: ("TV and Radio Propaganda.png", "TV and Radio Propaganda.png"),
TargetType.PORT: ("Port.png", "Port.png"),
TargetType.SHIP: ("Ship.png", None),
TargetType.SHIP_ENGINE: ("Ship_Engine.png", None),
TargetType.SHIP_FDC: ("Ship_FDC.png", None),
TargetType.SHIP_STRIPE: ("Ship_Stripe.png", "Ship_stripe.png"), # case differs
TargetType.SHIP_TURRET: ("Ship_Turret.png", None),
TargetType.TRAIN_LOCOMOTIVE: ("Train_Locomotive.png", None),
TargetType.TRAIN_STATION: ("Train_Station.png", "Train_Station.png"),
TargetType.TRAIN_TRANSPORT: ("Train_Transport.png", None),
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
}
assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, (
"every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST "
"are the deliberate exceptions, see the comment above target_icon_path)"
)
def _icon_for_side(target_type: TargetType, is_ally: bool) -> Path | None:
"""This SIDE's own icon for target_type specifically, with no
cross-side fallback -- used both by target_icon_path() (which adds
the fallback back on top) and by _has_own_icon() (which needs to know
whether this side has real art of its own, not whether *some* art is
available after falling back)."""
entry = _TARGET_ICON.get(target_type)
if entry is None:
return None
basename = entry[1 if is_ally else 0]
if basename is None:
return None
folder, prefix = ("friendly", "Friendly_") if is_ally else ("enemy", "Enemy_")
path = _ICONS_DIR / "targets" / folder / f"{prefix}{basename}"
return path if path.exists() else None
def target_type_from_icon(basename: str | None) -> TargetType | None:
"""Inverse of _TARGET_ICON, 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_, (enemy_basename, friendly_basename) in _TARGET_ICON.items():
if name in (enemy_basename, friendly_basename):
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 side of _TARGET_ICON over the
enemy one, falling back to the enemy icon if this particular type has
no friendly art of its own at all (the two sets aren't the same size,
see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact
point, not a unit -- player-placed vs called in by a friendly, see
STRIKE_REQUEST's own comment) both get the same crosshair rather than
a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all."""
if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST):
return STRIKE_ICON_PATH
own = _icon_for_side(target_type, is_ally)
if own is not None:
return own
return _icon_for_side(target_type, is_ally=False) if is_ally 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_icon_grid(items, columns, make_button) -> Gtk.Widget:
"""Shared grid layout: rows of up to `columns` buttons, one per item in
`items`, each built by `make_button(item) -> Gtk.Widget`. Used by every
icon-grid picker in this module (shells, target types).
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 item in a
single row regardless of what those properties were set to). Each of
these sets is small and fixed, 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."""
items = list(items)
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(items), columns):
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True)
for it in items[start:start + columns]:
row_box.append(make_button(it))
grid.append(row_box)
return grid
def _build_shell_grid(make_button) -> Gtk.Widget:
return _build_icon_grid(Shell, _GRID_COLUMNS, make_button)
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
# ---- TargetType icon-grid picker, same idea as the Shell picker above ----
_TYPE_GRID_ICON_WIDTH = 40 # smaller than the shell grid's: ~35 types vs 9 shells,
_TYPE_GRID_COLUMNS = 5 # needs to fit a lot more cells in the same dialog width
# Mirrors grid_widget.py's CATEGORY_COLOR["target"]/["ally"] (the colors the
# map itself draws the plain-dot fallback in). Duplicated rather than
# imported: grid_widget.py already imports this module for icon lookups, an
# import the other way would be circular. Unlike that module's palette,
# these two are not theme-swapped live -- the picker is a modal dialog, not
# the persistent map, redrawing it on a theme change isn't worth the wiring.
_DOT_COLOR = {False: (0.92, 0.30, 0.28), True: (0.30, 0.85, 0.85)}
def _plain_dot(is_ally: bool, width: int) -> Gtk.Widget:
"""The same 'plain dot' fallback the map itself draws for a type with
no dedicated icon (see grid_widget.py's _icon_for), so a type with no
icon reads as 'this type has no special marker' rather than as a
rendering gap in the picker."""
area = Gtk.DrawingArea()
area.set_content_width(width)
area.set_content_height(width)
def draw(_area, cr, w, h):
cr.set_source_rgb(*_DOT_COLOR[is_ally])
cr.arc(w / 2, h / 2, min(w, h) * 0.32, 0, 2 * 3.141592653589793)
cr.fill()
area.set_draw_func(draw)
return area
def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
"""A widget showing target_type's icon (friendly or enemy art per
`is_ally`), scaled to `width` px wide. Falls back to the same plain dot
the map itself draws for the types with no dedicated icon (UNKNOWN,
ENEMY -- see icons.py's _TARGET_ICON_BASENAME comment; STRIKE always
has its crosshair), so every cell in the grid stays the same size
whether or not it has real art."""
path = target_icon_path(target_type, is_ally=is_ally)
if path is not None and 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
return _plain_dot(is_ally, width)
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
"""Whether THIS side specifically has real art for t -- as opposed to
target_icon_path() quietly handing back the other side's icon because
this side has none of its own (see its own docstring). Used to keep
that cross-side fallback out of the picker grids entirely: showing a
red diamond as an option for 'Add ally', or offering 'King'/'Police'/
etc. (friendly-only, see _TARGET_ICON) as an enemy type, reads as a
real option of the wrong side rather than a missing-icon placeholder.
UNKNOWN and ENEMY are the exception: deliberately generic/icon-less on
BOTH sides (see their comments on TargetType), always offered
regardless."""
if t in (TargetType.UNKNOWN, TargetType.ENEMY):
return True
return _icon_for_side(t, is_ally) is not None
def available_target_types(is_ally: bool = False):
"""TargetType members worth offering in a picker for this side.
STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at
all (a planned impact point, not a contact), each is always created
through its own path instead -- STRIKE via app.py's dedicated "Add
strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support
request -- never by picking a type from this generic grid. There's
no such thing as a Strike-typed Ally either, offering either one
here is just confusing, not merely unlikely.
Otherwise: each side only offers types it actually has its own art
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
and some are friendly-only (King, Police, a friendly hospital, ...),
the game simply doesn't draw an installation of every kind on both
sides."""
return [
t for t in TargetType
if t not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally)
]
def target_type_label(t: "TargetType", is_ally: bool) -> str:
"""Display text for a picker cell/tooltip, or any other UI spot that
would otherwise print obj.type.value directly (map popover headings,
"Change type" buttons, toasts, ...). TargetType.ENEMY's own value is
literally 'Enemy' (it's the word the game's OCR'd text uses for an
ad-hoc *hostile* installation, see TargetType's own comment) --
exactly right in the enemy picker, but confusing in the Ally one,
where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
stored on the entity is still ENEMY either way, only the label shown
changes -- callers that need an id-safe short form (Ally.name etc.)
keep using TargetType.short, not this."""
if is_ally and t is TargetType.ENEMY:
return "Ally"
return t.value
# Old private name, kept as an alias: nothing outside this module should
# gain a new dependency on it, but this file's own internal callers below
# were written against it.
_target_type_label = target_type_label
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
"""Icon + name, both a FIXED size regardless of how long the name is --
a real cell size that varies with its label text (three-line names next
to one-line ones) makes every row in the grid a different height, which
reads as broken/uneven rather than a grid. One line, ellipsized, with
the full name in the button's tooltip (see build_target_type_grid)
covers the names a single line can't fit."""
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
cell.append(target_type_icon_image(t, is_ally=is_ally))
name_label = Gtk.Label(label=_target_type_label(t, is_ally), wrap=False, single_line_mode=True,
justify=Gtk.Justification.CENTER, width_chars=9,
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
name_label.add_css_class("caption")
name_label.add_css_class("dim-label")
cell.append(name_label)
return cell
def build_target_type_grid(selected: "TargetType | None", on_pick, *, is_ally: bool = False) -> Gtk.Widget:
"""Inline radio-style grid of every available TargetType (icon + name
below, same idea as build_shell_grid), replacing the old plain-text
dropdown/list. `is_ally` both picks the friendly icon set over the
enemy one and restricts the offered types to ones with real friendly
art (see available_target_types). Exactly one cell is ever
highlighted (`selected`, or none if `selected` is None or not offered
on this side).
`on_pick(target_type)` fires on every click, including a click on the
already-selected cell -- deliberately listening for "clicked", not
"toggled": a ToggleButton in a radio group doesn't emit "toggled" when
you click the one that's already active (nothing about its state
changed), which meant clicking the pre-selected default -- usually
exactly the type someone wants, e.g. plain "Target" -- silently did
nothing. "clicked" fires every time regardless, so confirming the
default now works the same as picking anything else.
See _target_type_cell for why the name label is single-line and
ellipsized rather than wrapped: an unbounded label size, besides
making uneven-height rows, could also (being inside a homogeneous
row) stretch every cell in that row wide enough to force the whole
dialog into horizontal scrolling -- coord_dialog.py's ScrolledWindow
has hscrollbar_policy=NEVER as a backstop against that same failure."""
_ensure_icon_button_css()
leader: Gtk.ToggleButton | None = None
def make_button(t: "TargetType") -> Gtk.Widget:
nonlocal leader
btn = Gtk.ToggleButton(child=_target_type_cell(t, is_ally))
btn.add_css_class("flat")
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
btn.set_tooltip_text(_target_type_label(t, is_ally))
if leader is None:
leader = btn
else:
btn.set_group(leader)
if t is selected:
btn.set_active(True)
btn.connect("clicked", lambda _b, t=t: on_pick(t))
return btn
return _build_icon_grid(available_target_types(is_ally), _TYPE_GRID_COLUMNS, make_button)