Map: square-cell toggle and scroll-wheel zoom

Refactored GridCanvas's geometry around a single _View namedtuple
(cell_w/cell_h/grid_w/grid_h/pad_x/pad_y/viewport origin/visible
extent) instead of threading cell_w/cell_h/grid_h separately through
every draw and hit-test method, that's what makes the two new features
below tractable without a parameter explosion.

- Square-cell toggle (header button, 'view-grid-symbolic'): forces
  cell_w == cell_h, letterboxing (padding) whichever axis has leftover
  space instead of stretching cells to fill the widget. Off by default,
  recovers the exact previous stretch-to-fill behavior.
- Scroll-wheel zoom: 1x (the whole 20x10 map, the old fixed behavior)
  up to 10x, anchored at the cursor's last known position so the km
  point under it stays under it as the zoom level changes, panned/
  clamped so the viewport never hangs off the grid's edge. Grid lines,
  column/row labels, and every marker only draw for the visible
  viewport, not always the full 20x10 grid.

Also two bugs found and fixed along the way:
- The header's cursor-location readout showed AZ/distance-from-nest
  numbers even when the cursor was off the map entirely:
  bearing_deg_point()/distance_km_point() are happy to compute on any
  raw km point, on- map or not, only the coord label itself checked
  bounds. Now the whole readout is just 'off map' whenever the cursor
  genuinely isn't over the grid.
- That bounds check initially reused solver.point_to_coord()'s own
  tolerance, which deliberately forgives up to 0.5km past an edge
  (rounding slop for noisy OCR'd coordinates), the wrong call for 'is
  the mouse over the map', a cursor visibly off the drawn grid still
  passed it. The cursor readout now uses a strict 0<=col<=COLS/
  0<=row<=ROWS check instead.

Verified with GTK smoke tests: zoom in/out and pan-anchoring math,
square-cell letterboxing padding, hover/hit-testing, the toggle wired
end-to-end through the real header button, and the full app launching
and surviving the exact scenario that crashed it earlier in this same
session (an incomplete mid-refactor commit referenced _on_scroll before
it was defined, caught immediately by re-running the app, fixed by
finishing the refactor properly instead of patching around it).

Also, on the firing card: swapped the shell icon to come after the
powder-charge segments instead of before (per feedback), and gave the
assignment cycle button (L/R/-) the same 'image-button' style class its
icon-only siblings get automatically, it was visibly wider than them
for carrying a text label instead of an icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dominik Moritz Roth 2026-08-09 19:43:35 +02:00
parent 08d046536e
commit e585fc5428
3 changed files with 198 additions and 95 deletions

View File

@ -27,7 +27,7 @@ from PIL import Image # noqa: E402
from . import ballistics, icons, ocr, solver # noqa: E402 from . import ballistics, icons, ocr, solver # noqa: E402
from .coord_dialog import CoordDialog # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402
from .firing_panel import FiringPanel # noqa: E402 from .firing_panel import FiringPanel # noqa: E402
from .grid_widget import GridCanvas # noqa: E402 from .grid_widget import COLS, ROWS, GridCanvas # noqa: E402
from .models import Board, Location, Target, TargetType # noqa: E402 from .models import Board, Location, Target, TargetType # noqa: E402
from .shells import Shell # noqa: E402 from .shells import Shell # noqa: E402
@ -269,6 +269,11 @@ class MainWindow(Adw.ApplicationWindow):
scout_btn.connect("clicked", lambda _b: self._add_scout_flight()) scout_btn.connect("clicked", lambda _b: self._add_scout_flight())
header.pack_start(scout_btn) header.pack_start(scout_btn)
square_cells_btn = Gtk.ToggleButton(icon_name="view-grid-symbolic")
square_cells_btn.set_tooltip_text("Force square grid cells (letterbox instead of stretch)")
square_cells_btn.connect("toggled", lambda b: self.canvas.set_square_cells(b.get_active()))
header.pack_start(square_cells_btn)
firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic") firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic")
firing_btn.set_tooltip_text("Firing commands") firing_btn.set_tooltip_text("Firing commands")
firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel()) firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel())
@ -691,13 +696,18 @@ class MainWindow(Adw.ApplicationWindow):
if point_km is None: if point_km is None:
self.cursor_label.set_label("") self.cursor_label.set_label("")
return return
col, row = point_km
# A strict bounds check, not solver.point_to_coord()'s own: that
# one deliberately tolerates up to 0.5km past an edge (rounding
# slop from noisy OCR'd coordinates), which is the right call
# for parsing text but not for 'is the mouse actually over the
# map', a cursor genuinely off the drawn grid still resolved to
# a real-looking coord within that slop margin.
if not (0 <= col <= COLS and 0 <= row <= ROWS):
self.cursor_label.set_label("off map")
return
coord = solver.point_to_coord(point_km) coord = solver.point_to_coord(point_km)
if coord is None: if coord is None:
# Off the map entirely, AZ/dist are still mathematically
# defined for any raw km point (nothing stops that math from
# running on a negative or >20/>10 coordinate), showing them
# anyway would read as a real reading for a position that
# isn't actually on the map.
self.cursor_label.set_label("off map") self.cursor_label.set_label("off map")
return return
nest = self.board.nest nest = self.board.nest

