Compare commits

..

3 Commits

Author SHA1 Message Date
512a0a41b4 Fix click/right-click placement landing one sub-cell off from the actual click
A real, reproducible bug reported as 'misplaced sometimes by a few
small squares': Coord.as_fraction() centers a sub-cell at x + 0.5 (so
a marker drawn at its own coord's exact pixel position round-trips
back to the same coord on click), which meant point_to_coord()'s own
rounding was landing exactly on a .5 boundary, the single worst case
for floating point, tiny representation error from the col/row math
upstream could tip round() to either side and silently return a coord
one sub-cell off from the one actually clicked.

Reproduced with zero pixel math involved at all, just feeding
Coord(...).as_fraction() straight back into point_to_coord(), ruling
out the zoom/pan refactor or the legend margin as the cause (both were
suspected first). Fixed by subtracting the 0.5 offset before rounding,
which recovers a value that's supposed to be an exact integer instead
of an exact half-integer, round() is robust to tiny float noise around
a true integer, just not around X.5.

Verified exhaustively (all 20,000 possible coordinates round-trip
correctly now, not just a handful of samples, since the original bug
was itself float-pattern-dependent) and locked in with a permanent
regression test.
2026-08-09 21:18:47 +02:00
084764aa9b Map: follow the app's light/dark color scheme, live
The rest of the UI already adapted to system theme automatically via
libadwaita, only the hand-drawn Cairo map (grid lines, markers,
overlays, everything in grid_widget.py) was hardcoded to the dark
palette. Added a parallel light palette (first-pass guesses, same as
the Shell descriptions were, flagged for correction) and hooked
Adw.StyleManager's dark/light state, including its own live-update
signal, so switching the system theme while the app is running
repaints the map with the other palette immediately, not just at
startup.

Verified: rendered both palettes side by side with the same board
state (legible in both), and a live theme-switch test confirming the
module-level color names actually change value when the StyleManager
signal fires, not just once at construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:47 +02:00
a92aff5e06 Fix right-click map menu opening at (0,0) instead of the cursor position
Gdk.Rectangle(x=..., y=..., width=..., height=...) silently ignores
every constructor keyword argument in this PyGObject version (verified
directly: it always built a zeroed rect regardless of what was passed
in), so popover.set_pointing_to() was always pointing at the canvas's
top-left corner. Fixed by constructing the rect and assigning its
fields afterward, which does work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:47 +02:00
4 changed files with 155 additions and 13 deletions

View File

@ -1044,7 +1044,15 @@ class MainWindow(Adw.ApplicationWindow):
you're pointing and don't need to type coordinates.""" you're pointing and don't need to type coordinates."""
popover = Gtk.Popover() popover = Gtk.Popover()
popover.set_parent(self.canvas) popover.set_parent(self.canvas)
popover.set_pointing_to(Gdk.Rectangle(x=int(x), y=int(y), width=1, height=1)) # NOT Gdk.Rectangle(x=..., y=..., ...): verified directly that this
# PyGObject version silently ignores every constructor keyword arg on
# boxed types like GdkRectangle (a real bug, not a style preference,
# it built a zeroed rect every time), which is exactly why this popover
# always opened pinned to the canvas's top-left corner instead of the
# actual click position. Assigning the fields after construction works.
rect = Gdk.Rectangle()
rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
popover.set_pointing_to(rect)
popover.connect("closed", lambda _p: popover.unparent()) popover.connect("closed", lambda _p: popover.unparent())
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,

View File

