Compare commits
No commits in common. "512a0a41b4ee38d8215b5ada6a51b37f679e76ba" and "fcf29ad5749cbdfdd23aa1543b6e87f1322f1341" have entirely different histories.
512a0a41b4
...
fcf29ad574
@ -1044,15 +1044,7 @@ 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)
|
||||||
# NOT Gdk.Rectangle(x=..., y=..., ...): verified directly that this
|
popover.set_pointing_to(Gdk.Rectangle(x=int(x), y=int(y), width=1, height=1))
|
||||||
# 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,
|
||||||
|
|||||||
@ -14,8 +14,7 @@ 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")
|
||||||
gi.require_version("Adw", "1")
|
from gi.repository import Gdk, Gtk # noqa: E402
|
||||||
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
|
||||||
@ -53,19 +52,6 @@ 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),
|
||||||
@ -77,89 +63,17 @@ 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.15) # verified by actually computing the blended-over-BG
|
SUBGRID_LINE = (0.72, 0.70, 0.65, 0.35) # a shade between BG and GRID_LINE's white, not a hue change
|
||||||
# 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) # not literally "white" any more in the light palette, see _LIGHT_PALETTE:
|
WHITE = (1.0, 1.0, 1.0)
|
||||||
# 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
|
||||||
@ -195,16 +109,6 @@ 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
|
||||||
@ -296,10 +200,6 @@ 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
|
||||||
|
|||||||
@ -182,28 +182,15 @@ 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)
|
||||||
|
|
||||||
# A real, reproducible bug lived here: Coord.as_fraction() centers a
|
x_idx = int(col)
|
||||||
# sub-cell at x + 0.5 (so a marker drawn at its own coord's exact
|
x = round((col - x_idx) * 10)
|
||||||
# pixel position round-trips back to the same coord), which means
|
if x > 9:
|
||||||
# the value being rounded here is supposed to land EXACTLY on a .5
|
x, x_idx = 0, min(x_idx + 1, 19)
|
||||||
# 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)
|
|
||||||
|
|
||||||
n_row = round(row * 10 - 0.5)
|
Y = int(row) + 1
|
||||||
y_idx, y = divmod(n_row, 10)
|
y = round((row - (Y - 1)) * 10)
|
||||||
Y = min(max(y_idx, 0), 9) + 1
|
if y > 9:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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 LARGE_X, Board, Clue, Coord, Location, TargetType
|
from fenigma.models import Board, Clue, Coord, Location, TargetType
|
||||||
|
|
||||||
|
|
||||||
def _board_with_spotters(*coords):
|
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
|
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
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user