fix(theme): editor UX polish — filter persistence + reset semantics + factory-loader v2#3199
Conversation
… factory-loader v2 Four targeted fixes to the Theme Editor surface, picking up the Tier 3 "editor UX polish" bullets from aethersdr#3184. Hand-tested across both bundled themes and a forked user theme; theme_manager_test gains coverage for the per-applet cascade + the new v2-aware factory snapshot. ## 1. Filter persistence across scope changes refreshTokenList() rebuilds the QTreeWidget rows from scratch, but never re-applied the current text/inspector subset filter — so switching the scope dropdown (or saving a theme As, or any path that triggers a refresh) caused the user's filter to silently fall back to "show everything" while the QLineEdit kept the text it was apparently filtering by. One-line fix: call filterTokensTo(m_activeSubset) at the end of refreshTokenList so the new rows immediately honour the filter. ## 2. Theme-delete-time cleanup of recent-color buckets Per-theme recent-color grids persist under AppSettings key ThemeEditor.RecentColors/<name>. Deleting the theme file used to leave those entries behind forever — a small but real leak called out in aethersdr#3184. onDeleteThemeClicked now removes the matching key after the theme file deletion succeeds. recentColorsKeyFor moved from private to public static on TokenEditorWidget so the dialog can call it (it's the canonical formatter for the AppSettings key shape). ## 3. Reset-to-default semantics at nested scopes At root scope, Reset has always loaded the bundled factory value into the edit buffer for the user to confirm via OK — that behaviour is preserved. At nested scopes (applet/tx, applet/rx, applet/comp, …) the previous behaviour was incorrect: Reset loaded the factory value into the buffer, and OK then wrote that value as a *new* override at the nested scope. Per aethersdr#3184 the intended semantic at a non-root scope is "clear my override here so the scope falls back to inheriting from its parent" — i.e. exactly what right-click → Clear Override on the scope-chain column already does. onResetClicked now branches: - root scope: existing factory-load-into-buffer behaviour - nested scope: calls removeOverride() directly, reloads the editor's buffer + picker from the now-inherited value, emits tokenChanged so the parent dialog repopulates the matching row (so the scope column flips to italic "inherited" and the Value column reflects the now-resolved value — without this signal the Value column would stay stale, as observed during testing) refreshResetButton also branches: at root scope it enables based on hasFactoryValue (existing), at nested scope it enables only when there's actually an override at this scope to clear — mirrors the right-click → Clear Override gating. ## 4. Factory-snapshot v2 schema awareness (uncovered during testing) While verifying Fix aethersdr#3 at root scope, discovered that Reset there was also broken — but for an unrelated reason that predates this PR. ThemeManager::ensureFactoryLoaded() reads :/themes/default-dark.json to build the m_factoryTokens map that factoryColor/factoryString/ factorySizing/hasFactoryValue all read from. Its single flattenTokens call read from doc.object().value("tokens"), which is the v1 schema shape. In v2 (the canonical shape since aethersdr#3176) the tokens live under scopes.root.tokens; the v1 path produces an empty m_factoryTokens against any v2 theme. The downstream effect: hasFactoryValue returns false for every token in a v2 theme, refreshResetButton disables the Reset button at root scope (or, with the existing styling, leaves it visually indistinguishable from enabled — silent failure either way), and factoryColor returns invalid QColor. Has been silently broken for ~2 months across every v2 theme. ensureFactoryLoaded now branches on schemaVersion: - v2: reads primitives + scopes.root.tokens, then resolves {primitive} aliases inline before storing in m_factoryTokens, so factoryColor sees concrete hex values without needing a second lookup pass (matches the live runtime resolver — single-hop). - v1: existing path preserved for older themes still on disk pending auto-migration. theme_manager_test gains assertions on hasFactoryValue + factoryColor for color.accent (alias-resolved) and color.background.0 to lock this in against future schema bumps. ## Verification (Windows 11, MSVC + Ninja + Qt 6.10.3, branched from ## aethersdr/AetherSDR main at 4ee99f7) - Filter persistence: typed "accent" filter, switched scope to applet/tx, list stayed filtered (was unfiltering before fix). - Nested-scope Reset: forked "A test toggle" from Default Dark, set scope to applet/tx, selected color.slider.foreground (#ff4d4d override), clicked Reset — picker reloaded #ff4d4d → #00b4d8, tx column flipped from "#ff4d4d" to italic "inherited", Value column updated to "#00b4d8", Reset button disabled. - Root-scope Reset (factory loader): same theme, set scope to (root), changed color.accent picker to magenta #a12c66, clicked OK, clicked Reset — picker flipped magenta → factory blue #00b4d8, "• unsaved" indicator appeared, OK + Cancel re-enabled, token list row still showed #a12c66 (uncommitted buffer state). - theme_manager_test: new factory-snapshot + per-applet cascade assertions pass. The 4 pre-existing failures on this branch are also present on clean main (lines 398/400/451/590 → 419/421/472/611 here, +21 from inserted test block) — importThemeFromFile-related, Windows-vs-CI environment issue. Not introduced here. cc @ten9876 @jensenpat @chibondking Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks @nigelfenton — this is a tidy, well-scoped fix bundle with a thoughtful "while I was here" find on the v2 factory loader. All four changes look correct.
Verified against the codebase:
- Fix 1 (filter persistence):
filterTokensTo(m_activeSubset)is the right hook — it reads theQLineEdittext andm_activeSubsetdirectly, so a single call at the tail ofrefreshTokenListis the minimum necessary change. - Fix 2 (recent-colors cleanup):
AppSettings::contains/remove/savepattern matches the API (AppSettings.h:31–37). PromotingrecentColorsKeyForto a public static avoids string duplication — good call. - Fix 3 (nested-scope Reset):
removeOverride+setToken+emit tokenChangedchain is right. TheisOverriddenAtguard pairs cleanly with the newrefreshResetButtonbranch, so the button accurately reflects whether the click has any effect. Symmetric with the right-click → Clear Override path. - Fix 4 (v2 factory loader): Inline
{primitive}resolution is single-hop, matchingresolveAliasatThemeManager.cpp:245–256. TheuserType() != QMetaType::QStringguard correctly skips gradient/font entries thatflattenTokensstores asQVariant<ThemeGradient>/QVariant<ThemeFont>. v1 path preserved for pre-migration files.
Conventions: AppSettings (not QSettings) ✓, no obvious null deref / leak risk, all five files in scope.
One small thought (not blocking): the new factory-snapshot test exercises one alias-resolved token (color.accent) and one direct-string token (color.background.0). If you wanted to lock in the gradient/font passthrough path under v2 too, a third assertion against a gradient token (e.g. factoryColor on a token that's a gradient → first stop fallback at ThemeManager.cpp:849–851) would round it out — but the current coverage is enough to catch the regression that motivated the fix.
Good catch on the pre-existing importThemeFromFile failures being a clean-main issue rather than introduced here.
LGTM.
🤖 aethersdr-agent · cost: $9.1271 · model: claude-opus-4-7
Addressing aethersdr-agent's PR aethersdr#3199 review suggestion: extend the factory-snapshot test to assert factoryColor() correctly takes the first-stop-fallback branch (ThemeManager.cpp:849-851) for a gradient token in m_factoryTokens. Gradient tokens land as QVariant<ThemeGradient> rather than QString, so two paths the v2 loader must handle correctly: - The alias-resolution loop (added in this PR) must skip them (userType() != QMetaType::QString guard). - factoryColor() must detect canConvert<ThemeGradient>() and return the first stop's colour. color.meter.bar.fillGradient is bundled in default-dark.json as a v2 gradient with first stop #2f9e6a — locks in both paths against a future schema change that might otherwise silently break gradient handling in the factory snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the review @aethersdr-agent — addressed the gradient-passthrough suggestion in The three observations on #3198 (warning primitive load-bearing-for-future-use, light disabled-state contrast, hover state deferred) are all intentional design choices on this pass and will be revisited in the per-site QSS migration sweep that follows that PR. Thanks for flagging them. 73 Nigel |
|
Thanks Nigel — Spot-checked the v2 path against Re the three #3198 observations — fair enough, all three are clearly scoped as design choices for the per-site QSS migration sweep rather than this PR. The warning primitive being load-bearing-for-future-use in particular is the kind of thing that's easier to justify in the sweep PR's diff than out-of-band here. Happy to revisit when that lands. Re the 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 aethersdr-agent · cost: $2.3285 · model: claude-opus-4-7 |
Four targeted fixes to the Theme Editor surface picking up the Tier 3 "editor UX polish" bullets from #3184. Hand-tested across both bundled themes and a forked user theme;
theme_manager_testgains coverage for the per-applet cascade and the new v2-aware factory snapshot.1. Filter persistence across scope changes
refreshTokenList()rebuilds the token tree from scratch but never re-applied the current text/inspector subset filter — so switching the scope dropdown (or any path that triggers a refresh) caused the filter to silently fall back to "show everything" while theQLineEditkept the text it was apparently filtering by.One-line fix: call
filterTokensTo(m_activeSubset)at the end ofrefreshTokenListso the rebuilt rows immediately honour the filter.2. Theme-delete-time cleanup of recent-color buckets
Per-theme recent-color grids persist under the
AppSettingskeyThemeEditor.RecentColors/<theme name>. Deleting the theme file used to leave those entries behind forever — a small but real leak called out in #3184.onDeleteThemeClickednow removes the matching key after the theme file deletion succeeds.TokenEditorWidget::recentColorsKeyForis promoted from private to public-static so the dialog can call the canonical key formatter directly (no string-duplication risk).3. Reset-to-default semantics at nested scopes
At root scope Reset still loads the bundled factory value into the edit buffer for the user to confirm via OK — that behaviour is preserved.
At nested scopes (
applet/tx,applet/rx,applet/comp, …) the previous behaviour was incorrect: Reset loaded the factory value into the buffer, and OK then wrote that value as a new override at the nested scope. Per #3184 the intended semantic at a non-root scope is "clear my override here so the scope falls back to inheriting from its parent" — i.e. exactly what right-click → Clear Override on the scope-chain column already does.onResetClickednow branches:removeOverride()directly, reloads the editor's buffer + picker from the now-inherited value, emitstokenChangedso the parent dialog repopulates the matching row (so the scope column flips to italic "inherited" AND the Value column reflects the now-resolved value — without that signal the Value column would stay stale, as observed during testing).refreshResetButtonalso branches: at root scope it enables based onhasFactoryValue(existing), at nested scope it enables only when there's actually an override at this scope to clear — mirrors the right-click → Clear Override gating, so the button visually reflects whether clicking it has any effect.4. Factory-snapshot v2 schema awareness (uncovered during testing)
While verifying Fix #3 at root scope, discovered that Reset there was also broken — but for an unrelated reason that predates this PR.
ThemeManager::ensureFactoryLoaded()reads:/themes/default-dark.jsonto build them_factoryTokensmap thatfactoryColor/factoryString/factorySizing/hasFactoryValueall read from. Its singleflattenTokenscall read fromdoc.object().value("tokens")— which is the v1 schema shape. In v2 (the canonical shape since #3176) the tokens live underscopes.root.tokens; the v1 path produces an emptym_factoryTokensagainst any v2 theme.Downstream effect:
hasFactoryValuereturns false for every token in a v2 theme →refreshResetButtondisables the Reset button at root scope (or with the existing styling, leaves it visually indistinguishable from enabled — silent failure either way) →factoryColorreturns invalid QColor. Silently broken across every v2 theme since #3176 landed in March.ensureFactoryLoadednow branches onschemaVersion:primitives+scopes.root.tokens, then resolves{primitive}aliases inline before storing inm_factoryTokens, sofactoryColorsees concrete hex values without needing a second lookup pass (matches the live runtime resolver — single-hop).theme_manager_testgains assertions onhasFactoryValue+factoryColorforcolor.accent(alias-resolved) andcolor.background.0to lock this in against future schema bumps.Verification
Windows 11, MSVC + Ninja + Qt 6.10.3, branched from
aethersdr/AetherSDRmain at4ee99f78.applet/txapplet/tx,color.slider.foreground(#ff4d4doverride) → click Reset#ff4d4d→#00b4d8, tx column flipped from override to italic "inherited", Value column updated to#00b4d8, Reset button disabled ✓(root), changedcolor.accentto magenta#a12c66, OK'd, then Reset#00b4d8,• unsavedindicator appeared, OK + Cancel re-enabled, token list row still showed#a12c66(uncommitted buffer) ✓theme_manager_test— new factory-snapshot + per-applet cascade assertionstheme_manager_test— 4 pre-existing failures (importThemeFromFile-related, Windows-vs-CI env issue)mainat the same source line numbers (398/400/451/590 there → 419/421/472/611 here, +21 from inserted test block). Not introduced by this PR. Happy to investigate separately.Out of scope (follow-ups)
importThemeFromFiletest failures are not investigated here — they predate this PR and the root-cause looks Windows-environment-specific (QTemporaryDir/XDG_CONFIG_HOMEinteraction).cc @ten9876 @jensenpat @chibondking
73 Nigel G0JKN
🤖 Generated with Claude Code