Repository navigation
fix(gui): dim the Radio Setup headphone row. Principle VIII. - #6353
Conversation
Refs aethersdr#5482 Radio Setup -> Audio -> "Radio Audio Outputs" offers the radio's headphone level and mute on every radio. RadioModel::setHeadphoneGain/Mute only send `mixer headphone ...` wire text, which RadioModel::sendCmd drops when there is no command plane, so on a Hermes-Lite 2 (or ANAN, Icom) the slider moves and nothing happens. The row (label, slider, value, mute) now uses the title bar's predicate, headphoneControlsAvailable(isConnected(), hasCommandPlane()): dimmed, not hidden, with the title-bar slider's reason on tooltip and accessibleDescription; restored on disconnect; re-evaluated on connectionStateChanged. The Line Out row above it keeps its own gate, lineoutControlsAvailable (aethersdr#6260): the two rows are gated separately, so on an ANAN, which declares RadioCapabilities::lineoutControl, Line Out stays live while Headphone dims. Test: radio_setup_headphone_output_gate_test, socket-free, with the capabilities the real hl2 and anan backends declare and the Demo as the command-plane control. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Issue fit
Yes. Refs #5482 is the honest relation: #5482 itself is the title-bar site, answered by #6044, and this PR takes the one remaining site the triage comment on that issue named and the author's 2026-10-04 follow-up comment left open — "the 'Radio Audio Outputs' group in Radio Setup → Audio … still offers the line-out and headphone controls on a radio with no command plane". Line Out was gated by #6260 (merged); this closes the headphone half, so every caller of the two setters is now gated. I grepped setHeadphoneGain|setHeadphoneMute across src/: the only callers are the title-bar connects (MainWindow.cpp:2055, :2088, gated at MainWindow_Session.cpp:3244), the wheel action (MainWindow_Controllers.cpp:1555, gated at :1545), the MIDI knob (:2342), and this row — no TCI, CAT, automation or rigctld path reaches them, so the set really is closed. The load-bearing premise checks out: RadioModel::setHeadphoneGain/setHeadphoneMute are wire-only (sendCmd("mixer headphone gain %1") / mute %1, no local-audio arm), so dimming removes nothing an HL2/ANAN/Icom operator could hear.
The triage's own suggestion was one outGroup->setEnabled(...) for all four controls. The PR deliberately does not do that, and is right not to: an ANAN declares RadioCapabilities::lineoutControl, so a group-level gate would dim a Line Out row that works. Gating the two rows on their two predicates is the better answer and the body says so.
Scope
| File | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/gui/RadioSetupDialog.cpp |
+23: one applyHeadphoneAvailability lambda, its initial call, one connect to connectionStateChanged |
Yes | In scope |
tests/radio_setup_headphone_output_gate_test.cpp |
New socket-free test, 3 cases incl. a positive control | Yes | In scope |
tests/tests.cmake |
Registers the target; adds it to AETHER_SETTINGS_CONSUMERS |
Yes | In scope |
Everything in the diff is explained by the issue. No CHANGELOG.md entry (correct). No new public/protocol surface, no settings key, no capability field, no default changed. Nothing removed — the diff is additive only (git diff --stat: 291 insertions, 0 deletions), so there is no deleted guard or comment to account for. The tests.cmake block is a verbatim clone of the radio_setup_recording_mode_dim_test block above it (same source list, same aetherdesktop_support link, same QT_QPA_PLATFORM=offscreen + TIMEOUT 60), which is also why the settings-consumer registration is right.
Preference check: this is a fix, not a preference. #5482's acceptance criteria name the behaviour ("visibly dimmed with a reason; accessibleDescription carries it"), the three-state doctrine from #5262 forbids hiding, and the PR dims rather than hides. The reason string is lifted from TitleBar.cpp:1300-1302 rather than invented.
Socket tests: none added or removed. The new test is socket-free in fact, not just in claim — the injected NoCommandPlaneBackend never touches a transport; realCapabilities() builds a real Hl2Backend/AnanBackend, and I read both constructors: MetisClient and P2Client are constructed but neither binds nor connects until connectRadio(), which is never called. The command-plane control goes through SimBackend, whose connectRadio() just sets m_connected = true and emits — no socket despite the LocalHost:4992 in the RadioInfo (the comment "never dialled" is accurate).
Blockers
None.
Nits (non-blocking)
src/gui/RadioSetupDialog.cpp:4827-4829— the mute button is given the volume slider's reason. The body discloses this ("One reason text for the whole row … not the title bar's separate mute sentence"), so this is a maintainer call rather than an oversight — raising it because the divergence from the surface this PR says it mirrors is the kind of thing that gets re-filed later:const QString reason = available ? QString() : QStringLiteral("Unavailable: this radio has no headphone output. " "Its audio plays on this computer; set its level " "with the master volume slider.");
TitleBar::setHeadphoneAvailablesplits this deliberately —TitleBar.cpp:1296-1303gives the mute button "…mute it with the speaker button." and only the slider "…set its level with the master volume slider." Here all four widgets share the slider's text, so a screen-reader user on Mute is told how to change the level. The row's own Line Out twin has the same single-string shape, so it is consistency-with-the-sibling rather than a regression, but if you want the title bar's wording verbatim:(The test'sauto applyHeadphoneAvailability = [this, hpLabel, hpSlider, hpValue, hpMute] { const bool available = AetherSDR::headphoneControlsAvailable( m_model->isConnected(), m_model->hasCommandPlane()); const QString prefix = QStringLiteral( "Unavailable: this radio has no headphone output. " "Its audio plays on this computer; "); const QString levelReason = available ? QString() : prefix + QStringLiteral("set its level with the master volume slider."); const QString muteReason = available ? QString() : prefix + QStringLiteral("mute it with the speaker button."); for (QWidget* w : {static_cast<QWidget*>(hpLabel), static_cast<QWidget*>(hpSlider), static_cast<QWidget*>(hpValue)}) { w->setEnabled(available); w->setToolTip(levelReason); w->setAccessibleDescription(levelReason); } hpMute->setEnabled(available); hpMute->setToolTip(muteReason); hpMute->setAccessibleDescription(muteReason); };
announcesReason()checksstartsWith("Unavailable:"),contains("no headphone output"),contains("master volume")andtoolTip() == accessibleDescription(), so the mute assertion would need its substring changed with it.)tests/radio_setup_headphone_output_gate_test.cpp:189— the ANAN case asserts!model.hasCommandPlane()but nevermodel.isConnected(), unlike the HL2 case. It cannot pass vacuously (a disconnected radio would make the row live androwDimmedwould fail), so this is cosmetic symmetry only.tests/radio_setup_headphone_output_gate_test.cpp—announcesReason()is asserted on the slider and the mute only;hpLabelandhpValueare checked forisEnabled()but their reason text is pinned only in the negative (rowHasNoReason). Cheap to extend, not worth a round trip.- The two known failures the body names match what I have on record for this tree —
peripheral_auth_dialog_interaction_testis the #6008 clipped-page check on a host serial-port name, andunified_title_bar_test's two checks are #6224. The hand comparison offered for the first one (page minimum still 784, no widget added) is the right check to have made, given this PR does touchRadioSetupDialog.cpp. I could not re-run either.
What I tried to break
- The missing
capabilitiesChangedconnect. The Line Out twin 35 lines above (RadioSetupDialog.cpp:4785-4789) wires bothconnectionStateChangedandcapabilitiesChanged; this hunk wires only the former, which looks like a copy-paste omission. I went after it from both ends.headphoneControlsAvailablereads onlyhasCommandPlane(), which ism_wanConn != nullptr || m_connection != nullptr(RadioModel.h:608), set insetupBackend()(RadioModel.cpp:885;:914for the Sim) and cleared inteardownBackend()(:1800) — so a capability edge cannot move it. Then the inverse, which is the one that would actually bite: are therecapabilitiesChangedemissions on edgesconnectionStateChangedmisses? Yes —publishCapabilities()has four call sites (RadioModel.cpp:1150,:1161,:1173,:2195): theconnectionStateChangedrelay, ameterDefined/meterRemovededge that flipshasMicPeakMeter/hasSupplyVoltage, and the backend's owncapabilitiesChanged. None of those three extra edges touchesm_connectionorm_wanConn. The remaining window is the live family switch inconnectToRadio()(:3683), which tears the backend down mid-session — but neither signal fires there (rebuildBackendForFamilyemits onlybackendRebuilt), so the Line Out row is equally stale, and the connect attempt that follows raises the edge both rows need. Conclusion: the second connect would be a no-op for this predicate. Still worth a maintainer's one-line ruling on whether two adjacent rows should nonetheless look alike; note the test leg hand-emitsconnectionStateChanged, so it would not catch a regression here either way. - Blanking the widgets' own tooltips.
setToolTip(QString())on the available branch is exactly the failure mode #5859/#6253 is about, so I grepped everyhpLabel/hpSlider/hpValue/hpMutereference in the file (lines 4793-4876): none of the four is ever given a tooltip or anaccessibleDescriptionof its own, and theaudioOutputChangedreconciler at:4866writes values only. Nothing is clobbered. Same for the enabled state — no other site touches these four. - Would the test pass against unfixed code? No, and the body backs that empirically rather than by assertion: it pastes the fail-before run on
b3e0302ewith the tworowDimmed(hp)failures and the control passing. My own read agrees —findRow()is keyed on the operator-visible caption"Headphone:", which exists onmain, so the lookup succeeds and the assertion fails rather than skipping. TheconnectedCommandPlaneUnchangedpositive control means the three cases can't pass on a row dimmed for every connected radio, anddropped.count() == 0plus thegain/mutedround-trip means they can't pass on a hunk that also writes the level. - Compile risk from the fake backend.
NoCommandPlaneBackendoverrides 11 methods;IRadioBackendhas exactly 11 pure virtuals (capabilities,connectRadio,disconnectRadio,isConnected,setSliceFrequency,setSliceMode,setSliceFilter,setSliceAgc,setPanCenter,setKeying,invokeExtension), andsetBackendForTest's third parameter defaults, so the two-argument call compiles. All five checks are green ona0ea328—build,check-macosandcheck-windowseach compiled with the new target registered, so the test source builds on every lane that reaches it. Note the new test is not in anyctest -Rfilter inci.yml— it is build-only untilfull-suite.ymlpicks it up after merge. - CodeGuard's 20 findings. All 20 are false positives, verified line by line in the head checkout — credit security-audit (CodeGuard):
:181isif (!manager || id.isEmpty()), a C++||and not a shell pipe; the fourCS-VLN-CMDI-AMPhits (:179,:5118,:5809,:5965) areconst QString&/const QByteArray&reference declarators; all fourteenCS-VLN-CMDI-BACKTICKhits are Markdown-quoted Flex/HL2 wire verbs inside//comments (apd configurable=1,freqcal,interlock set,file update failed=, …) with noQProcess/system()/popenanywhere near them; andCS-INJ-homoglyphis em-dashes in comments and label text, no mixed-script identifier. None is in the added hunk, and the hunk builds no string from input — the reason is a compile-timeQStringLiteral. - Principle VI / the drop notice. Dimming means the control cannot be moved, so nothing reaches
sendCmdand the once-per-session "nothing was sent to the radio" notice is no longer burned by a headphone move — the opposite of the #5919 concern, and the test pinscommandDroppedat zero. No TX path is touched.Hl2Backend's constructor does setm_txAllowed/enableTransmit(true)when the test builds one for its capabilities, butconnectRadio()is never called and no keying request is made. - a11y lint.
tools/check_a11y.py src/gui/RadioSetupDialog.cppreports 9 warnings, every one on a pre-existing line (:2056,:2152,:2156,:2230,:2379,:2392,:4591,:4599,:6304). The new block is clean — it writesaccessibleDescriptionbeside the tooltip, which is what the lint asks for.
All of the above is reasoned from the head checkout at /tmp/aetherclaude/pr-6353 and from CI status; I have no build and no way to drive the dialog, so nothing here was reproduced at runtime. Not checked, and named as such: whether an Icom actually has a headphone mixer (#5482 leaves that open and the predicate gates it either way); how the row looks on screen; and the body's own local numbers (759 tests, 753 pass, 4 skipped, 2 known failures) — that claim stands or falls on the author's run, and it is consistent with the two failures I have on record here.
Recommendation
Approve with nits. Small, additive, correctly scoped, reuses the predicate and the wording the title bar already shipped, and the test is honest — a positive control, a findRow lookup that fails rather than skips against unfixed code, and a check that the dimming moves no level and drops no command. The one thing I would change before merge is the mute button's reason string (:4827); the capabilitiesChanged asymmetry with the Line Out row deserves a maintainer's one-line ruling but is a no-op either way. Thanks ON8ST — the "Limits, first" section did real work here: the two-rows-gated-separately table stopped me writing a wrong-footed finding about the ANAN case, and disclosing the single reason string up front is the difference between a nit and an argument.
🤖 aethersdr-agent · cost: $15.1509 · model: claude-opus-5
|
Cisco CodeGuard — static analysis of this PR (20 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $15.3397 · model: claude-opus-5 |
Review of aethersdr#6353: the dimmed mute button carried the level slider's reason, so a screen reader on Mute said how to change the level. It now carries the title bar's mute sentence (TitleBar::setHeadphoneAvailable); label, slider and value keep the level sentence. The test compares all four widgets with the title bar's two sentences in full, on the HL2 and the ANAN case, and the ANAN case asserts the injected radio is connected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. Fixed in
Not changed: the Full ctest at 73, ON8ST |
NF0T
left a comment
There was a problem hiding this comment.
Approve
Small, additive, correctly scoped. The Headphone row takes the same predicate the title bar already uses (headphoneControlsAvailable) and the title bar's two sentences, word for word. I checked that against TitleBar.cpp:1296-1302. The Line Out row is left alone, which is right: an ANAN declares lineoutControl, so a group-level setEnabled would have dimmed a row that works. The "Limits, first" section and the two-rows-gated-separately table made this quick to review. Thank you for that.
Scope
| File | Change | Claimed? | Verdict |
|---|---|---|---|
src/gui/RadioSetupDialog.cpp |
+30: one applyHeadphoneAvailability lambda, its initial call, one connect |
Yes | In scope |
tests/radio_setup_headphone_output_gate_test.cpp |
New, 3 cases incl. a positive control | Yes | In scope |
tests/tests.cmake |
Registers the target, adds it to AETHER_SETTINGS_CONSUMERS |
Yes | In scope |
Additive only (+311/−0). No CHANGELOG.md entry, no new public surface, no capability field, no changed default, no deleted guard. It is a fix, not a preference: #5482 names the behaviour (dimmed, reason announced) and the three-state doctrine forbids hiding. CODEOWNERS: * and tests/ only, so Tier 3 (@aethersdr/reviewers). Docs: audio-settings.md already says "On radios without one of these outputs, the matching controls are dimmed with the reason in the tooltip", so this change brings the code in line with the docs and none are owed.
CI limits
build, check-macos, check-windows, Static checks and Sanitizer option configures are green on 6df430e3. The new test is not in the frozen per-PR allow-list, so CI only compiled it. It first runs on main via full-suite.yml. The run below is the only execution of it on Windows. The PR is 10 commits behind main and merges cleanly.
What I tried to break
Built the merge result of main (d6402657) + this PR on Windows (MinGW 13.1, Qt 6.12.0, ENABLE_ASR=OFF), not the branch tip alone.
- Test passes with the change:
radio_setup_headphone_output_gate_test,radio_setup_recording_mode_dim_test,mixer_control_availability_testall pass. - Fail-before reproduced. With the
RadioSetupDialog.cpphunk reverted:hl2DimsTheHeadphoneRowWithItsReasonandananDimsHeadphoneButKeepsLineOutfail onrowDimmed(hp), andconnectedCommandPlaneUnchangedpasses. This matches your numbers. - Two finer mutations, both caught. (1) Deleting the
connectionStateChangedconnect failsrowLive(hp)("disconnected: the row is live again"). (2) Giving the mute button the level sentence failsrowAnnouncesItsReasons(hp)on both the HL2 and ANAN cases. So the mute-wording fix in6df430e3is pinned. - Connect and disconnect ordering, from source. On
connectionStateChanged(true)for HL2,Hl2Backendsetsm_connected = true(:427) beforeemit connected()(:450), which reachesRadioModel::onConnected(:6959, emit at:7012), soisConnected()is already true. For Flex,m_connectionis set insetupBackend()(:885) andm_wanConnat:3813, both before the edge. On disconnectisConnected()is false, so the row goes live whichever pointer is still set. The row cannot be wrongly dimmed on a Flex connect. - The missing
capabilitiesChangedconnect. The predicate reads onlyhasCommandPlane()andisConnected(). I traced wherem_connection/m_wanConnare assigned and cleared, and none of those edges is a capabilities-only edge. I agree it is a no-op for this predicate. If you want the two adjacent rows to look alike, it is one line, and I would not hold the PR for it. - Step 0 (socket boundary). The new test opens no socket.
NoCommandPlaneBackendis a stub with no transport, andSimBackendhas zeroQTcp*/QUdp*/QLocal*/QWebSocket*references. TheLocalHost:4992inRadioInfois never dialled. - Bridge, Demo only (
DEMO-0001,AETHER_AUTOMATION_NO_TX=1, isolated settings dir, own socket). Radio Setup → Audio on the PR build: Line Out and Headphone rows live, both Mute buttonsenabled: true, no tooltip or accessible description on them. The title-bar headphone pair is live too. I closed my instance afterwards. - Not tested: HL2 or ANAN in the running app (the Demo is the only no-hardware backend, and it has a command plane), so the HL2 and ANAN rows are covered by the injected-backend test and by reading the code, not by driving the app. macOS and Linux were not run. The 20 CodeGuard findings in the timeline are on lines this PR does not touch. I checked only that the added hunk builds no string from input (compile-time
QStringLiteral, no process or shell call).
Forward guidance (non-blocking)
-
"Dimmed" is visible on the slider only. I rendered the group box on the HL2 case and on the Demo case and compared pixels region by region:
Region Differing pixels, dimmed vs live Headphone label 0 / 1504 Headphone slider groove 2350 / 9528 Headphone value label 0 / 432 Headphone Mute button 0 / 864 Line Out Mute button (#6260, merged) 0 / 918 The label, value and Mute button carry explicit stylesheets with no
:disabledrule, so they look identical when disabled. They are still disabled (they do not respond) and still announce the reason, so this does not block the PR. It is the same shape as the merged Line Out row, and changing only one row would make the two disagree.theme-style-guide.md§4a namescolor.control.unavailablefor the unavailable state, so a small follow-up covering both rows (a:disabledrule on the Mute buttons and a disabled colour on the labels) seems right to me. That is your call or the maintainer's. The pixels came from a throwawaygrab()in my local copy of the test; nothing from it is in the PR. -
The reason sentences now live in three places (
TitleBar.cpp, this lambda, and the test'skLevelReason/kMuteReason). "The two sites cannot disagree" holds for the predicate, but not for the wording: if the title bar's text changes, this test still passes against its own copy. Hoisting the two sentences next to the predicate inMixerControlAvailability.hwould make that structural. It is not worth a round trip here.
Squash subject
fix(gui): dim the Radio Setup headphone row. Principle VIII. (#6353)
Principle VIII (Evidence Over Assertion) fits: the claim is backed by a functional check that fails before and passes after.
73, Ryan NF0T
👨🏼💻 Co-authored by Claude Sonnet 5.5
Review of aethersdr#6388, nit 1: :checked and :disabled tie on specificity, so a Mute engaged on an unavailable Radio Audio Outputs row kept the engaged background under unavailable text. QPushButton:checked:disabled now sets the row's own unchecked background, color.background.1, and the unavailable token. A control renders in one of three states (theme-style-guide.md, Three-state controls), and this one is unavailable: engaged or not, it renders the same. The test engages both rows' Mute in both themes with the toggled signal blocked, so no model setter runs and commandDropped stays at 0. The dimmed button has no #8b0000 pixel, the live one does, and the dimmed button renders as it does not engaged. Nit 2: a size mismatch reports "regions differ in size", not "dimmed renders as live". Nit 3: an RAII guard restores the starting theme on every exit from the slot. The header comment says the Line Out reason is not one of the hoisted sentences. Refs aethersdr#5482, aethersdr#6353 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted file: tests/tests.cmake, in two places. 1. The registration block after radio_setup_recording_mode_dim_test. Main added radio_setup_headphone_output_gate_test there (aethersdr#6353, 9fabcbf); this branch has radio_setup_show_tx_waterfall_test in the same spot. Both blocks are kept whole, main's first, ours after it. The two source lists are identical, so ours links what main's sibling links. 2. AETHER_SETTINGS_CONSUMERS: both names kept, main's first. The merged file is origin/main plus exactly this branch's 40 lines; nothing from either side is dropped. No other file conflicted. RtlSdrBackend.cpp, SimBackend.cpp, RadioSetupDialog.cpp and RadioModel.cpp merged as text; each still states txWaterfallClientFlag next to main's new receiveAudioExport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#6388) Refs #5482, #6353 This is the follow-up to the two forward notes in the approving review of #6353. ## Limits, first - **macOS, offscreen platform only** (Qt 6.12.0). The pixels are from `QWidget::grab()` in a test. Nobody looked at the dialog on a screen, and there is no screenshot. - **Two themes rendered: Default Dark and Default Light.** No user theme. - **The Line Out row's styling is touched; its logic is not.** That row is @jcmerg's (#6260). His predicate, his reason text and his `applyLineoutAvailability` lambda are unchanged. Only the three stylesheets of the row gain a `:disabled` rule, the same one the Headphone row gets, so the two rows keep agreeing. - **The Front Speaker row is not touched.** It is never disabled. - **A dimmed Mute looks the same engaged or not** (from this PR's review, `c0bfd883`). An engaged Mute on an unavailable row took the unavailable text on the engaged red background. It now takes the row's own unchecked background (`color.background.1`), so the engaged state is not shown while the row is unavailable. §4a names no look for a control that is unavailable and also has state, so whether the state should stay visible is the maintainer's call. The checked state is still on the widget for accessibility clients. - **Contrast is what the token gives.** `color.control.unavailable` is 2.09:1 (dark) and 1.77:1 (light) by the style guide's own table (§4a). This PR uses the token and does not change it. - **The Line Out reason sentence is not hoisted.** It exists in one place only (`src/gui/RadioSetupDialog.cpp:4774-4775` at `9fabcbf8`); no other source file and no test repeats it. - `Refs`, not `Fixes`: closing #5482 stays your call. ## What was wrong At `9fabcbf8`, in Radio Setup → Audio → "Radio Audio Outputs", a disabled row shows it on the slider only. The other three widgets of each row carry their own stylesheet, and none has a `:disabled` rule: | widget | stylesheet | applied at (Line Out / Headphone) | |---|---|---| | caption label | `kLabelStyleTemplate`, `QLabel { color: {{color.text.primary}}; … }` (`src/gui/RadioSetupDialogCommon.h:31-32`) | `src/gui/RadioSetupDialog.cpp:4740` / `:4794` | | value label | `kValueStyleTemplate`, `QLabel { color: {{color.accent.bright}}; … }` (`RadioSetupDialog.cpp:129-130`) | `:4745` / `:4799` | | Mute button | inline `QPushButton { … color: {{color.text.primary}}; … }` plus `:checked` | `:4751-4753` / `:4805-4807` | | slider | none of its own | `:4742` / `:4796` | An explicit `color` with no `:disabled` rule also applies when the widget is disabled, so the label, the value and the Mute button render the same in both states. The review of #6353 measured 0 differing pixels on them; the test here reproduces that (below). The two headphone reason sentences were written out three times: `src/gui/TitleBar.cpp:1297-1302`, `src/gui/RadioSetupDialog.cpp:4826-4834`, and `tests/radio_setup_headphone_output_gate_test.cpp:134-139`. ## The change **The look.** A disabled label, value or Mute button of the two rows takes `color.control.unavailable`, the token §4a of `docs/style/theme-style-guide.md` names for the unavailable state: ``` QLabel:disabled { color: {{color.control.unavailable}}; } QPushButton:disabled { color: {{color.control.unavailable}}; } ``` This is the pattern already in the tree for the TX timing fields (#6166, `RadioSetupDialog.cpp:2565-2567`, `QLineEdit:disabled`) and for `WfmApplet.cpp:58`. The rules go through `ThemeManager::applyStyleSheet`, so they follow a theme switch like their neighbours. No colour literal is added. The enabled look is unchanged: the rule is appended to the existing template, and only for these six widgets. `makeValueLabel` takes an optional extra rule so `kValueStyleTemplate` keeps its single reference. The shared `applyLabelStyle` is not changed, so no other page of the dialog moves. **The wording.** `src/gui/MixerControlAvailability.h` now holds the two sentences next to the predicate, as `kHeadphoneLevelUnavailableReason` and `kHeadphoneMuteUnavailableReason`. They are `constexpr char[]`, so the header stays free of Qt. `TitleBar::setHeadphoneAvailable`, the Radio Setup lambda and both tests read them from there. The text itself is unchanged, character for character. No predicate, no capability field, no default and no user-visible string changes. `docs/user/docs/audio-settings.md` already says the controls "are dimmed with the reason in the tooltip", which stays true. ## Tests `tests/radio_setup_headphone_output_gate_test.cpp` gains `dimmedRowsLookUnavailableInBothThemes` (offscreen, no socket). For each of Default Dark and Default Light it builds the dialog on the HL2 capabilities (both rows dimmed) and on the Demo (both rows live), and for the label, value and Mute button of each row: - renders the widget's own region with `grab()` and counts differing pixels, exactly, no tolerance. It requires more than 0 between dimmed and live; - first asserts the two renders are comparable: same size, same value text, same mute state. So the enabled state is the only difference; - control 1: the same widget rendered twice differs in 0 pixels; - control 2: after a disconnect the same HL2 widgets are live again and render pixel-identical to the Demo's, and different from their own dimmed render; - requires the resolved stylesheet to carry the colour `color.control.unavailable` has in the active theme. With the change, differing pixels (measured at `6c83ac5a`, the same diff before it was rebased onto current `main`; the test that asserts them passes at the head), dimmed vs live, identical in both themes: | region | Headphone | Line Out | |---|---|---| | label | 1329 / 6840 | 852 / 6840 | | value | 410 / 2280 | 410 / 2280 | | Mute | 512 / 3800 | 512 / 3800 | **Fail-before.** Named mutation: this commit with both `:disabled` rule strings emptied, which is main's styling. Full build and ctest. All twelve regions read 0 differing pixels, as in the review: ``` PIXELS Default Dark | Headphone: label: dimmed vs live 0 / 6840 (same widget twice: 0) PIXELS Default Dark | Headphone: value: dimmed vs live 0 / 2280 (same widget twice: 0) PIXELS Default Dark | Headphone: mute: dimmed vs live 0 / 3800 (same widget twice: 0) PIXELS Default Dark | Line Out: label: dimmed vs live 0 / 6840 (same widget twice: 0) PIXELS Default Dark | Line Out: value: dimmed vs live 0 / 2280 (same widget twice: 0) PIXELS Default Dark | Line Out: mute: dimmed vs live 0 / 3800 (same widget twice: 0) (the same six lines for Default Light) FAIL! : RadioSetupHeadphoneOutputGateTest::dimmedRowsLookUnavailableInBothThemes() 'failures.isEmpty()' returned FALSE. (Default Dark | Headphone: label: dimmed renders as live Default Dark | Headphone: label: not the unavailable token #3a4a5a … Totals: 5 passed, 1 failed, 0 skipped, 0 blacklisted ``` The test could not be run on unmodified main as it stands, because it reads the sentences from the header. That is why the fail-before is a mutation and not "the test alone". **The wording.** `radio_setup_headphone_output_gate_test` compares the row against the header's two constants. `tests/mixer_control_availability_test.cpp` gains two rows: the title bar's mute and slider descriptions equal the header's sentences. Its existing rows still check the content independently ("Unavailable:", "no headphone output", "speaker button", "master volume"). ## ctest macOS (Apple Silicon), Qt 6.12.0, `ENABLE_ASR=OFF`, full suite at `-j8`, failures rerun serially, on `c0bfd883` (this PR's head): - **765 tests, 763 passed, 2 failed**, both also on the serial rerun: the dialog test below, and `vkamp_connection_test` with only its two known checks (#5254). - `peripheral_auth_dialog_interaction_test` fails, at `-j8` and serially: "Clear code or Status is clipped at the default dialog size", `dialog 960x680, viewport 658, page minimum 784`. This is the host-dependent failure of #6008 (diagnostic in #6134): the Peripherals page is as wide as the longest serial-port name on this Mac. The page minimum is 784, the same figure as on clean main here. This PR does not touch the Peripherals page. - `radio_setup_headphone_output_gate_test` (6 passed), `mixer_control_availability_test`, `titlebar_headphone_mute_test`, `unified_title_bar_test` and `radio_setup_label_theme_token_test` pass. Static checks, run locally: `tools/check_a11y.py` on `RadioSetupDialog.cpp` 9 findings before and 9 after, `TitleBar.cpp` 0; `tools/audit_colours.py` summary identical before and after; `gen_touchpoint_manifest.py --check` and `docs/gen_reference.py --check` up to date; the `tools/check_*.py --strict` set passes (`check_codeql_coverage.py` needs a build directory and was not run). 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #5482
Limits, first
lineoutControlsAvailable,src/gui/RadioSetupDialog.cpp:4769atb3e0302e), and this PR does not touch it.Refs, notFixes. With fix(gui): non-Flex controls work or refuse aloud. Principle VIII. #6044 (title bar), fix(audio): the title-bar mute reaches the radio's line out only where it has one. Principle VIII. #6260 (Line Out row) and this row, every control the issue and its triage name is gated. The "whose output is this" labelling the issue raises is not done here; it stays at M4 unless you pull it forward, so closing M0 item 3 follow-up: the title-bar headphone slider is a second unconverted site for mixer headphone gain/mute #5482 is your call.hl2andananbackends declare, with an injected backend that reports connected. No socket was opened.radio_setup_recording_mode_dim_test.The defect
At
b3e0302e, Radio Setup → Audio → "Radio Audio Outputs" offers the radio's headphone level and mute on every radio (src/gui/RadioSetupDialog.cpp:4791-4818, no gate).RadioModel::setHeadphoneGain/setHeadphoneMuteonly sendmixer headphone ...wire text.RadioModel::sendCmddrops that when there is no command plane, so on a Hermes-Lite 2 (or ANAN, Icom) the slider moves and nothing happens. #6044 fixed the same pair in the title bar. This is the second site the triage on #5482 found.Relation to #6260
#6260 dimmed the Line Out row of the same group, on
lineoutControlsAvailable(connected, hasCommandPlane, declaresLineout). This PR adds the Headphone row in the same shape, directly below it, on the predicate the title bar already uses:src/gui/MixerControlAvailability.h)lineoutControlsAvailable, line 43headphoneControlsAvailable, line 37The title bar's headphone pair is driven from the same accessor (
src/gui/MainWindow_Session.cpp:3244), so the two sites cannot disagree. No predicate and no capability field is added or changed.The change
src/gui/RadioSetupDialog.cpp,buildAudioTab(): the Headphone row (caption, slider, value, mute) takesheadphoneControlsAvailable(isConnected(), hasCommandPlane()):accessibleDescription("Unavailable: this radio has no headphone output. Its audio plays on this computer; set its level with the master volume slider."), the sentence the title-bar slider carries;connectionStateChanged.Neither direction writes the level or the mute.
Test
tests/radio_setup_headphone_output_gate_test.cpp(new, offscreen, socket-free; registered like itsradio_setup_*siblings):accessibleDescription. The level and mute are unchanged and nocommandDroppedfires. On disconnect the row is live again with no reason left on it, and on reconnect it dims again.lineoutControl): the Headphone row is dimmed, the Line Out row stays live with no reason. This pins that the two rows are gated separately.Fail-before. The test alone on
b3e0302e, full build and ctest: 2 of its 3 cases fail, the control passes.With the change the test passes.
Checks
6df430e3(macOS, Qt 6.12.0,-j8, failures rerun serially): 759 tests, 753 pass, 4 skipped (nr_stereo_independence_dfnr_24000/48000,_bnr_24000/48000), 2 fail. Both fail identically in the test-only run onb3e0302e, so neither comes from this change:peripheral_auth_dialog_interaction_test(feat(peripherals): authenticate direct TGXL, PGXL, and Antenna Genius connections #6008, diagnostic test: drop two host-dependent assumptions that fail on macOS. Principle VIII. #6134): "dialog 960x680, viewport 658, page minimum 784, horizontal scroll max 126", widest item a USB serial-port name on this Mac. This PR touchesRadioSetupDialog.cpp, so it was compared by hand: the same four lines, the page minimum still 784 with and without the change. The change adds no widget and does not widen the page.unified_title_bar_test: its two checks reported on Unified title bar (#6198): native-platform checks still open — Windows Snap Layouts, #4557 drift, macOS M1, xcb move #6224 ("Alt+F still opens the File menu with the menu bar hidden", "opening a menu rounds the submenus added to it since"), no other.tools/check_a11y.pyonRadioSetupDialog.cpp: 9 warnings, the same 9 as onb3e0302e, none on the new block.check_capability_records(70, unchanged),check_command_plane,check_engine_boundary,check_test_registration,check_wdsp_test_links,check_qsettings,check_ci_test_gate,check_build_options,check_network_timeouts,check_qt_pin,check_shader_dialects,check_theme_seedwith--strict: OK.gen_touchpoint_manifest.py --check: up to date.73, ON8ST
🤖 Generated with Claude Code