Skip to content

accessibility: phase 2 — widget names, live announcements, VFO tabs, keyboard nav#3303

Merged
jensenpat merged 1 commit into
aethersdr:mainfrom
w9fyi:accessibility/phase-2a-2b-names-and-live-announcements
Jun 2, 2026
Merged

accessibility: phase 2 — widget names, live announcements, VFO tabs, keyboard nav#3303
jensenpat merged 1 commit into
aethersdr:mainfrom
w9fyi:accessibility/phase-2a-2b-names-and-live-announcements

Conversation

@w9fyi

@w9fyi w9fyi commented May 31, 2026

Copy link
Copy Markdown
Contributor

Companion to #3288 (Phase 2 accessibility audit), per maintainer guidance from @jensenpat.

Addresses all four issues from the @jensenpat review on the previous iteration.

What's in this PR

Phase 2a — setAccessibleName on previously unnamed widgets

All changes are additive, no behavioral risk.

MeterApplet (MeterApplet.cpp): PA temperature, supply voltage, main fan speed gauges

AmpApplet (AmpApplet.cpp): Forward power, SWR, drain current gauges — rebased onto the new row-label/ballistics layout from #3301

TunerApplet (TunerApplet.cpp): Forward power, SWR gauges + C1/L/C2 relay bars — rebased onto the same row-label refactor

SpectrumWidget (SpectrumWidget.cpp): "Panadapter spectrum display"

TciApplet / DaxApplet: RX 1–N gain sliders, TX gain slider (named in loop)

Phase 2b — Live accessibility announcements

VFO frequency (VfoWidget.cpp / VfoWidget.h):

  • updateFreqLabel() now routes through scheduleFrequencyAnnouncement() — a 300 ms single-shot debounce timer that fires once the frequency settles, preventing VoiceOver from being asked to speak ~20 times/second during active wheel tuning
  • LOCKED state announces immediately (user-triggered, infrequent) but suppresses repeats while the 500 ms lock-feedback gate is active

S-meter (SMeterWidget.cpp):

  • setFocusPolicy(Qt::TabFocus) added — widget is keyboard-reachable
  • setLevel() announces the displayed value, not raw dBm: in S-Meter Peak mode the event value is derived from m_peakDbm (what the needle and text readout show), not m_levelDbm; formatted as "S7, -79 dBm" to match the painted readout exactly

MeterSlider (MeterSlider.h):

  • setFocusPolicy(Qt::TabFocus) added — DAX/TCI gain sliders are now keyboard-reachable
  • keyPressEvent handler: Left/Down −5%, Right/Up +5% — gain thumb is adjustable without a mouse

Phase 2c — VFO tab bar: QLabelQPushButton

VFO tab labels were QLabel with a click event filter; VoiceOver could not activate them. Replaced with QPushButton throughout (VfoWidget.cpp, VfoWidget.h).

Phase 2d — Keyboard navigation: RelayBar + PhaseKnob

  • RelayBar gains arrow-key support (Left/Right step through relay positions)
  • PhaseKnob excluded from the tab order (it is already accessible via the paired ESC sliders)

Test notes

On macOS with VoiceOver:

  1. Tab to the S-meter — subsequent signal level changes should be spoken as "S7, -79 dBm" etc.
  2. Tune the VFO wheel quickly — VoiceOver speaks once after tuning settles (not on every step)
  3. Lock the VFO — VoiceOver speaks "LOCKED" once
  4. Tab to a DAX/TCI gain slider — Left/Right arrows move the thumb
  5. VFO tab bar buttons ("DSP", "USB", "X/RIT", "DAX") are activatable via VoiceOver

— AI5OS (@w9fyi)

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing the Phase 2 accessibility work forward. I reviewed this against latest upstream/main (73117bb7) and found a few issues to address before merge.

Findings:

  1. This PR is currently non-mergeable with main. AmpApplet.cpp and TunerApplet.cpp both conflict with the recent PWR/SWR applet refactor: main now uses external value labels and meter ballistics, while this PR adds accessible names to the older inline gauge layout. The fix should preserve the new row-label/ballistics layout and add the accessibility metadata on top of it.

  2. VfoWidget::updateFreqLabel() now posts a QAccessibleValueChangeEvent every time the frequency label is refreshed. That method is reached from more than explicit user tuning: slice sync, collapse refresh, lock-feedback state changes, and every SliceModel::frequencyChanged emission from local, CAT, TCI, hardware, or radio status updates. The VFO wheel path alone accepts one tune step every 50 ms, so VoiceOver could be asked to speak around 20 times per second during active scrolling. Please debounce/coalesce this so accessibility gets the settled value rather than every intermediate label refresh.

