FeNigma/src/fenigma/firing_panel.py
Dominik Roth ba6b07a476 Sweep em-dashes out of code, docstrings, and requirements.txt
Same style fix applied to the README earlier, extended everywhere:
replaced " -- " with commas/colons/periods (picking whichever reads
right per occurrence, splitting into two sentences where the clauses
were independent), fixed a few user-facing strings along the way
(entity list rows, placement/strike toasts, ambiguous-candidate tag,
shell picker button label). Left three intentional non-prose uses
alone: the "unassigned" dash glyph in firing_panel.py (and its
docstring diagram), and ocr.py's dash-variant regex character class,
which needs to literally match em/en-dashes in OCR'd text.

Also caught and fixed a stale models.py docstring claiming "no solver
yet" (solver.py has existed for a while) while touching that
paragraph anyway, and a formatting artifact in coord_dialog.py's
docstring left by the sed pass (misaligned comma from a since-removed
alignment gap).

Verified: py_compile across all files, the 9-screenshot OCR
regression sweep, and a GTK smoke test exercising the edited
toast/placement code paths.

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

436 lines
18 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
from .models import Board, Target, TargetType
from .shells import Shell
_SELECTED_CSS = "firing-card-selected"
_HOVERED_CSS = "firing-card-hovered"
_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)
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
strike_first = 0 if t.type is TargetType.STRIKE else 1
return (dead_last, strike_first)
# Stable sort: ties keep board order, which is exactly what drag
# reordering (Board.reorder_target) manipulates.
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")
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))
self.on_change()
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")
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:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
shell_btn = Gtk.MenuButton(label=target.effective_shell.name)
shell_btn.add_css_class("flat")
shell_btn.set_tooltip_text("Change shell (blast radius)")
popover = Gtk.Popover()
shell_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
for s in Shell:
radius = f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius"
btn = Gtk.Button(label=f"{s.name}: {s.description} ({radius})")
btn.add_css_class("flat")
btn.get_child().set_xalign(0)
btn.connect("clicked", lambda _b, s=s: self._pick_shell(target, s, popover))
shell_list.append(btn)
popover.set_child(shell_list)
shell_btn.set_popover(popover)
row.append(shell_btn)
segments: list[Gtk.Button] = []
count_label = Gtk.Label(label=str(charges))
count_label.set_margin_start(4)
def apply_fill(value: int) -> None:
for i, seg in enumerate(segments, start=1):
seg.remove_css_class("suggested-action")
seg.remove_css_class("flat")
seg.add_css_class("suggested-action" if i <= value else "flat")
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):
seg = Gtk.Button(label=" ")
seg.set_size_request(14, 14)
seg.add_css_class("circular")
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)
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, popover: Gtk.Popover) -> None:
target.shell = shell
popover.popdown()
self.on_change()