Skip to content

RFC: Theming subsystem (light + dark + user-customizable + import/export) #3076

Description

@ten9876

RFC: Theming subsystem (light + dark + user-customizable + share/import)

Motivation

Multiple operators have asked for light-mode support. The current UI is hardcoded dark — hex literals (#0a0e14, #00b4d8, etc.) embedded directly into every setStyleSheet() call and every QColor(...) constructor in custom paint code. A simple light/dark toggle would either ship a second hardcoded palette or require duplicating every stylesheet. Both are dead ends.

This RFC proposes a token-based theming subsystem that surfaces every color, font, and sizing decision as a named token, lets the user customize any of them, and supports import/export of complete theme files for community sharing.

Goals

  1. Replace every hardcoded color, font, and key spacing value in the GUI with a lookup against a named token.
  2. Ship two high-quality built-in themes — Default Dark (matches today's look exactly so existing users see no change) and Default Light (new, designed properly, not just colour-inverted dark).
  3. Theme files live outside AppSettings in their own nested JSON files at ~/.config/AetherSDR/themes/<name>.json. Active theme name persists in AppSettings (single string key).
  4. Theme Editor dialog — modeless, with an inspector mode ("click on the wrong-coloured part of the UI to edit it") as the primary interaction model. Token tree is the secondary navigation. Live preview, color/gradient/font/sizing pickers, save / save-as / delete / reset-to-default per token group.
  5. Import / Export.aethertheme files (JSON with version-stamped schema) that operators can share via Discussions, Discord, etc. Drag-and-drop import.
  6. Schema-versioned so future tokens can be added without breaking older theme files (missing tokens fall back to the built-in default).
  7. Forward-compatible architecture — adding a new themable surface is a one-line token declaration plus the consuming site, no plumbing changes.

Non-goals (v1)

  • OS-tracked auto dark/light switching (prefers-color-scheme). Phase 7 candidate.
  • Time-of-day automatic switching. Phase 7 candidate.
  • Animated transitions between themes.
  • Per-window themes (e.g. dark main + light pop-out panadapter).
  • Hosted theme marketplace. Community sharing happens via file exchange — Discussions, Discord, gists.
  • Per-applet themes. Theme is application-wide.

Scope inventory (current state, 2026-05-24)

Surface Approximate count Notes
setStyleSheet(...) call sites ~1876 Many are template strings with one to many embedded hex colours
QColor(0x...) / QColor("#...") literals ~336 Custom paint code (panadapter, waterfall, meters, slice indicators)
qlineargradient / QLinearGradient / QRadialGradient sites ~61 Button states, meter fills, panel headers, dialog ribbons
QPalette configurations 1 Effectively unused — stylesheets dominate
GUI source files 127 Migration touches most of them eventually

Migration is not a one-PR change. Plan below phases it.

Architecture

Theme token model

Tokens are organized by category, addressed by dotted name:

color.background.0        // darkest tier
color.background.1        // panels
color.background.2        // raised surfaces (popups, dialogs)
color.background.tx       // active-TX tint
color.accent              // brand colour (today: #00b4d8 cyan)
color.accent.warning      // amber
color.accent.danger       // red
color.accent.success      // green
color.text.primary
color.text.secondary
color.text.disabled
color.text.label          // dim header labels
color.border.subtle
color.border.strong
color.meter.crst          // crest factor (scalar)
color.meter.rms
color.meter.thresh
color.meter.peak
color.meter.gainReduction
color.meter.barFill       // gradient — typical meter bar fill
color.waterfall.colormap  // multi-stop gradient — the entire RF colormap
color.spectrum.trace
color.spectrum.peakHold
color.spectrum.average
color.slice.a             // … through slice.h
color.slice.tx            // active-TX slice highlight
color.button.idle         // gradient — default button fill
color.button.hover
color.button.pressed
color.button.checked
color.panel.header        // gradient — applet/dialog header strip
color.led.armed           // radial gradient — TX-armed indicator

font.family.ui
font.family.mono
font.size.tiny
font.size.small
font.size.normal
font.size.large
font.weight.normal
font.weight.bold

sizing.panel.padding
sizing.panel.spacing
sizing.panel.cornerRadius
sizing.border.subtle
sizing.border.strong

Categories are open-ended — adding a new token is a one-line registration plus the consuming site.

Gradients as first-class tokens

A color token's value is either a scalar ("#00b4d8") or a gradient object. The token system treats them uniformly — callers ask for a "brush" or "color stylesheet fragment" and get back whatever the active theme has defined.

This matters because 61 gradient sites already exist in the current codebase (button states, meter fills, panel headers, dialog ribbons, panadapter overlays, waterfall colormap), and reducing the waterfall colormap to eight separate color.waterfall.stop.N scalars (the v0 proposal) is a clunky special case that doesn't generalize.

Schema (JSON):

// Scalar — back-compat with the simplest form
"color.accent": "#00b4d8"

// Linear gradient
"color.button.idle": {
  "type": "linear-gradient",
  "angle": 180,                      // degrees, 0 = top-to-bottom would be 180
  "stops": [
    { "at": 0.0, "color": "#1a2330" },
    { "at": 1.0, "color": "#0a0e14" }
  ]
}

// Radial gradient
"color.led.armed": {
  "type": "radial-gradient",
  "center": [0.5, 0.5],              // 0–1 normalized
  "radius": 0.7,
  "stops": [
    { "at": 0.0, "color": "#ff4040" },
    { "at": 1.0, "color": "#600000" }
  ]
}

// Multi-stop gradient — natural representation of the waterfall colormap
"color.waterfall.colormap": {
  "type": "linear-gradient",
  "angle": 0,
  "stops": [
    { "at": 0.00, "color": "#000020" },
    { "at": 0.20, "color": "#001f6a" },
    { "at": 0.40, "color": "#00b4d8" },
    { "at": 0.60, "color": "#ffd166" },
    { "at": 0.80, "color": "#ef476f" },
    { "at": 1.00, "color": "#ffffff" }
  ]
}

Conical gradients are deferred — Qt supports them but they're rare in radio UI.

API surface (extends ThemeManager):

// Scalar accessor — first stop of a gradient when invoked on a gradient token
QColor   color(const QString& token) const;

// Brush accessor — returns the correct Qt brush type for the token
//   - scalar token  → QBrush(QColor)
//   - linear/radial → QBrush(QGradient)
QBrush   brush(const QString& token, const QRect& bounds = {}) const;

// Stylesheet fragment — emits the right syntax for use inside a stylesheet
//   - scalar token  → "#00b4d8"
//   - linear        → "qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a2330, stop:1 #0a0e14)"
//   - radial        → "qradialgradient(cx:0.5,cy:0.5,radius:0.7, ...)"
QString  cssFragment(const QString& token) const;

The stylesheet template resolver routes {{token.name}} through cssFragment() automatically, so existing stylesheet templating "just works" for gradient tokens.

Editor implications (Phase 5):

The Theme Editor needs a gradient editor widget — a horizontal strip with draggable stop markers, each marker opening a colour picker. Add/remove stops, drag-to-reposition, angle/centre/radius numeric inputs. This is more complex than a flat colour picker but well within Qt's capabilities — QGradientStops is straightforward.

Migration mapping:

The current color.waterfall.stop.0stop.7 scalars proposed earlier collapse into a single color.waterfall.colormap gradient. Many button stylesheets currently hardcode qlineargradient(...) inline; those become {{color.button.idle}} referencing a gradient-typed token.

ThemeManager singleton (proposed src/core/ThemeManager.{cpp,h})

class ThemeManager : public QObject {
    Q_OBJECT
public:
    static ThemeManager& instance();

    QColor   color(const QString& token) const;      // scalar or first stop of gradient
    QBrush   brush(const QString& token, const QRect& bounds = {}) const;
    QString  cssFragment(const QString& token) const; // stylesheet-syntax fragment
    QFont    font(const QString& token) const;
    int      sizing(const QString& token) const;
    QString  value(const QString& token) const;  // raw token resolution

    // Stylesheet templating — replaces {{color.accent}} etc. with resolved values.
    // For gradient tokens, emits qlineargradient(...) / qradialgradient(...)
    QString  resolve(const QString& stylesheetTemplate) const;

    // Theme management
    QStringList availableThemes() const;        // scans built-in + user dir
    QString     activeTheme() const;
    bool        setActiveTheme(const QString& name);

    bool        saveCurrentAs(const QString& name);
    bool        deleteTheme(const QString& name);
    bool        importFromFile(const QString& path);
    bool        exportToFile(const QString& name, const QString& path);

    // Inspector-mode reverse lookup.  Populated lazily as widgets call
    // resolve() on stylesheet templates and as ThemableWidget subclasses
    // register their themeRegions().  Inspector queries this to map a
    // (widget, point) pair back to the token(s) that paint that region.
    QStringList tokensForWidget(const QWidget* w) const;
    QStringList tokensAtPoint(const QWidget* w, const QPoint& localPos) const;

    // Draft layer for live editing — overrides on-disk theme without
    // persisting.  Inspector edits flow through here.
    void  beginDraft();
    void  setDraftToken(const QString& token, const QVariant& value);
    void  commitDraft();    // → save current; emits themeChanged
    void  discardDraft();   // → restore on-disk; emits themeChanged

signals:
    void themeChanged();   // every widget that draws via tokens listens here
};

Token migration strategy

Two parallel migration paths because the codebase uses two styling mechanisms:

Stylesheet templating for Qt stylesheet strings:

// Before
m_btn->setStyleSheet("QPushButton { background: #0a0e14; color: #00b4d8; }");

// After
m_btn->setStyleSheet(ThemeManager::instance().resolve(
    "QPushButton { background: {{color.background.0}}; color: {{color.accent}}; }"));

The template engine is dirt simple — {{token.name}} placeholders, resolved at apply time, re-applied whenever themeChanged fires.

Direct token lookup for QColor literals in paint code:

// Before
painter.setPen(QColor(0x00, 0xb4, 0xd8));

// After
painter.setPen(theme.color("color.accent"));

Custom widgets connect to themeChanged and call update() to repaint.

Inspector-mode editor (Phase 5 primary UX)

The token tree alone is the wrong primary interface for our audience — operators know what looks wrong visually but have no reason to memorize 80+ token names. The Theme Editor's main interaction model is therefore inspect-and-edit, modelled on browser devtools:

  1. Modeless dialog stays open while the operator uses the main UI normally.
  2. Operator clicks an Inspect toggle on the dialog (becomes a sticky state with a cyan border and a custom cursor).
  3. As the operator moves the cursor over the main UI, a transparent overlay highlights the widget under cursor with a cyan outline.
  4. Operator clicks the wrong-coloured region — the dialog displays the token(s) that paint it and opens the editor inline.
  5. Operator picks a new colour / gradient / font / size. Live preview applies via themeChanged immediately.
  6. Save / Save As / Revert / Discard buttons commit or roll back the edit. The token tree view is still available as a secondary tab for "I want to scan everything" workflows.

Reverse-lookup mechanism — widget → token(s)

Today's paint code doesn't record which token painted which pixel. The inspector needs a reverse lookup. Three approaches considered (A coarse widget-level, B fine sub-region, C hybrid). Settling on hybrid:

Every themable widget exposes:

struct ThemeRegion {
    QString          token;       // e.g. "color.panel.header"
    std::function<bool(QPoint)> hitTest;  // QRect-wrapped or custom
    QString          description; // optional human label for the picker UI
};

class ThemableWidget /* mixin / interface */ {
public:
    virtual QList<ThemeRegion> themeRegions() const;
};

Default implementation in a base class returns one entry covering the whole widget, populated from a themeTokens Q_PROPERTY annotation. Eighty percent of widgets get coarse-grained for free. Custom widgets (PanadapterWidget, WaterfallWidget, MeterSlider) override to declare fine-grained regions ("the trace line vs the peak hold vs the noise floor vs the background").

Stylesheet-painted widgets

Many widgets are styled via QSS strings, not custom paint code. When the inspector clicks one, we need to know which tokens its stylesheet references.

This falls out of the Phase 2 migration for free. setStyleSheet(theme.resolve("...template...")) requires the resolver to parse {{token}} placeholders to substitute them — at the same time, it can record (widget instance, list of referenced tokens) into a reverse-map. Inspector click → look up the map → list those tokens.

Inspector mode mechanics

  1. Toggle button on the dialog ("Inspect" with a target-reticule icon). Sticky state.
  2. While active: QApplication::installEventFilter grabs mouse moves and clicks globally.
  3. Mouse move → resolve widget under cursor (QApplication::widgetAt(globalPos)), draw a top-level transparent overlay with a cyan outline. Update on each move.
  4. Click → resolve the most specific ThemeRegion at the click point. Show token(s) in the dialog; auto-exit inspect mode after a click (one click = one edit, less mode confusion).
  5. ESC exits inspect mode without picking.
  6. Clicks on the editor dialog itself never count as targets — the dialog is invisible to the event filter.
  7. If the click hits an unmigrated widget (no themeRegions() and no stylesheet template): show a fallback message, "this region isn't themed yet — pick a token from a parent widget or browse the token tree." Offers a list of the parent widget's tokens.

Realtime preview

The editor writes edits to an in-memory draft layer that overrides the on-disk theme. All widgets repaint through themeChanged as usual. Save commits the draft to disk under the active theme name; Save-As writes to a new theme name; Revert drops the draft; Discard restores the saved-disk version and exits the editor. Closing the dialog with unsaved drafts prompts confirmation.

Theme file format (~/.config/AetherSDR/themes/default-light.json)

{
  "schemaVersion": 1,
  "name": "Default Light",
  "author": "AetherSDR Team",
  "version": "1.0",
  "description": "High-contrast light theme — designed, not inverted.",
  "tokens": {
    "color": {
      "background.0": "#f5f5f5",
      "background.1": "#ffffff",
      "accent": "#0066cc",
      "text.primary": "#1a1a1a",
      "button.idle": {
        "type": "linear-gradient",
        "angle": 180,
        "stops": [
          { "at": 0.0, "color": "#ffffff" },
          { "at": 1.0, "color": "#e6e6e6" }
        ]
      },
      "waterfall.colormap": {
        "type": "linear-gradient",
        "angle": 0,
        "stops": [
          { "at": 0.0, "color": "#ffffff" },
          { "at": 0.5, "color": "#0077bb" },
          { "at": 1.0, "color": "#000033" }
        ]
      },
      ...
    },
    "font": {
      "family.ui": "Inter",
      "size.normal": 13,
      ...
    },
    "sizing": {
      "panel.padding": 8,
      ...
    }
  }
}

Schema version bumps additively — missing tokens fall back to built-in defaults so old files still work.

Storage:

  • ~/.config/AetherSDR/themes/ — user themes, full read/write
  • Built-in default-dark.json and default-light.json shipped inside the Qt resource system (:/themes/), exposed to the manager as read-only baselines
  • Active theme name persists to AppSettings under a single key (ActiveTheme, defaults to "Default Dark")

Theme Editor (Phase 5)

Modeless dialog with two panes:

  • Primary: Inspector pane. "Inspect" toggle, current target display (widget class + token name + current value), inline editor (colour picker / gradient stop-strip / font picker / sizing input). One click in inspect mode picks a target; live preview repaints the rest of the UI as you change the value.
  • Secondary: Token tree pane. Tree grouped by category (Color → Background → tier 0, tier 1, …). Useful for "I want to scan all backgrounds" workflows. Each row shows the resolved value and clicking opens the same inline editor.
  • Save / Save As / Revert / Discard buttons commit or roll back the draft layer.
  • Reset-token-to-default and Reset-theme-to-default actions per row / globally.
  • Import button — opens file dialog for .aethertheme JSON files (Phase 6).
  • Export button — saves current theme to user-selected location (Phase 6).

Phased rollout

Each phase is a coherent shippable unit. Phases 1-3 ship the engine and most-visible migration. Phases 4-6 layer the user-facing capabilities on top.

Phase 1 — Foundation (1 PR)

  • ThemeManager class + JSON loader + token API
  • Ship default-dark.json baked into Qt resources, identical to current colours so v0 ships with zero visible diff
  • Stylesheet template resolver ({{token}} substitution)
  • AppSettings hook for active-theme persistence
  • Unit tests for the manager (load, fallback to default on missing token, schema-version check)
  • Acceptance: existing UI renders identically; ThemeManager::instance().color("color.accent") returns #00b4d8

Phase 2 — First migration pass (1-2 PRs)

  • Migrate the most visible shared stylesheets to the template form
  • Stylesheet resolver records the (widget instance → tokens referenced) reverse-map at apply time — feeds the Phase 5 inspector for free
  • Audit shared style strings in src/gui/SliceLabel.h, src/gui/CommonStyles.h, applet base classes
  • Categorize the ~336 QColor literals into a spreadsheet and map each to a token (a tools/audit-colours.py helper can generate the inventory)
  • Acceptance: at least 30% of setStyleSheet sites converted; visual diff still zero; tokensForWidget() returns non-empty for migrated widgets

Phase 3 — Custom widget migration (2-3 PRs)

  • PanadapterWidget, WaterfallWidget (gradient stops), MeterSlider, MeterWidget, slice indicators, peak-hold markers
  • These widgets connect to themeChanged and repaint
  • ThemableWidget mixin lands here — coarse-grained default (single region covering the whole widget) for migrated widgets; custom widgets override themeRegions() to declare fine-grained regions (trace vs peak-hold vs noise-floor vs background)
  • Acceptance: all custom paint code reads from tokens; switching ActiveTheme between two identical themes is a no-op visually; tokensAtPoint(panadapter, ...) returns the correct token for each sub-region

Phase 4 — Default Light theme (1 PR)

  • Designed, not inverted. Light theme needs:
    • White-to-light-gray background tiers
    • Dark text with proper contrast
    • Adjusted accent colours (the cyan #00b4d8 needs to shift to something with 4.5:1+ contrast against #ffffff)
    • Waterfall gradient redesigned for light backgrounds (current dark-to-bright doesn't translate; light theme wants light-grey to dark colour-spectrum)
    • Spectrum trace, peak-hold, average all retuned for contrast against white panadapter background
  • Internal review on screenshots before ship
  • Acceptance: Default Light is usable for at least 4 hours of continuous operation without eye fatigue; receives positive feedback from at least 2 community operators

Phase 5 — Theme Editor UI (2-3 PRs)

  • ThemeEditorDialog — modeless, inspector-first.
  • Inspector mode: global event filter, transparent cyan-outline overlay tracking the widget under cursor, click→pick→edit flow with auto-exit
  • Token tree pane as secondary navigation
  • Colour picker, gradient stop-strip editor, font picker, sizing input — each surfaces when the picked token is of the matching type
  • Draft layer wiring (beginDraft/commitDraft/discardDraft); Save / Save As / Revert / Discard
  • Settings → Appearance shortcut to launch
  • Acceptance: user can enter inspect mode, click a wrong-colored region in the main UI, pick a new colour, watch it apply in realtime, save under a new theme name, switch back to default, switch to new theme again

Phase 6 — Import / Export (1 PR)

  • .aethertheme file format (basically .json with a magic identifier and signature)
  • Export button writes current theme
  • Import button validates schema version, sanity-checks values, drops to user themes dir
  • Drag-and-drop import on the Theme Editor dialog
  • Schema migration logic (v1 → v2 when we add tokens)
  • Acceptance: export a theme on Linux, import on macOS, theme switches and renders correctly

Phase 7 (out of scope for v1) — OS tracking, time-of-day, gallery

Risks

  • Migration scope is large — ~336 literal QColor sites plus ~1876 stylesheet sites. Realistic estimate 4-6 weeks of focused work to complete all phases. Phasing keeps each PR reviewable.
  • Waterfall gradient redesign — light-theme waterfall is genuinely hard. The cyan-magenta-yellow gradient that reads well on black doesn't read on white. May need a domain-knowledgeable second pass.
  • Per-widget cachingthemeChanged repaints every widget; performance regression risk on panadapter heavy paint loops. Mitigation: token values cache resolved QColor/QFont and only invalidate on themeChanged.
  • Stylesheet template overhead — every setStyleSheet call now passes through string substitution. Mitigation: cache resolved strings keyed by (template, themeId).
  • Hard contrast regressions — operators on bad monitors might find Default Light unreadable. Mitigation: Theme Editor lets them fix it themselves; ship a "high contrast" preset as a third built-in eventually.
  • Schema evolution — adding new tokens later. Mitigation: every token has a default in code; missing-in-file tokens fall back; schema version controls major restructures.
  • Gradient editor complexity — Phase 5's editor needs a stop-marker strip widget on top of the basic colour picker. Mitigation: build the scalar editor first, layer the gradient editor as an "advanced" panel that appears when the token's current value is a gradient or the user clicks "Convert to gradient" on a scalar token.
  • Inspector reverse-lookup misses on unmigrated widgets — clicking a widget that hasn't been migrated yet returns no tokens. Mitigation: fallback UI suggests parent-widget tokens and the token tree as alternatives. Migration completeness improves over time as Phases 2/3 progress; Phase 5 ships even with partial coverage.
  • Global event filter performance — installing a Qt event filter on QApplication during inspect mode adds a hop to every mouse event. Mitigation: only install while inspect mode is active; remove the filter on toggle-off. Cyan-outline overlay redraws only on widget-change, not on every mouse pixel.
  • Custom-cursor and overlay focus issues — modeless dialog with global event capture is a known source of focus bugs (Wayland in particular). Mitigation: test on KDE Plasma Wayland early; fall back to a less intrusive "Inspect (Hold Ctrl)" modifier-key trigger if the always-on overlay causes issues.

Out-of-scope decisions to revisit

  • Should built-in themes be editable in-place, or always require Save-As? Recommendation: built-ins are read-only; first edit prompts Save-As.
  • Should font.family.ui honour system fallback fonts? Recommendation: yes — if requested family is missing, fall back through a configured chain.
  • Should the theme file include a preview thumbnail? Recommendation: not in v1; can be added in schema v2.

Why label no-claude

This is multi-month architectural work touching most of the GUI. Each phase needs human design judgement (especially the Default Light visual design and the Waterfall gradient). The orchestrator's strength is bounded one-issue-one-fix work; this is the opposite shape.

73, Jeremy KK7GWY & Claude (AI dev partner)

Metadata

Metadata

Assignees

No one assigned

    Labels

    GUIUser interfaceenhancementImprovement to existing featuremaintainer-reviewRequires maintainer review before any action is takenno-claudeReserved for human implementation

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions