diff --git a/CMakeLists.txt b/CMakeLists.txt index 70dc52af4b..b9a4f95333 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1446,6 +1446,7 @@ set(GUI_SOURCES src/gui/GreenHeronApplet.cpp src/gui/TitleBar.cpp src/gui/RoundedMenu.cpp + src/gui/StatusIndicator.cpp src/gui/BrandMark.cpp src/gui/RadioTabBar.cpp src/gui/WindowCaptionButtons.cpp diff --git a/docs/architecture/aetherd-touchpoints.md b/docs/architecture/aetherd-touchpoints.md index 0ba07ac2d1..6a4e2f9cd2 100644 --- a/docs/architecture/aetherd-touchpoints.md +++ b/docs/architecture/aetherd-touchpoints.md @@ -150,7 +150,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/SystemInventory.h` | 1 | ui-support — Startup and support-bundle inventory of host CPU, SIMD and RAM capabilities. Process diagnostics, not radio state. | unconverted | | `core/TciServer.h` | 3 | mixed(flex) — TCI WebSocket server for WSJT-X et al: protocol surface is canonical radio state, but audio/IQ rides Flex DAX | unconverted | | `core/TgxlConnection.h` | 3 | peripheral(4o3a) — Direct TCP client for the 4O3A Tuner Genius XL (port 9010, relay/autotune), reverse-engineered from the 4O3A management app — a standalone accessory transport, not SmartSDR. Not radio-family wire; a peripheral accessory, NOT behind the IRadioBackend radio seam (reclassified from vendor(flex), #4087 follow-up). | unconverted | -| `core/ThemeManager.h` | 167 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted | +| `core/ThemeManager.h` | 168 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted | | `core/ThreadCpuRing.h` | 2 | ui-support — Short host-thread CPU history used by Runtime Monitor peak and sparkline presentation. Diagnostic UI support, not radio state. | unconverted | | `core/TimeFrameVoter.h` | 1 | universal — Shared AetherClock time-frame types plus confidence-weighted cross-frame bit voting over a sliding window. Map-agnostic pure DSP/logic — no Qt, no GUI, no vendor ties. | unconverted | | `core/TxKeyingMarker.h` | 15 | ui-support — QWidget property marker guarding TX-keying controls from the automation bridge; GUI-shell plumbing, no radio state. | unconverted | diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 82ea69b3eb..9290303e02 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -133,6 +133,7 @@ #include "DvkAvailabilityGate.h" #include "VoiceModeGate.h" #include "DvkPanel.h" +#include "StatusIndicator.h" #include "core/DvkWavTransfer.h" #include "AmpApplet.h" #include "MeterApplet.h" @@ -5656,6 +5657,15 @@ void MainWindow::buildUI() const QString redInd = "QLabel { color: #e04040; font-weight: bold; font-size: 21px; }"; const QString greyIndLg = "QLabel { color: #404858; font-weight: bold; font-size: 24px; }"; const QString greenIndLg= "QLabel { color: #00e060; font-weight: bold; font-size: 24px; }"; + // Tab, Return/Enter and the accessible Press action reach the same action + // as a click (#6257, docs/a11y.md interactive-QLabel rule). On/off + // indicators are checkable, so their state is not colour alone. + const auto keyboardOperable = [this](QWidget* indicator, bool onOff = false) { + StatusIndicator* helper = StatusIndicator::attach(indicator); + helper->setCheckable(onOff); + connect(helper, &StatusIndicator::activated, this, + [this, indicator] { activateStatusIndicator(indicator); }); + }; // Use a container with HBoxLayout for 3-section layout: // [left items] → stretch → [STATION centered] → stretch → [right items] @@ -5733,7 +5743,9 @@ void MainWindow::buildUI() m_bandStackIndicator->setPixmap(buildBandStackIndicatorPixmap(false)); m_bandStackIndicator->setCursor(Qt::PointingHandCursor); m_bandStackIndicator->setToolTip("Open band stack panel"); + m_bandStackIndicator->setAccessibleName(QStringLiteral("Band stack panel")); m_bandStackIndicator->installEventFilter(this); + keyboardOperable(m_bandStackIndicator, true); hbox->addWidget(m_bandStackIndicator); } @@ -5746,6 +5758,7 @@ void MainWindow::buildUI() addPanBtn->setCursor(Qt::PointingHandCursor); addPanBtn->setToolTip("Add Panadapter"); addPanBtn->installEventFilter(this); + keyboardOperable(addPanBtn); hbox->addWidget(addPanBtn); m_addPanLabel = addPanBtn; } @@ -5756,7 +5769,9 @@ void MainWindow::buildUI() m_tnfIndicator->setStyleSheet(greyIndLg); m_tnfIndicator->setCursor(Qt::PointingHandCursor); m_tnfIndicator->setToolTip(buildTnfTooltip(m_radioModel.tnfModel())); + m_tnfIndicator->setAccessibleName(QStringLiteral("Tracking notch filters")); m_tnfIndicator->installEventFilter(this); + keyboardOperable(m_tnfIndicator, true); hbox->addWidget(m_tnfIndicator); auto updateTnfTooltip = [this]() { if (m_tnfIndicator) { @@ -5772,7 +5787,9 @@ void MainWindow::buildUI() m_cwxIndicator->setStyleSheet(greyIndLg); m_cwxIndicator->setCursor(Qt::PointingHandCursor); m_cwxIndicator->setToolTip("CW Keyer — click to toggle"); + m_cwxIndicator->setAccessibleName(QStringLiteral("CW keyer")); m_cwxIndicator->installEventFilter(this); + keyboardOperable(m_cwxIndicator, true); hbox->addWidget(m_cwxIndicator); #ifdef AETHER_ASR_ENABLED @@ -5780,7 +5797,9 @@ void MainWindow::buildUI() m_asrIndicator->setStyleSheet(greyIndLg); m_asrIndicator->setCursor(Qt::PointingHandCursor); m_asrIndicator->setToolTip("Speech-to-text (Copy Assist) — click to toggle"); + m_asrIndicator->setAccessibleName(QStringLiteral("Speech-to-text (Copy Assist)")); m_asrIndicator->installEventFilter(this); + keyboardOperable(m_asrIndicator, true); hbox->addWidget(m_asrIndicator); #endif @@ -5792,13 +5811,16 @@ void MainWindow::buildUI() m_dvkIndicator->setToolTip(dvkIndicatorTooltip(DvkIndicatorBlocker::None)); m_dvkIndicator->setAccessibleDescription(dvkIndicatorTooltip(DvkIndicatorBlocker::None)); m_dvkIndicator->installEventFilter(this); + keyboardOperable(m_dvkIndicator, true); hbox->addWidget(m_dvkIndicator); m_fdxIndicator = new QLabel("FDX"); m_fdxIndicator->setStyleSheet(greyIndLg); m_fdxIndicator->setCursor(Qt::PointingHandCursor); m_fdxIndicator->setToolTip("Full Duplex — RX stays active during TX (click to toggle)"); + m_fdxIndicator->setAccessibleName(QStringLiteral("Full duplex")); m_fdxIndicator->installEventFilter(this); + keyboardOperable(m_fdxIndicator, true); hbox->addWidget(m_fdxIndicator); addSep(); @@ -6111,7 +6133,10 @@ void MainWindow::buildUI() m_tgxlContainer->setToolTip("Tuner Genius XL\nClick to cycle OPERATE / BYPASS / STANDBY"); m_tgxlContainer->setAccessibleName("Tuner Genius XL status"); m_tgxlContainer->setAccessibleDescription("Click to cycle between OPERATE, BYPASS, and STANDBY"); + m_tgxlContainer->setProperty("aetherStateCycle", + QStringLiteral("Press to cycle between OPERATE, BYPASS, and STANDBY")); m_tgxlContainer->installEventFilter(this); + keyboardOperable(m_tgxlContainer); m_tgxlContainer->setVisible(false); { auto* vbox = new QVBoxLayout(m_tgxlContainer); @@ -6148,7 +6173,10 @@ void MainWindow::buildUI() m_pgxlContainer->setToolTip("Power Genius XL\nClick to cycle OPERATE / STANDBY"); m_pgxlContainer->setAccessibleName("Power Genius XL status"); m_pgxlContainer->setAccessibleDescription("Click to cycle between OPERATE and STANDBY"); + m_pgxlContainer->setProperty("aetherStateCycle", + QStringLiteral("Press to cycle between OPERATE and STANDBY")); m_pgxlContainer->installEventFilter(this); + keyboardOperable(m_pgxlContainer); m_pgxlContainer->setVisible(false); { auto* vbox = new QVBoxLayout(m_pgxlContainer); @@ -6183,6 +6211,7 @@ void MainWindow::buildUI() m_txIndicator->setAccessibleName("Cancel transmit"); m_txIndicator->setAccessibleDescription("Click to send key up, PTT off, Tune off, and MOX off."); m_txIndicator->installEventFilter(this); + keyboardOperable(m_txIndicator); m_txIndicator->setStyleSheet("QLabel { color: rgba(255,255,255,128); font-weight: bold; font-size: 21px; }"); hbox->addWidget(m_txIndicator); @@ -7356,6 +7385,7 @@ void MainWindow::updateBandStackIndicator() m_bandStackIndicator->setPixmap(buildBandStackIndicatorPixmap(visible)); m_bandStackIndicator->setToolTip(visible ? "Close band stack panel" : "Open band stack panel"); + StatusIndicator::setCheckedFor(m_bandStackIndicator, visible); } bool MainWindow::activateMemorySpot(int memoryIndex, const QString& preferredPanId) @@ -10461,6 +10491,7 @@ void MainWindow::updateKeyerAvailability() setIndicatorStyle(m_cwxIndicator, txIsCw ? kAvail : kDisabled); } m_cwxIndicator->setCursor(txIsCw ? Qt::PointingHandCursor : Qt::ArrowCursor); + StatusIndicator::setCheckedFor(m_cwxIndicator, m_cwxPanel->isVisible()); // DVK: available in voice modes (SSB, AM, FM — not DIGU/DIGL), and only on // a radio that reports the DVK entitlement (see dvkIndicatorBlocker). @@ -10478,6 +10509,7 @@ void MainWindow::updateKeyerAvailability() } m_dvkIndicator->setCursor(dvkAvailable ? Qt::PointingHandCursor : Qt::ArrowCursor); + StatusIndicator::setCheckedFor(m_dvkIndicator, m_dvkPanel->isVisible()); // An unlicensed radio gets a tooltip naming the missing subscription; the // mode gate keeps the normal one, since the panel's own title and the F-key // rows already make "wrong mode" obvious once it opens. @@ -10541,6 +10573,7 @@ void MainWindow::updateKeyerAvailability() setIndicatorStyle(m_asrIndicator, asrVisible ? kActive : (asrIsVoice ? kAvail : kDisabled)); + StatusIndicator::setCheckedFor(m_asrIndicator, asrVisible); // Cursor tracks the ENABLED state, not the mode, so the hand appears on // exactly the clicks that do something — including the close-an-open- // panel case where the mode gate says no. diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 654104f2a1..02ddd424c2 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -905,6 +905,10 @@ private slots: // keyer panels mutually exclusive and restore the splitter identically. void toggleCwKeyerPanel(); void toggleVoiceKeyerPanel(); + // Status-bar indicators are click-handled labels and containers; a mouse + // press (eventFilter) and the keyboard (StatusIndicator) share one action. + bool isStatusIndicator(const QObject* obj) const; + void activateStatusIndicator(QObject* obj); // Shared by the status-bar +PAN affordance and Tools ▸ Add Panadapter… so // both route through PanLayoutDialog and the layout machinery. void showAddPanadapterDialog(); diff --git a/src/gui/MainWindow_Shortcuts.cpp b/src/gui/MainWindow_Shortcuts.cpp index 6e4292d846..036307173f 100644 --- a/src/gui/MainWindow_Shortcuts.cpp +++ b/src/gui/MainWindow_Shortcuts.cpp @@ -851,24 +851,55 @@ bool MainWindow::eventFilter(QObject* obj, QEvent* event) toggleConnectionDialog(); return true; } + // Status-bar indicators: a press and the keyboard (StatusIndicator's + // Return/Enter/Space and the accessible Press action) run one action. + if (event->type() == QEvent::MouseButtonPress && isStatusIndicator(obj)) { + if (obj == m_txIndicator + && static_cast(event)->button() != Qt::LeftButton) { + return true; + } + activateStatusIndicator(obj); + return true; + } + return QMainWindow::eventFilter(obj, event); +} + +bool MainWindow::isStatusIndicator(const QObject* obj) const +{ + if (!obj) { + return false; + } #ifdef AETHER_ASR_ENABLED - if (obj == m_asrIndicator && event->type() == QEvent::MouseButtonPress) { - if (!m_asrIndicator->isEnabled()) return true; + if (obj == m_asrIndicator) { + return true; + } +#endif + return obj == m_cwxIndicator || obj == m_tnfIndicator + || obj == m_fdxIndicator || obj == m_bandStackIndicator || obj == m_tgxlContainer + || obj == m_pgxlContainer || obj == m_txIndicator || obj == m_addPanLabel + || obj == m_dvkIndicator; +} + +void MainWindow::activateStatusIndicator(QObject* obj) +{ +#ifdef AETHER_ASR_ENABLED + if (obj == m_asrIndicator) { + if (!m_asrIndicator->isEnabled()) return; showCopyAssist(); // toggles the docked Copy Assist panel updateKeyerAvailability(); // refresh the indicator's active/available style - return true; + return; } #endif - if (obj == m_cwxIndicator && event->type() == QEvent::MouseButtonPress) { - if (!m_cwxIndicator->isEnabled()) return true; + if (obj == m_cwxIndicator) { + if (!m_cwxIndicator->isEnabled()) return; toggleCwKeyerPanel(); - return true; + return; } - if (obj == m_tnfIndicator && event->type() == QEvent::MouseButtonPress) { + if (obj == m_tnfIndicator) { m_radioModel.tnfModel().requestGlobalTnfEnabled(!m_radioModel.tnfModel().globalEnabled()); - return true; + return; } - if (obj == m_fdxIndicator && event->type() == QEvent::MouseButtonPress) { + if (obj == m_fdxIndicator) { bool on = !m_radioModel.fullDuplexEnabled(); m_radioModel.sendCmdPublic( QString("radio set full_duplex_enabled=%1").arg(on ? 1 : 0), @@ -882,15 +913,15 @@ bool MainWindow::eventFilter(QObject* obj, QEvent* event) // Radio accepted; no status echo follows, so apply manually. m_radioModel.setFullDuplex(on); }); - return true; + return; } - if (obj == m_bandStackIndicator && event->type() == QEvent::MouseButtonPress) { + if (obj == m_bandStackIndicator) { bool show = !m_panStack->bandStackPanel()->isVisible(); setBandStackPanelVisible(show); updateBandStackIndicator(); - return true; + return; } - if (obj == m_tgxlContainer && event->type() == QEvent::MouseButtonPress) { + if (obj == m_tgxlContainer) { auto& t = m_radioModel.tunerModel(); // Cycle: OPERATE → BYPASS → STANDBY → OPERATE if (t.isOperate() && !t.isBypass()) @@ -901,29 +932,26 @@ bool MainWindow::eventFilter(QObject* obj, QEvent* event) t.setBypass(false); t.setOperate(true); } - return true; + return; } - if (obj == m_pgxlContainer && event->type() == QEvent::MouseButtonPress) { + if (obj == m_pgxlContainer) { // Simple toggle: OPERATE ↔ STANDBY (PGXL has no BYPASS) m_radioModel.amplifier().setOperate(!m_radioModel.amplifier().operate()); - return true; + return; } - if (obj == m_txIndicator && event->type() == QEvent::MouseButtonPress) { - auto* mouseEvent = static_cast(event); - if (mouseEvent->button() == Qt::LeftButton) - cancelTransmitFromIndicator(); - return true; + if (obj == m_txIndicator) { + cancelTransmitFromIndicator(); + return; } - if (obj == m_addPanLabel && event->type() == QEvent::MouseButtonPress) { + if (obj == m_addPanLabel) { showAddPanadapterDialog(); - return true; + return; } - if (obj == m_dvkIndicator && event->type() == QEvent::MouseButtonPress) { - if (!m_dvkIndicator->isEnabled()) return true; + if (obj == m_dvkIndicator) { + if (!m_dvkIndicator->isEnabled()) return; toggleVoiceKeyerPanel(); - return true; + return; } - return QMainWindow::eventFilter(obj, event); } // Shared by the status-bar +PAN affordance and Tools ▸ Add Panadapter… so both diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index ebbadfc87f..946adb41ec 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -4,6 +4,7 @@ // panFollowVfo()/revealFrequencyIfNeeded(). #include "MainWindow.h" +#include "StatusIndicator.h" #include "models/CwDecodeSettings.h" #include "PeripheralAuthStore.h" #include "core/backends/AutoRfGainControl.h" @@ -4373,6 +4374,7 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) m_tnfIndicator->setStyleSheet(on ? "QLabel { color: #00b4d8; font-weight: bold; font-size: 24px; }" : "QLabel { color: #404858; font-weight: bold; font-size: 24px; }"); + StatusIndicator::setCheckedFor(m_tnfIndicator, on); }); // FDX indicator style update @@ -4383,6 +4385,7 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) m_fdxIndicator->setStyleSheet(fdx ? "QLabel { color: #00b4d8; font-weight: bold; font-size: 24px; }" : "QLabel { color: #404858; font-weight: bold; font-size: 24px; }"); + StatusIndicator::setCheckedFor(m_fdxIndicator, fdx); }); connect(sw, &SpectrumWidget::tnfCreateRequested, tnf, &TnfModel::createTnf, Qt::UniqueConnection); @@ -7305,6 +7308,16 @@ void MainWindow::wireMeters() stateLbl->setStyleSheet( QString("QLabel { color: %1; font-size:11px; }").arg(color)); stateLbl->setText(state); + // The TUN/AMP container is the keyboard target; its description leads + // with the state, which the colour alone otherwise carries. + if (QWidget* container = stateLbl->parentWidget()) { + const QString cycle = container->property("aetherStateCycle").toString(); + const QString described = cycle.isEmpty() ? state + : QStringLiteral("%1. %2").arg(state, cycle); + if (container->accessibleDescription() != described) { + container->setAccessibleDescription(described); + } + } }; auto updateTgxlStyle = [this, setIndicatorHtml]() { diff --git a/src/gui/StatusIndicator.cpp b/src/gui/StatusIndicator.cpp new file mode 100644 index 0000000000..58b7ef6cc6 --- /dev/null +++ b/src/gui/StatusIndicator.cpp @@ -0,0 +1,207 @@ +#include "StatusIndicator.h" + +#include "core/ThemeManager.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { + +namespace { + +constexpr const char* kHelperProperty = "aetherStatusIndicator"; +constexpr int kFocusBarHeight = 2; + +// Reports an indicator as a Button whose Press action runs the indicator. +class StatusIndicatorAccessible : public QAccessibleWidget { +public: + explicit StatusIndicatorAccessible(QWidget* widget) + : QAccessibleWidget(widget, QAccessible::Button) + { + } + + QAccessible::State state() const override + { + QAccessible::State s = QAccessibleWidget::state(); + if (const StatusIndicator* helper = StatusIndicator::of(widget())) { + s.checkable = helper->isCheckable(); + s.checked = helper->isCheckable() && helper->isChecked(); + } + return s; + } + + QStringList actionNames() const override + { + return QStringList{pressAction()} + QAccessibleWidget::actionNames(); + } + + void doAction(const QString& actionName) override + { + if (actionName == pressAction()) { + if (StatusIndicator* helper = StatusIndicator::of(widget())) { + helper->activate(); + } + return; + } + QAccessibleWidget::doAction(actionName); + } +}; + +QAccessibleInterface* statusIndicatorFactory(const QString& className, QObject* object) +{ + // Qt asks every factory once per level of the object's class chain; only + // the most-derived level can be an indicator, so every other level (and + // every other object in the app) skips the property lookup. + if (!object || className != QLatin1String(object->metaObject()->className())) { + return nullptr; + } + auto* widget = qobject_cast(object); + if (widget && StatusIndicator::of(widget)) { + return new StatusIndicatorAccessible(widget); + } + return nullptr; +} + +} // namespace + +StatusIndicator* StatusIndicator::attach(QWidget* widget) +{ + if (!widget) { + return nullptr; + } + if (StatusIndicator* existing = of(widget)) { + return existing; + } + static const bool factoryInstalled = [] { + QAccessible::installFactory(statusIndicatorFactory); + return true; + }(); + Q_UNUSED(factoryInstalled); + auto* helper = new StatusIndicator(widget); + // With accessibility already active, Qt may have created and cached the + // widget's plain interface (setAccessibleName() does so). The cache is + // consulted before factories, so drop it; the next query builds the + // Button. ObjectDestroyed then ObjectCreated tell an assistive client that + // already saw the old object to let it go and look again. + if (QAccessible::isActive()) { + if (QAccessibleInterface* cached = QAccessible::queryAccessibleInterface(widget)) { + if (cached->role() != QAccessible::Button) { + // Retire the old object for clients holding its id, then + // announce the Button that replaces it. + QAccessibleEvent destroyed(cached, QAccessible::ObjectDestroyed); + QAccessible::updateAccessibility(&destroyed); + QAccessible::deleteAccessibleInterface(QAccessible::uniqueId(cached)); + QAccessibleEvent created(widget, QAccessible::ObjectCreated); + QAccessible::updateAccessibility(&created); + } + } + } + return helper; +} + +StatusIndicator* StatusIndicator::of(const QWidget* widget) +{ + return widget ? widget->property(kHelperProperty).value() : nullptr; +} + +StatusIndicator::StatusIndicator(QWidget* widget) + : QObject(widget), m_widget(widget), m_focusBar(new QWidget(widget)) +{ + widget->setProperty(kHelperProperty, QVariant::fromValue(this)); + widget->setFocusPolicy(Qt::TabFocus); + + // A child bar rather than a stylesheet rule: the indicators restyle + // themselves with whole-sheet setStyleSheet() calls on every state change. + m_focusBar->setObjectName(QStringLiteral("statusIndicatorFocusBar")); + m_focusBar->setAttribute(Qt::WA_TransparentForMouseEvents); + m_focusBar->setFocusPolicy(Qt::NoFocus); + ThemeManager::instance().applyStyleSheet( + m_focusBar, QStringLiteral("QWidget { background: {{color.canon.cyan}}; border: none; }")); + m_focusBar->hide(); + + widget->installEventFilter(this); +} + +void StatusIndicator::activate() +{ + if (m_widget->isEnabled()) { + emit activated(); + } +} + +void StatusIndicator::setCheckable(bool checkable) +{ + m_checkable = checkable; +} + +void StatusIndicator::setChecked(bool checked) +{ + if (m_checked == checked) { + return; + } + m_checked = checked; + if (m_checkable && QAccessible::isActive()) { + QAccessible::State changed; + changed.checked = true; + QAccessibleStateChangeEvent event(m_widget, changed); + QAccessible::updateAccessibility(&event); + } +} + +void StatusIndicator::setCheckedFor(QWidget* widget, bool checked) +{ + if (StatusIndicator* helper = of(widget)) { + helper->setChecked(checked); + } +} + +void StatusIndicator::placeFocusBar() +{ + m_focusBar->setGeometry(0, m_widget->height() - kFocusBarHeight, + m_widget->width(), kFocusBarHeight); + m_focusBar->raise(); +} + +bool StatusIndicator::eventFilter(QObject* watched, QEvent* event) +{ + if (watched != m_widget) { + return QObject::eventFilter(watched, event); + } + switch (event->type()) { + case QEvent::KeyPress: { + const auto* keyEvent = static_cast(event); + const int key = keyEvent->key(); + // Not Space: MainWindow's app-level filter binds it to PTT (Hold), and + // it runs before this one, so Space would key the radio, not this. + if (key == Qt::Key_Return || key == Qt::Key_Enter) { + // One action per press: these toggle panels and cycle TUN/AMP, so + // a held key's auto-repeat is consumed, not acted on. + if (!keyEvent->isAutoRepeat()) { + activate(); + } + return true; + } + break; + } + case QEvent::FocusIn: + placeFocusBar(); + m_focusBar->show(); + break; + case QEvent::FocusOut: + m_focusBar->hide(); + break; + case QEvent::Resize: + placeFocusBar(); + break; + default: + break; + } + return QObject::eventFilter(watched, event); +} + +} // namespace AetherSDR diff --git a/src/gui/StatusIndicator.h b/src/gui/StatusIndicator.h new file mode 100644 index 0000000000..f80906dcd3 --- /dev/null +++ b/src/gui/StatusIndicator.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +class QWidget; + +namespace AetherSDR { + +// Makes a click-handled status-bar indicator (a QLabel, or a small container +// of labels) operable without a mouse, per docs/a11y.md's interactive-QLabel +// rule (#6257): Tab reaches it, Return/Enter activate it, assistive technology +// sees a Button with a Press action (checkable for on/off indicators), and a +// 2 px canon-cyan underline marks keyboard focus. The indicator keeps its own +// styling. Space is left alone: MainWindow binds it to PTT (Hold) app-wide. +class StatusIndicator : public QObject { + Q_OBJECT +public: + // Attaches to `widget` (once; a second call returns the same helper). + static StatusIndicator* attach(QWidget* widget); + // The helper attached to `widget`, or nullptr. + static StatusIndicator* of(const QWidget* widget); + + QWidget* widget() const { return m_widget; } + // Runs the indicator's action if it is enabled (keys and the Press action). + void activate(); + + // On/off indicators report their state to assistive technology; colour + // alone is not an accessible state (docs/a11y.md). + void setCheckable(bool checkable); + bool isCheckable() const { return m_checkable; } + void setChecked(bool checked); + bool isChecked() const { return m_checked; } + // setChecked() on `widget`'s helper, if it has one. + static void setCheckedFor(QWidget* widget, bool checked); + +signals: + void activated(); + +protected: + bool eventFilter(QObject* watched, QEvent* event) override; + +private: + explicit StatusIndicator(QWidget* widget); + void placeFocusBar(); + + QWidget* m_widget; + QWidget* m_focusBar; + bool m_checkable{false}; + bool m_checked{false}; +}; + +} // namespace AetherSDR diff --git a/tests/status_indicator_test.cpp b/tests/status_indicator_test.cpp new file mode 100644 index 0000000000..b1cb7595ee --- /dev/null +++ b/tests/status_indicator_test.cpp @@ -0,0 +1,289 @@ +// #6257 — StatusIndicator makes a click-handled status-bar label (or a small +// container of labels) operable without a mouse: Tab reaches it, Return/Enter +// activate it, assistive technology sees a (checkable) Button with a Press +// action, and a canon-cyan underline marks keyboard focus. Space stays PTT. +#include "TestSettingsProfile.h" +#include "gui/PttHoldKeyStep.h" +#include "gui/StatusIndicator.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace AetherSDR; + +namespace { + +int g_failed = 0; +int g_total = 0; + +void report(const char* label, bool ok) +{ + ++g_total; + std::printf("%s %s\n", ok ? "[ OK ]" : "[FAIL]", label); + if (!ok) { + ++g_failed; + } +} + +void press(QWidget* widget, int key) +{ + QKeyEvent down(QEvent::KeyPress, key, Qt::NoModifier); + QApplication::sendEvent(widget, &down); +} + +int g_stateChanges = 0; +void countStateChanges(QAccessibleEvent* event) +{ + if (event->type() == QAccessible::StateChanged) { + ++g_stateChanges; + } +} + +// MainWindow's app-level filter, reduced to its PTT (Hold) decision: with a +// radio connected and shortcuts on, pttHoldKeyStep() turns Space into a key. +struct PttFilter : QObject { + bool holdActive{false}; + int pttKeys{0}; + bool eventFilter(QObject*, QEvent* e) override + { + if (e->type() != QEvent::KeyPress && e->type() != QEvent::KeyRelease) { + return false; + } + auto* ke = static_cast(e); + if (ke->key() != Qt::Key_Space || ke->isAutoRepeat()) { + return false; + } + switch (pttHoldKeyStep(e->type(), holdActive, true, false, true)) { + case PttHoldKeyStep::PassThrough: + return false; + case PttHoldKeyStep::KeyTx: + holdActive = true; + ++pttKeys; + return true; + case PttHoldKeyStep::UnkeyTx: + holdActive = false; + return true; + case PttHoldKeyStep::Consume: + return true; + } + return false; + } +}; + +int g_objectCreated = 0; +int g_objectDestroyed = 0; +QAccessible::Role g_destroyedRole = QAccessible::NoRole; +bool g_destroyedBeforeCreated = true; +void countObjectCreated(QAccessibleEvent* event) +{ + if (event->type() == QAccessible::ObjectCreated) { + ++g_objectCreated; + } else if (event->type() == QAccessible::ObjectDestroyed) { + if (g_objectCreated > g_objectDestroyed) { + g_destroyedBeforeCreated = false; + } + ++g_objectDestroyed; + if (QAccessibleInterface* iface = event->accessibleInterface()) { + g_destroyedRole = iface->role(); + } + } +} + +QWidget* focusBar(QWidget* widget) +{ + return widget->findChild(QStringLiteral("statusIndicatorFocusBar")); +} + +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("status-indicator-test")); + if (!profile.isValid()) { + return 1; + } + QApplication app(argc, argv); + + QWidget bar; + auto* layout = new QVBoxLayout(&bar); + auto* dvk = new QLabel(QStringLiteral("DVK")); + dvk->setAccessibleName(QStringLiteral("Digital Voice Keyer")); + layout->addWidget(dvk); + // A container like the TUN/AMP stacks: a widget holding two labels. + auto* stack = new QWidget; + auto* stackLayout = new QVBoxLayout(stack); + stackLayout->addWidget(new QLabel(QStringLiteral("TUN"))); + stackLayout->addWidget(new QLabel(QStringLiteral("OPERATE"))); + stack->setAccessibleName(QStringLiteral("Tuner Genius XL status")); + layout->addWidget(stack); + bar.resize(200, 120); + bar.show(); + + StatusIndicator* dvkHelper = StatusIndicator::attach(dvk); + StatusIndicator* stackHelper = StatusIndicator::attach(stack); + int dvkActivations = 0; + int stackActivations = 0; + QObject::connect(dvkHelper, &StatusIndicator::activated, [&] { ++dvkActivations; }); + QObject::connect(stackHelper, &StatusIndicator::activated, [&] { ++stackActivations; }); + + report("attach is idempotent and findable", StatusIndicator::attach(dvk) == dvkHelper + && StatusIndicator::of(dvk) == dvkHelper); + report("the indicator is reachable by Tab", dvk->focusPolicy() == Qt::TabFocus); + + press(dvk, Qt::Key_Return); + press(dvk, Qt::Key_Enter); + press(dvk, Qt::Key_Return); + report("Return and Enter each activate it", dvkActivations == 3); + QKeyEvent space(QEvent::KeyPress, Qt::Key_Space, Qt::NoModifier); + space.ignore(); + QApplication::sendEvent(dvk, &space); + report("Space does not activate it, and the helper does not take it", + dvkActivations == 3 && !space.isAccepted()); + press(dvk, Qt::Key_A); + press(dvk, Qt::Key_Tab); + report("other keys do not", dvkActivations == 3); + QKeyEvent held(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier, QString(), true); + const bool consumed = QApplication::sendEvent(dvk, &held) && held.isAccepted(); + report("a held key's auto-repeat is consumed without acting again", + dvkActivations == 3 && consumed); + + { + // Review #6267: in the app, Space is PTT (Hold). Behind the same + // app-level filter, a focused indicator keys PTT on Space and does not + // activate; Return still activates it and keys nothing. + PttFilter ptt; + app.installEventFilter(&ptt); + const int before = dvkActivations; + QKeyEvent down(QEvent::KeyPress, Qt::Key_Space, Qt::NoModifier); + QApplication::sendEvent(dvk, &down); + QKeyEvent up(QEvent::KeyRelease, Qt::Key_Space, Qt::NoModifier); + QApplication::sendEvent(dvk, &up); + report("connected: Space on a focused indicator is PTT, not the indicator", + ptt.pttKeys == 1 && dvkActivations == before); + press(dvk, Qt::Key_Return); + report("connected: Return still activates it and keys nothing", + dvkActivations == before + 1 && ptt.pttKeys == 1); + app.removeEventFilter(&ptt); + dvkActivations = before; + } + + press(stack, Qt::Key_Return); + report("a container of labels activates the same way", stackActivations == 1); + + dvk->setEnabled(false); + press(dvk, Qt::Key_Return); + dvkHelper->activate(); + report("a disabled indicator does not activate, by key or directly", dvkActivations == 3); + dvk->setEnabled(true); + + QAccessible::setActive(true); + QAccessibleInterface* iface = QAccessible::queryAccessibleInterface(dvk); + report("assistive technology sees a Button", + iface && iface->role() == QAccessible::Button); + report("named by its accessible name", + iface && iface->text(QAccessible::Name) == QStringLiteral("Digital Voice Keyer")); + QAccessibleActionInterface* actions = iface ? iface->actionInterface() : nullptr; + report("with a Press action", + actions && actions->actionNames().contains(QAccessibleActionInterface::pressAction())); + if (actions) { + actions->doAction(QAccessibleActionInterface::pressAction()); + } + report("the Press action activates it", dvkActivations == 4); + QAccessibleInterface* stackIface = QAccessible::queryAccessibleInterface(stack); + report("a container is a Button too", stackIface && stackIface->role() == QAccessible::Button); + QAccessibleInterface* plain = QAccessible::queryAccessibleInterface(stack->findChild()); + report("labels inside a container stay plain text", + plain && plain->role() != QAccessible::Button); + + { + // On/off indicators report checked state; colour alone is not a state. + QAccessible::installUpdateHandler(countStateChanges); + dvkHelper->setCheckable(true); + g_stateChanges = 0; + report("a checkable indicator starts unchecked", + iface && iface->state().checkable && !iface->state().checked); + dvkHelper->setChecked(true); + report("checking it reports Checked and one state change", + iface && iface->state().checked && g_stateChanges == 1); + dvkHelper->setChecked(true); + report("setting the same state again announces nothing", g_stateChanges == 1); + StatusIndicator::setCheckedFor(dvk, false); + report("setCheckedFor reaches the helper", + iface && !iface->state().checked && g_stateChanges == 2); + StatusIndicator::setCheckedFor(new QLabel(&bar), true); // no helper: no-op + report("a non-toggle indicator is not checkable", + stackIface && !stackIface->state().checkable); + QAccessible::installUpdateHandler(nullptr); + } + + QWidget* underline = focusBar(dvk); + report("the focus mark exists and starts hidden", underline && !underline->isVisible()); + QFocusEvent in(QEvent::FocusIn, Qt::TabFocusReason); + QApplication::sendEvent(dvk, &in); + report("keyboard focus shows the underline along the bottom edge", + underline && underline->isVisible() && underline->height() == 2 + && underline->width() == dvk->width() + && underline->geometry().bottom() == dvk->height() - 1); + QFocusEvent out(QEvent::FocusOut, Qt::TabFocusReason); + QApplication::sendEvent(dvk, &out); + report("losing focus hides it", underline && !underline->isVisible()); + report("the underline never takes focus or clicks", + underline && underline->focusPolicy() == Qt::NoFocus + && underline->testAttribute(Qt::WA_TransparentForMouseEvents)); + + // Review #6267: with accessibility already active when the indicator is + // built and named (as MainWindow does), Qt caches the plain interface + // before attach(). The helper must replace it with the Button. + { + QAccessible::installUpdateHandler(countObjectCreated); + auto* late = new QLabel(QStringLiteral("FDX")); + layout->addWidget(late); + late->setAccessibleName(QStringLiteral("Full duplex")); + QAccessibleInterface* before = QAccessible::queryAccessibleInterface(late); + report("active accessibility: the named label starts as plain text", + before && before->role() != QAccessible::Button); + auto* lateStack = new QWidget; + lateStack->setAccessibleName(QStringLiteral("Power Genius XL status")); + layout->addWidget(lateStack); + QAccessible::queryAccessibleInterface(lateStack); + + g_objectCreated = 0; + g_objectDestroyed = 0; + StatusIndicator* lateHelper = StatusIndicator::attach(late); + StatusIndicator::attach(lateStack); + int lateActivations = 0; + QObject::connect(lateHelper, &StatusIndicator::activated, [&] { ++lateActivations; }); + QAccessibleInterface* after = QAccessible::queryAccessibleInterface(late); + QAccessibleActionInterface* lateActions = after ? after->actionInterface() : nullptr; + report("attach replaces the cached interface with a Button", + after && after->role() == QAccessible::Button); + report("the replacement keeps the accessible name", + after && after->text(QAccessible::Name) == QStringLiteral("Full duplex")); + report("and offers Press, which activates it", + lateActions + && lateActions->actionNames().contains(QAccessibleActionInterface::pressAction())); + if (lateActions) { + lateActions->doAction(QAccessibleActionInterface::pressAction()); + } + report("Press on the replacement activates it", lateActivations == 1); + QAccessibleInterface* stackAfter = QAccessible::queryAccessibleInterface(lateStack); + report("a cached container is replaced with a Button too", + stackAfter && stackAfter->role() == QAccessible::Button); + report("each replacement tells assistive clients about the new object", + g_objectCreated == 2); + report("and first retires the old object it replaces", + g_objectDestroyed == 2 && g_destroyedBeforeCreated + && g_destroyedRole != QAccessible::Button); + QAccessible::installUpdateHandler(nullptr); + } + + std::printf("\n%d/%d passed\n", g_total - g_failed, g_total); + return g_failed == 0 ? 0 : 1; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index 00ad3e070c..2f128c3280 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -3201,6 +3201,20 @@ add_test(NAME dvk_panel_theme_test COMMAND dvk_panel_theme_test) set_tests_properties(dvk_panel_theme_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) +# #6257 — click-handled status-bar indicators are operable without a mouse: +# Tab focus, Return/Enter/Space, a Button role with a Press action, and a +# canon-cyan underline while focused. +add_executable(status_indicator_test + tests/status_indicator_test.cpp + src/gui/StatusIndicator.cpp + ${THEME_TEST_RESOURCES} +) +target_include_directories(status_indicator_test PRIVATE src tests) +target_link_libraries(status_indicator_test PRIVATE aetherdesktop_support Qt6::Widgets) +add_test(NAME status_indicator_test COMMAND status_indicator_test) +set_tests_properties(status_indicator_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) + # #5640 — QsoRecorder claims filename candidates atomically so a same-second # recording cannot truncate a populated WAV or a concurrently-created file. add_executable(qso_recorder_filename_collision_test