diff --git a/src/fenigma/app.py b/src/fenigma/app.py index cc2b97f..eb78f7c 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -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 diff --git a/src/fenigma/firing_panel.py b/src/fenigma/firing_panel.py index 227d5f0..7350ab5 100644 --- a/src/fenigma/firing_panel.py +++ b/src/fenigma/firing_panel.py @@ -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: diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index b2d2a3a..90e3eb0 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -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)