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>
This commit is contained in:
Dominik Moritz Roth 2026-08-09 21:18:47 +02:00
parent a92aff5e06
commit 084764aa9b

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