Skip to content

feat(theme): Phase 2 widget→tokens reverse-map + live re-theme on change#3085

Merged
ten9876 merged 2 commits into
mainfrom
auto/theme-phase2-widget-map
May 25, 2026
Merged

feat(theme): Phase 2 widget→tokens reverse-map + live re-theme on change#3085
ten9876 merged 2 commits into
mainfrom
auto/theme-phase2-widget-map

Conversation

@ten9876

@ten9876 ten9876 commented May 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the widget→tokens reverse-map infrastructure that powers Phase 5's inspector mode. Phase 5's planned UX is "click the wrong-coloured part of the UI to edit its token" — that requires the manager to remember which tokens paint which widgets after `resolve()` substitutes them away.

Public API additions

```cpp
// Wrap setStyleSheet(resolve(...)) + record the reverse-map.
void applyStyleSheet(QWidget*, const QString& template);

// Stop tracking a widget (no more themeChanged re-applies).
void clearWidgetTracking(QWidget*);

// Inspector lookup: tokens referenced by the widget's last template.
QStringList tokensForWidget(const QWidget*) const;

// Stateless helper — list tokens a template references without applying.
static QStringList extractReferencedTokens(const QString&);
```

Behaviour notes

  • Free live re-theme. Stylesheet-painted widgets registered through `applyStyleSheet` get re-applied automatically when `themeChanged` fires. No consumer plumbing required.
  • Automatic cleanup. `QObject::destroyed` removes the widget's entry from the map — no dangling pointers.
  • Iteration safety. `reapplyAllTrackedStyleSheets` snapshots keys before iterating in case a re-apply triggers widget creation/destruction in side effects.
  • Static `extractReferencedTokens` — useful for audit tooling and the Phase 5 editor's "this template would reference these tokens" preview before any widget exists.

Tests

`theme_manager_test` extended to verify:

  • Applied stylesheet is the resolved form, not the template
  • Reverse-map records exactly the referenced tokens (deduplicated)
  • `clearWidgetTracking` detaches a widget
  • destroyed-signal cleanup happens (no crash, no leak)
  • `extractReferencedTokens` handles duplicates and empty `{{}}` placeholders

Test target now uses `QApplication` (needed for QWidget signals) and links `Qt6::Widgets`. Runs under `QT_QPA_PLATFORM=offscreen` in CI.

Behavior at runtime today

Zero visible change. `applyStyleSheet` exists but no call site uses it yet. The pilot stylesheet conversion (likely `SliceLabel` or `CommonStyles`) is the next PR and will be the first consumer.

Test plan

  • Linux: theme_manager_test passes locally (worktree build, PASS)
  • Linux: full AetherSDR target builds clean (391 link steps)
  • CI: build / check-paths / check-windows / CodeQL green

Sequence note

This PR is independent of #3084 (gradient support) — they touch the same files but different functions. Whichever lands first, the other rebases trivially.

73, Jeremy KK7GWY & Claude (AI dev partner)

🤖 Generated with Claude Code

Phase 5's inspector mode ("click the wrong-coloured part of the UI to
edit its token") needs to answer "which tokens paint this widget?"
when the operator clicks a region.  Today's resolve() throws away
that information after substitution.

Adds applyStyleSheet(QWidget*, const QString& template) — a wrapper
around setStyleSheet(resolve(...)) that additionally:

  - records the (widget → template + tokens-it-references) reverse-map
  - connects the widget's destroyed() signal so the entry vanishes
    automatically when the widget is gone (no dangling pointers)
  - on themeChanged, walks the recorded set and re-applies every
    stylesheet with freshly resolved values — stylesheet-painted
    widgets get free live-reload when the user switches themes

Public API surface:

  void  applyStyleSheet(QWidget*, const QString&);
  void  clearWidgetTracking(QWidget*);
  QStringList tokensForWidget(const QWidget*) const;
  static QStringList extractReferencedTokens(const QString&);

The static extractReferencedTokens() helper is callable without
state — useful for audit tooling and the Phase 5 editor's preview
of "which tokens would this template reference?" before any widget
exists.

