Compare commits

..

No commits in common. "512a0a41b4ee38d8215b5ada6a51b37f679e76ba" and "fcf29ad5749cbdfdd23aa1543b6e87f1322f1341" have entirely different histories.

4 changed files with 13 additions and 155 deletions

View File

@ -1044,15 +1044,7 @@ class MainWindow(Adw.ApplicationWindow):
you're pointing and don't need to type coordinates."""
popover = Gtk.Popover()
popover.set_parent(self.canvas)
# 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.set_pointing_to(Gdk.Rectangle(x=int(x), y=int(y), width=1, height=1))
popover.connect("closed", lambda _p: popover.unparent())
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,

View File

@ -14,8 +14,7 @@ import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk # noqa: E402
from gi.repository import Gdk, Gtk # noqa: E402
from . import ballistics, icons, solver
from .models import LARGE_X, Board, Target
@ -53,19 +52,6 @@ ICON_MIN_CELL_PX = 42
# 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")
# 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 = {
"nest": (0.35, 0.60, 0.95),
"spotter": (0.35, 0.78, 0.40),
@ -77,89 +63,17 @@ 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) # 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.
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.35) # a shade between BG and GRID_LINE's white, not a hue change
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)
COORD_LABEL = (0.60, 0.58, 0.54)
YELLOW = (0.95, 0.85, 0.20)
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.
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)
_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
# 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
@ -195,16 +109,6 @@ class GridCanvas(Gtk.DrawingArea):
def __init__(self, board: Board) -> None:
super().__init__()
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_point = None # which candidate, when obj has more than one point
self.selected = None
@ -296,10 +200,6 @@ class GridCanvas(Gtk.DrawingArea):
def refresh(self) -> None:
self.queue_draw()
def _on_style_changed(self, style_manager, _pspec) -> None:
_apply_palette(style_manager.get_dark())
self.queue_draw()
# -- placement mode -----------------------------------------------------------
def start_placement(self, callback, preview_radius_km=None) -> None:
self.placement_callback = callback

View File

@ -182,28 +182,15 @@ def point_to_coord(point: Point) -> Coord | None:
col = min(max(col, 0.0), 19.999)
row = min(max(row, 0.0), 9.999)
# A real, reproducible bug lived here: 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), which means
# 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)
x_idx = int(col)
x = round((col - x_idx) * 10)
if x > 9:
x, x_idx = 0, min(x_idx + 1, 19)
n_row = round(row * 10 - 0.5)
y_idx, y = divmod(n_row, 10)
Y = min(max(y_idx, 0), 9) + 1
Y = int(row) + 1
y = round((row - (Y - 1)) * 10)
if y > 9:
y, Y = 0, min(Y + 1, 10)
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."""
from fenigma import solver
from fenigma.models import LARGE_X, Board, Clue, Coord, Location, TargetType
from fenigma.models import Board, Clue, Coord, Location, TargetType
def _board_with_spotters(*coords):
@ -104,24 +104,3 @@ def test_manual_coord_override_clears_a_stale_note():
assert target.location.note is not None
target.coord = Coord("A", 1, 0, 0)
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