A possible shape:

void VfoWidget::scheduleFrequencyAnnouncement(const QString& text)
{
    if (!QAccessible::isActive()) return;
    m_pendingAccessibleFrequencyText = text;
    m_accessibleFrequencyTimer.start(300); // single-shot, restart on each tune
}

// timeout:
if (m_pendingAccessibleFrequencyText != m_lastAccessibleFrequencyText) {
    m_lastAccessibleFrequencyText = m_pendingAccessibleFrequencyText;
    QAccessibleValueChangeEvent event(m_freqLabel, m_pendingAccessibleFrequencyText);
    QAccessible::updateAccessibility(&event);
}

I would also consider calling this only from the tune-driven path, or passing an explicit AnnounceFrequencyChange flag into updateFreqLabel(), so sync/collapse repaint paths do not speak. LOCKED can probably announce immediately, but should still suppress repeated identical announcements while the 500 ms locked-feedback gate is active.

  1. SMeterWidget::setLevel() announces the raw dbm, but S-Meter Peak mode paints m_peakDbm instead. That can make VoiceOver announce a value that disagrees with the visible meter. The accessible value should be derived from the same displayed value/text used by paint, e.g. current S-units plus dBm for the active RX mode.

  2. The DAX/TCI MeterSliders receive accessible names, but MeterSlider is still a custom QWidget with no focus policy or keyboard handling. The PR test note says these should announce when focused, but normal keyboard focus cannot reach them yet. Either add real focus/keyboard/value accessibility for MeterSlider, or narrow this PR's claim to naming only.

git diff --check upstream/main...refs/remotes/upstream/pr-3303 is clean. I did not build because the PR does not merge cleanly yet.

@w9fyi w9fyi force-pushed the accessibility/phase-2a-2b-names-and-live-announcements branch from 3e57050 to 3a07f7f Compare May 31, 2026 22:31
@w9fyi w9fyi changed the title accessibility: phase 2a+2b — widget names and live VFO/S-meter events accessibility: phase 2 — widget names, live announcements, VFO tabs, keyboard nav May 31, 2026
@w9fyi

w9fyi commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @jensenpat. I've rebased onto `0f4a6875` and addressed all four items:

AmpApplet / TunerApplet conflicts — Both files are now based on the row-label + ballistics layout from main. Accessible names sit on the refactored widgets (`m_fwdGauge`, `m_swrGauge`, `m_idGauge` for the amp; `m_fwdGauge`, `m_swrGauge` for the tuner). `m_tempGauge` is gone — the `m_c1Bar`/`m_lBar`/`m_c2Bar` names were already present in your version of TunerApplet, so those needed no change.

VFO frequency debounce — `updateFreqLabel()` now routes through `scheduleFrequencyAnnouncement()`, a 300 ms single-shot timer that restarts on each call and only fires `QAccessibleValueChangeEvent` when the settled text differs from the last announced value. LOCKED is still immediate but suppressed on repeats (the 500 ms lock-feedback gate already serialises those).

S-meter displayed value — `setLevel()` now derives the accessible text from the same `displayDbm` selection that `paintEvent` uses (`m_peakDbm` in SMeterPeak mode, `m_levelDbm` otherwise) and formats it as "S7, -79 dBm" to match the painted readout exactly.

MeterSlider focus + keyboard — Added `Qt::TabFocus` and a `keyPressEvent` handler (Left/Down −5%, Right/Up +5%) to `MeterSlider.h`. DAX/TCI sliders are now reachable by Tab and operable without a mouse.

`git diff --check upstream/main...HEAD` is still clean. Force-pushed to the same branch so the PR URL is unchanged.

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase-2 feedback from the prior review is addressed. Verified locally: full build clean on macOS (merged onto current main), and CI is green on Linux, Windows, macOS, and CodeQL. Approving.

@jensenpat jensenpat enabled auto-merge (squash) June 2, 2026 14:00
…keyboard nav (aethersdr#3303)

Phase 2a/2b/2c/2d: setAccessibleName on meter/amp/tuner/spectrum/DAX/TCI widgets; debounced VFO frequency + S-meter live announcements; VFO tab bar QLabel->QPushButton; RelayBar/PhaseKnob keyboard navigation.

Squashed to satisfy signed-commit branch protection; original authorship preserved (w9fyi / Justin Mann / AI5OS).
@jensenpat jensenpat force-pushed the accessibility/phase-2a-2b-names-and-live-announcements branch from 440f1db to 97ec9aa Compare June 2, 2026 14:35
@jensenpat

Copy link
Copy Markdown
Collaborator

Thanks for this, @w9fyi — phase 2 looks great, and I verified it builds clean on Linux, Windows, and macOS.

One heads-up on how it's landing: our main branch requires signed commits, and the commits here were unsigned, which blocked the merge. So I squashed them into a single signed commit on my end so it can go through. Your credit is fully preserved — the commit is still authored by you (w9fyi / Justin Mann / AI5OS); I'm only the committer/signer.

To speed up future merges (and skip the manual squash-and-sign), it's worth setting up SSH commit signing for GitHub on your side. If you're working with an AI agent, you can just ask it to "set up SSH commit signing for GitHub." The gist:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true

…then add that same key to GitHub under Settings → SSH and GPG keys as a Signing Key (separate from an auth key). After that your commits show up as Verified and clear the merge gate automatically — no maintainer squash needed.

Thanks again for the accessibility work — this is a meaningful improvement. 🙏

@jensenpat jensenpat merged commit cd30049 into aethersdr:main Jun 2, 2026
4 checks passed
NF0T pushed a commit that referenced this pull request Jun 5, 2026
#3396)

## Summary

Two small follow-ups to the applet-slider keyboard support that landed
in #3303, plus an audit so the same UX is consistent across every applet
slider. Both features make the keyboard path behave like the mouse path
on applet sliders (AGC level, TCI/DAX gain, etc.).

### 1. Value badge on keyboard steps

When you drag an applet slider with the mouse, a value badge pops up
above the thumb and lingers briefly (~450 ms) after release. Adjusting
the **same slider with the keyboard** previously showed **no badge**.

Now keyboard stepping flashes the identical badge with the identical
linger/fade timeout, so keyboard and mouse give matching visual
feedback.

- `GuardedSlider` (AGC threshold, most applet sliders): gains a public
`flashDragValue()`. Keyboard nudges for these sliders are routed through
`MainWindow`'s shortcut-lease key handler, so that handler calls
`flashDragValue()` after each arrow / `Ctrl`+arrow / `PageUp`/`PageDown`
/ `Home`/`End` step. It's reached via `dynamic_cast` because
`GuardedSlider` has no `Q_OBJECT` macro.
- `MeterSlider` (TCI/DAX combined meter + gain fader): shows and lingers
the badge directly from its own `keyPressEvent`.

### 2. Enter returns focus to the panadapter

After a mouse interaction, a slider holds a short keyboard lease (2 s) /
focus, during which arrow keys nudge the slider instead of driving the
global panadapter shortcuts. If you want those global shortcuts back
**immediately** — without waiting for the lease to expire — you can now
press **Enter** while the slider has focus.

- `GuardedSlider`: `MainWindow` releases the shortcut lease and clears
slider focus, re-enabling the global operating shortcuts.
- `MeterSlider`: hides the badge and clears focus.

### 3. TCI/DAX (MeterSlider) — making the keyboard path actually
reachable

The first pass added the badge + Enter handling to `MeterSlider`, but
that code was **dead**: `MeterSlider` is a plain `QWidget` with
`Qt::TabFocus`, so a mouse drag never left keyboard focus on it, and it
never engaged the shortcut lease. Because the arrow keys are **global
shortcuts** (`Left`/`Right` tune frequency, `Up`/`Down` adjust AF gain),
a matched `QShortcut` consumes the key before the focused widget's
`keyPressEvent` runs — so `MeterSlider`'s own arrow stepping (and the
new badge) never executed.

Fixes, mirroring `GuardedSlider` behavior exactly:
- `MeterSlider`: `Qt::TabFocus` → `Qt::StrongFocus`, plus an explicit
`setFocus(MouseFocusReason)` on left-press, so a mouse interaction hands
off to the keyboard. Adds `isDragging()`.
- `MainWindow`: the shortcut lease is generalized from
`QAbstractSlider*` to `QWidget*` so `MeterSlider` can hold it (freeing
the arrows from the global tune/AF shortcuts while focused).
`focusChanged` and the mouse-press event filter begin the lease for
`MeterSlider` too, and a keypress hook renews it on each step so it
doesn't expire mid-adjustment. A `leaseHolderBusy()` helper unifies
`isSliderDown()`/`isDragging()` so the lease never times out mid-drag
for either type.

### Applet-slider audit (gaps found)

| Slider type | Count | Mouse badge | Keyboard badge | Status |
|---|---|---|---|---|
| `GuardedSlider` | 66 | ✅ | ✅ (this PR) | covered |
| `MeterSlider` (TCI/DAX) | 4 | ✅ | ✅ (this PR) | covered |
| plain `QSlider` | 8 | ❌ | ❌ | pre-existing gap, see below |