View File

@ -317,6 +317,13 @@ class FiringPanel(Gtk.Box):
assign_btn = Gtk.Button(label=_ASSIGNMENT_LABELS[target.assignment]) assign_btn = Gtk.Button(label=_ASSIGNMENT_LABELS[target.assignment])
assign_btn.add_css_class("flat") 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.set_tooltip_text(_ASSIGNMENT_TOOLTIPS[target.assignment])
assign_btn.connect("clicked", lambda _b: self._cycle_assignment(target)) assign_btn.connect("clicked", lambda _b: self._cycle_assignment(target))
top_row.append(assign_btn) top_row.append(assign_btn)
@ -433,22 +440,6 @@ class FiringPanel(Gtk.Box):
_ensure_charge_segment_css() _ensure_charge_segment_css()
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, valign=Gtk.Align.CENTER) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, valign=Gtk.Align.CENTER)
# 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 now, everything else needs to center against it
# rather than stretch to match its height (see the segments below).
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_tooltip_text(f"{target.effective_shell.name}: change shell (blast radius)")
row.append(shell_btn)
segments: list[Gtk.Button] = [] segments: list[Gtk.Button] = []
count_label = Gtk.Label(label=str(charges), valign=Gtk.Align.CENTER) count_label = Gtk.Label(label=str(charges), valign=Gtk.Align.CENTER)
count_label.set_margin_start(4) count_label.set_margin_start(4)
@ -482,6 +473,26 @@ class FiringPanel(Gtk.Box):
apply_fill(charges) apply_fill(charges)
row.append(count_label) 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 return row
def _cycle_assignment(self, target: Target) -> None: def _cycle_assignment(self, target: Target) -> None:

View File

