Skip to content

feat(theme): waterfall colormap runtime theming for all 5 presets#3122

Merged
ten9876 merged 1 commit into
mainfrom
auto/waterfall-runtime-theming
May 25, 2026
Merged

feat(theme): waterfall colormap runtime theming for all 5 presets#3122
ten9876 merged 1 commit into
mainfrom
auto/waterfall-runtime-theming

Conversation

@ten9876

@ten9876 ten9876 commented May 25, 2026

Copy link
Copy Markdown
Collaborator

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`:

  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

  • AetherSDR builds clean
  • All 320 targets including every test target

Phase 3/4 status

Item Status
Slice indicators runtime themed #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

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>
@ten9876 ten9876 requested a review from a team as a code owner May 25, 2026 07:37

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ten9876 ten9876 merged commit 7e65116 into main May 25, 2026
5 checks passed
@ten9876 ten9876 deleted the auto/waterfall-runtime-theming branch May 25, 2026 08:21
ten9876 added a commit that referenced this pull request May 25, 2026
## 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>
ten9876 added a commit that referenced this pull request May 25, 2026
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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant