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>
575 lines
27 KiB
Python
575 lines
27 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, 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, "Civil–Military.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)
|