@ -14,7 +14,8 @@ import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402 gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk # noqa: E402
from . import ballistics, icons, solver from . import ballistics, icons, solver
from .models import LARGE_X, Board, Target from .models import LARGE_X, Board, Target
@ -52,6 +53,19 @@ ICON_MIN_CELL_PX = 42
# 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 vis_cols vis_rows") _View = namedtuple("_View", "cell_w cell_h grid_w grid_h pad_x pad_y ox oy vis_cols vis_rows")
# Everything below (CATEGORY_COLOR through PLACEMENT_PREVIEW) is a
# module-level name deliberately kept mutable: _apply_palette() below
# reassigns all of them via `global`, in place, whenever the app's
# light/dark scheme changes (see GridCanvas.__init__, which hooks
# Adw.StyleManager's own dark/light detection, including live updates
# if the system theme changes while running). Every draw method
# references these bare names directly (`cr.set_source_rgb(*BG)` etc.)
# rather than threading a palette object through every call, reassigning
# the names in place is what makes that keep working without touching
# every call site. The values set here at import time are the dark
# palette, _apply_palette(is_dark=True) (called from __init__) reapplies
# the same values, it's the light branch that actually changes anything
# the first time it runs.
CATEGORY_COLOR = { CATEGORY_COLOR = {
"nest": (0.35, 0.60, 0.95), "nest": (0.35, 0.60, 0.95),
"spotter": (0.35, 0.78, 0.40), "spotter": (0.35, 0.78, 0.40),
@ -63,17 +77,89 @@ SCOUT_FLIGHT = (0.70, 0.45, 0.92)
BG = (0.13, 0.12, 0.10) BG = (0.13, 0.12, 0.10)
GRID_LINE = (1.0, 1.0, 1.0, 0.20) GRID_LINE = (1.0, 1.0, 1.0, 0.20)
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.35) # a shade between BG and GRID_LINE's white, not a hue change SUBGRID_LINE = (0.72, 0.70, 0.65, 0.15) # verified by actually computing the blended-over-BG
# pixel values, not eyeballing it: alpha 0.35 (a previous version) blended this same RGB out to
# (86, 83, 75), BRIGHTER than GRID_LINE's own blended (77, 76, 72), backwards from the intent.
# 0.15 blends to (56, 53, 47): sits between BG (33, 31, 26) and GRID_LINE (77, 76, 72), the RGB
# tint stays visible but the line itself reads as genuinely fainter, not louder.
HOVER_LEGEND = (0.45, 0.65, 0.95) # blue, not yellow, for the highlighted X/Y legend label HOVER_LEGEND = (0.45, 0.65, 0.95) # blue, not yellow, for the highlighted X/Y legend label
LABEL = (0.88, 0.86, 0.80) LABEL = (0.88, 0.86, 0.80)
COORD_LABEL = (0.60, 0.58, 0.54) COORD_LABEL = (0.60, 0.58, 0.54)
YELLOW = (0.95, 0.85, 0.20) YELLOW = (0.95, 0.85, 0.20)
WHITE = (1.0, 1.0, 1.0) WHITE = (1.0, 1.0, 1.0) # not literally "white" any more in the light palette, see _LIGHT_PALETTE:
# its role is "a neutral that maximally contrasts with BG", the name stuck around from when this
# only ever ran on a dark background.
FIRING_ARROW = (0.95, 0.15, 0.15) FIRING_ARROW = (0.95, 0.15, 0.15)
SELECTION_RING = (1.0, 1.0, 1.0) SELECTION_RING = (1.0, 1.0, 1.0)
BLAST_RADIUS = (0.95, 0.40, 0.10) BLAST_RADIUS = (0.95, 0.40, 0.10)
PLACEMENT_PREVIEW = (0.95, 0.85, 0.20) PLACEMENT_PREVIEW = (0.95, 0.85, 0.20)
_DARK_PALETTE = dict(
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), "ally": (0.30, 0.85, 0.85),
},
SCOUT_FLIGHT=(0.70, 0.45, 0.92),
BG=(0.13, 0.12, 0.10),
GRID_LINE=(1.0, 1.0, 1.0, 0.20),
SUBGRID_LINE=(0.72, 0.70, 0.65, 0.15),
HOVER_LEGEND=(0.45, 0.65, 0.95),
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),
)
# Same relative brightness relationships as the dark palette (main grid
# line vs. the fainter subgrid one, category colors distinct from each
# other), just inverted for a light background: every color that needs
# to contrast against BG got darkened instead of brightened. First-pass
# guesses, flagged the same way the Shell descriptions were, correct
# whichever look off once actually seen on a real light-themed desktop.
_LIGHT_PALETTE = dict(
CATEGORY_COLOR={
"nest": (0.15, 0.35, 0.75), "spotter": (0.10, 0.50, 0.15),
"rp": (0.65, 0.50, 0.05), "target": (0.75, 0.12, 0.10), "ally": (0.05, 0.45, 0.45),
},
SCOUT_FLIGHT=(0.45, 0.20, 0.65),
BG=(0.96, 0.95, 0.93),
GRID_LINE=(0.08, 0.08, 0.08, 0.20),
SUBGRID_LINE=(0.08, 0.08, 0.08, 0.15),
HOVER_LEGEND=(0.10, 0.35, 0.75),
LABEL=(0.15, 0.14, 0.12),
COORD_LABEL=(0.42, 0.40, 0.37),
YELLOW=(0.65, 0.48, 0.02),
WHITE=(0.10, 0.10, 0.10),
FIRING_ARROW=(0.80, 0.10, 0.10),
SELECTION_RING=(0.05, 0.05, 0.05),
BLAST_RADIUS=(0.80, 0.35, 0.05),
PLACEMENT_PREVIEW=(0.65, 0.48, 0.02),
)
def _apply_palette(is_dark: bool) -> None:
global CATEGORY_COLOR, SCOUT_FLIGHT, BG, GRID_LINE, SUBGRID_LINE, HOVER_LEGEND, LABEL, \
COORD_LABEL, YELLOW, WHITE, FIRING_ARROW, SELECTION_RING, BLAST_RADIUS, PLACEMENT_PREVIEW
p = _DARK_PALETTE if is_dark else _LIGHT_PALETTE
CATEGORY_COLOR = p["CATEGORY_COLOR"]
SCOUT_FLIGHT = p["SCOUT_FLIGHT"]
BG = p["BG"]
GRID_LINE = p["GRID_LINE"]
SUBGRID_LINE = p["SUBGRID_LINE"]
HOVER_LEGEND = p["HOVER_LEGEND"]
LABEL = p["LABEL"]
COORD_LABEL = p["COORD_LABEL"]
YELLOW = p["YELLOW"]
WHITE = p["WHITE"]
FIRING_ARROW = p["FIRING_ARROW"]
SELECTION_RING = p["SELECTION_RING"]
BLAST_RADIUS = p["BLAST_RADIUS"]
PLACEMENT_PREVIEW = p["PLACEMENT_PREVIEW"]
# path -> loaded cairo.ImageSurface (or None for a path that failed to # path -> loaded cairo.ImageSurface (or None for a path that failed to
# load, so a missing/bad icon file only ever gets one failed attempt, # load, so a missing/bad icon file only ever gets one failed attempt,
# not one per frame). Module-level, not per-canvas: the icon set is # not one per frame). Module-level, not per-canvas: the icon set is
@ -109,6 +195,16 @@ class GridCanvas(Gtk.DrawingArea):
def __init__(self, board: Board) -> None: def __init__(self, board: Board) -> None:
super().__init__() super().__init__()
self.board = board self.board = board
# Follow the app's light/dark scheme (system setting, or an
# in-app override if one's ever added later) for every color
# this canvas draws with, live: if the scheme changes while
# running, redraw with the other palette rather than staying
# stuck on whichever was active at startup.
style_manager = Adw.StyleManager.get_default()
_apply_palette(style_manager.get_dark())
style_manager.connect("notify::dark", self._on_style_changed)
self.hovered = None self.hovered = None
self.hovered_point = None # which candidate, when obj has more than one point self.hovered_point = None # which candidate, when obj has more than one point
self.selected = None self.selected = None
@ -200,6 +296,10 @@ class GridCanvas(Gtk.DrawingArea):
def refresh(self) -> None: def refresh(self) -> None:
self.queue_draw() self.queue_draw()
def _on_style_changed(self, style_manager, _pspec) -> None:
_apply_palette(style_manager.get_dark())
self.queue_draw()
# -- placement mode ----------------------------------------------------------- # -- placement mode -----------------------------------------------------------
def start_placement(self, callback, preview_radius_km=None) -> None: def start_placement(self, callback, preview_radius_km=None) -> None:
self.placement_callback = callback self.placement_callback = callback