Implementation notes:
  - QHash<QWidget*, TrackedWidget> for O(1) lookup by widget pointer.
  - extractReferencedTokens is order-preserving + deduplicating; the
    same token referenced N times in a template only counts once in
    the reverse-map.
  - reapplyAllTrackedStyleSheets snapshots keys before iterating to
    avoid QHash-mutation-during-iteration UB if a re-apply triggers
    further widget creation/destruction.
  - onTrackedWidgetDestroyed slot uses static_cast<QWidget*> on the
    QObject pointer purely for hash lookup — it never dereferences
    the widget (already destroyed by signal-emission time).

Test coverage: applyStyleSheet sets the stylesheet, records the
correct tokens, clearWidgetTracking detaches, destroyed-signal
cleanup works without crash, extractReferencedTokens handles
duplicates and empty placeholders.

No consumers wired yet — applyStyleSheet is foundation only.  The
pilot stylesheet conversion (SliceLabel or CommonStyles) follows
this commit and will be the first call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 requested review from a team as code owners May 24, 2026 23:35
…get-map

# Conflicts:
#	src/core/ThemeManager.cpp
@ten9876 ten9876 merged commit 3a5688e into main May 25, 2026
4 checks passed
@ten9876 ten9876 deleted the auto/theme-phase2-widget-map branch May 25, 2026 00:05
ten9876 added a commit that referenced this pull request May 25, 2026
…tokens

First real consumer of the RFC #3076 Phase 2 theming infrastructure.
ComboStyle.h is the central styling helper for every QComboBox in
AetherSDR — applyComboStyle() is called from 20+ sites (AppletPanel,
RadioSetupDialog, PhoneCwApplet, RxApplet, StripEqPanel, WaveApplet,
SpectrumOverlayMenu, etc.).