The 8 plain `QSlider`s have **never** shown a badge (mouse or keyboard):
`AetherDspWidget` DFNR atten/beta, `StripFinalOutputPanel`
freq/level×2/morse-pitch, `StripWaveformPanel` window,
`SpectrumOverlayMenu` line-width. Most live in transient
**dialogs/menus**, not the main applet panel, and the keyboard-badge
path depends on the panadapter shortcut lease that only applies in the
main window. Converting them to `GuardedSlider` would give them the
mouse badge for free; left out of this PR to keep it focused — easy
follow-up if wanted.

## Behavior summary

| Action | Before | After |
|---|---|---|
| Keyboard-step an applet slider (incl. TCI/DAX) | no badge | badge
flashes + lingers (same as mouse) |
| Press Enter with slider focused | nothing; wait out the lease | global
panadapter shortcuts resume immediately |

## Testing

- Builds clean (Ninja / RelWithDebInfo, Qt6).
- Deployed to the test Mac for hands-on verification of badge timing,
TCI/DAX keyboard stepping, and Enter-to-release behavior.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Codex <noreply@openai.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…keyboard nav (aethersdr#3303)

Companion to aethersdr#3288 (Phase 2 accessibility audit), per maintainer
guidance from @jensenpat.

Addresses all four issues from the @jensenpat review on the previous
iteration.

## What's in this PR

### Phase 2a — `setAccessibleName` on previously unnamed widgets

All changes are additive, no behavioral risk.

**MeterApplet** (`MeterApplet.cpp`): PA temperature, supply voltage,
main fan speed gauges

**AmpApplet** (`AmpApplet.cpp`): Forward power, SWR, drain current
gauges — rebased onto the new row-label/ballistics layout from aethersdr#3301

**TunerApplet** (`TunerApplet.cpp`): Forward power, SWR gauges + C1/L/C2
relay bars — rebased onto the same row-label refactor

**SpectrumWidget** (`SpectrumWidget.cpp`): "Panadapter spectrum display"

**TciApplet / DaxApplet**: RX 1–N gain sliders, TX gain slider (named in
loop)

### Phase 2b — Live accessibility announcements

**VFO frequency** (`VfoWidget.cpp` / `VfoWidget.h`):
- `updateFreqLabel()` now routes through
`scheduleFrequencyAnnouncement()` — a 300 ms single-shot debounce timer
that fires once the frequency settles, preventing VoiceOver from being
asked to speak ~20 times/second during active wheel tuning
- LOCKED state announces immediately (user-triggered, infrequent) but
suppresses repeats while the 500 ms lock-feedback gate is active

**S-meter** (`SMeterWidget.cpp`):
- `setFocusPolicy(Qt::TabFocus)` added — widget is keyboard-reachable
- `setLevel()` announces the *displayed* value, not raw dBm: in S-Meter
Peak mode the event value is derived from `m_peakDbm` (what the needle
and text readout show), not `m_levelDbm`; formatted as "S7, -79 dBm" to
match the painted readout exactly

**MeterSlider** (`MeterSlider.h`):
- `setFocusPolicy(Qt::TabFocus)` added — DAX/TCI gain sliders are now
keyboard-reachable
- `keyPressEvent` handler: Left/Down −5%, Right/Up +5% — gain thumb is
adjustable without a mouse

### Phase 2c — VFO tab bar: `QLabel` → `QPushButton`

VFO tab labels were `QLabel` with a click event filter; VoiceOver could
not activate them. Replaced with `QPushButton` throughout
(`VfoWidget.cpp`, `VfoWidget.h`).

### Phase 2d — Keyboard navigation: RelayBar + PhaseKnob

- `RelayBar` gains arrow-key support (Left/Right step through relay
positions)
- `PhaseKnob` excluded from the tab order (it is already accessible via
the paired ESC sliders)

## Test notes

On macOS with VoiceOver:
1. Tab to the S-meter — subsequent signal level changes should be spoken
as "S7, -79 dBm" etc.
2. Tune the VFO wheel quickly — VoiceOver speaks once after tuning
settles (not on every step)
3. Lock the VFO — VoiceOver speaks "LOCKED" once
4. Tab to a DAX/TCI gain slider — Left/Right arrows move the thumb
5. VFO tab bar buttons ("DSP", "USB", "X/RIT", "DAX") are activatable
via VoiceOver

— AI5OS (@w9fyi)
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
aethersdr#3396)

## Summary

Two small follow-ups to the applet-slider keyboard support that landed
in aethersdr#3303, plus an audit so the same UX is consistent across every applet
slider. Both features make the keyboard path behave like the mouse path
on applet sliders (AGC level, TCI/DAX gain, etc.).

### 1. Value badge on keyboard steps

When you drag an applet slider with the mouse, a value badge pops up
above the thumb and lingers briefly (~450 ms) after release. Adjusting
the **same slider with the keyboard** previously showed **no badge**.

Now keyboard stepping flashes the identical badge with the identical
linger/fade timeout, so keyboard and mouse give matching visual
feedback.

- `GuardedSlider` (AGC threshold, most applet sliders): gains a public
`flashDragValue()`. Keyboard nudges for these sliders are routed through
`MainWindow`'s shortcut-lease key handler, so that handler calls
`flashDragValue()` after each arrow / `Ctrl`+arrow / `PageUp`/`PageDown`
/ `Home`/`End` step. It's reached via `dynamic_cast` because
`GuardedSlider` has no `Q_OBJECT` macro.
- `MeterSlider` (TCI/DAX combined meter + gain fader): shows and lingers
the badge directly from its own `keyPressEvent`.

### 2. Enter returns focus to the panadapter

After a mouse interaction, a slider holds a short keyboard lease (2 s) /
focus, during which arrow keys nudge the slider instead of driving the
global panadapter shortcuts. If you want those global shortcuts back
**immediately** — without waiting for the lease to expire — you can now
press **Enter** while the slider has focus.

- `GuardedSlider`: `MainWindow` releases the shortcut lease and clears
slider focus, re-enabling the global operating shortcuts.
- `MeterSlider`: hides the badge and clears focus.

### 3. TCI/DAX (MeterSlider) — making the keyboard path actually
reachable

The first pass added the badge + Enter handling to `MeterSlider`, but
that code was **dead**: `MeterSlider` is a plain `QWidget` with
`Qt::TabFocus`, so a mouse drag never left keyboard focus on it, and it
never engaged the shortcut lease. Because the arrow keys are **global
shortcuts** (`Left`/`Right` tune frequency, `Up`/`Down` adjust AF gain),
a matched `QShortcut` consumes the key before the focused widget's
`keyPressEvent` runs — so `MeterSlider`'s own arrow stepping (and the
new badge) never executed.

Fixes, mirroring `GuardedSlider` behavior exactly:
- `MeterSlider`: `Qt::TabFocus` → `Qt::StrongFocus`, plus an explicit
`setFocus(MouseFocusReason)` on left-press, so a mouse interaction hands
off to the keyboard. Adds `isDragging()`.
- `MainWindow`: the shortcut lease is generalized from
`QAbstractSlider*` to `QWidget*` so `MeterSlider` can hold it (freeing
the arrows from the global tune/AF shortcuts while focused).
`focusChanged` and the mouse-press event filter begin the lease for
`MeterSlider` too, and a keypress hook renews it on each step so it
doesn't expire mid-adjustment. A `leaseHolderBusy()` helper unifies
`isSliderDown()`/`isDragging()` so the lease never times out mid-drag
for either type.

### Applet-slider audit (gaps found)

| Slider type | Count | Mouse badge | Keyboard badge | Status |
|---|---|---|---|---|
| `GuardedSlider` | 66 | ✅ | ✅ (this PR) | covered |
| `MeterSlider` (TCI/DAX) | 4 | ✅ | ✅ (this PR) | covered |
| plain `QSlider` | 8 | ❌ | ❌ | pre-existing gap, see below |

The 8 plain `QSlider`s have **never** shown a badge (mouse or keyboard):
`AetherDspWidget` DFNR atten/beta, `StripFinalOutputPanel`
freq/level×2/morse-pitch, `StripWaveformPanel` window,
`SpectrumOverlayMenu` line-width. Most live in transient
**dialogs/menus**, not the main applet panel, and the keyboard-badge
path depends on the panadapter shortcut lease that only applies in the
main window. Converting them to `GuardedSlider` would give them the
mouse badge for free; left out of this PR to keep it focused — easy
follow-up if wanted.

## Behavior summary

| Action | Before | After |
|---|---|---|
| Keyboard-step an applet slider (incl. TCI/DAX) | no badge | badge
flashes + lingers (same as mouse) |
| Press Enter with slider focused | nothing; wait out the lease | global
panadapter shortcuts resume immediately |

## Testing

- Builds clean (Ninja / RelWithDebInfo, Qt6).
- Deployed to the test Mac for hands-on verification of badge timing,
TCI/DAX keyboard stepping, and Enter-to-release behavior.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Codex <noreply@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants