FEnigma/src/fenigma/firing_panel.py
Dominik Roth 1ddb532325 Fix ally/target bugs, OCR fire-support parsing, add debug capture
- Board.clear() now also drops allies; the "clear board?" guard checks
  allies too. New Board.clear_units() + Clear button right-click menu
  ("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
  popover/toast instead of "Ally" (icons.target_type_label already had
  the fix for the picker, now reused everywhere else via app.py's
  _display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
  (solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
  candidate list); blast radius only respected selection, not the
  show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
  taking fire ... Requesting X Shell on our position at <coord> before
  <time>", plus a bearing/distance-from-position variant), distinct
  from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
  badly, for later tuning of map_vision/ocr against real failures:
  map-read errors, user grid corrections (paired with the auto-detected
  grid), screenshots that read as text but may have been a map, and
  marker-detection ground truth (every proposal's accept/reject verdict
  plus units added with no matching proposal) captured whenever a
  screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
  detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
  additions to tests/test_ocr.py), 38/38 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:35:37 +02:00

519 lines
23 KiB
Python

"""Firing-commands sidebar: one card per target (or one per candidate
position, for an ambiguous target), shown alongside the map in an
Adw.OverlaySplitView (opening it narrows the map, doesn't overlay it).
Opened/closed from the main header's toggle button, no close control of
its own, so no header bar here, just the sort/filter toolbar.
Card layout:
Target#5 [edit] [L/R/—] [alive]
ELEV AZ
48.42° 31.2°
12.10km
AP ▾ [charge segments 1-6] 3
Elevation/azimuth are real (ballistics.py), computed from the Nest to
whichever coord the card represents. Shell defaults per target type
(Target.effective_shell, AP for FDC/AmmoCache, HE otherwise) but is
editable per-target via the shell button, which also drives the map's
blast-radius overlay when this target is selected (grid_widget.py).
Cards are drag-reorderable, `self.board.targets`' own list order is the
persisted order and doubles as the sort's tie-break (see refresh()).
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, GObject, Gtk # noqa: E402
from . import ballistics, icons
from .models import Board, Target, TargetType
from .shells import Shell
_SELECTED_CSS = "firing-card-selected"
_HOVERED_CSS = "firing-card-hovered"
_CHARGE_SEG_CSS_CLASS = "firing-charge-segment"
_CHARGE_SEG_ON = "charge-seg-on"
_CHARGE_SEG_OFF = "charge-seg-off"
_charge_seg_css_loaded = False
def _ensure_charge_segment_css() -> None:
"""Powder-charge segment buttons used to rely on plain 'flat'/
'suggested-action' for their off/on look: flat draws no background
or border at all, so once the shell icon next to them made the row
taller (and the segments, with no valign set, stretched to fill
that height instead of staying a fixed 14px circle), an unselected
segment became an invisible blank area and a selected one an
elongated blue oval instead of a small circle. This gives them
their own fixed size (immune to row height) and a visible outline
when off, not just 'not colored in yet'."""
global _charge_seg_css_loaded
if _charge_seg_css_loaded:
return
display = Gdk.Display.get_default()
if display is None:
return
# button.<class> (type + class), not just .<class>: GTK CSS has no
# !important, a more specific selector is the only way to actually
# beat libadwaita's own button padding/min-size rules.
provider = Gtk.CssProvider()
provider.load_from_string(f"""
button.{_CHARGE_SEG_CSS_CLASS} {{
min-width: 28px;
min-height: 28px;
padding: 0;
border-radius: 9999px;
}}
button.{_CHARGE_SEG_CSS_CLASS}.{_CHARGE_SEG_OFF} {{
background-color: transparent;
border: 1.5px solid alpha(currentColor, 0.45);
}}
button.{_CHARGE_SEG_CSS_CLASS}.{_CHARGE_SEG_ON} {{
background-color: @accent_bg_color;
border: 1.5px solid @accent_bg_color;
}}
""")
Gtk.StyleContext.add_provider_for_display(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
_charge_seg_css_loaded = True
_SHOW_DEAD_STATES = ["hide", "show", "sort_later"]
_SHOW_DEAD_ICONS = {
"hide": "view-conceal-symbolic",
"show": "view-reveal-symbolic",
"sort_later": "view-list-symbolic",
}
_SHOW_DEAD_LABELS = {
"hide": "Dead targets: hidden",
"show": "Dead targets: shown",
"sort_later": "Dead targets: sorted last",
}
_ASSIGNMENT_STATES = ["unassigned", "left", "right"]
_ASSIGNMENT_LABELS = {"unassigned": "", "left": "L", "right": "R"}
_ASSIGNMENT_TOOLTIPS = {
"unassigned": "Unassigned (click to assign left gun)",
"left": "Assigned: left gun (click to assign right gun)",
"right": "Assigned: right gun (click to unassign)",
}
class FiringPanel(Gtk.Box):
"""Right-hand sidebar content: sort/filter toolbar + scrollable cards."""
def __init__(
self, board: Board, *, on_change, on_select, on_edit_position, on_set_position, on_remove,
on_toggle_hide_dead_map,
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.board = board
self.on_change = on_change
self.on_select = on_select
self.on_edit_position = on_edit_position
self.on_set_position = on_set_position
self.on_remove = on_remove
self.on_toggle_hide_dead_map = on_toggle_hide_dead_map
self.selected: Target | None = None
self.selected_point = None
self.hovered: Target | None = None
self.hovered_point = None
self.show_dead = "sort_later" # "hide" | "show" | "sort_later"
self.hide_dead_from_map = False # off by default, the map's own filter, separate from sort/hide-in-list
# target -> [(card widget, point)] (usually 1; several for an ambiguous target)
self._cards_by_target: dict[Target, list[tuple[Gtk.Widget, object]]] = {}
self.append(self._build_toolbar())
self._list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self._list_box.set_margin_top(4)
self._list_box.set_margin_bottom(10)
self._list_box.set_margin_start(10)
self._list_box.set_margin_end(10)
scroller = Gtk.ScrolledWindow(child=self._list_box, vexpand=True)
# Horizontal scrolling is never wanted here (fixed-width sidebar),
# leaving it on AUTOMATIC (the default) lets a vertical scrollbar's
# own width shrink the content area enough to trigger a horizontal
# one too, which then perturbs card heights and can trip vertical
# scrolling that wasn't actually needed. Pin it off outright.
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.append(scroller)
self.refresh()
# -- sort/filter toolbar -----------------------------------------------------
def _build_toolbar(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
row.set_margin_top(10)
row.set_margin_bottom(6)
row.set_margin_start(10)
row.set_margin_end(10)
self._show_dead_btn = Gtk.Button()
self._show_dead_btn.connect("clicked", self._on_show_dead_clicked)
row.append(self._show_dead_btn)
self._update_show_dead_button()
self._hide_dead_map_btn = Gtk.ToggleButton()
self._hide_dead_map_btn.connect("toggled", self._on_hide_dead_map_toggled)
row.append(self._hide_dead_map_btn)
self._update_hide_dead_map_button()
return row
def _update_show_dead_button(self) -> None:
self._show_dead_btn.set_icon_name(_SHOW_DEAD_ICONS[self.show_dead])
self._show_dead_btn.set_tooltip_text(f"{_SHOW_DEAD_LABELS[self.show_dead]} (click to cycle)")
def _on_show_dead_clicked(self, _btn) -> None:
idx = _SHOW_DEAD_STATES.index(self.show_dead)
self.show_dead = _SHOW_DEAD_STATES[(idx + 1) % len(_SHOW_DEAD_STATES)]
self._update_show_dead_button()
self.refresh()
def _update_hide_dead_map_button(self) -> None:
self._hide_dead_map_btn.set_icon_name(
"view-conceal-symbolic" if self.hide_dead_from_map else "view-reveal-symbolic"
)
label = "Dead targets hidden from map" if self.hide_dead_from_map else "Dead targets shown on map"
self._hide_dead_map_btn.set_tooltip_text(f"{label} (click to toggle; selecting one still shows it)")
def _on_hide_dead_map_toggled(self, btn) -> None:
self.hide_dead_from_map = btn.get_active()
self._update_hide_dead_map_button()
self.on_toggle_hide_dead_map(self.hide_dead_from_map)
# -- selection / hover highlight (lightweight, no rebuild) -------------------
def set_selected(self, target, point=None) -> None:
if target is self.selected and point == self.selected_point:
return
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, False)
self.selected, self.selected_point = target, point
self._restyle(self.selected, self.selected_point, _SELECTED_CSS, True)
def set_hovered(self, target, point=None) -> None:
if target is self.hovered and point == self.hovered_point:
return
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, False)
self.hovered, self.hovered_point = target, point
self._restyle(self.hovered, self.hovered_point, _HOVERED_CSS, True)
def _restyle(self, target, point, css_class: str, add: bool) -> None:
"""point=None means "the whole target" (every one of its cards);
otherwise only the card for that specific ambiguous candidate,
without this, both candidate cards light up identically and you
can't tell which one was actually picked."""
for card, card_point in self._cards_by_target.get(target, []):
if point is None or card_point == point:
(card.add_css_class if add else card.remove_css_class)(css_class)
# -- rebuild --------------------------------------------------------------------
def refresh(self) -> None:
while (child := self._list_box.get_first_child()) is not None:
self._list_box.remove(child)
self._cards_by_target = {}
targets = list(self.board.targets)
if self.show_dead == "hide":
targets = [t for t in targets if t.alive]
def sort_key(t: Target):
dead_last = 1 if (self.show_dead == "sort_later" and not t.alive) else 0
return dead_last
# Stable sort: ties keep board order, which is exactly what drag
# reordering (Board.reorder_target) manipulates. A new strike goes
# to the front once, at creation (see app.py's _add_strike_at),
# not forced back there on every refresh, that would fight any
# later manual reorder.
targets.sort(key=sort_key)
if not targets:
placeholder = Gtk.Label(label="No targets yet.", wrap=True)
placeholder.add_css_class("dim-label")
placeholder.set_margin_top(24)
self._list_box.append(placeholder)
return
prev_was_dead_group = False
for target in targets:
in_dead_group = self.show_dead == "sort_later" and not target.alive
if in_dead_group and not prev_was_dead_group:
self._list_box.append(Gtk.Separator(margin_top=4, margin_bottom=4))
prev_was_dead_group = in_dead_group
cards = self._build_cards(target) # list of (card, point)
self._cards_by_target[target] = cards
for card, point in cards:
if target is self.selected and (self.selected_point is None or point == self.selected_point):
card.add_css_class(_SELECTED_CSS)
if target is self.hovered and (self.hovered_point is None or point == self.hovered_point):
card.add_css_class(_HOVERED_CSS)
self._list_box.append(card)
def _build_cards(self, target: Target) -> list[tuple[Gtk.Widget, object]]:
if target.coord is not None:
return [(self._build_card(target, target.coord), target.coord)]
if target.location.potential_coords:
return [
(self._build_card(target, coord, ambiguous_index=i + 1), coord)
for i, coord in enumerate(target.location.potential_coords)
]
return [(self._build_unresolved_card(target), None)]
def _build_card_shell(self, target: Target, point=None) -> tuple[Gtk.Box, Gtk.Box]:
"""Card frame + top row (name, edit, assignment, alive) common to
every card kind. `point` is the specific coord this card represents
(None for the "position unknown" card), passed back on select so
the map can highlight/arrow exactly this candidate, not every one
of them."""
card = Gtk.Box()
card.add_css_class("card")
card.add_css_class("firing-card")
click = Gtk.GestureClick()
click.connect("released", lambda *_a: self.on_select(target, point))
card.add_controller(click)
self._add_drag_reorder(card, target)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
inner.set_margin_top(10)
inner.set_margin_bottom(10)
inner.set_margin_start(12)
inner.set_margin_end(12)
card.append(inner)
top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
name_label = Gtk.Label(label=target.name, xalign=0, hexpand=True)
name_label.add_css_class("heading")
if not target.alive:
name_label.add_css_class("dim-label")
top_row.append(name_label)
if target.type is TargetType.STRIKE:
# A strike is placed by clicking the map (set_pos_btn below);
# typing in coordinates to "edit" one doesn't fit that, delete
# and re-place instead.
delete_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Delete strike")
delete_btn.add_css_class("flat")
delete_btn.connect("clicked", lambda _b: self.on_remove(target))
top_row.append(delete_btn)
else:
edit_btn = Gtk.Button(icon_name="document-edit-symbolic", tooltip_text="Edit position (type in coords)")
edit_btn.add_css_class("flat")
edit_btn.connect("clicked", lambda _b: self.on_edit_position(target))
top_row.append(edit_btn)
set_pos_btn = Gtk.Button(icon_name="find-location-symbolic", tooltip_text="Set position on map")
set_pos_btn.add_css_class("flat")
set_pos_btn.connect("clicked", lambda _b: self.on_set_position(target))
top_row.append(set_pos_btn)
assign_btn = Gtk.Button(label=_ASSIGNMENT_LABELS[target.assignment])
assign_btn.add_css_class("flat")
# Its siblings here (edit/set-position/alive) are all icon_name=
# buttons, which GTK auto-styles with tighter square padding
# meant for a single glyph ('image-button'). A plain label=
# button doesn't get that treatment on its own and keeps normal
# (wider) text-button padding, even though its label is also
# just one character, adding the class explicitly matches it up.
assign_btn.add_css_class("image-button")
assign_btn.set_tooltip_text(_ASSIGNMENT_TOOLTIPS[target.assignment])
assign_btn.connect("clicked", lambda _b: self._cycle_assignment(target))
top_row.append(assign_btn)
alive_btn = Gtk.Button(
icon_name="object-select-symbolic" if target.alive else "action-unavailable-symbolic",
tooltip_text="Mark destroyed" if target.alive else "Mark alive",
)
alive_btn.add_css_class("flat")
alive_btn.connect("clicked", lambda _b: self._toggle_alive(target))
top_row.append(alive_btn)
inner.append(top_row)
if not target.alive:
card.set_opacity(0.55)
return card, inner
def _add_drag_reorder(self, card: Gtk.Widget, target: Target) -> None:
drag_source = Gtk.DragSource()
drag_source.set_actions(Gdk.DragAction.MOVE)
def on_prepare(_src, _x, _y, target=target):
return Gdk.ContentProvider.new_for_value(GObject.Value(GObject.TYPE_PYOBJECT, target))
drag_source.connect("prepare", on_prepare)
drag_source.connect("drag-begin", lambda *_a: card.add_css_class("firing-card-dragging"))
drag_source.connect("drag-end", lambda *_a: card.remove_css_class("firing-card-dragging"))
card.add_controller(drag_source)
drop_target = Gtk.DropTarget.new(GObject.TYPE_PYOBJECT, Gdk.DragAction.MOVE)
def on_drop(_dt, dragged, _x, _y, drop_onto=target):
if dragged is drop_onto:
return False
self._reorder(dragged, drop_onto)
return True
drop_target.connect("drop", on_drop)
card.add_controller(drop_target)
def _reorder(self, dragged: Target, drop_onto: Target) -> None:
targets = self.board.targets
if dragged not in targets or drop_onto not in targets:
return
self.board.reorder_target(dragged, targets.index(drop_onto))
# NOT self.on_change(): that's app.py's "single choke point" full
# refresh (re-run the solver over every target's clues, dedupe,
# redraw the map, THEN rebuild this panel), all of it wasted work
# for a pure order change -- no location/clue/coord/alive state
# moved, so nothing the solver or the map drawing cares about
# changed, only this panel's own card order did. Calling that
# full pipeline on every single drag-drop was what made
# reordering feel laggy; a local refresh() is the only rebuild a
# reorder actually needs.
self.refresh()
def _build_unresolved_card(self, target: Target) -> Gtk.Widget:
card, inner = self._build_card_shell(target)
note = Gtk.Label(label="Position unknown", xalign=0, wrap=True)
note.add_css_class("dim-label")
inner.append(note)
return card
def _build_card(self, target: Target, coord, ambiguous_index: int | None = None) -> Gtk.Widget:
card, inner = self._build_card_shell(target, coord)
if ambiguous_index is not None:
tag = Gtk.Label(label=f"AMBIGUOUS, candidate {ambiguous_index}", xalign=0)
tag.add_css_class("caption")
tag.add_css_class("warning")
inner.append(tag)
nest = self.board.nest
if nest.coord is None:
note = Gtk.Label(label="Nest position unknown", xalign=0, wrap=True)
note.add_css_class("dim-label")
inner.append(note)
return card
dist = ballistics.distance_km(nest.coord, coord)
az = ballistics.bearing_deg(nest.coord, coord)
min_charge = ballistics.min_powder_charge(dist)
charges = target.powder_charges or min_charge
charges = max(min_charge, min(charges, ballistics.MAX_POWDER_CHARGE))
readouts = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
elev_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
elev_caption = Gtk.Label(label="ELEV", xalign=0)
elev_caption.add_css_class("caption")
elev_caption.add_css_class("dim-label")
elev_value = Gtk.Label(label=f"{ballistics.elevation_deg(dist, charges):.2f}°", xalign=0)
elev_value.add_css_class("title-3")
# Reserves a fixed character width regardless of the actual
# digit count ('9.33°' vs '49.33°' vs '180.00°'), otherwise the
# AZ column right after it visibly shifts sideways every time
# picking a different powder charge changes the elevation's
# digit count.
elev_value.set_width_chars(7)
dist_label = Gtk.Label(label=f"{dist:.2f}km", xalign=0)
dist_label.add_css_class("caption")
dist_label.add_css_class("dim-label")
elev_box.append(elev_caption)
elev_box.append(elev_value)
elev_box.append(dist_label)
readouts.append(elev_box)
az_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
az_caption = Gtk.Label(label="AZ", xalign=0)
az_caption.add_css_class("caption")
az_caption.add_css_class("dim-label")
az_value = Gtk.Label(label=f"{az:.1f}°", xalign=0)
az_value.add_css_class("title-3")
az_box.append(az_caption)
az_box.append(az_value)
readouts.append(az_box)
inner.append(readouts)
inner.append(self._build_charge_row(target, dist, min_charge, charges, elev_value))
return card
def _build_charge_row(self, target: Target, dist_km: float, min_charge: int,
charges: int, elev_value_label: Gtk.Label) -> Gtk.Widget:
_ensure_charge_segment_css()
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, valign=Gtk.Align.CENTER)
segments: list[Gtk.Button] = []
count_label = Gtk.Label(label=str(charges), valign=Gtk.Align.CENTER)
count_label.set_margin_start(4)
def apply_fill(value: int) -> None:
for i, seg in enumerate(segments, start=1):
seg.remove_css_class(_CHARGE_SEG_ON)
seg.remove_css_class(_CHARGE_SEG_OFF)
seg.add_css_class(_CHARGE_SEG_ON if i <= value else _CHARGE_SEG_OFF)
count_label.set_label(str(value))
def on_pick(n: int) -> None:
target.powder_charges = n
apply_fill(n)
elev_value_label.set_label(f"{ballistics.elevation_deg(dist_km, n):.2f}°")
for n in range(1, ballistics.MAX_POWDER_CHARGE + 1):
# valign/halign=CENTER pins these to a real fixed 14x14 circle
# (see _ensure_charge_segment_css) no matter how tall the row
# around them gets, without it they silently inherit the
# default FILL alignment and stretch to match the row's
# height, which is what turned them into a giant blue oval
# once the shell icon next to them got taller than plain text.
seg = Gtk.Button(label="", valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
seg.add_css_class(_CHARGE_SEG_CSS_CLASS)
seg.set_sensitive(n >= min_charge)
seg.set_tooltip_text(f"{n} charge{'s' if n != 1 else ''}")
seg.connect("clicked", lambda _b, n=n: on_pick(n))
segments.append(seg)
row.append(seg)
apply_fill(charges)
row.append(count_label)
# Icon only, not name-plus-icon: the icon already has the shell's
# short code baked in (see assets/icons/README.md), a redundant
# text label would just eat width on an already-tight card row.
# 96px wide (the source art, after its built-in padding got
# cropped out, is roughly 2.5:1, so ~39 tall) is what it
# actually takes to read the baked-in code at a glance.
# valign=CENTER matters here specifically: it's the tallest thing
# in this row, everything else needs to center against it rather
# than stretch to match its height (see the segments above).
# Placed last (after the powder circles), not first.
shell_btn = icons.build_shell_button(
target.effective_shell, lambda s: self._pick_shell(target, s), show_label=False, icon_width=96
)
shell_btn.set_valign(Gtk.Align.CENTER)
shell_btn.set_halign(Gtk.Align.END)
shell_btn.set_hexpand(True)
shell_btn.set_tooltip_text(f"{target.effective_shell.name}: change shell (blast radius)")
row.append(shell_btn)
return row
def _cycle_assignment(self, target: Target) -> None:
idx = _ASSIGNMENT_STATES.index(target.assignment)
target.assignment = _ASSIGNMENT_STATES[(idx + 1) % len(_ASSIGNMENT_STATES)]
self.on_change()
def _toggle_alive(self, target: Target) -> None:
target.alive = not target.alive
self.on_change()
def _pick_shell(self, target: Target, shell: Shell) -> None:
target.shell = shell
self.on_change()