From 084764aa9bae10f3fae78bde99f45e0f2ce06519 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 9 Aug 2026 21:18:47 +0200 Subject: [PATCH] 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 --- src/fenigma/grid_widget.py | 106 +++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index 9b664ae..7f16654 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -14,7 +14,8 @@ import gi gi.require_version("Gtk", "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 .models import LARGE_X, Board, Target @@ -52,6 +53,19 @@ 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), @@ -63,17 +77,89 @@ 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.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 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) +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) 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 @@ -109,6 +195,16 @@ 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 @@ -200,6 +296,10 @@ 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