"""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()