feat(ux): Shared Qt 6.12 window chrome refactor and radio switcher — macOS testing - #5489
feat(ux): Shared Qt 6.12 window chrome refactor and radio switcher — macOS testing#5489jensenpat wants to merge 11 commits into
Conversation
…hersdr#4764) Replaces the 32 px in-app strip with a single 52 px bar that is the window's only title bar on all three platforms. The window stays frameless everywhere (Windows via WM_NCCALCSIZE, as before), so exactly one thing draws chrome. What the bar owns, left to right: window controls, brand mark, radio tabs, spacer, audio cluster, applet-dock selectors. New widgets * BrandMark logo + wordmark, "SDR" filled with the brand gradient * RadioTabBar per-radio tabs, status dots, discovered-radios popover * WindowCaptionButtons three control styles: macOS traffic lights (12 px on a 20 px pitch, glyphs on group hover, grey when the window is inactive), Windows 46 px caption buttons, Linux bordered chips * mac/WindowChrome objc++ trim for the frameless NSWindow — puts NSWindowStyleMaskResizable back so AppKit keeps owning edge resize, plus continuous rounded corners and shadow Heartbeat folded into the active tab's dot The standalone lamp is retired; one dot now answers both "which radio is this" and "is its link alive". The state machine is unchanged, including the two rules that are easy to lose in a rewrite: three missed sweeps is the alarm threshold, and the alarm holds SOLID red when the operator has blinking disabled rather than going quiet. Only its output moved. Platform chrome * Windows: WM_NCHITTEST answers HTMAXBUTTON over the maximize control so Snap Layouts opens on hover, and DwmSetWindowAttribute asks for DWMWCP_ROUND (Win10 falls back to square). Hover and click there are driven from the WM_NC* messages, since answering HTMAXBUTTON takes the mouse away from Qt. * Resize band widened to 8 px, still via QWindow::startSystemResize() so the compositor keeps ownership and OS tiling/snapping behave. No more emoji as controls The speaker/headphone controls were literal 🔊 / 🎧 glyphs, which render in the platform's own colour and weight and cannot follow the theme. Replaced with painter-drawn thin-line marks at the house 1.8 px stroke. Theming 15 new tokens under color.titlebar.* and color.brand.* in both bundled themes, seed regenerated, canonical-tokens.md updated. The traffic-light and chip fills are deliberately NOT tokenised — red/amber/green is the affordance being borrowed from the platform, and a themed "red" close control would stop reading as one. Automation New `get titlebar` model and `titlebar` verb (selectRadio / showDiscovery / minimize / maximize / close). Both the bar and each tab report a screenRect, and the model exposes a live linkPulse level so the heartbeat is assertable instead of eyeballed. docs/automation-bridge.md regenerated. Tests tests/unified_title_bar_test.cpp, headless. Pins the bar height, that nothing reserves a strip above it (offsetInWindow == 0), that a status-only change reuses the tab widget rather than rebuilding it, and that state is never encoded by dot colour alone — every tab spells it in words on the rendered status line and in its accessible name. 242/242 pass. Not yet verified on hardware: Windows (Snap Layouts, DWM corners, and the aethersdr#4557 top-edge-drift path this touches) and Linux (chips, fractional-scaling drag jitter). Per RFC review those are PR gates and need someone else's machines. Refs aethersdr#4764, aethersdr#3445 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the textual rebase merged cleanly but got semantically wrong, plus one regression of my own that only became visible once the bar landed. setHeadphoneMuted() was setting an emoji again (aethersdr#4722) Upstream added the reconcile setter while this branch was replacing the 🔊/🎧 emoji with painter-drawn marks, so git kept its setText() verbatim and the button would have carried an emoji AND an icon. Routed through applyAudioIcon(). The setter's contract is unchanged and still load-bearing: the QSignalBlocker suppresses `toggled`, so the repaint has to be explicit or the checked state and the mark disagree. titlebar_headphone_mute_test asserted on those emoji bytes It would have failed outright. Both contracts it exists for are still exactly right — the explicit repaint, and that a reconcile must not re-emit and turn the radio's status echo into a fresh command (Principle II) — so the test is retargeted from the label text to the rendered icon rather than weakened. Comparing the drawn mark is also stronger: it survives the next change of mark, where a byte comparison against a glyph does not. Minimal mode had lost the radio-link indicator This branch folded the heartbeat lamp into the active tab's dot, and setMinimalMode() hides the radio strip — so minimal mode would have had no link indicator at all, where the old standalone lamp stayed visible. Added RadioTabBar::setCompactMode(): active tab only, no "+", indicator intact. Minimal mode is precisely when an operator has nothing else on screen that would show them a dropped link. Also: TitleBar.h's class comment still described the macOS approach this branch abandoned (keeping the real NSWindow and floating the native traffic lights over the bar). Rewritten to describe what actually ships. Bridge verb table regenerated — 65 verbs with main's additions alongside `titlebar`. Test suite on the new base: 282 tests, all green. Five hl2_* tests fail under `ctest -j8` and pass serially; they share a single-client hpsdrsim and carry no RESOURCE_LOCK, and this branch touches no HL2 file. Pre-existing, not from here. Refs aethersdr#4764 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 52 px bar's three applet controls each toggled a field of their own —
dock side, visibility, floating — with no single owner, so the fields could
disagree. A dock click while the panel was floating docked it back and then
fell through and hid it: one click doing two things, leaving an invisible
panel that took two more clicks to recover. That is the "takes two or three
pop-outs" report.
Route all three controls, Ctrl+Shift+S and the bridge through one entry
point, applyAppletPanelState(floating, dockedLeft, visible), which takes the
COMPLETE desired state instead of mutating one field. Two combinations are
now unrepresentable by construction: floating-and-hidden (an empty float
window has no affordance to bring the contents back), and side-set-without-
visibility. The dock-click rule is stated once — clicking the wall the panel
already occupies is the hide gesture, and that only reads as "hide" when the
panel is genuinely docked there and on screen.
Two further defects surfaced only under test, both of which would have
survived a reading:
* QSplitter will not re-slot a HIDDEN child. Setting the wall while the
panel was hidden persisted the new side but left the widget in its old
slot, so it reappeared on the wrong wall. Show before reordering.
* setAppletPanelDockedLeft() was not idempotent — pre-existing, and the
one with teeth. insertWidget() detaches the panel first, which shifts
m_panStack down one index, so re-inserting at the now-stale panIdx drops
the panel on the far side and silently flips the dock. Docking left twice
moved it right. The title-bar path never hit it because clicking the same
wall means hide; the new verb hit it on the first call. Re-asserting the
current side is legitimate (it happens on un-float and on restore), so
the guard lives in the setter rather than in its callers.
The dock and pop-out controls carried only tooltips: no objectName, no
accessibleName. They were absent from dumpTree and invisible to screen
readers, and had to be driven by raw pixel coordinates. Named
appletDockLeftBtn / appletDockRightBtn / appletPopOutBtn, with accessible
names and descriptions.
New `applet` verb (dock left|right, float on|off, show, hide, state), 66
verbs total. The snapshot returns geometry and splitterIndex/panIndex beside
the flags, because splitterIndex < panIndex is what actually proves the left
dock — asserting on `side` alone lets a zero-width panel pass as a success.
The mute toggles kept an accent focus ring painted around them after a
click. Fixed with focusPolicy(Qt::TabFocus) rather than by deleting the
:focus rule, so the ring still appears for keyboard navigation, where it is
the only indication of where focus sits.
Verified against the built-in simulator over the bridge: 158 checks, 0
failures, driven through the real widgets via clickAt on the labels rather
than through the verb, so it exercises the operator's own path. Covers the
original repro, eight consecutive pop-out clicks alternating every time, the
full 3-control x 4-state transition matrix with invariants asserted after
every transition, one-click recovery onto the REQUESTED wall from every
state, verb/echo parity, and 30 rapid-fire clicks with no settle. Asserting
the wall rather than just visibility is what caught the hidden-reorder bug.
The state machine is radio-independent and was reproduced with the radio
fully disconnected — it is not specific to the simulator.
Not covered: the applet logic lives inside MainWindow, which has no test
harness, so none of these three bugs is guarded in CI. The driver script is
not checked in.
Developed in parallel with the preceding reconcile commit, which fixed the
same setHeadphoneMuted() emoji leftover and retargeted
titlebar_headphone_mute_test to the rendered icon independently; that work is
kept as it stands there and dropped from here.
Refs aethersdr#4764
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving the applet panel to the LEFT wall left a 260 px strip on the right
that showed the desktop through the window. Measured on real screen
captures: 6/6 left docks on a fresh launch, 0/6 right docks, with the strip
falling at exactly the slot the panel had occupied when docked right.
The panel does not resize when it changes walls, it TRANSLATES, so the
panadapter has to slide the same 260 px the other way. Geometry was already
correct — the widget reported the full 1450 px while its native surface was
still the pre-flip 1190. So the pan repainted happily, every frame, into a
drawable 260 px too narrow, and the strip beyond it was painted by nobody.
MainWindow is WA_TranslucentBackground and its paintEvent is the only source
of pixels for regions the tree leaves bare, and Qt excludes native children
from that backdrop, so "painted by nobody" composites to the desktop rather
than to the app background.
The asymmetry is why the report was always "panel on the left": when the
panel moves RIGHT the strip needing new cover is taken by the applet panel,
an ordinary widget that paints correctly. Only the left flip depends on the
native surface keeping up.
Two changes, because either alone leaves a hole:
* PanadapterStack::refreshAfterLayoutShift() re-realizes each spectrum's
native window after a same-window move. resetGpuResources() + update()
was tried first and did NOT work — re-rendering into the same undersized
surface changes nothing, which is also why the continuously redrawing
waterfall never healed it. Destroying the native window is the part that
re-establishes the geometry, so this reuses the full reparent path at the
cost of a Metal re-bind on an explicit, infrequent operator action.
* AppletPanel paints its own background. It had none — no
autoFillBackground, no WA_StyledBackground, no paintEvent — so its area
depended entirely on a backdrop that does not reach behind native
children. The FLOATING panel has always been opaque; this gives the
docked panel the same guarantee.
Also: the pop-out opened full-screen. restoreGeometry() faithfully restores a
MAXIMIZED state if one was ever captured, and the float window re-saves its
geometry on every Move/Resize — so one accidental maximize (a title-bar
double-click will do it) poisons the setting and every later pop-out opens
across the display. A 260 px applet rail maximized full-screen is never the
intent, so the state is dropped on restore and clean geometry written back,
healing an already-poisoned config on first use.
Verified over the bridge against the built-in simulator: 0/6 left docks
broken (was 6/6), 0/8 right docks, plus 14 rapid clicks with no settle,
float/dock interleaving, and hide/show interleaving — all clean. Every
measurement gates on confirming AetherSDR is frontmost first, after an
earlier run measured an overlapping window and reported false failures.
Refs aethersdr#4764
…rier, Windows minimal mode Five blockers from @ten9876 and @aethersdr-agent, plus the two nits. The radio-tab commit is split out to its own branch as asked; what remains here is the chrome. **A re-clicked tab stayed unchecked forever.** RadioTab is checkable and belongs to no exclusive group, so QAbstractButton toggles it off on press; setActiveRadio() then early-returned on an unchanged id, so applyActiveState() never re-checked it — and nothing else did either, since setRadios() also early-returns on an identical list. Clicking the tab of the radio you are on is the most natural thing on the strip to click, and it stopped the strip showing which radio you were on. The click now re-asserts the checked state. **Activation was optimistic.** The click moved m_activeId even though MainWindow deliberately opens the picker instead of switching, so the strip and the bridge's activeId both claimed a radio the client had never connected to, with the link visuals following the wrong tab. The active tab now follows the session only — setActiveRadio() from refreshRadioTabs(). Same fix on the popover row, which had the same optimistic claim. **The link indicator had no carrier while disconnected.** Only the tab matching m_activeId took the override and alarm, so with nothing connected the state rendered nowhere — and "searching" is reported precisely when nothing is connected, which is the state it exists for. Minimal mode compounded it: the strip showed only the active tab, so with no active radio it rendered empty, the exact loss the strip is kept in minimal mode to prevent. The carrier now falls back to the first tab, and compact mode shows whichever tab is carrying. An active id that matches no tab yet (mid-rebuild) is deliberately left uncarried rather than painted onto an unrelated radio. **Windows: the maximize control stopped exiting Minimal Mode.** WM_NCHITTEST answered HTMAXBUTTON unconditionally, so Windows routed the click non-client, Qt never saw it, and the branch maximized a strip that is not meant to be maximized — a regression against main, where the control was a QLabel through TitleBar::eventFilter. Both the hit-test answer and the click are now gated on minimal mode; declining HTMAXBUTTON there also stops Snap Layouts offering to tile the strip. Also added the missing TrackMouseEvent(TME_LEAVE|TME_NONCLIENT), without which WM_NCMOUSELEAVE never arrives and the maximize button stays lit after the cursor moves into the client area. **refreshAfterLayoutShift() fired on the no-op path.** applyAppletPanelState() routes show, hide, un-float, pop-out, Ctrl+Shift+S and the bridge verb through setAppletPanelDockedLeft(), so a per-panadapter native re-realize ran on transitions where nothing moved — and on the hide path it ran before the visibility change that causes the geometry shift. Now scoped to an actual wall change. The visibility flip does need the same treatment (the panadapter gains or loses the panel's 260 px), so that call moved into setAppletPanelVisible() where the width actually changes, guarded on a real change. Nits: the pulse decay was 1400 ms against a ~1 s beat interval, so the ticker was re-armed mid-decay and ran at 12.5 Hz for the whole session — the opposite of the "an idle bar costs nothing" claim next to it. Now 850 ms, with the comment made honest about why. The macOS CMake comment described keeping the real NSWindow chrome, which is the approach the branch abandoned; reworded per the suggestion. All four behaviours are now guarded in unified_title_bar_test, which is where the review found the gap — blockers 1, 2 and 7 were caught by a reviewer's scratch harness and by running the app, not by CI. Verified as real guards: reverting the fixes fails all four assertions. Verified: 282 tests pass; 158 bridge checks for the dock/float state machine; and the see-through strip stays fixed after the scoping change — 0/10 across six dock flips and four hide/show cycles, the path the refresh moved to. NOT verified, no hardware here: the Windows minimal-mode fix, the TrackMouseEvent change, and the two PR-gate items the RFC review moved from RFC to PR (aethersdr#4557 frameless top-edge drift, Wayland fractional-scaling drag jitter). Refs aethersdr#4764
Use Qt expanded client areas and native captions where supported, retaining a shared Linux fallback. Improve radio-switcher search, action gating, naming and tab visibility. Align the macOS brand with measured native traffic-light bounds using a native unified toolbar. Validated with the macOS Qt 6.12 preview build, four focused offscreen tests, native Cocoa geometry assertions and isolated automation-bridge checks. Windows/Linux CI and native tiling certification remain deferred; this is a testing branch, not a release-toolchain migration.
|
Thanks for this — the unified title bar work is a lot of careful design, and the All five checks on 1. Four jobs die at CMake Configure — the Qt 6.12 floor
find_package(Qt6 6.12 REQUIRED COMPONENTS ...)Every CI leg pins Qt 6.8.3, so
This isn't infrastructure breakage and it isn't a mistake in your C++ — it's the expected consequence of the migration, and your README note and design doc already say so ("Release CI and the Windows/Linux packaging toolchains have deliberately not been migrated yet"). Worth stating plainly though: there is no code change on this branch that makes CI green while the floor stays at 6.12. The options are a maintainer decision, not yours:
One thing I'd flag regardless of which route you take: the diff also removes the 2. Static checks — one stale generated file (real fix, one line)This one is genuinely independent of Qt and is a quick fix. I ran the full Static-checks suite against your branch locally; five of the six pass (engine boundary, test registration, frozen CI gate, theme seed, network timeouts, shader dialects — and the hardcoded-colour ratchet is clean too, all three counters down vs base: Your three new GUI files include python tools/gen_touchpoint_manifest.pywhich produces exactly one changed line in -| `core/ThemeManager.h` | 144 | ui-support — ... | unconverted |
+| `core/ThemeManager.h` | 147 | ui-support — ... | unconverted |Commit that and Static checks goes green on its own, regardless of what happens with the Qt decision. (The accessibility scan in that job is warnings-only and never fails it — see docs/a11y.md.) Summary
No Copilot or reviewer comments on the PR to reconcile against. Regenerate the manifest whenever you get a moment, and it'd be worth a maintainer weighing in on the Qt floor before you invest more in rebases — that call decides whether this branch can ever be green in its current shape. Thanks again for the thorough validation notes; they made this much faster to diagnose. 🤖 aethersdr-agent · cost: $5.2388 · model: claude-opus-5 |
Summary
MacOS-first testing continuation of the unified-window-chrome work from closed PR #4906 and RFC #4764. This draft preserves the rebased feature history and adds the Qt 6.12 preview migration, radio-switcher usability changes, and native traffic-light/brand alignment. It is not ready for release or cross-platform approval.
PR split
The grouped Add: applet picker, brighter category headings, and pinned S-Meter viewport alignment now live independently in draft PR #5490: #5490. That branch is based directly on main and retains the Qt 6.8 minimum; it does not depend on this Qt 6.12 chrome branch.
This PR remains window chrome and radio-switcher work only. Signed follow-up
f7f1786fupdates the scope/evidence documentation; chrome implementation remains unchanged from0de5a2b6. No new chrome build or tests are claimed for this documentation-only follow-up. Both branches merge together without conflicts in the local merge-tree check.Constitution principle honored
Principle XI — Fixes Are Demonstrated: distinguish headless checks, native Cocoa geometry, automation-bridge behavior, packaging, and outstanding native-platform acceptance. Principle V — radio-switcher visibility is a nested client-owned UI document rather than radio operating state.
Test plan
cmake --build build -j22before committing this identical source tree. The existing app embeds the priorbf53789bdirty-build identity; it was not rebuilt just to stamp the new commit.unified_title_bar_test,titlebar_headphone_mute_test,hl2_pc_audio_lock_test, andconnection_panel_size_test; 4/4 passed.[19,19,60,14], brand bounds[95,0,109,52], a 16-pixel gap, and shared vertical center at 26 pixels across resize, maximize/restore, and fullscreen round trips.Testing notes
Build requirements, architecture choices, and validation boundaries are documented in
docs/qt612-window-chrome.md. Qt 6.12 is a preview SDK in this branch. The current macOS test package lacks QtKeychain, so credentials are session-only. It is ad-hoc signed, not a notarized release.Latest deployed combined app:
~/Desktop/AetherSDR qt612-applet-picker-0de5a2b6-dirty 1748.appon the test Mac. It includes both the chrome and picker changes, with verified bundle hashes, symlinks, ad-hoc signing, quarantine removal, and a loader smoke check. It is not a deployment of either standalone PR head. The priorqt612-spacing-bf53789b-dirty 1627chrome package remains separate. Manual laptop acceptance remains with the operator.Generated with OpenAI Codex (GPT-6 Astra)