View File

@ -182,15 +182,28 @@ def point_to_coord(point: Point) -> Coord | None:
col = min(max(col, 0.0), 19.999) col = min(max(col, 0.0), 19.999)
row = min(max(row, 0.0), 9.999) row = min(max(row, 0.0), 9.999)
x_idx = int(col) # A real, reproducible bug lived here: Coord.as_fraction() centers a
x = round((col - x_idx) * 10) # sub-cell at x + 0.5 (so a marker drawn at its own coord's exact
if x > 9: # pixel position round-trips back to the same coord), which means
x, x_idx = 0, min(x_idx + 1, 19) # the value being rounded here is supposed to land EXACTLY on a .5
# boundary, the single worst case for floating point, tiny
# representation error from the col/row math upstream (pixel <->
# km conversions, zoom/pan, or even just this function's own
# subtraction) can tip it to either side of round()'s tie-breaking
# rule and silently return a coord one sub-cell off from the one
# that was actually clicked (verified directly: reproduced with
# zero pixel math involved at all, just Coord(...).as_fraction()
# fed straight back into this function). Subtracting the 0.5 offset
# BEFORE rounding recovers a value that's supposed to be an exact
# integer instead of an exact half-integer, round() is robust to
# tiny float noise around a true integer, just not around X.5.
n_col = round(col * 10 - 0.5)
x_idx, x = divmod(n_col, 10)
x_idx = min(max(x_idx, 0), 19)
Y = int(row) + 1 n_row = round(row * 10 - 0.5)
y = round((row - (Y - 1)) * 10) y_idx, y = divmod(n_row, 10)
if y > 9: Y = min(max(y_idx, 0), 9) + 1
y, Y = 0, min(Y + 1, 10)
return Coord(X=LARGE_X[x_idx], Y=Y, x=x, y=y) return Coord(X=LARGE_X[x_idx], Y=Y, x=x, y=y)

