"""GridCanvas: draws the 20x10 map, every placed entity, ambiguous solver candidates, and geo-description overlays (bearing/distance clues) for whatever's hovered or pinned via show_geo_desc. Also handles click-to-select and hover notification so the firing-commands panel can stay in sync with the map (see app.py).""" from __future__ import annotations import math import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gdk, Gtk # noqa: E402 from . import solver from .models import LARGE_X, Board, Target COLS, ROWS = 20, 10 MARGIN_LEFT = 34 MARGIN_TOP = 30 MARGIN_RIGHT = 50 MARGIN_BOTTOM = 30 LABEL_PAD = 8 # gap between a marker and its name label HOVER_RADIUS_PX = 12 OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin CATEGORY_COLOR = { "nest": (0.35, 0.60, 0.95), "spotter": (0.35, 0.78, 0.40), "rp": (0.95, 0.78, 0.20), "target": (0.92, 0.30, 0.28), } BG = (0.13, 0.12, 0.10) GRID_LINE = (1.0, 1.0, 1.0, 0.20) LABEL = (0.88, 0.86, 0.80) COORD_LABEL = (0.60, 0.58, 0.54) YELLOW = (0.95, 0.85, 0.20) WHITE = (1.0, 1.0, 1.0) FIRING_ARROW = (0.95, 0.15, 0.15) SELECTION_RING = (1.0, 1.0, 1.0) BLAST_RADIUS = (0.95, 0.40, 0.10) PLACEMENT_PREVIEW = (0.95, 0.85, 0.20) class GridCanvas(Gtk.DrawingArea): def __init__(self, board: Board) -> None: super().__init__() self.board = board self.hovered = None self.hovered_point = None # which candidate, when obj has more than one point self.selected = None self.selected_point = None # which candidate, when obj has more than one point self.on_select = None # callback(obj | None, point | None), fired on click self.on_hover_change = None # callback(obj | None, point | None), fired on hover change self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing) self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar # Placement mode: while armed, the next left-click calls # placement_callback(Coord) instead of doing the normal # select/hit-test, and (if placement_preview_radius_km is set) a # circle of that radius follows the cursor as a preview. self.placement_callback = None self.placement_preview_radius_km = None self._placement_cursor_km = None self.set_hexpand(True) self.set_vexpand(True) self.set_draw_func(self._draw) motion = Gtk.EventControllerMotion() motion.connect("motion", self._on_motion) motion.connect("leave", self._on_leave) self.add_controller(motion) click = Gtk.GestureClick() click.connect("released", self._on_click) self.add_controller(click) right_click = Gtk.GestureClick() right_click.set_button(Gdk.BUTTON_SECONDARY) right_click.connect("released", self._on_right_click) self.add_controller(right_click) keys = Gtk.EventControllerKey() keys.connect("key-pressed", self._on_key_pressed) self.set_focusable(True) self.add_controller(keys) def refresh(self) -> None: self.queue_draw() # -- placement mode ----------------------------------------------------------- def start_placement(self, callback, preview_radius_km=None) -> None: self.placement_callback = callback self.placement_preview_radius_km = preview_radius_km self.set_cursor_from_name("crosshair") self.queue_draw() def cancel_placement(self) -> None: if self.placement_callback is None: return self.placement_callback = None self.placement_preview_radius_km = None self.set_cursor_from_name(None) self.queue_draw() def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool: if keyval == Gdk.KEY_Escape and self.placement_callback is not None: self.cancel_placement() return True return False def set_selected(self, obj, point=None) -> None: if obj is not self.selected or point != self.selected_point: self.selected = obj self.selected_point = point 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 _km_to_px(self, point_km, cell_w, cell_h, grid_h) -> tuple[float, float]: col, row = point_km return MARGIN_LEFT + col * cell_w, MARGIN_TOP + grid_h - row * cell_h 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 _excluded_from_map(self, obj) -> bool: """True if `obj` should be dropped from the map view entirely — it's hidden, or it's a dead target with the map's dead-hiding toggle on — unless it's the current selection, in which case it's still drawn (darkened) so it stays reachable/un-hideable.""" if obj is self.selected: return False if obj.hidden: return True if self.hide_dead_from_map and isinstance(obj, Target) and not obj.alive: return True return False def _all_positions(self): """Yield (obj, coord) for every point drawn on the map, including each ambiguous candidate separately (hover/click targets each of them individually, but they all resolve to the same obj). Hidden entities, and (if toggled) dead targets, are excluded from the map entirely unless they're the current selection (so they can still be un-hidden/interacted with once selected some other way, e.g. from the firing panel).""" for _category, obj in self.board.placed_entities_all(): if self._excluded_from_map(obj): continue yield obj, obj.coord for _category, obj in self.board.ambiguous_entities_all(): if self._excluded_from_map(obj): continue for candidate in obj.location.potential_coords: yield obj, candidate def _hit_test(self, 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) dist = math.hypot(px - x, py - y) if dist < best_dist: best_dist, best_obj, best_coord = dist, obj, coord return best_obj, best_coord # -- 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) if self.on_cursor_move is not None: self.on_cursor_move(cursor_km) if self.placement_callback is not None: self._placement_cursor_km = cursor_km 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) if hit is not self.hovered or coord != self.hovered_point: self.hovered = hit self.hovered_point = coord self.queue_draw() if self.on_hover_change is not None: self.on_hover_change(hit, coord) def _on_leave(self, _controller) -> None: if self.on_cursor_move is not None: self.on_cursor_move(None) if self.placement_callback is not None: self._placement_cursor_km = None self.queue_draw() if self.hovered is not None: self.hovered = None self.hovered_point = None self.queue_draw() if self.on_hover_change is not None: self.on_hover_change(None, None) def _on_click(self, _gesture, _n_press, x: float, y: float) -> None: 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 coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h)) callback = self.placement_callback self.cancel_placement() if coord is not None: callback(coord) return hit, coord = self._hit_test(x, y) self.set_selected(hit, coord) if self.on_select is not None: self.on_select(hit, coord) def _on_right_click(self, _gesture, _n_press, x: float, y: float) -> None: if self.placement_callback is not None: self.cancel_placement() 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)) if coord is not None: self.on_right_click(coord, x, y) # -- drawing ---------------------------------------------------------------- def _draw(self, _area, cr, width, height) -> None: 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 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) 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) 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) 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, dim=(category == "target" and not obj.alive) or obj.hidden, selected=(obj is self.selected), coord=obj.coord) for category, obj in self.board.ambiguous_entities_all(): if self._excluded_from_map(obj): continue 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, hollow=True, dim=obj.hidden or (category == "target" and not obj.alive), selected=is_selected, coord=candidate) def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h, canvas_width, canvas_height, *, hollow=False, dim=False, selected=False, coord=None) -> None: x, y = self._km_to_px(point_km, cell_w, cell_h, grid_h) r, g, b = color alpha = 0.45 if dim else 1.0 if selected: cr.new_path() cr.set_source_rgba(*SELECTION_RING, 0.9) cr.set_line_width(2) cr.arc(x, y, 9, 0, 2 * math.pi) cr.stroke() if hollow: cr.new_path() # cairo's arc() draws a line from any stale current cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's cr.set_line_width(1.5) # show_text position) to the arc's start — cr.set_dash([3, 2]) # this is what stops that connector line. cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.stroke() cr.set_dash([]) else: cr.new_path() cr.set_source_rgba(r, g, b, alpha) cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.fill() cr.new_path() cr.set_source_rgba(0, 0, 0, 0.6 * alpha) cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.set_line_width(1) cr.stroke() cr.set_font_size(11) text_width = cr.text_extents(label).width coord_text = coord.label() if coord is not None else None coord_width = cr.text_extents(coord_text).width if coord_text else 0 # Flip to the left of the marker if the label would run past the # right edge; clamp vertically so it doesn't clip top/bottom either. label_x = x + LABEL_PAD if label_x + max(text_width, coord_width) > canvas_width - 4: label_x = x - LABEL_PAD - max(text_width, coord_width) label_y = max(10, min(y - 7, canvas_height - 20)) cr.set_source_rgba(*LABEL, alpha) cr.move_to(label_x, label_y) cr.show_text(label) if coord_text: cr.set_font_size(9) cr.set_source_rgba(*COORD_LABEL, alpha) cr.move_to(label_x, label_y + 12) cr.show_text(coord_text) cr.set_font_size(11) def _draw_firing_arrows(self, cr, cell_w, cell_h, grid_h) -> 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 candidate of that target — same reasoning as the selection ring: drawing to all of them makes it impossible to tell which is which.""" nest = self.board.nest if nest.coord is None: return nest_km = nest.coord.as_fraction() points: list = [] for target, point in ((self.hovered, self.hovered_point), (self.selected, self.selected_point)): if not isinstance(target, Target): continue if target.coord is not None: points.append(target.coord) elif point is not None: points.append(point) else: points.extend(target.location.potential_coords) seen = set() for candidate in points: 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) 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: """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 is ambiguous; skipped entirely if there's no known point yet, or the shell's blast radius isn't known.""" if not isinstance(self.selected, Target): return target = self.selected point = target.coord if target.coord is not None else self.selected_point if point is None: return radius_km = target.effective_shell.blast_radius_km 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 self._draw_ellipse(cr, x, y, rx, ry) cr.set_source_rgba(*BLAST_RADIUS, 0.18) cr.fill_preserve() cr.set_source_rgba(*BLAST_RADIUS, 0.85) cr.set_line_width(2) cr.stroke() def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None: """While armed to place/reposition something, a small crosshair dot follows the cursor, plus a blast-radius preview circle if one was given (e.g. placing a Strike — see its shell before you commit).""" if self.placement_callback is None or self._placement_cursor_km is None: return x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h) 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 self._draw_ellipse(cr, x, y, rx, ry) cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15) cr.fill_preserve() cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.8) cr.set_line_width(1.5) cr.set_dash([3, 2]) cr.stroke() cr.set_dash([]) cr.new_path() cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.9) cr.set_line_width(1.5) cr.move_to(x - 7, y) cr.line_to(x + 7, y) cr.move_to(x, y - 7) cr.line_to(x, y + 7) cr.stroke() def _draw_geo_overlays(self, cr, cell_w, cell_h, grid_h) -> None: to_show = [] for category, obj in self.board.placed_entities(): if obj is self.hovered or obj.show_geo_desc: to_show.append(obj) for category, obj in self.board.ambiguous_entities(): if obj is self.hovered or obj.show_geo_desc: to_show.append(obj) for obj in to_show: for clue in obj.location.clues: ref = self.board.find_by_name(clue.reference) 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) 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) cr.set_source_rgb(*YELLOW) cr.set_line_width(2) self._draw_arrow(cr, rx, ry, tx, ty) 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) 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 self._draw_ellipse(cr, rx, ry, radius_x, radius_y) cr.set_source_rgba(*WHITE, 0.85) cr.set_line_width(1.5) cr.stroke() # Radius indicator: a line from center to an actual point # on the circle — the intersection with another of this # entity's clues if one pairs with it (same geometry the # solver would use; picks the nearer of two candidates), # else straight up as a last-resort fallback with nothing # to intersect against yet. radius_target_km = None for other in obj.location.clues: if other is clue or other.bearing_deg is None or other.distance_km is not None: continue other_ref = self.board.find_by_name(other.reference) if other_ref is None or other_ref.coord is None: continue points = solver.ray_circle_intersections( other_ref.coord.as_fraction(), other.bearing_deg, ref_km, clue.distance_km ) if points: radius_target_km = points[0] break 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) cr.new_path() cr.set_source_rgba(*WHITE, 0.85) cr.set_line_width(1) cr.set_dash([1.5, 2.5]) cr.move_to(rx, ry) cr.line_to(tx, ty) cr.stroke() cr.set_dash([]) def _draw_arrow(self, cr, x0, y0, x1, y1, head_size=8) -> None: cr.new_path() cr.move_to(x0, y0) cr.line_to(x1, y1) cr.stroke() angle = math.atan2(y1 - y0, x1 - x0) cr.new_path() for delta in (math.pi * 5 / 6, -math.pi * 5 / 6): cr.move_to(x1, y1) cr.line_to(x1 + head_size * math.cos(angle + delta), y1 + head_size * math.sin(angle + delta)) cr.stroke() def _draw_ellipse(self, cr, cx, cy, rx, ry, steps=72) -> None: # Points computed explicitly (not via cr.scale) so the stroke width # stays uniform regardless of rx/ry — a scaled CTM would stretch it. cr.new_path() cr.move_to(cx + rx, cy) for i in range(1, steps + 1): theta = 2 * math.pi * i / steps cr.line_to(cx + rx * math.cos(theta), cy + ry * math.sin(theta))