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>
436 lines
18 KiB
Python
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()
|