View File

@ -1,6 +1,6 @@
"""Regression coverage for solver.py's geometric resolution.""" """Regression coverage for solver.py's geometric resolution."""
from fenigma import solver from fenigma import solver
from fenigma.models import Board, Clue, Coord, Location, TargetType from fenigma.models import LARGE_X, Board, Clue, Coord, Location, TargetType
def _board_with_spotters(*coords): def _board_with_spotters(*coords):
@ -104,3 +104,24 @@ def test_manual_coord_override_clears_a_stale_note():
assert target.location.note is not None assert target.location.note is not None
target.coord = Coord("A", 1, 0, 0) target.coord = Coord("A", 1, 0, 0)
assert target.location.note is None assert target.location.note is None
def test_point_to_coord_round_trips_every_sub_cell():
"""A real, reproducible bug: Coord.as_fraction() centers a sub-cell
at x + 0.5 (so a marker drawn at its own coord's exact pixel
position round-trips back to the same coord on click), which put
point_to_coord()'s own rounding exactly on a .5 boundary, the worst
case for floating point. A tiny representation error from the
subtraction it used to do could tip round() to either side,
silently returning a coord one sub-cell off from the one actually
clicked, this reproduced with zero pixel math involved at all, just
feeding as_fraction() straight back into point_to_coord(). Checked
exhaustively, not just a couple of samples, since the failure was
itself pattern-dependent (only some coords tripped the float
rounding the wrong way)."""
for X in LARGE_X:
for Y in range(1, 11):
for x in range(10):
for y in range(10):
c = Coord(X, Y, x, y)
assert solver.point_to_coord(c.as_fraction()) == c