feat(theme): waterfall colormap runtime theming for all 5 presets#3122
Conversation
The third deferred item from the Phase 3 closeout — and the final one before Phase 4's default-light theme. The five waterfall colormap presets exposed via the spectrum overlay's `Scheme:` dropdown (Default / Grayscale / Blue-Green / Fire / Plasma) used to live as compile-time `WfGradientStop[]` tables. They now resolve through ThemeManager against five named gradient tokens. ## Why "all five presets" not "one colormap" User-visible presets stay — the dropdown still exposes the same five choices and the operator's pick still drives which colormap renders. What changed is the *content* of each preset: a future light theme can ship a brighter "Default" appropriate for daylight viewing, redesign "Fire" with a different stop set, etc., entirely from JSON without touching code. ## JSON shape — `resources/themes/default-dark.json` The previous flat `color.waterfall.colormap` gradient (never wired up, stops drifted from the actual compile-time defaults) is replaced by a nested object with five named gradient sub-tokens. Stops match the existing compile-time values: color.waterfall.colormap.default (7 stops, black→…→red) color.waterfall.colormap.grayscale (2 stops, black→white) color.waterfall.colormap.blueGreen (5 stops, black→blue→teal→green→white) color.waterfall.colormap.fire (5 stops, black→red→orange→yellow→white) color.waterfall.colormap.plasma (5 stops, black→purple→magenta→orange→yellow) ThemeManager's flatten pass treats the inner objects as gradient leaves (via their `type` discriminator) so each resolves through `brush()` and `cssFragment()` like any other gradient token. ## C++ wiring — `src/gui/SpectrumWidget.cpp` A file-static `WfStopsCache` holds five `std::vector<WfGradientStop>` — the converted form of each named gradient's stop list. Lazy-populated on first `wfSchemeStops()` call so the function works during early static-init sequences where the SpectrumWidget hasn't been constructed yet. The widget's constructor connects to `ThemeManager::themeChanged`: 1. Rebuilds the cache from the new theme's gradient tokens. 2. Marks the static overlay dirty (slice indicators, axis labels). 3. Calls `update()`. Already-rendered waterfall rows keep their pre-switch colours — same behaviour as changing the `Scheme:` dropdown, since recolouring the history image would force an O(rows × cols) repaint we don't currently amortise. New rows pick up the new palette on the next push. The five compile-time `kDefaultStops` / `kGrayscaleStops` / etc. tables are deleted; JSON is now the source of truth. ## Visual continuity Default-dark's stop hex values mirror the deleted compile-time tables exactly, so the dark theme renders the same waterfall as before across all five presets. Only the source of truth has moved. ## Build verified clean AetherSDR + all 320 targets including every test. ## Phase 3/4 status ✅ Slice indicators runtime themed (#3121) ✅ Waterfall colormap runtime themed (this commit) ⏳ default-light theme — last remaining Phase 4 milestone 73, Jeremy KK7GWY & Claude (AI dev partner) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Clean migration — small surface area, the JSON↔compile-time hex values check out exactly across all five presets (I diffed each stop against the deleted k*Stops tables), so default-dark renders identically as claimed. Lazy init + themeChanged rebuild + size < 2 fallback together cover the awkward cases nicely. Thanks @ten9876!
A few non-blocking observations:
1. Cache lifetime hinges on single-thread access. wfSchemeStops returns v.data() straight out of the file-static WfStopsCache. Both current call sites (SpectrumWidget.cpp:4498, :4529) consume the pointer inside the same GUI-thread paint call and the only mutator (rebuildWfStopsCacheFromTheme) runs via the ThemeManager::themeChanged AutoConnection — so today the pointer can't dangle. But if any future caller invokes wfSchemeStops from a worker thread (e.g. a GPU-side waterfall row builder), a concurrent theme switch would invalidate the vector mid-read. Worth a // GUI-thread only comment near the function declaration in the header, or a Q_ASSERT(qApp->thread() == QThread::currentThread()) inside, to make the contract explicit.
2. interpolateGradient UB on n < 2. The fallback (v.size() < 2 → {black, white}) is exactly the right defense — interpolateGradient at SpectrumWidget.cpp:255-256 reads stops[i+1] unconditionally and would access OOB / divide by zero on a single-stop gradient. Good catch.
3. Per-widget rebuild on theme change. The connect-target is this, so the lambda fires once per SpectrumWidget instance, but the work it does is on a file-static cache. AetherSDR only constructs one SpectrumWidget today so it's harmless, but conceptually rebuildWfStopsCacheFromTheme() is global — a one-shot QObject::connect(&ThemeManager::instance(), ..., &ThemeManager::instance(), [](){ rebuildWfStopsCacheFromTheme(); }) at static-init (or routed through ThemeManager itself) would make the ownership cleaner. Not worth blocking on.
4. JSON shape. The nested-under-waterfall.colormap structure works because flattenTokens (ThemeManager.cpp:74) only treats objects with a type string as gradients and otherwise recurses — verified the discriminator path produces color.waterfall.colormap.default etc. as expected.
Conventions look good: no AppSettings/QSettings drift, no leaks, RAII clean (the std::vector cache owns its storage). Ship it once CI is green.
🤖 aethersdr-agent · cost: $5.0117 · model: claude-opus-4-7
## Summary The final Phase 4 milestone for the theming subsystem — every load-bearing colour decision in the GUI now flows through the token system, so authoring a second theme is purely a JSON exercise. ## What Default Light is Daylight-readable variant of the canonical taxonomy. Backgrounds invert (panel ramp goes light→dark for elevation), text colours invert (primary text now dark on light), accents darken to maintain contrast against the light canvas. Slice and meter colours keep semantic meaning but shift toward the darker / more-saturated end of their hue family so they read clearly on the light background. ## Design choices, by category | Category | Approach | |---|---| | **Backgrounds** | Light at the base (\`#f5f5f8\`), darker with elevation (\`#b0bcc8\` at the highest tier). \`background.tx\` warms to \`#ffeacc\` to preserve "transmit zone" feel. \`background.spectrum\` flips to pure white. | | **Accents** | Pulled toward the darker end of each hue family. Cyan \`#00b4d8\` → \`#0088b0\`; warning amber → \`#d08010\`; danger red → \`#c02020\`; success green → \`#1a8040\`. | | **Text** | Full inversion of the dark ramp. primary \`#c8d8e8\` → \`#1a2a3a\`; secondary / label / disabled shift accordingly. | | **Borders** | Light grey at subtle, medium grey at strong. TX border keeps the warm cast: \`#5a4a28\` → \`#b08840\`. | | **Meters** | Semantic colours preserved (red crest, cyan rms, amber threshold, gold GR). All darkened for contrast on the light meter background. \`peak\` reads dark on light instead of near-white. | | **Spectrum** | trace / peakHold / average shift to darker tones. grid lightens to \`#d0d8e0\` so it stays visible-but-subtle on the white canvas. | | **Waterfall** | Colormap gradients **UNCHANGED** across all 5 presets. The waterfall renders OPAQUE pixels from a heatmap; canvas underneath doesn't show through. Same Default / Grayscale / Blue-Green / Fire / Plasma in both themes. | | **Slice** | Active hues stay distinct (cyan / magenta / green / yellow / orange / teal / coral / lavender) but shift toward saturated mid-tones. Dim variants become **washed-out light pastels** instead of half-brightness saturated versions — they sit visibly above the light background without being attention-grabbing. | ## Wiring - \`resources/themes/default-light.json\` — new file, 152 lines. - \`resources/resources.qrc\` — one-line entry registering the new theme as \`:/themes/default-light.json\`. No C++ changes needed. \`ThemeManager::scanAvailableThemes()\` already auto-discovers any \`*.json\` in \`:/themes/\` at startup, so the new theme appears in \`availableThemes()\` and is settable via \`setActiveTheme(\"Default Light\")\`. All widgets that registered through \`ThemeManager::applyStyleSheet\` (50+ sites from the Phase 2/3 migration) live-re-theme automatically. Slice indicators, waterfall colormaps, FFT background fill, primary sliders — all flip on the next paint. ## Build verified clean - [x] AetherSDR builds clean - [x] All 320 targets including every test target - [x] Both themes appear in the generated qrc_resources.cpp ## Phase 4 status — complete | Item | PR | |---|---| | Slice indicators runtime themed | #3121 ✅ | | Waterfall colormap runtime themed | #3122 ✅ | | **Default Light theme** | **this PR** ✅ | Phase 5 picks up the theme editor UI (modeless dialog with inspector mode), import/export, and custom-theme persistence in \`~/.config/AetherSDR/themes/\`. ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: launch the app, open Settings → switch active theme to "Default Light", confirm: - [ ] Panels invert correctly (light backgrounds, dark text) - [ ] Spectrum widget renders with a white canvas, dark FFT trace, light grid lines - [ ] Waterfall colormap unchanged (still black→…→hot) - [ ] All 8 slice indicators readable on the light spectrum - [ ] Primary sliders (sub-page fill + light handle) still visible with darker accent fill - [ ] Strip panels' amber-sub-page sliders still TX-coloured - [ ] Bonus: switch back to "Default Dark" — every widget should snap back without restart 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yesterday's PR #3110 darkening-workaround flow had me dropping a hand-fixed "Default Dark.json" into ~/.config/AetherSDR/themes/ to bypass the Qt resource rebuild. That file stuck around, and because ThemeManager::scanAvailableThemes() runs the user-dir scan after the built-in scan with an unconditional `m_themePaths.insert(name, full)`, the stale user-dir copy SHADOWED the bundled :/themes/default-dark.json. The stale copy predated PR #3122's waterfall-colormap restructure (flat single gradient → nested object with 5 named schemes). After it loaded: * color.waterfall.colormap = ThemeGradient (single, the old flat shape) * color.waterfall.colormap.{default,grayscale,blueGreen,fire,plasma} = MISSING from m_tokens The SpectrumWidget cache rebuild fell back to the black→white grayscale ramp for all 5 schemes — the dropdown still listed them but every selection rendered identical greyscale, which is exactly the symptom Jeremy reported. This commit: * Treats built-in names (paths starting with `:/themes/`) as RESERVED. A user-dir file with the same `name` field is logged with a clear warning and skipped, so the bundled version wins. * Comment documents the failure mode + points readers at "Save As under a new name" as the correct workflow for tweaked themes. The stale file itself was deleted out-of-band — this commit prevents the symptom from recurring on any other developer / user who happens to have an old user-dir Default Dark.json sitting around (anyone who followed yesterday's workaround in particular). Save As in the Theme Editor was already disallowing same-name overwrites via QMessageBox, so the editor itself can't manufacture this state. This guard catches the manual / migration case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…thersdr#3122) ## Summary Closes the third (and last mechanical) deferred item from the Phase 3 closeout. All five waterfall colormap presets exposed via the Spectrum Overlay → Display → \`Scheme:\` dropdown (**Default / Grayscale / Blue-Green / Fire / Plasma**) now resolve through \`ThemeManager\` against named gradient tokens. ## Why all five (not one) The user-facing dropdown stays — operators still pick from the same five presets and the choice still drives which colormap paints. **What changed is the *content* of each preset**: a future light theme can ship a brighter \"Default\" for daylight viewing, redesign \"Fire\" with a softer stop set, etc., entirely from JSON without touching code. ## JSON shape The previous flat \`color.waterfall.colormap\` gradient (never wired up — its stops had drifted from the actual compile-time defaults) is replaced by a nested object with five named gradient sub-tokens: \`\`\` color.waterfall.colormap.default (7 stops, black→…→red) color.waterfall.colormap.grayscale (2 stops, black→white) color.waterfall.colormap.blueGreen (5 stops, black→blue→teal→green→white) color.waterfall.colormap.fire (5 stops, black→red→orange→yellow→white) color.waterfall.colormap.plasma (5 stops, black→purple→magenta→orange→yellow) \`\`\` \`ThemeManager::flattenTokens()\` already routes inner objects through \`parseGradient()\` whenever they carry the \`type\` discriminator, so each sub-token resolves through \`brush()\` / \`cssFragment()\` like any other gradient. ## C++ wiring — \`src/gui/SpectrumWidget.cpp\` A file-static \`WfStopsCache\` holds five \`std::vector<WfGradientStop>\` — the converted form of each named gradient's stop list. Lazy-populated on first \`wfSchemeStops()\` call so the function works during early static-init sequences where the SpectrumWidget hasn't been constructed yet. SpectrumWidget's constructor connects to \`ThemeManager::themeChanged\`: 1. Rebuild the cache from the new theme's gradient tokens. 2. Mark the static overlay dirty. 3. \`update()\`. **Already-rendered waterfall rows keep their pre-switch colours** — same behaviour as changing the \`Scheme:\` dropdown today, since recolouring the history image would force an O(rows × cols) repaint we don't currently amortise. New rows pick up the new palette on the next push. The five compile-time \`kDefaultStops\` / \`kGrayscaleStops\` / etc. tables are deleted; JSON is now the source of truth. ## Visual continuity Default-dark's stop hex values mirror the deleted compile-time tables exactly, so the dark theme renders the same waterfall across all five presets. Only the source of truth has moved. ## Build verified clean - [x] AetherSDR builds clean - [x] All 320 targets including every test target ## Phase 3/4 status | Item | Status | |---|---| | Slice indicators runtime themed | ✅ aethersdr#3121 | | Waterfall colormap runtime themed | ✅ this PR | | default-light theme | ⏳ last Phase 4 milestone | After this lands, every load-bearing colour decision in the GUI flows through the token system. Default-light becomes a pure JSON authoring exercise (plus a contrast pass). ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: open the Spectrum Overlay → Display tab and cycle through all 5 colormap presets, confirm each renders identically to current main on default-dark - [ ] Bonus: drop a custom theme JSON into \`~/.config/AetherSDR/themes/\` with a re-coloured \`color.waterfall.colormap.fire\` to confirm theme switching repaints new rows with the override 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…3129) ## Summary The final Phase 4 milestone for the theming subsystem — every load-bearing colour decision in the GUI now flows through the token system, so authoring a second theme is purely a JSON exercise. ## What Default Light is Daylight-readable variant of the canonical taxonomy. Backgrounds invert (panel ramp goes light→dark for elevation), text colours invert (primary text now dark on light), accents darken to maintain contrast against the light canvas. Slice and meter colours keep semantic meaning but shift toward the darker / more-saturated end of their hue family so they read clearly on the light background. ## Design choices, by category | Category | Approach | |---|---| | **Backgrounds** | Light at the base (\`#f5f5f8\`), darker with elevation (\`#b0bcc8\` at the highest tier). \`background.tx\` warms to \`#ffeacc\` to preserve "transmit zone" feel. \`background.spectrum\` flips to pure white. | | **Accents** | Pulled toward the darker end of each hue family. Cyan \`#00b4d8\` → \`#0088b0\`; warning amber → \`#d08010\`; danger red → \`#c02020\`; success green → \`#1a8040\`. | | **Text** | Full inversion of the dark ramp. primary \`#c8d8e8\` → \`#1a2a3a\`; secondary / label / disabled shift accordingly. | | **Borders** | Light grey at subtle, medium grey at strong. TX border keeps the warm cast: \`#5a4a28\` → \`#b08840\`. | | **Meters** | Semantic colours preserved (red crest, cyan rms, amber threshold, gold GR). All darkened for contrast on the light meter background. \`peak\` reads dark on light instead of near-white. | | **Spectrum** | trace / peakHold / average shift to darker tones. grid lightens to \`#d0d8e0\` so it stays visible-but-subtle on the white canvas. | | **Waterfall** | Colormap gradients **UNCHANGED** across all 5 presets. The waterfall renders OPAQUE pixels from a heatmap; canvas underneath doesn't show through. Same Default / Grayscale / Blue-Green / Fire / Plasma in both themes. | | **Slice** | Active hues stay distinct (cyan / magenta / green / yellow / orange / teal / coral / lavender) but shift toward saturated mid-tones. Dim variants become **washed-out light pastels** instead of half-brightness saturated versions — they sit visibly above the light background without being attention-grabbing. | ## Wiring - \`resources/themes/default-light.json\` — new file, 152 lines. - \`resources/resources.qrc\` — one-line entry registering the new theme as \`:/themes/default-light.json\`. No C++ changes needed. \`ThemeManager::scanAvailableThemes()\` already auto-discovers any \`*.json\` in \`:/themes/\` at startup, so the new theme appears in \`availableThemes()\` and is settable via \`setActiveTheme(\"Default Light\")\`. All widgets that registered through \`ThemeManager::applyStyleSheet\` (50+ sites from the Phase 2/3 migration) live-re-theme automatically. Slice indicators, waterfall colormaps, FFT background fill, primary sliders — all flip on the next paint. ## Build verified clean - [x] AetherSDR builds clean - [x] All 320 targets including every test target - [x] Both themes appear in the generated qrc_resources.cpp ## Phase 4 status — complete | Item | PR | |---|---| | Slice indicators runtime themed | aethersdr#3121 ✅ | | Waterfall colormap runtime themed | aethersdr#3122 ✅ | | **Default Light theme** | **this PR** ✅ | Phase 5 picks up the theme editor UI (modeless dialog with inspector mode), import/export, and custom-theme persistence in \`~/.config/AetherSDR/themes/\`. ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: launch the app, open Settings → switch active theme to "Default Light", confirm: - [ ] Panels invert correctly (light backgrounds, dark text) - [ ] Spectrum widget renders with a white canvas, dark FFT trace, light grid lines - [ ] Waterfall colormap unchanged (still black→…→hot) - [ ] All 8 slice indicators readable on the light spectrum - [ ] Primary sliders (sub-page fill + light handle) still visible with darker accent fill - [ ] Strip panels' amber-sub-page sliders still TX-coloured - [ ] Bonus: switch back to "Default Dark" — every widget should snap back without restart 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the third (and last mechanical) deferred item from the Phase 3 closeout. All five waterfall colormap presets exposed via the Spectrum Overlay → Display → `Scheme:` dropdown (Default / Grayscale / Blue-Green / Fire / Plasma) now resolve through `ThemeManager` against named gradient tokens.
Why all five (not one)
The user-facing dropdown stays — operators still pick from the same five presets and the choice still drives which colormap paints. What changed is the content of each preset: a future light theme can ship a brighter "Default" for daylight viewing, redesign "Fire" with a softer stop set, etc., entirely from JSON without touching code.
JSON shape
The previous flat `color.waterfall.colormap` gradient (never wired up — its stops had drifted from the actual compile-time defaults) is replaced by a nested object with five named gradient sub-tokens:
```
color.waterfall.colormap.default (7 stops, black→…→red)
color.waterfall.colormap.grayscale (2 stops, black→white)
color.waterfall.colormap.blueGreen (5 stops, black→blue→teal→green→white)
color.waterfall.colormap.fire (5 stops, black→red→orange→yellow→white)
color.waterfall.colormap.plasma (5 stops, black→purple→magenta→orange→yellow)
```
`ThemeManager::flattenTokens()` already routes inner objects through `parseGradient()` whenever they carry the `type` discriminator, so each sub-token resolves through `brush()` / `cssFragment()` like any other gradient.
C++ wiring — `src/gui/SpectrumWidget.cpp`
A file-static `WfStopsCache` holds five `std::vector` — the converted form of each named gradient's stop list. Lazy-populated on first `wfSchemeStops()` call so the function works during early static-init sequences where the SpectrumWidget hasn't been constructed yet.
SpectrumWidget's constructor connects to `ThemeManager::themeChanged`:
Already-rendered waterfall rows keep their pre-switch colours — same behaviour as changing the `Scheme:` dropdown today, since recolouring the history image would force an O(rows × cols) repaint we don't currently amortise. New rows pick up the new palette on the next push.
The five compile-time `kDefaultStops` / `kGrayscaleStops` / etc. tables are deleted; JSON is now the source of truth.
Visual continuity
Default-dark's stop hex values mirror the deleted compile-time tables exactly, so the dark theme renders the same waterfall across all five presets. Only the source of truth has moved.
Build verified clean
Phase 3/4 status
After this lands, every load-bearing colour decision in the GUI flows through the token system. Default-light becomes a pure JSON authoring exercise (plus a contrast pass).
Test plan
73, Jeremy KK7GWY & Claude (AI dev partner)
🤖 Generated with Claude Code