diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 61cb0a3..6c07897 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -917,14 +917,15 @@ class MainWindow(Adw.ApplicationWindow): return changed def _open_coord_dialog( - self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None, - initial_location=None, initial_id=None, initial_type=None, + self, *, title, on_submit, show_id=False, show_type=False, is_ally=False, + id_placeholder=None, initial_location=None, initial_id=None, initial_type=None, ): dialog = CoordDialog( title=title, on_submit=on_submit, show_id=show_id, show_type=show_type, + is_ally=is_ally, id_placeholder=id_placeholder, initial_location=initial_location, initial_id=initial_id, @@ -1117,6 +1118,7 @@ class MainWindow(Adw.ApplicationWindow): on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_), show_id=True, show_type=True, + is_ally=False, ), close)) return box @@ -1186,6 +1188,7 @@ class MainWindow(Adw.ApplicationWindow): on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_), show_id=True, show_type=True, + is_ally=True, ), close)) return box @@ -1380,14 +1383,19 @@ class MainWindow(Adw.ApplicationWindow): def show_type(): box = page() heading(box, "Type") + # propagate_natural_WIDTH too, not just height: without it a + # ScrolledWindow doesn't grow to fit a wide child (the 5-column + # icon grid), which squeezes the popover down to a + # near-unusable sliver -- same bug the icon grid's own + # docstring and _open_quick_add_menu's identical scroller ran + # into first. scroller = Gtk.ScrolledWindow(propagate_natural_height=True, + propagate_natural_width=True, max_content_height=340, hscrollbar_policy=Gtk.PolicyType.NEVER) - inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - for t in TargetType: - label = f"• {t.value}" if t is obj.type else f" {t.value}" - button(inner, label, lambda t=t: set_type(t)) - scroller.set_child(inner) + scroller.set_child(icons.build_target_type_grid( + obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally), + )) box.append(scroller) popover.set_child(box) @@ -1477,37 +1485,108 @@ class MainWindow(Adw.ApplicationWindow): field = self._id_field_of(obj) return getattr(obj, field) if field else "" + def _open_type_dialog(self, title: str, is_ally: bool, on_pick) -> None: + """A real top-level modal (Adw.Dialog, same shape as CoordDialog) + showing the TargetType icon grid, for contexts that need "pick a + type" on its own with no coordinate/id fields alongside it. + + Deliberately NOT a Gtk.Popover: a transient popover anchored to a + screen point re-derives its own size/position from its content + every time that content is swapped in, and a wide multi-row icon + grid nested inside a ScrolledWindow inside a Popover turned out + unreliable there in practice -- sometimes rendering squeezed down + to a near-unusable sliver, sometimes not registering clicks at + all. A dialog has a fixed, predictable content_width/height and + the exact same grid+scroller construction CoordDialog already + uses successfully for its own Type section, so reusing that shape + here sidesteps the whole class of popover-sizing bug rather than + debugging it further.""" + dialog = Adw.Dialog(title=title, content_width=420, content_height=520) + toolbar_view = Adw.ToolbarView() + dialog.set_child(toolbar_view) + toolbar_view.add_top_bar(Adw.HeaderBar()) + + def pick(t): + dialog.close() + on_pick(t) + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, + margin_top=16, margin_bottom=16, margin_start=20, margin_end=20) + # selected=None: this is a pick-one-and-close picker, not an editor + # showing a current value, so nothing starts pre-highlighted. + outer.append(icons.build_target_type_grid(None, pick, is_ally=is_ally)) + scroller = Gtk.ScrolledWindow(child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER) + toolbar_view.set_content(scroller) + dialog.present(self) + def _open_quick_add_menu(self, coord, x: float, y: float) -> None: - """Right-click on empty map: quick-add a Target or Strike - right there, no dialog, for when you already know exactly where - you're pointing and don't need to type coordinates.""" + """Right-click on empty map: quick-add anything right there, no + coordinate dialog, for when you already know exactly where you're + pointing. Target/Ally still ask for a type (via _open_type_dialog) + rather than silently defaulting to TargetType.UNKNOWN -- picking + the wrong generic type and having to notice and fix it later is + worse than one extra click now. + + The coordinate is shown once, as this menu's own heading, not + repeated on every row ("Add target at K5 4:3", "Add ally at K5 + 4:3", ...) -- it's the same point for every option here (that's + the whole premise of a menu anchored to where you clicked), so + repeating it back on each row is just noise, not information. + """ if coord is None: return popover = self._popover_at(x, y) + location = Location.from_coord(coord) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, - margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) + margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) + heading = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2) + heading.set_markup(f"{GLib.markup_escape_text(coord.label())}") + box.append(heading) - def add_target(_b): - self.board.add_target(TargetType.UNKNOWN, coord) + def row(label, handler): + btn = Gtk.Button(label=label) + btn.add_css_class("flat") + btn.connect("clicked", lambda _b: handler()) + box.append(btn) + + def pick_target_type(t): + self.board.add_target(t, location) + self._refresh() + + def pick_ally_type(t): + self.board.add_ally(t, location) + self._refresh() + + def add_target(): + popover.popdown() + self._open_type_dialog("Add target", is_ally=False, on_pick=pick_target_type) + + def add_ally(): + popover.popdown() + self._open_type_dialog("Add ally", is_ally=True, on_pick=pick_ally_type) + + def add_spotter(): + self.board.add_spotter(location) self._refresh() popover.popdown() - def add_strike(_b): + def add_rp(): + self.board.add_reference_point(location) + self._refresh() + popover.popdown() + + def add_strike(): target = self.board.add_target(TargetType.STRIKE, coord) self.board.reorder_target(target, 0) # new strikes go to the front of the list self._refresh() popover.popdown() - target_btn = Gtk.Button(label=f"Add target at {coord.label()}") - target_btn.add_css_class("flat") - target_btn.connect("clicked", add_target) - box.append(target_btn) - - strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}") - strike_btn.add_css_class("flat") - strike_btn.connect("clicked", add_strike) - box.append(strike_btn) + row("Add target", add_target) + row("Add ally", add_ally) + row("Add spotter", add_spotter) + row("Add reference point", add_rp) + row("Add strike", add_strike) popover.set_child(box) popover.popup() @@ -1555,6 +1634,7 @@ class MainWindow(Adw.ApplicationWindow): initial_location=target.location, show_id=True, show_type=True, + is_ally=False, id_placeholder=f"ID (current: {target.id})", initial_id=target.id, initial_type=target.type, diff --git a/src/fenigma/coord_dialog.py b/src/fenigma/coord_dialog.py index 3ff4629..d2111af 100644 --- a/src/fenigma/coord_dialog.py +++ b/src/fenigma/coord_dialog.py @@ -23,7 +23,7 @@ gi.require_version("Adw", "1") gi.require_version("Gdk", "4.0") from gi.repository import Adw, Gdk, Gtk # noqa: E402 -from . import ocr +from . import icons, ocr from .models import LARGE_X, Coord, Location, TargetType @@ -37,6 +37,7 @@ class CoordDialog(Adw.Dialog): on_submit: Callable[[Location, str | None, TargetType | None], None], show_id: bool = False, show_type: bool = False, + is_ally: bool = False, id_placeholder: str | None = None, initial_location: Location | None = None, initial_id: str | None = None, @@ -46,10 +47,12 @@ class CoordDialog(Adw.Dialog): self._on_submit = on_submit self._show_id = show_id self._show_type = show_type + self._is_ally = is_ally self._id_placeholder = id_placeholder or "ID (blank = auto)" self._initial_location = initial_location or Location() self._initial_id = initial_id self._initial_type = initial_type + self._type_val = initial_type or TargetType.UNKNOWN toolbar_view = Adw.ToolbarView() self.set_child(toolbar_view) @@ -91,7 +94,14 @@ class CoordDialog(Adw.Dialog): flow.set_homogeneous(True) flow.set_row_spacing(4) flow.set_column_spacing(4) - flow.set_max_children_per_line(10) + # Fixed at 5, not a min/max range: the dialog's width now varies + # with whatever else is in it (e.g. the Type grid, see + # build_target_type_grid), and a FlowBox reflows to fit whatever + # width it's given -- letting it range up to 10 made X (A-T) jump + # to 7-wide rows whenever the dialog happened to be wider, which + # read as broken rather than deliberate. 5 was the one that looked + # right at the dialog's normal size. + flow.set_max_children_per_line(5) flow.set_min_children_per_line(5) buttons = [] @@ -130,8 +140,8 @@ class CoordDialog(Adw.Dialog): spacing=16, margin_top=16, margin_bottom=16, - margin_start=16, - margin_end=16, + margin_start=20, + margin_end=20, ) initial = self._initial_location.coord @@ -175,24 +185,29 @@ class CoordDialog(Adw.Dialog): self._stage_widgets = [Y_group, x_group, y_group, None] self.row_id = None - self.row_type = None if self._show_id or self._show_type: - extra_group = Adw.PreferencesGroup(title="Identity") + identity_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) if self._show_id: + extra_group = Adw.PreferencesGroup(title="Identity") self.row_id = Adw.EntryRow(title=self._id_placeholder) if self._initial_id is not None: self.row_id.set_text(str(self._initial_id)) extra_group.add(self.row_id) + identity_box.append(extra_group) if self._show_type: - self.row_type = Adw.ComboRow( - title="Type", - model=Gtk.StringList.new([t.short for t in TargetType]), - ) - if self._initial_type is not None: - self.row_type.set_selected(list(TargetType).index(self._initial_type)) - extra_group.add(self.row_type) - outer.append(extra_group) - self._stage_widgets[3] = extra_group + # An icon grid (same idea as the Shell picker), not a plain + # text dropdown -- with ~35 types now, seeing the actual + # marker art is the difference between recognizing the + # right one and reading a wall of similar-sounding names. + identity_box.append(self._picker_group( + "Type", + icons.build_target_type_grid( + self._type_val, lambda t: setattr(self, "_type_val", t), + is_ally=self._is_ally, + ), + )) + outer.append(identity_box) + self._stage_widgets[3] = identity_box submit = Gtk.Button(label="Set coordinates") submit.add_css_class("suggested-action") @@ -201,7 +216,9 @@ class CoordDialog(Adw.Dialog): submit.connect("clicked", self._on_submit_clicked) outer.append(submit) - self._exact_scroller = Gtk.ScrolledWindow(child=outer) + self._exact_scroller = Gtk.ScrolledWindow( + child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER + ) self._exact_content = outer return self._exact_scroller @@ -262,7 +279,7 @@ class CoordDialog(Adw.Dialog): def _id_and_type(self) -> tuple[str | None, TargetType | None]: id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None - type_ = list(TargetType)[self.row_type.get_selected()] if self.row_type is not None else None + type_ = self._type_val if self._show_type else None return id_, type_ def _on_submit_clicked(self, _button: Gtk.Button) -> None: diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py index 2f71d44..2bbda28 100644 --- a/src/fenigma/icons.py +++ b/src/fenigma/icons.py @@ -15,7 +15,9 @@ 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 gi.repository import Gdk, GdkPixbuf, Gtk, Pango # noqa: E402 + +import cairo from .models import TargetType from .shells import Shell @@ -28,30 +30,103 @@ _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", +# 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}, ( + "every TargetType needs a row in _TARGET_ICON (STRIKE is the one " + "deliberate exception, see the comment above it)" +) + + +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_BASENAME, for the map-vision marker classifier, - which names what it matched by icon file rather than by TargetType. + """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 @@ -64,31 +139,26 @@ def target_type_from_icon(basename: str | None) -> TargetType | None: for prefix in ("Enemy_", "Friendly_"): if name.startswith(prefix): name = name[len(prefix):] - for type_, base in _TARGET_ICON_BASENAME.items(): - if base == name: + 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_ 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.""" + 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 (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 + 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: @@ -200,10 +270,10 @@ def _shell_cell(s: Shell) -> Gtk.Widget: 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. +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 @@ -213,24 +283,28 @@ def _build_shell_grid(make_button) -> Gtk.Widget: 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 + (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.""" - shells = list(Shell) + 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(shells), _GRID_COLUMNS): + for start in range(0, len(items), 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)) + 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 @@ -321,3 +395,166 @@ def build_shell_button(selected: Shell, on_pick, *, show_label: bool = True, ico 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 is never offered: it's not a unit type at all (a planned + impact point, not a contact), it's always created through its own + dedicated "Add strike" action (see app.py's _open_quick_add_menu), + never by picking a type from this generic grid -- there's no such + thing as a Strike-typed Ally either, offering it there 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 is not TargetType.STRIKE and _has_own_icon(t, is_ally)] + + +def _target_type_label(t: "TargetType", is_ally: bool) -> str: + """Display text for a picker cell/tooltip. 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 + while picking it changes.""" + if is_ally and t is TargetType.ENEMY: + return "Ally" + return t.value + + +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) diff --git a/src/fenigma/map_vision.py b/src/fenigma/map_vision.py index 04d021d..dd76759 100644 --- a/src/fenigma/map_vision.py +++ b/src/fenigma/map_vision.py @@ -29,12 +29,15 @@ Shape of the solution: therefore selects scale, axis assignment, direction, phase and anchor together. -Measured on the 10 fixtures in tests/fixtures/map_shots: solves 7 of them, +Measured on the 12 fixtures in tests/fixtures/map_shots: solves 9 of them, with 100% of each solved shot's annotated points landing in the correct cell -(85 of 112 overall) and a residual spread of 0.005-0.033 cells. The other +(104 of 131 overall) and a residual spread of 0.005-0.033 cells. The other three are rejected rather than guessed at, and no fixture has ever produced a plausible-but-wrong grid. Rejection is a supported outcome -- a silently -misplaced target is far worse than a refusal. +misplaced target is far worse than a refusal. A further 11 screenshots sit +in tests/fixtures/map_shots/too_hard/, kept for the record but excluded from +the evaluation set: see its README.md for why each one is a legitimate +rejection rather than a bug. Over the 122 typewriter screenshots this was checked against, solve() accepted none, which is what makes it safe to route clipboard images through @@ -744,12 +747,29 @@ def solve_path(path): _ICON_BANK: dict = {} ICON_SIZE = 64 -DIAMOND_IOU = 0.64 # blob-vs-ideal-diamond overlap needed to be a marker. +MARKER_IOU = 0.64 # blob-vs-ideal-shape overlap needed to be a marker. # Swept against verified counts: 0.64 keeps every shot # confirmed correct by hand (5/2/2/3 markers) while cutting # ribbon+hatching false positives from 43 to 2 on the worst # fixture. Loosening to 0.50 regains one real marker on one # shot but quadruples the false positives. + # + # The ideal shape depends on SIDE: hostile markers are + # diamonds, friendly ones are rectangles (see + # `_ideal_shape`) -- the game draws the two factions with + # different marker geometry, not just different colours. + # Fitting only the diamond shape used to mean no cyan + # rectangle could ever pass this gate, however good its + # colour match. +FILL_RANGE = { # min/max of (blob area / bbox area); the diamond and + # rectangle marker families genuinely differ here (a diamond covers half + # its bounding box, a solid rectangle covers nearly all of it), so one + # shared band would either admit hatching as "diamonds" or reject real + # rectangles as "not filled enough". Measured off real markers: diamonds + # 0.30-0.85, rectangles (minus their border and X-crossing) 0.75-0.97. + "diamond": (0.30, 0.85), + "rect": (0.55, 0.97), +} SYMBOL_KEEP = 0.52 # central fraction of the marker that carries the symbol @@ -852,16 +872,32 @@ def marker_masks(img): for m in (hostile, friendly)] -def diamonds(mask, cell_px): +def _ideal_mask(shape, w, h): + """The filled-in silhouette a marker of this SHAPE should have, inscribed + in a w x h bounding box: a diamond for hostile markers, the full box + itself for friendly ones (they are drawn as solid rectangles, so their + own bounding box IS their ideal silhouette).""" + if shape == "rect": + return np.ones((h, w), bool) + ideal = np.zeros((h, w), np.uint8) + cv2.fillConvexPoly(ideal, np.array( + [[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1) + return ideal.astype(bool) + + +def diamonds(mask, cell_px, shape="diamond"): """Marker-sized, marker-shaped blobs. The markers scale with the map, so a solved grid tells us how big one - must be (~0.14 of a cell). A diamond also fills about half its bounding - box, which rejects the long thin territory hatching and front-line - ribbons that share the markers' colours. + must be (~0.14 of a cell). SHAPE selects which silhouette a blob must + match -- "diamond" for hostile markers, "rect" for friendly ones, which + the game draws as solid rectangles rather than diamonds. Matching the + shape (not just the size and colour) rejects the long thin territory + hatching and front-line ribbons that share the markers' colours. """ want = 0.14 * cell_px lo, hi = 0.55 * want, 1.9 * want + fill_lo, fill_hi = FILL_RANGE[shape] n, lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8) out = [] for i in range(1, n): @@ -870,33 +906,33 @@ def diamonds(mask, cell_px): continue if not (0.55 <= w / h <= 1.8): continue - if not (0.30 <= a / float(w * h) <= 0.85): + if not (fill_lo <= a / float(w * h) <= fill_hi): continue - # Actually test for a DIAMOND. A bounding-box fill ratio near 0.5 is - # not enough: a chunk of the territory hatching or of a front-line + # Actually test the SHAPE. A bounding-box fill ratio alone is not + # enough: a chunk of the territory hatching or of a front-line # ribbon hits the same ratio and the same colour, which is where the - # tens of spurious markers came from. Compare the blob against an - # ideal diamond inscribed in its own bounding box. + # tens of spurious markers came from. Compare the blob against the + # ideal silhouette inscribed in its own bounding box. blob = (lab[y:y + h, x:x + w] == i) - ideal = np.zeros((h, w), np.uint8) - cv2.fillConvexPoly(ideal, np.array( - [[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1) - ideal = ideal.astype(bool) + ideal = _ideal_mask(shape, w, h) union = int(np.logical_or(blob, ideal).sum()) if union == 0: continue - if int(np.logical_and(blob, ideal).sum()) / union < DIAMOND_IOU: + if int(np.logical_and(blob, ideal).sum()) / union < MARKER_IOU: continue out.append((float(cent[i][0]), float(cent[i][1]), (int(x), int(y), int(w), int(h)))) return out +MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"} + + def find_markers(img, sol): """-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box.""" cell = max(sol.steps) found = [] for side, mask in zip(("hostile", "friendly"), marker_masks(img)): - for (cx, cy, box) in diamonds(mask, cell): + for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]): c = sol.cell_of(cx, cy) if c is None: continue diff --git a/src/fenigma/models.py b/src/fenigma/models.py index 3785931..672697e 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -44,21 +44,76 @@ class TargetType(Enum): collection from Target (see Board.allies), not this same type with a flag flipped, targets and allies don't share an id-namespace or a firing-relevant shape (no shell/powder_charges/assignment).""" + # Declaration order is also picker order (icons.build_target_type_grid + # and the old plain-text dropdown both just iterate TargetType), so + # it's grouped by category, most-reached-for category first, and + # alphabetical by value within a category -- not the order these were + # added to the codebase. + + # -- Generic / non-unit ------------------------------------------- UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice - SUPPLY_CACHE = "Supply Cache" - FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire - INFANTRY = "Infantry" # ground troops - MECHANIZED = "Mechanized" # armored/vehicle unit - ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py - TANK = "Tank" - PILLBOX = "Pillbox" # armoured emplacement, fixed position - MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time) ENEMY = "Enemy" # ad-hoc installation named directly in the intel text # ("Enemy Signal Station", "Enemy Field Command"), not one of the # game's fixed unit types, its id is the rest of that name with # spaces stripped, see ocr.py's squash_enemy_names() STRIKE = "Strike" # a planned impact point, not an enemy contact + # -- Ground combat units ------------------------------------------- + ANTI_AIR = "Anti-Air" + ANTI_TANK = "Anti-Tank" + ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py + ARTILLERY_OBSERVER = "Field Artillery Observer" + HEAVY_GUN_TURRET = "Heavy Gun Turret" + INFANTRY = "Infantry" # ground troops + INFANTRY_MECHANIZED = "Mechanized Infantry" + MECH_ANTI_TANK = "Mechanized Anti-Tank" # friendly-only, no Enemy_ art (icons.py's + # _FRIENDLY_ONLY_BASENAME), unlike ANTI_TANK which both sides draw + MECHANIZED = "Mechanized" # armored/vehicle unit + PILLBOX = "Pillbox" # armoured emplacement, fixed position + TANK = "Tank" + + # -- Command & installations ---------------------------------------- + BASE = "Base" + COMMANDER = "Commander" + FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire + FORT = "Fort" # friendly-only; UNDERGROUND_FORT is the enemy-side equivalent concept + GENERAL = "General" # friendly-only, no Enemy_ art + KING = "King" # friendly-only, no Enemy_ art + MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time) + POLICE = "Police" # friendly-only, no Enemy_ art + SUPPLY_CACHE = "Supply Cache" + UNDERGROUND_FORT = "Underground Fort" + + # -- Medical ---------------------------------------------------------- + EMERGENCY_MEDICAL = "Emergency Medical Operation" + HOSPITAL = "Hospital" # friendly-only; MEDICAL_FACILITY is the enemy-side equivalent concept + MEDICAL = "Medical" + MEDICAL_FACILITY = "Medical Treatment Facility" + + # -- Civil -------------------------------------------------------------- + CIVIL_MILITARY = "Civil–Military" # friendly-only, no Enemy_ art + CIVILIAN = "Civilian" + CIVIL_RIOTING = "Civil Rioting" + RIOTING = "Rioting" + TV_RADIO_PROPAGANDA = "TV and Radio Propaganda" + + # -- Naval ------------------------------------------------------------ + PORT = "Port" + SHIP = "Ship" + SHIP_ENGINE = "Ship Engine" + SHIP_FDC = "Ship FDC" + SHIP_STRIPE = "Ship (Stripe)" + SHIP_TURRET = "Ship Turret" + + # -- Rail ----------------------------------------------------------- + TRAIN_LOCOMOTIVE = "Train Locomotive" + TRAIN_STATION = "Train Station" + TRAIN_TRANSPORT = "Train Transport" + + # -- Reconnaissance --------------------------------------------------- + RECON = "Recon" + RECON_LISTENING = "Recon (Listening)" + @property def short(self) -> str: """Compact form used in item names, e.g. 'SupplyCache' / 'FDC'.""" diff --git a/tests/fixtures/map_shots/15.png b/tests/fixtures/map_shots/15.png new file mode 100644 index 0000000..44b9331 Binary files /dev/null and b/tests/fixtures/map_shots/15.png differ diff --git a/tests/fixtures/map_shots/16.png b/tests/fixtures/map_shots/16.png new file mode 100644 index 0000000..4d91a9c Binary files /dev/null and b/tests/fixtures/map_shots/16.png differ diff --git a/tests/fixtures/map_shots/too_hard/17.png b/tests/fixtures/map_shots/too_hard/17.png new file mode 100644 index 0000000..40232fe Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/17.png differ diff --git a/tests/fixtures/map_shots/too_hard/18.png b/tests/fixtures/map_shots/too_hard/18.png new file mode 100644 index 0000000..cc48d1e Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/18.png differ diff --git a/tests/fixtures/map_shots/too_hard/19.png b/tests/fixtures/map_shots/too_hard/19.png new file mode 100644 index 0000000..c3b2566 Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/19.png differ diff --git a/tests/fixtures/map_shots/too_hard/20.png b/tests/fixtures/map_shots/too_hard/20.png new file mode 100644 index 0000000..dba5a03 Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/20.png differ diff --git a/tests/fixtures/map_shots/too_hard/21.png b/tests/fixtures/map_shots/too_hard/21.png new file mode 100644 index 0000000..c7dad4d Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/21.png differ diff --git a/tests/fixtures/map_shots/too_hard/22.png b/tests/fixtures/map_shots/too_hard/22.png new file mode 100644 index 0000000..cbc9e1d Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/22.png differ diff --git a/tests/fixtures/map_shots/too_hard/23.png b/tests/fixtures/map_shots/too_hard/23.png new file mode 100644 index 0000000..2ad2fa7 Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/23.png differ diff --git a/tests/fixtures/map_shots/too_hard/24.png b/tests/fixtures/map_shots/too_hard/24.png new file mode 100644 index 0000000..bbfdd4b Binary files /dev/null and b/tests/fixtures/map_shots/too_hard/24.png differ diff --git a/tests/fixtures/map_shots/too_hard/README.md b/tests/fixtures/map_shots/too_hard/README.md index 8f0ec0f..43dc282 100644 --- a/tests/fixtures/map_shots/too_hard/README.md +++ b/tests/fixtures/map_shots/too_hard/README.md @@ -10,5 +10,14 @@ them would mean guessing: - `12.png` — 710x594 native. Too few pixels per cell for the label glyphs to correlate; measured, the best label score stays ~0.44 at every working resolution, so it is not a tuning problem. +- `17.png`-`24.png` — same class as `12.png`: native resolution too low (605-1789px + wide, vs. 1392-2400px for the fixtures that do solve) for the label glyphs to + correlate once warped to the canonical cell size; measured, the best label + score stays ~0.45-0.52 at every working resolution tried (including the + 2400px retry pass), well short of LABEL_ACCEPT (0.62) and inside the + documented "wrong read" band (0.40-0.56), not close enough to call it a + tuning problem. Two of the ten screenshots this batch came from (now + `15.png`/`16.png` in the main set) were high enough resolution to solve -- + the same camera distance/game zoom just wasn't consistent across the batch. "Too zoomed in" and "too low resolution" are legitimate hard rejections. diff --git a/tests/fixtures/map_shots_gt.json b/tests/fixtures/map_shots_gt.json index c20028b..30b4440 100644 --- a/tests/fixtures/map_shots_gt.json +++ b/tests/fixtures/map_shots_gt.json @@ -636,5 +636,104 @@ 1659 ] ] - } -} + }, + "15.png": [ + [ + "N8", + 140, + 859 + ], + [ + "N9", + 153, + 2 + ], + [ + "O8", + 1042, + 859 + ], + [ + "P8", + 1949, + 859 + ] + ], + "16.png": [ + [ + "J5", + 62, + 573 + ], + [ + "J6", + 80, + 289 + ], + [ + "J7", + 97, + 31 + ], + [ + "K5", + 370, + 572 + ], + [ + "K6", + 374, + 289 + ], + [ + "K7", + 377, + 31 + ], + [ + "L5", + 678, + 572 + ], + [ + "L6", + 667, + 289 + ], + [ + "L7", + 657, + 31 + ], + [ + "M5", + 985, + 572 + ], + [ + "M6", + 959, + 288 + ], + [ + "M7", + 936, + 31 + ], + [ + "N5", + 1293, + 571 + ], + [ + "N6", + 1252, + 288 + ], + [ + "N7", + 1215, + 31 + ] + ] +} \ No newline at end of file