What changed:
  - Hardcoded hex (#1a2a3a, #c8d8e8, #304050, #00b4d8) replaced with
    canonical token references: {{color.background.1}},
    {{color.text.primary}}, {{color.background.2}}, {{color.accent}}.
  - QColor(0x8a, 0xa8, 0xc0) in the arrow PNG replaced with
    ThemeManager::color("color.text.secondary").
  - applyComboStyle() now routes through ThemeManager::applyStyleSheet
    so every combo gets free live re-theme on theme change (registered
    in the widget-tracked reverse-map for the Phase 5 inspector).
  - SpectrumOverlayMenu.cpp's two direct calls to comboStyleSheet()
    converted to applyComboStyle() — the underlying helper is gone.
  - Arrow PNG cache key now includes the resolved text.secondary hex
    so each theme variation gets its own cached arrow without stomping.

Tradeoff documented inline: applyComboStyle installs TWO connections
on themeChanged — the reverse-map's automatic re-apply, plus an
explicit refresh to regenerate the arrow URL (since the URL is
computed at apply time, not a {{token}} placeholder).  The double-
apply is wasteful but small; Phase 5 could introduce a "computed
token" mechanism if combos become a hot path.  QPointer guards
against widget destruction between the connection and the next
themeChanged.

Verified locally: Linux build clean (all 20+ call sites still resolve),
binary launches without crash.  No visible diff expected today —
the resolved colours match the previous hardcoded values exactly.

Visible payoff arrives when the user switches themes: every combo
re-themes simultaneously without any per-call-site plumbing.  Phase
4's Default Light theme will demonstrate this end-to-end.

Stacked on top of #3085 (applyStyleSheet infrastructure) — when
that lands the pilot rebases cleanly to a single-commit diff against
main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request May 25, 2026
…tokens (#3087)

## Summary

First real consumer of the RFC #3076 Phase 2 theming infrastructure.
Migrates [src/gui/ComboStyle.h](src/gui/ComboStyle.h) — the central
styling helper for every QComboBox in AetherSDR — from hardcoded hex to
canonical token references via \`ThemeManager::applyStyleSheet\`.

## Why ComboStyle as the pilot

- **High leverage:** \`applyComboStyle()\` is called from 20+ sites
(AppletPanel, RadioSetupDialog, PhoneCwApplet, RxApplet, StripEqPanel,
WaveApplet, SpectrumOverlayMenu, …) — one function migrated themes them
all.
- **Small surface:** single 54-line header, easy to review and rollback.
- **Covers both code paths:** stylesheet template + paint-code QColor
literal (the arrow PNG), exercising both \`resolve()\` and direct
\`ThemeManager::color()\` lookup.

## Migration details

**Hex → tokens:**
| Was | Now |
|---|---|
| \`#1a2a3a\` | \`{{color.background.1}}\` |
| \`#c8d8e8\` | \`{{color.text.primary}}\` |
| \`#304050\` | \`{{color.background.2}}\` |
| \`#00b4d8\` | \`{{color.accent}}\` |
| \`QColor(0x8a, 0xa8, 0xc0)\` |
\`ThemeManager::color(\"color.text.secondary\")\` |

**Routing:** \`applyComboStyle\` now calls
\`ThemeManager::applyStyleSheet\` instead of \`combo->setStyleSheet\`.
Combos get free live re-theme on theme change via the widget-tracked
reverse-map (PR #3085).

**Arrow PNG cache:** filename now includes the resolved
\`text.secondary\` hex so each theme variation gets its own cached arrow
(\`/tmp/aethersdr_combo_arrow_<hex>.png\`).

**SpectrumOverlayMenu.cpp:** two direct calls to \`comboStyleSheet()\`
(the underlying-now-removed helper) converted to \`applyComboStyle()\`.

## Tradeoff documented inline

\`applyComboStyle\` installs **two** connections on \`themeChanged\`:
1. The reverse-map's automatic re-apply (from #3085)
2. An explicit refresh lambda that regenerates the arrow URL

The double-apply is wasteful but small. The arrow URL has to be computed
at apply time (it's a filesystem path that depends on the resolved
colour), not a \`{{token}}\` placeholder. Phase 5 could introduce a
"computed token" mechanism if combos become a hot path.

QPointer guards the lambda against widget destruction between the
connection and the next theme change.

## Verified locally

- [x] Linux build clean (all 20+ call sites still resolve)
- [x] AetherSDR binary launches without crash (offscreen QPA, 3s smoke
test)
- [x] No visible diff expected today — resolved colours match the
previous hardcoded values exactly

## Stacking note

This PR is based on \`auto/theme-phase2-widget-map\` (PR #3085) because
it consumes \`ThemeManager::applyStyleSheet\` which only exists there.
When #3085 merges, this rebases cleanly to a single-commit diff against
main.

## Test plan

- [ ] CI green on the dependent #3085 first
- [ ] CI green here after rebase
- [ ] Visual smoke: launch app, open Radio Setup dialog, confirm every
combo box (profile picker, RCA/ACC, mic source dropdowns) renders
identically to v26.5.3
- [ ] After Phase 4 Light theme lands: confirm theme switch repaints
every combo simultaneously without a 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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…nge (aethersdr#3085)

## Summary

Adds the widget→tokens reverse-map infrastructure that powers Phase 5's
inspector mode. Phase 5's planned UX is "click the wrong-coloured part
of the UI to edit its token" — that requires the manager to remember
which tokens paint which widgets after \`resolve()\` substitutes them
away.

## Public API additions

\`\`\`cpp
// Wrap setStyleSheet(resolve(...)) + record the reverse-map.
void  applyStyleSheet(QWidget*, const QString& template);

// Stop tracking a widget (no more themeChanged re-applies).
void  clearWidgetTracking(QWidget*);

// Inspector lookup: tokens referenced by the widget's last template.
QStringList tokensForWidget(const QWidget*) const;

// Stateless helper — list tokens a template references without
applying.
static QStringList extractReferencedTokens(const QString&);
\`\`\`

## Behaviour notes

- **Free live re-theme.** Stylesheet-painted widgets registered through
\`applyStyleSheet\` get re-applied automatically when \`themeChanged\`
fires. No consumer plumbing required.
- **Automatic cleanup.** \`QObject::destroyed\` removes the widget's
entry from the map — no dangling pointers.
- **Iteration safety.** \`reapplyAllTrackedStyleSheets\` snapshots keys
before iterating in case a re-apply triggers widget creation/destruction
in side effects.
- **Static \`extractReferencedTokens\`** — useful for audit tooling and
the Phase 5 editor's "this template would reference these tokens"
preview before any widget exists.

## Tests

\`theme_manager_test\` extended to verify:
- Applied stylesheet is the resolved form, not the template
- Reverse-map records exactly the referenced tokens (deduplicated)
- \`clearWidgetTracking\` detaches a widget
- destroyed-signal cleanup happens (no crash, no leak)
- \`extractReferencedTokens\` handles duplicates and empty \`{{}}\`
placeholders

Test target now uses \`QApplication\` (needed for QWidget signals) and
links \`Qt6::Widgets\`. Runs under \`QT_QPA_PLATFORM=offscreen\` in CI.

## Behavior at runtime today

Zero visible change. \`applyStyleSheet\` exists but no call site uses it
yet. The pilot stylesheet conversion (likely \`SliceLabel\` or
\`CommonStyles\`) is the next PR and will be the first consumer.

## Test plan

- [x] Linux: theme_manager_test passes locally (worktree build, PASS)
- [x] Linux: full AetherSDR target builds clean (391 link steps)
- [ ] CI: build / check-paths / check-windows / CodeQL green

## Sequence note

This PR is independent of aethersdr#3084 (gradient support) — they touch the same
files but different functions. Whichever lands first, the other rebases
trivially.

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
…tokens (aethersdr#3087)

## Summary

First real consumer of the RFC aethersdr#3076 Phase 2 theming infrastructure.
Migrates [src/gui/ComboStyle.h](src/gui/ComboStyle.h) — the central
styling helper for every QComboBox in AetherSDR — from hardcoded hex to
canonical token references via \`ThemeManager::applyStyleSheet\`.

## Why ComboStyle as the pilot

- **High leverage:** \`applyComboStyle()\` is called from 20+ sites
(AppletPanel, RadioSetupDialog, PhoneCwApplet, RxApplet, StripEqPanel,
WaveApplet, SpectrumOverlayMenu, …) — one function migrated themes them
all.
- **Small surface:** single 54-line header, easy to review and rollback.
- **Covers both code paths:** stylesheet template + paint-code QColor
literal (the arrow PNG), exercising both \`resolve()\` and direct
\`ThemeManager::color()\` lookup.

## Migration details

**Hex → tokens:**
| Was | Now |
|---|---|
| \`#1a2a3a\` | \`{{color.background.1}}\` |
| \`#c8d8e8\` | \`{{color.text.primary}}\` |
| \`#304050\` | \`{{color.background.2}}\` |
| \`#00b4d8\` | \`{{color.accent}}\` |
| \`QColor(0x8a, 0xa8, 0xc0)\` |
\`ThemeManager::color(\"color.text.secondary\")\` |

**Routing:** \`applyComboStyle\` now calls
\`ThemeManager::applyStyleSheet\` instead of \`combo->setStyleSheet\`.
Combos get free live re-theme on theme change via the widget-tracked
reverse-map (PR aethersdr#3085).

**Arrow PNG cache:** filename now includes the resolved
\`text.secondary\` hex so each theme variation gets its own cached arrow
(\`/tmp/aethersdr_combo_arrow_<hex>.png\`).

**SpectrumOverlayMenu.cpp:** two direct calls to \`comboStyleSheet()\`
(the underlying-now-removed helper) converted to \`applyComboStyle()\`.

## Tradeoff documented inline

\`applyComboStyle\` installs **two** connections on \`themeChanged\`:
1. The reverse-map's automatic re-apply (from aethersdr#3085)
2. An explicit refresh lambda that regenerates the arrow URL

The double-apply is wasteful but small. The arrow URL has to be computed
at apply time (it's a filesystem path that depends on the resolved
colour), not a \`{{token}}\` placeholder. Phase 5 could introduce a
"computed token" mechanism if combos become a hot path.

QPointer guards the lambda against widget destruction between the
connection and the next theme change.

## Verified locally

- [x] Linux build clean (all 20+ call sites still resolve)
- [x] AetherSDR binary launches without crash (offscreen QPA, 3s smoke
test)
- [x] No visible diff expected today — resolved colours match the
previous hardcoded values exactly

## Stacking note

This PR is based on \`auto/theme-phase2-widget-map\` (PR aethersdr#3085) because
it consumes \`ThemeManager::applyStyleSheet\` which only exists there.
When aethersdr#3085 merges, this rebases cleanly to a single-commit diff against
main.

## Test plan

- [ ] CI green on the dependent aethersdr#3085 first
- [ ] CI green here after rebase
- [ ] Visual smoke: launch app, open Radio Setup dialog, confirm every
combo box (profile picker, RCA/ACC, mic source dropdowns) renders
identically to v26.5.3
- [ ] After Phase 4 Light theme lands: confirm theme switch repaints
every combo simultaneously without a 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