"""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. (type + class), not just .: 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()