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:
parent
08d046536e
commit
e585fc5428
@ -27,7 +27,7 @@ from PIL import Image # noqa: E402
|
||||
from . import ballistics, icons, ocr, solver # noqa: E402
|
||||
from .coord_dialog import CoordDialog # 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 .shells import Shell # noqa: E402
|
||||
|
||||
@ -269,6 +269,11 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
scout_btn.connect("clicked", lambda _b: self._add_scout_flight())
|
||||
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.set_tooltip_text("Firing commands")
|
||||
firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel())
|
||||
@ -691,13 +696,18 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
if point_km is None:
|
||||
self.cursor_label.set_label("")
|
||||
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)
|
||||
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")
|
||||
return
|
||||
nest = self.board.nest
|
||||
|
||||
@ -317,6 +317,13 @@ class FiringPanel(Gtk.Box):
|
||||
|
||||
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)
|
||||
@ -433,22 +440,6 @@ class FiringPanel(Gtk.Box):
|
||||
_ensure_charge_segment_css()
|
||||
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] = []
|
||||
count_label = Gtk.Label(label=str(charges), valign=Gtk.Align.CENTER)
|
||||
count_label.set_margin_start(4)
|
||||
@ -482,6 +473,26 @@ class FiringPanel(Gtk.Box):
|
||||
|
||||
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:
|
||||
|
||||
@ -44,7 +44,7 @@ ZOOM_STEP = 1.15 # per scroll-wheel notch
|
||||
# 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
|
||||
# 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 = {
|
||||
"nest": (0.35, 0.60, 0.95),
|
||||
@ -93,6 +93,20 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self.placement_kind = "point"
|
||||
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_vexpand(True)
|
||||
self.set_draw_func(self._draw)
|
||||
@ -102,6 +116,10 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
motion.connect("leave", self._on_leave)
|
||||
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.connect("released", self._on_click)
|
||||
self.add_controller(click)
|
||||
@ -176,17 +194,76 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self.queue_draw()
|
||||
|
||||
# -- geometry -------------------------------------------------------------
|
||||
def _cell_size(self, width: int, height: int) -> tuple[float, float]:
|
||||
grid_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1)
|
||||
grid_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1)
|
||||
return grid_w / COLS, grid_h / ROWS
|
||||
def set_square_cells(self, square: bool) -> None:
|
||||
self.square_cells = square
|
||||
self.queue_draw()
|
||||
|
||||
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
|
||||
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]:
|
||||
return (x - MARGIN_LEFT) / cell_w, (grid_h - (y - MARGIN_TOP)) / cell_h
|
||||
def _px_to_km(self, view: _View, x, y) -> tuple[float, float]:
|
||||
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:
|
||||
"""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:
|
||||
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
|
||||
(None, None), coord disambiguates which candidate of an
|
||||
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
|
||||
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)
|
||||
if dist < best_dist:
|
||||
best_dist, best_obj, best_coord = dist, obj, coord
|
||||
@ -235,9 +310,9 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
|
||||
# -- hover / click ------------------------------------------------------------
|
||||
def _on_motion(self, _controller, x: float, y: float) -> None:
|
||||
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
||||
grid_h = cell_h * ROWS
|
||||
cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h)
|
||||
self._last_pointer_px = (x, y)
|
||||
view = self._view(self.get_width(), self.get_height())
|
||||
cursor_km = self._px_to_km(view, x, y)
|
||||
|
||||
if self.on_cursor_move is not None:
|
||||
self.on_cursor_move(cursor_km)
|
||||
@ -247,7 +322,7 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self.queue_draw()
|
||||
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:
|
||||
self.hovered = hit
|
||||
self.hovered_point = coord
|
||||
@ -256,6 +331,7 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self.on_hover_change(hit, coord)
|
||||
|
||||
def _on_leave(self, _controller) -> None:
|
||||
self._last_pointer_px = None
|
||||
if self.on_cursor_move is not None:
|
||||
self.on_cursor_move(None)
|
||||
if self.placement_callback is not None:
|
||||
@ -269,10 +345,9 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
self.on_hover_change(None, 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:
|
||||
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
||||
grid_h = cell_h * ROWS
|
||||
cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h)
|
||||
cursor_km = self._px_to_km(view, x, y)
|
||||
callback, kind = self.placement_callback, self.placement_kind
|
||||
self.cancel_placement()
|
||||
if kind == "scout_flight":
|
||||
@ -284,7 +359,7 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
callback(coord)
|
||||
return
|
||||
|
||||
hit, coord = self._hit_test(x, y)
|
||||
hit, coord = self._hit_test(view, x, y)
|
||||
self.set_selected(hit, coord)
|
||||
if self.on_select is not None:
|
||||
self.on_select(hit, coord)
|
||||
@ -295,9 +370,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
return
|
||||
if self.on_right_click is None:
|
||||
return
|
||||
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
|
||||
grid_h = cell_h * ROWS
|
||||
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h))
|
||||
view = self._view(self.get_width(), self.get_height())
|
||||
coord = solver.point_to_coord(self._px_to_km(view, x, y))
|
||||
if coord is not None:
|
||||
self.on_right_click(coord, x, y)
|
||||
|
||||
@ -306,42 +380,50 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.set_source_rgb(*BG)
|
||||
cr.paint()
|
||||
|
||||
cell_w, cell_h = self._cell_size(width, height)
|
||||
grid_w, grid_h = cell_w * COLS, cell_h * ROWS
|
||||
view = self._view(width, height)
|
||||
|
||||
# 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_line_width(1)
|
||||
for c in range(COLS + 1):
|
||||
x = MARGIN_LEFT + c * cell_w
|
||||
cr.move_to(x, MARGIN_TOP)
|
||||
cr.line_to(x, MARGIN_TOP + grid_h)
|
||||
for r in range(ROWS + 1):
|
||||
y = MARGIN_TOP + grid_h - r * cell_h
|
||||
cr.move_to(MARGIN_LEFT, y)
|
||||
cr.line_to(MARGIN_LEFT + grid_w, y)
|
||||
for c in range(first_col, min(last_col, COLS + 1)):
|
||||
x, _ = self._km_to_px(view, (c, 0))
|
||||
cr.move_to(x, MARGIN_TOP + view.pad_y)
|
||||
cr.line_to(x, MARGIN_TOP + view.pad_y + view.grid_h)
|
||||
for r in range(first_row, min(last_row, ROWS + 1)):
|
||||
_, y = self._km_to_px(view, (0, r))
|
||||
cr.move_to(MARGIN_LEFT + view.pad_x, y)
|
||||
cr.line_to(MARGIN_LEFT + view.pad_x + view.grid_w, y)
|
||||
cr.stroke()
|
||||
|
||||
cr.set_source_rgb(*LABEL)
|
||||
cr.set_font_size(11)
|
||||
for i, letter in enumerate(LARGE_X):
|
||||
x = MARGIN_LEFT + i * cell_w + cell_w / 2 - 4
|
||||
cr.move_to(x, MARGIN_TOP - 10)
|
||||
cr.show_text(letter)
|
||||
for r in range(ROWS):
|
||||
y = MARGIN_TOP + grid_h - r * cell_h - cell_h / 2 + 4
|
||||
cr.move_to(4, y)
|
||||
for i in range(max(first_col, 0), min(last_col, COLS)):
|
||||
x, _ = self._km_to_px(view, (i + 0.5, 0))
|
||||
cr.move_to(x - 4, MARGIN_TOP + view.pad_y - 10)
|
||||
cr.show_text(LARGE_X[i])
|
||||
for r in range(max(first_row, 0), min(last_row, ROWS)):
|
||||
_, y = self._km_to_px(view, (0, r + 0.5))
|
||||
cr.move_to(4, y + 4)
|
||||
cr.show_text(str(r + 1))
|
||||
|
||||
self._draw_geo_overlays(cr, cell_w, cell_h, grid_h)
|
||||
self._draw_firing_arrows(cr, cell_w, cell_h, grid_h)
|
||||
self._draw_blast_radius(cr, cell_w, cell_h, grid_h)
|
||||
self._draw_placement_preview(cr, cell_w, cell_h, grid_h)
|
||||
self._draw_geo_overlays(cr, view)
|
||||
self._draw_firing_arrows(cr, view)
|
||||
self._draw_blast_radius(cr, view)
|
||||
self._draw_placement_preview(cr, view)
|
||||
|
||||
for category, obj in self.board.placed_entities_all():
|
||||
if self._excluded_from_map(obj):
|
||||
continue # hidden/dead-and-toggled-off entities are removed, not just darkened
|
||||
self._draw_marker(cr, obj.coord.as_fraction(), CATEGORY_COLOR[category],
|
||||
obj.name, cell_w, cell_h, grid_h, width, height,
|
||||
self._draw_marker(cr, view, obj.coord.as_fraction(), CATEGORY_COLOR[category],
|
||||
obj.name, width, height,
|
||||
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||
selected=(obj is self.selected), coord=obj.coord,
|
||||
extra_line=getattr(obj, "requested_time", None))
|
||||
@ -352,8 +434,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
color = CATEGORY_COLOR[category]
|
||||
for i, candidate in enumerate(obj.location.potential_coords):
|
||||
is_selected = obj is self.selected and candidate == self.selected_point
|
||||
self._draw_marker(cr, candidate.as_fraction(), color,
|
||||
f"{obj.name}? ({i + 1})", cell_w, cell_h, grid_h, width, height,
|
||||
self._draw_marker(cr, view, candidate.as_fraction(), color,
|
||||
f"{obj.name}? ({i + 1})", width, height,
|
||||
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
|
||||
selected=is_selected, coord=candidate,
|
||||
extra_line=getattr(obj, "requested_time", None))
|
||||
@ -361,8 +443,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
for sf in self.board.scout_flights:
|
||||
if sf.hidden:
|
||||
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)
|
||||
cx, cy = self._km_to_px(sf.center, cell_w, cell_h, grid_h)
|
||||
self._draw_scout_flight_rect(cr, view, sf.center, sf.bearing_deg)
|
||||
cx, cy = self._km_to_px(view, 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 "?"
|
||||
|
||||
@ -377,10 +459,10 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.show_text(f"{grid_name} {sf.bearing_deg:05.1f}°")
|
||||
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,
|
||||
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
|
||||
alpha = 0.45 if dim else 1.0
|
||||
|
||||
@ -440,7 +522,7 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.show_text(extra_line)
|
||||
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.
|
||||
Points at exactly the hovered/selected candidate when one is known
|
||||
(mouse over/click on a specific ambiguous marker) rather than every
|
||||
@ -467,13 +549,13 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
if candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
tx, ty = self._km_to_px(candidate.as_fraction(), cell_w, cell_h, grid_h)
|
||||
nx, ny = self._km_to_px(nest_km, cell_w, cell_h, grid_h)
|
||||
tx, ty = self._km_to_px(view, candidate.as_fraction())
|
||||
nx, ny = self._km_to_px(view, nest_km)
|
||||
cr.set_source_rgb(*FIRING_ARROW)
|
||||
cr.set_line_width(2)
|
||||
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,
|
||||
selection only, not hover (unlike the geo overlays/firing arrow),
|
||||
per spec. Uses the specific selected candidate point if the target
|
||||
@ -489,8 +571,8 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
if radius_km is None:
|
||||
return
|
||||
|
||||
x, y = self._km_to_px(point.as_fraction(), cell_w, cell_h, grid_h)
|
||||
rx, ry = cell_w * radius_km, cell_h * radius_km
|
||||
x, y = self._km_to_px(view, point.as_fraction())
|
||||
rx, ry = view.cell_w * radius_km, view.cell_h * radius_km
|
||||
self._draw_ellipse(cr, x, y, rx, ry)
|
||||
cr.set_source_rgba(*BLAST_RADIUS, 0.18)
|
||||
cr.fill_preserve()
|
||||
@ -498,10 +580,10 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.set_line_width(2)
|
||||
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:
|
||||
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
|
||||
|
||||
cr.new_path()
|
||||
@ -519,7 +601,7 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
if dashed:
|
||||
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
|
||||
follows the cursor, plus a preview of whatever shape is being
|
||||
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":
|
||||
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:
|
||||
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)
|
||||
cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15)
|
||||
cr.fill_preserve()
|
||||
@ -553,7 +635,7 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.line_to(x, y + 7)
|
||||
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
|
||||
via show_geo_desc, or currently selected. Selection matters even
|
||||
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:
|
||||
continue
|
||||
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:
|
||||
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_line_width(2)
|
||||
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)
|
||||
hi_km = solver.point_from_bearing_distance(
|
||||
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)
|
||||
hx, hy = self._km_to_px(hi_km, cell_w, cell_h, grid_h)
|
||||
lx, ly = self._km_to_px(view, lo_km)
|
||||
hx, hy = self._km_to_px(view, hi_km)
|
||||
cr.new_path()
|
||||
cr.move_to(rx, ry)
|
||||
cr.line_to(lx, ly)
|
||||
@ -609,14 +691,14 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
cr.set_dash([])
|
||||
elif clue.bearing_deg is not None:
|
||||
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_line_width(1.5)
|
||||
cr.move_to(rx, ry)
|
||||
cr.line_to(fx, fy)
|
||||
cr.stroke()
|
||||
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)
|
||||
cr.set_source_rgba(*WHITE, 0.85)
|
||||
cr.set_line_width(1.5)
|
||||
@ -644,7 +726,7 @@ class GridCanvas(Gtk.DrawingArea):
|
||||
if radius_target_km is None:
|
||||
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.set_source_rgba(*WHITE, 0.85)
|
||||
cr.set_line_width(1)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user