feat(aethercontrol): double-click to latch / unlatch (replaces click+ESC asymmetry)#3103
Conversation
…ESC asymmetry)
AetherControl's virtual FlexControl knob captured the mouse on
single-click and released it on Escape. Operators reported losing
track of the release path (especially first-time users who didn't see
the small "Press ESC to release" hint) and feeling stuck.
Bind both directions to the same gesture:
Latch → double-click on the wheel
Unlatch → double-click on the wheel (or ESC as secondary safety)
Single-click on the wheel → no-op (event accepted so the parent
dialog doesn't mis-interpret it as a
window-drag start)
ESC stays as a secondary release path for the edge case where the
operator can't get the cursor back over the knob to double-click
(e.g. wheel scrolled the radio's VFO far enough that the cursor
ended up offscreen).
Updated user-visible strings:
- Resting hint: "Double-click the knob to capture circular tuning."
- Armed hint: "Mouse locked to FlexControl. Double-click the knob
to release (or press ESC)."
- Accessible description mirrors the new gesture.
AetherControl original implementation @rfoust (#2888); FlexControl
wheel UX overhaul @rfoust (#3029). This change preserves the
existing wheel-feel work and only touches the capture/release
gesture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks @ten9876 — agree the click-to-latch / Esc-to-unlatch asymmetry was a real discoverability cliff, and double-click as a single binding is a nice fix. Implementation looks clean: the press-event no-op is correctly still accept()-ing so it doesn't fall through to a window drag, mouseDoubleClickEvent handles both directions, and the explanatory comments capture the why well.
One inconsistency worth fixing in this PR
The in-knob "armed" badge painted at src/gui/FlexControlDialog.cpp:621 still reads:
const QString hint = QStringLiteral("Press Esc\nto release");That overlay is the most prominent capture-state cue (centered on the wheel, accented border) and now contradicts the updated bottom-of-dialog hint and accessible description, both of which name double-click as the primary release path. A first-time captured user will see "Press Esc to release" front-and-centre and never discover that double-click also works — which is the exact UX gap this PR is trying to close. Suggest something like "Double-click\nor press Esc" (or two lines, whichever still fits the badge rect at the existing point size).
Minor observations, not blockers
mousePressEventonly checksisEnabled()and notm_captured— fine, since while captured the press just accepts and the double-click event drives the release, but worth a mental note in case anyone adds press-side behavior later.- Test plan still has the CI / manual boxes unchecked; once Linux build + manual smoke confirm, this is ready.
Nice quality-of-life improvement. Just the badge text update and this is good to go from my end.
🤖 aethersdr-agent · cost: $3.6850 · model: claude-opus-4-7
…+ Save As) (#3130) ## Summary First user-facing surface of the theming subsystem. Operators can now edit any colour token in the active theme and ship a custom theme to disk, all without touching a JSON file. **View → Theme Editor…** opens a modeless dialog with: - "Editing: \<active theme\>" header - Filter bar — type-to-match against dotted token names (e.g. \`accent\`, \`slice\`, \`meter\`) - Sorted list of every colour token with a 16×16 swatch + hex value per row - Click row → \`QColorDialog\` opens seeded with the current value - Accept → \`ThemeManager::setColor()\` → emits \`themeChanged\` → every widget registered through \`applyStyleSheet\` repaints on the next event-loop turn. **No restart**. - "Save As…" prompts for a theme name and writes the current tokens to \`~/.config/AetherSDR/themes/\<name\>.json\` via \`saveCurrentThemeAs()\`. The new theme is registered + made active immediately. ## ThemeManager additions | Method | Purpose | |---|---| | \`allTokenKeys()\` | Sorted enumeration of every loaded token — used by the editor to populate its list. | | \`setColor(token, color)\` | In-memory mutation + themeChanged. Hex round-trip so \`cssFragment()\` / \`resolve()\` pick up the new value the same way they pick up loaded tokens. No-op on a same-value write. | | \`setSizing(token, int)\` | Integer-token equivalent for panel padding / border widths. Not yet exposed in the editor UI; will land in PR 2 alongside font/sizing surface. | | \`saveCurrentThemeAs(name)\` | Rebuilds nested JSON from the flat dotted-key map (so on-disk shape matches bundled themes), writes to user themes dir, registers + activates. Handles QString/int/double/bool scalars + full \`ThemeGradient\` round-trip so the waterfall colormap and slice.dim tokens persist correctly. | ## Explicitly NOT in this PR (deferred to Phase 5 PR 2-4) 1. **Inspector mode** (click-on-widget to find tokens that paint it). \`ThemeManager::tokensForWidget()\` already exposes the reverse-map populated by \`applyStyleSheet\`; wiring is a few hundred lines once we settle on the latch/unlatch UX. Probably matches the AetherControl double-click convention (#3103). 2. **Gradient editing** for \`waterfall.colormap.{default,grayscale,fire,…}\` and \`slice.dim.{a..h}\`. The list filters them out via the \`brush().gradient()\` probe so they don't render as misleading-first-stop swatches. 3. **Font / sizing editing** — same Phase 5 PR 2 surface as the inspector mode. 4. **Import** (file picker for arbitrary JSON), drag-and-drop, theme deletion, theme renaming. ## Build verified clean - [x] AetherSDR builds clean - [x] All 320 targets including every test target ## Phase status | Phase | Item | Status | |---|---|---| | 1 | ThemeManager scaffolding + JSON loader | ✅ | | 2 | Gradient tokens + applyStyleSheet reverse-map + mass migration | ✅ | | 3 | Paint-code migration + canonical-table closeout | ✅ | | 4 | Default Light theme | ⏳ (#3129, in review) | | **5** | **Editor UI (PR 1: live colour editing + Save As)** | **this PR** | ## Test plan - [ ] CI: build / check-paths / check-windows / CodeQL - [ ] Visual smoke: - [ ] View → Theme Editor… opens the modeless dialog - [ ] Title shows "Editing: Default Dark" (or whatever's active) - [ ] Filter bar — type \`accent\` → only accent.* rows visible - [ ] Click an accent row → QColorDialog opens, pick a wild hue, accept → that colour appears immediately wherever \`color.accent\` paints (most buttons, slider handles, badges) - [ ] Save As… → name it "Test", confirm: - File written to \`~/.config/AetherSDR/themes/Test.json\` - Title bar updates to "Editing: Test" - View → Theme submenu (from #3129) now lists "Test" - [ ] Switch back to Default Dark via the View menu → original colours restored, custom edits gone (file on disk preserved) - [ ] Reopen Test from the menu → custom edits restored from the saved file 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
Changes AetherControl's virtual FlexControl knob from a click-to-latch / Escape-to-unlatch UX to a double-click for both directions, with Escape kept as a secondary safety release.
Why
Operators report losing track of the release path. First-time users miss the small "Press ESC to release" hint at the bottom of the dialog and feel stuck with the cursor captured.
What changed
Updated user-visible strings:
"Double-click the knob to capture circular tuning.""Mouse locked to FlexControl. Double-click the knob to release (or press ESC)."ESC stays as a backup for the edge case where the cursor ended up offscreen and can't get back over the knob to double-click.
Attribution
AetherControl original implementation by @rfoust (#2888); FlexControl wheel UX overhaul also by @rfoust (#3029). This change preserves the existing wheel-feel work and only touches the capture/release gesture.
Test plan
73, Jeremy KK7GWY & Claude (AI dev partner)
🤖 Generated with Claude Code