@ -44,7 +44,7 @@ ZOOM_STEP = 1.15 # per scroll-wheel notch
# margin added on whichever axis has leftover space when square_cells # margin added on whichever axis has leftover space when square_cells
# is on. ox/oy are the visible viewport's origin in km-space (0,0 # is on. ox/oy are the visible viewport's origin in km-space (0,0
# unless zoomed in and panned). # unless zoomed in and panned).
_View = namedtuple("_View", "cell_w cell_h grid_w grid_h pad_x pad_y ox oy") _View = namedtuple("_View", "cell_w cell_h grid_w grid_h pad_x pad_y ox oy vis_cols vis_rows")
CATEGORY_COLOR = { CATEGORY_COLOR = {
"nest": (0.35, 0.60, 0.95), "nest": (0.35, 0.60, 0.95),
@ -93,6 +93,20 @@ class GridCanvas(Gtk.DrawingArea):
self.placement_kind = "point" self.placement_kind = "point"
self._placement_cursor_km = None self._placement_cursor_km = None
# square_cells: off by default (the map stretches to fill the
# widget, cell_w != cell_h unless the widget's own aspect ratio
# happens to match 20:10), toggled from the header (see app.py).
# zoom/pan_km: scroll-wheel zoom state, pan_km is the km-space
# point the current view is centered on; _view() clamps it so
# the visible viewport never hangs off the grid's edge.
# _last_pointer_px: tracked from motion events so a scroll event
# (which doesn't carry its own pointer position) has somewhere
# to zoom towards.
self.square_cells = False
self.zoom = MIN_ZOOM
self.pan_km = (COLS / 2.0, ROWS / 2.0)
self._last_pointer_px: tuple[float, float] | None = None
self.set_hexpand(True) self.set_hexpand(True)
self.set_vexpand(True) self.set_vexpand(True)
self.set_draw_func(self._draw) self.set_draw_func(self._draw)
@ -102,6 +116,10 @@ class GridCanvas(Gtk.DrawingArea):
motion.connect("leave", self._on_leave) motion.connect("leave", self._on_leave)
self.add_controller(motion) self.add_controller(motion)
scroll = Gtk.EventControllerScroll(flags=Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect("scroll", self._on_scroll)
self.add_controller(scroll)
click = Gtk.GestureClick() click = Gtk.GestureClick()
click.connect("released", self._on_click) click.connect("released", self._on_click)
self.add_controller(click) self.add_controller(click)
@ -176,17 +194,76 @@ class GridCanvas(Gtk.DrawingArea):
self.queue_draw() self.queue_draw()
# -- geometry ------------------------------------------------------------- # -- geometry -------------------------------------------------------------
def _cell_size(self, width: int, height: int) -> tuple[float, float]: def set_square_cells(self, square: bool) -> None:
grid_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1) self.square_cells = square
grid_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1) self.queue_draw()
return grid_w / COLS, grid_h / ROWS
def _km_to_px(self, point_km, cell_w, cell_h, grid_h) -> tuple[float, float]: def _view(self, width: int, height: int) -> _View:
"""Everything needed to convert km-space <-> pixels for one
frame, see _View's own docstring. zoom==MIN_ZOOM (the default)
recovers the exact pre-zoom/pre-square_cells behavior: the whole
grid stretched edge to edge, no letterboxing."""
avail_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1)
avail_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1)
vis_cols = COLS / self.zoom
vis_rows = ROWS / self.zoom
cx, cy = self.pan_km
ox = min(max(cx - vis_cols / 2, 0.0), COLS - vis_cols)
oy = min(max(cy - vis_rows / 2, 0.0), ROWS - vis_rows)
cell_w = avail_w / vis_cols
cell_h = avail_h / vis_rows
pad_x = pad_y = 0.0
if self.square_cells:
cell = min(cell_w, cell_h)
grid_w, grid_h = cell * vis_cols, cell * vis_rows
pad_x = (avail_w - grid_w) / 2
pad_y = (avail_h - grid_h) / 2
cell_w = cell_h = cell
else:
grid_w, grid_h = avail_w, avail_h
return _View(cell_w, cell_h, grid_w, grid_h, pad_x, pad_y, ox, oy, vis_cols, vis_rows)
def _km_to_px(self, view: _View, point_km) -> tuple[float, float]:
col, row = point_km col, row = point_km
return MARGIN_LEFT + col * cell_w, MARGIN_TOP + grid_h - row * cell_h x = MARGIN_LEFT + view.pad_x + (col - view.ox) * view.cell_w
y = MARGIN_TOP + view.pad_y + view.grid_h - (row - view.oy) * view.cell_h
return x, y
def _px_to_km(self, x, y, cell_w, cell_h, grid_h) -> tuple[float, float]: def _px_to_km(self, view: _View, x, y) -> tuple[float, float]:
return (x - MARGIN_LEFT) / cell_w, (grid_h - (y - MARGIN_TOP)) / cell_h col = (x - MARGIN_LEFT - view.pad_x) / view.cell_w + view.ox
row = (view.grid_h - (y - MARGIN_TOP - view.pad_y)) / view.cell_h + view.oy
return col, row
def _on_scroll(self, _controller, _dx, dy) -> bool:
"""Zoom in/out anchored at the last known cursor position (a
scroll event carries no position of its own), so the km point
under the cursor stays under it after the zoom level changes
instead of the view just re-centering on the grid's middle."""
width, height = self.get_width(), self.get_height()
view_before = self._view(width, height)
px, py = self._last_pointer_px or (
MARGIN_LEFT + view_before.pad_x + view_before.grid_w / 2,
MARGIN_TOP + view_before.pad_y + view_before.grid_h / 2,
)
anchor_km = self._px_to_km(view_before, px, py)
self.zoom = min(max(self.zoom * (ZOOM_STEP ** -dy), MIN_ZOOM), MAX_ZOOM)
vis_cols = COLS / self.zoom
vis_rows = ROWS / self.zoom
frac_x = (px - MARGIN_LEFT - view_before.pad_x) / view_before.grid_w if view_before.grid_w else 0.5
frac_y = 1 - (py - MARGIN_TOP - view_before.pad_y) / view_before.grid_h if view_before.grid_h else 0.5
# _view() clamps this back onto the grid itself if it would
# otherwise hang the viewport off an edge, no separate bounds
# check needed here.
self.pan_km = (
anchor_km[0] + (0.5 - frac_x) * vis_cols,
anchor_km[1] + (0.5 - frac_y) * vis_rows,
)
self.queue_draw()
return True
def _excluded_from_map(self, obj) -> bool: def _excluded_from_map(self, obj) -> bool:
"""True if `obj` should be dropped from the map view entirely, """True if `obj` should be dropped from the map view entirely,
@ -219,15 +296,13 @@ class GridCanvas(Gtk.DrawingArea):
for candidate in obj.location.potential_coords: for candidate in obj.location.potential_coords:
yield obj, candidate yield obj, candidate
def _hit_test(self, x: float, y: float): def _hit_test(self, view: _View, x: float, y: float):
"""Returns (obj, coord) of the nearest marker within range, or """Returns (obj, coord) of the nearest marker within range, or
(None, None), coord disambiguates which candidate of an (None, None), coord disambiguates which candidate of an
ambiguous obj was actually hit, since it can have several points.""" ambiguous obj was actually hit, since it can have several points."""
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
grid_h = cell_h * ROWS
best_obj, best_coord, best_dist = None, None, HOVER_RADIUS_PX best_obj, best_coord, best_dist = None, None, HOVER_RADIUS_PX
for obj, coord in self._all_positions(): for obj, coord in self._all_positions():
px, py = self._km_to_px(coord.as_fraction(), cell_w, cell_h, grid_h) px, py = self._km_to_px(view, coord.as_fraction())
dist = math.hypot(px - x, py - y) dist = math.hypot(px - x, py - y)
if dist < best_dist: if dist < best_dist:
best_dist, best_obj, best_coord = dist, obj, coord best_dist, best_obj, best_coord = dist, obj, coord
@ -235,9 +310,9 @@ class GridCanvas(Gtk.DrawingArea):
# -- hover / click ------------------------------------------------------------ # -- hover / click ------------------------------------------------------------
def _on_motion(self, _controller, x: float, y: float) -> None: def _on_motion(self, _controller, x: float, y: float) -> None:
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height()) self._last_pointer_px = (x, y)
grid_h = cell_h * ROWS view = self._view(self.get_width(), self.get_height())
cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h) cursor_km = self._px_to_km(view, x, y)
if self.on_cursor_move is not None: if self.on_cursor_move is not None:
self.on_cursor_move(cursor_km) self.on_cursor_move(cursor_km)
@ -247,7 +322,7 @@ class GridCanvas(Gtk.DrawingArea):
self.queue_draw() self.queue_draw()
return # no hover/select while placing, the map's just a target picker right now return # no hover/select while placing, the map's just a target picker right now
hit, coord = self._hit_test(x, y) hit, coord = self._hit_test(view, x, y)
if hit is not self.hovered or coord != self.hovered_point: if hit is not self.hovered or coord != self.hovered_point:
self.hovered = hit self.hovered = hit
self.hovered_point = coord self.hovered_point = coord
@ -256,6 +331,7 @@ class GridCanvas(Gtk.DrawingArea):
self.on_hover_change(hit, coord) self.on_hover_change(hit, coord)
def _on_leave(self, _controller) -> None: def _on_leave(self, _controller) -> None:
self._last_pointer_px = None
if self.on_cursor_move is not None: if self.on_cursor_move is not None:
self.on_cursor_move(None) self.on_cursor_move(None)
if self.placement_callback is not None: if self.placement_callback is not None:
@ -269,10 +345,9 @@ class GridCanvas(Gtk.DrawingArea):
self.on_hover_change(None, None) self.on_hover_change(None, None)
def _on_click(self, _gesture, _n_press, x: float, y: float) -> None: def _on_click(self, _gesture, _n_press, x: float, y: float) -> None:
view = self._view(self.get_width(), self.get_height())
if self.placement_callback is not None: if self.placement_callback is not None:
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height()) cursor_km = self._px_to_km(view, x, y)
grid_h = cell_h * ROWS
cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h)
callback, kind = self.placement_callback, self.placement_kind callback, kind = self.placement_callback, self.placement_kind
self.cancel_placement() self.cancel_placement()
if kind == "scout_flight": if kind == "scout_flight":
@ -284,7 +359,7 @@ class GridCanvas(Gtk.DrawingArea):
callback(coord) callback(coord)
return return
hit, coord = self._hit_test(x, y) hit, coord = self._hit_test(view, x, y)
self.set_selected(hit, coord) self.set_selected(hit, coord)
if self.on_select is not None: if self.on_select is not None:
self.on_select(hit, coord) self.on_select(hit, coord)
@ -295,9 +370,8 @@ class GridCanvas(Gtk.DrawingArea):
return return
if self.on_right_click is None: if self.on_right_click is None:
return return
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height()) view = self._view(self.get_width(), self.get_height())
grid_h = cell_h * ROWS coord = solver.point_to_coord(self._px_to_km(view, x, y))
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
if coord is not None: if coord is not None:
self.on_right_click(coord, x, y) self.on_right_click(coord, x, y)
@ -306,42 +380,50 @@ class GridCanvas(Gtk.DrawingArea):
cr.set_source_rgb(*BG) cr.set_source_rgb(*BG)
cr.paint() cr.paint()
cell_w, cell_h = self._cell_size(width, height) view = self._view(width, height)
grid_w, grid_h = cell_w * COLS, cell_h * ROWS
# Only the columns/rows actually within the visible viewport,
# not always 0..COLS/0..ROWS, once zoomed in most of the grid
# isn't on screen at all. +2 on the upper bound of range(): one
# to cover the trailing partial cell (int() truncates toward the
# viewport's start), one more because range()'s own upper bound
# is exclusive.
first_col, last_col = int(view.ox), int(view.ox + view.vis_cols) + 2
first_row, last_row = int(view.oy), int(view.oy + view.vis_rows) + 2
cr.set_source_rgba(*GRID_LINE) cr.set_source_rgba(*GRID_LINE)
cr.set_line_width(1) cr.set_line_width(1)
for c in range(COLS + 1): for c in range(first_col, min(last_col, COLS + 1)):
x = MARGIN_LEFT + c * cell_w x, _ = self._km_to_px(view, (c, 0))
cr.move_to(x, MARGIN_TOP) cr.move_to(x, MARGIN_TOP + view.pad_y)
cr.line_to(x, MARGIN_TOP + grid_h) cr.line_to(x, MARGIN_TOP + view.pad_y + view.grid_h)
for r in range(ROWS + 1): for r in range(first_row, min(last_row, ROWS + 1)):
y = MARGIN_TOP + grid_h - r * cell_h _, y = self._km_to_px(view, (0, r))
cr.move_to(MARGIN_LEFT, y) cr.move_to(MARGIN_LEFT + view.pad_x, y)
cr.line_to(MARGIN_LEFT + grid_w, y) cr.line_to(MARGIN_LEFT + view.pad_x + view.grid_w, y)
cr.stroke() cr.stroke()
cr.set_source_rgb(*LABEL) cr.set_source_rgb(*LABEL)
cr.set_font_size(11) cr.set_font_size(11)
for i, letter in enumerate(LARGE_X): for i in range(max(first_col, 0), min(last_col, COLS)):
x = MARGIN_LEFT + i * cell_w + cell_w / 2 - 4 x, _ = self._km_to_px(view, (i + 0.5, 0))
cr.move_to(x, MARGIN_TOP - 10) cr.move_to(x - 4, MARGIN_TOP + view.pad_y - 10)
cr.show_text(letter) cr.show_text(LARGE_X[i])
for r in range(ROWS): for r in range(max(first_row, 0), min(last_row, ROWS)):
y = MARGIN_TOP + grid_h - r * cell_h - cell_h / 2 + 4 _, y = self._km_to_px(view, (0, r + 0.5))
cr.move_to(4, y) cr.move_to(4, y + 4)
cr.show_text(str(r + 1)) cr.show_text(str(r + 1))
self._draw_geo_overlays(cr, cell_w, cell_h, grid_h) self._draw_geo_overlays(cr, view)
self._draw_firing_arrows(cr, cell_w, cell_h, grid_h) self._draw_firing_arrows(cr, view)
self._draw_blast_radius(cr, cell_w, cell_h, grid_h) self._draw_blast_radius(cr, view)
self._draw_placement_preview(cr, cell_w, cell_h, grid_h) self._draw_placement_preview(cr, view)
for category, obj in self.board.placed_entities_all(): for category, obj in self.board.placed_entities_all():
if self._excluded_from_map(obj): if self._excluded_from_map(obj):
continue # hidden/dead-and-toggled-off entities are removed, not just darkened continue # hidden/dead-and-toggled-off entities are removed, not just darkened
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category], self._draw_marker(cr, view, obj.coord.as_fraction(), CATEGORY_COLOR[category],
obj.name, cell_w, cell_h, grid_h, width, height, obj.name, width, height,
dim=(category == "target" and not obj.alive) or obj.hidden, dim=(category == "target" and not obj.alive) or obj.hidden,
selected=(obj is self.selected), coord=obj.coord, selected=(obj is self.selected), coord=obj.coord,
extra_line=getattr(obj, "requested_time", None)) extra_line=getattr(obj, "requested_time", None))
@ -352,8 +434,8 @@ class GridCanvas(Gtk.DrawingArea):
color = CATEGORY_COLOR[category] color = CATEGORY_COLOR[category]
for i, candidate in enumerate(obj.location.potential_coords): for i, candidate in enumerate(obj.location.potential_coords):
is_selected = obj is self.selected and candidate == self.selected_point is_selected = obj is self.selected and candidate == self.selected_point
self._draw_marker(cr, candidate.as_fraction(), color, self._draw_marker(cr, view, candidate.as_fraction(), color,
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height, f"{obj.name}? ({i + 1})", width, height,
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive), hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
selected=is_selected, coord=candidate, selected=is_selected, coord=candidate,
extra_line=getattr(obj, "requested_time", None)) extra_line=getattr(obj, "requested_time", None))
@ -361,8 +443,8 @@ class GridCanvas(Gtk.DrawingArea):
for sf in self.board.scout_flights: for sf in self.board.scout_flights:
if sf.hidden: if sf.hidden:
continue # hidden means gone from the map, not just darkened, no selection to reinstate it continue # hidden means gone from the map, not just darkened, no selection to reinstate it
self._draw_scout_flight_rect(cr, sf.center, sf.bearing_deg, cell_w, cell_h, grid_h) self._draw_scout_flight_rect(cr, view, sf.center, sf.bearing_deg)
cx, cy = self._km_to_px(sf.center, cell_w, cell_h, grid_h) cx, cy = self._km_to_px(view, sf.center)
start_coord = solver.point_to_coord(sf.center) start_coord = solver.point_to_coord(sf.center)
grid_name = f"{start_coord.X}{start_coord.Y}" if start_coord is not None else "?" grid_name = f"{start_coord.X}{start_coord.Y}" if start_coord is not None else "?"
@ -377,10 +459,10 @@ class GridCanvas(Gtk.DrawingArea):
cr.show_text(f"{grid_name} {sf.bearing_deg:05.1f}°") cr.show_text(f"{grid_name} {sf.bearing_deg:05.1f}°")
cr.set_font_size(11) cr.set_font_size(11)
def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h, def _draw_marker(self, cr, view, point_km, color, label,
canvas_width, canvas_height, *, hollow=False, dim=False, canvas_width, canvas_height, *, hollow=False, dim=False,
selected=False, coord=None, extra_line=None) -> None: selected=False, coord=None, extra_line=None) -> None:
x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h) x, y = self._km_to_px(view, point_km)
r, g, b = color r, g, b = color
alpha = 0.45 if dim else 1.0 alpha = 0.45 if dim else 1.0
@ -440,7 +522,7 @@ class GridCanvas(Gtk.DrawingArea):
cr.show_text(extra_line) cr.show_text(extra_line)
cr.set_font_size(11) cr.set_font_size(11)
def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> None: def _draw_firing_arrows(self, cr, view) -> None:
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected. """Red arrow(s) Nest -> Target, for whatever's hovered or selected.
Points at exactly the hovered/selected candidate when one is known Points at exactly the hovered/selected candidate when one is known
(mouse over/click on a specific ambiguous marker) rather than every (mouse over/click on a specific ambiguous marker) rather than every
@ -467,13 +549,13 @@ class GridCanvas(Gtk.DrawingArea):
if candidate in seen: if candidate in seen:
continue continue
seen.add(candidate) seen.add(candidate)
tx, ty = self._km_to_px(candidate.as_fraction(), cell_w, cell_h, grid_h) tx, ty = self._km_to_px(view, candidate.as_fraction())
nx, ny = self._km_to_px(nest_km, cell_w, cell_h, grid_h) nx, ny = self._km_to_px(view, nest_km)
cr.set_source_rgb(*FIRING_ARROW) cr.set_source_rgb(*FIRING_ARROW)
cr.set_line_width(2) cr.set_line_width(2)
self._draw_arrow(cr, nx, ny, tx, ty) self._draw_arrow(cr, nx, ny, tx, ty)
def _draw_blast_radius(self, cr, cell_w, cell_h, grid_h) -> None: def _draw_blast_radius(self, cr, view) -> None:
"""When a Target is selected, its effective shell's blast radius, """When a Target is selected, its effective shell's blast radius,
selection only, not hover (unlike the geo overlays/firing arrow), selection only, not hover (unlike the geo overlays/firing arrow),
per spec. Uses the specific selected candidate point if the target per spec. Uses the specific selected candidate point if the target
@ -489,8 +571,8 @@ class GridCanvas(Gtk.DrawingArea):
if radius_km is None: if radius_km is None:
return return
x, y = self._km_to_px(point.as_fraction(), cell_w, cell_h, grid_h) x, y = self._km_to_px(view, point.as_fraction())
rx, ry = cell_w * radius_km, cell_h * radius_km rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
self._draw_ellipse(cr, x, y, rx, ry) self._draw_ellipse(cr, x, y, rx, ry)
cr.set_source_rgba(*BLAST_RADIUS, 0.18) cr.set_source_rgba(*BLAST_RADIUS, 0.18)
cr.fill_preserve() cr.fill_preserve()
@ -498,10 +580,10 @@ class GridCanvas(Gtk.DrawingArea):
cr.set_line_width(2) cr.set_line_width(2)
cr.stroke() cr.stroke()
def _draw_scout_flight_rect(self, cr, center_km, bearing_deg, cell_w, cell_h, grid_h, *, def _draw_scout_flight_rect(self, cr, view, center_km, bearing_deg, *,
dashed=False, alpha_mult=1.0) -> None: dashed=False, alpha_mult=1.0) -> None:
corners = solver.scout_flight_corners(center_km, bearing_deg) corners = solver.scout_flight_corners(center_km, bearing_deg)
px_corners = [self._km_to_px(p, cell_w, cell_h, grid_h) for p in corners] px_corners = [self._km_to_px(view, p) for p in corners]
r, g, b = SCOUT_FLIGHT r, g, b = SCOUT_FLIGHT
cr.new_path() cr.new_path()
@ -519,7 +601,7 @@ class GridCanvas(Gtk.DrawingArea):
if dashed: if dashed:
cr.set_dash([]) cr.set_dash([])
def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None: def _draw_placement_preview(self, cr, view) -> None:
"""While armed to place/reposition something, a small crosshair dot """While armed to place/reposition something, a small crosshair dot
follows the cursor, plus a preview of whatever shape is being follows the cursor, plus a preview of whatever shape is being
placed: a blast-radius circle (e.g. a Strike, see its shell before placed: a blast-radius circle (e.g. a Strike, see its shell before
@ -529,12 +611,12 @@ class GridCanvas(Gtk.DrawingArea):
if self.placement_kind == "scout_flight": if self.placement_kind == "scout_flight":
center_km, bearing = self._scout_flight_anchor(self._placement_cursor_km) center_km, bearing = self._scout_flight_anchor(self._placement_cursor_km)
self._draw_scout_flight_rect(cr, center_km, bearing, cell_w, cell_h, grid_h, dashed=True) self._draw_scout_flight_rect(cr, view, center_km, bearing, dashed=True)
x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h) x, y = self._km_to_px(view, self._placement_cursor_km)
if self.placement_preview_radius_km is not None: if self.placement_preview_radius_km is not None:
rx, ry = cell_w * self.placement_preview_radius_km, cell_h * self.placement_preview_radius_km rx, ry = view.cell_w * self.placement_preview_radius_km, view.cell_h * self.placement_preview_radius_km
self._draw_ellipse(cr, x, y, rx, ry) self._draw_ellipse(cr, x, y, rx, ry)
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15) cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15)
cr.fill_preserve() cr.fill_preserve()
@ -553,7 +635,7 @@ class GridCanvas(Gtk.DrawingArea):
cr.line_to(x, y + 7) cr.line_to(x, y + 7)
cr.stroke() cr.stroke()
def _draw_geo_overlays(self, cr, cell_w, cell_h, grid_h) -> None: def _draw_geo_overlays(self, cr, view) -> None:
"""Bearing/distance overlay lines for whatever's hovered, pinned """Bearing/distance overlay lines for whatever's hovered, pinned
via show_geo_desc, or currently selected. Selection matters even via show_geo_desc, or currently selected. Selection matters even
for a target that never resolved at all (no coord, no for a target that never resolved at all (no coord, no
@ -575,11 +657,11 @@ class GridCanvas(Gtk.DrawingArea):
if ref is None or ref.coord is None: if ref is None or ref.coord is None:
continue continue
ref_km = ref.coord.as_fraction() ref_km = ref.coord.as_fraction()
rx, ry = self._km_to_px(ref_km, cell_w, cell_h, grid_h) rx, ry = self._km_to_px(view, ref_km)
if clue.bearing_deg is not None and clue.distance_km is not None: if clue.bearing_deg is not None and clue.distance_km is not None:
target_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, clue.distance_km) target_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, clue.distance_km)
tx, ty = self._km_to_px(target_km, cell_w, cell_h, grid_h) tx, ty = self._km_to_px(view, target_km)
cr.set_source_rgb(*YELLOW) cr.set_source_rgb(*YELLOW)
cr.set_line_width(2) cr.set_line_width(2)
self._draw_arrow(cr, rx, ry, tx, ty) self._draw_arrow(cr, rx, ry, tx, ty)
@ -593,8 +675,8 @@ class GridCanvas(Gtk.DrawingArea):
ref_km, clue.bearing_deg - clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM) ref_km, clue.bearing_deg - clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM)
hi_km = solver.point_from_bearing_distance( hi_km = solver.point_from_bearing_distance(
ref_km, clue.bearing_deg + clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM) ref_km, clue.bearing_deg + clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM)
lx, ly = self._km_to_px(lo_km, cell_w, cell_h, grid_h) lx, ly = self._km_to_px(view, lo_km)
hx, hy = self._km_to_px(hi_km, cell_w, cell_h, grid_h) hx, hy = self._km_to_px(view, hi_km)
cr.new_path() cr.new_path()
cr.move_to(rx, ry) cr.move_to(rx, ry)
cr.line_to(lx, ly) cr.line_to(lx, ly)
@ -609,14 +691,14 @@ class GridCanvas(Gtk.DrawingArea):
cr.set_dash([]) cr.set_dash([])
elif clue.bearing_deg is not None: elif clue.bearing_deg is not None:
far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM) far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM)
fx, fy = self._km_to_px(far_km, cell_w, cell_h, grid_h) fx, fy = self._km_to_px(view, far_km)
cr.set_source_rgb(*YELLOW) cr.set_source_rgb(*YELLOW)
cr.set_line_width(1.5) cr.set_line_width(1.5)
cr.move_to(rx, ry) cr.move_to(rx, ry)
cr.line_to(fx, fy) cr.line_to(fx, fy)
cr.stroke() cr.stroke()
elif clue.distance_km is not None: elif clue.distance_km is not None:
radius_x, radius_y = cell_w * clue.distance_km, cell_h * clue.distance_km radius_x, radius_y = view.cell_w * clue.distance_km, view.cell_h * clue.distance_km
self._draw_ellipse(cr, rx, ry, radius_x, radius_y) self._draw_ellipse(cr, rx, ry, radius_x, radius_y)
cr.set_source_rgba(*WHITE, 0.85) cr.set_source_rgba(*WHITE, 0.85)
cr.set_line_width(1.5) cr.set_line_width(1.5)
@ -644,7 +726,7 @@ class GridCanvas(Gtk.DrawingArea):
if radius_target_km is None: if radius_target_km is None:
radius_target_km = (ref_km[0], ref_km[1] + clue.distance_km) radius_target_km = (ref_km[0], ref_km[1] + clue.distance_km)
tx, ty = self._km_to_px(radius_target_km, cell_w, cell_h, grid_h) tx, ty = self._km_to_px(view, radius_target_km)
cr.new_path() cr.new_path()
cr.set_source_rgba(*WHITE, 0.85) cr.set_source_rgba(*WHITE, 0.85)
cr.set_line_width(1) cr.set_line_width(1)