From 02b2843e7bf8cefa90942cfd2e8d5ffc4351dc49 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Sun, 11 Oct 2026 20:09:13 -0700 Subject: [PATCH 1/7] feat(gui): Radio Setup is a CanonWindow workspace styled by one canon sheet. Principle VIII. Part of #6239. Radio Setup (RadioSetupDialog) moves from PersistentDialog to a CanonWindow workspace, following SpotHub and AetherGPS: - No title bar: the ambient ground, the canon header and the corner close button; it resizes from every edge and closes with Esc or Ctrl+W. The footer Close button is gone. It no longer follows View -> Frameless Window, so openRadioSetupPage() creates it directly. Every close path keeps the firmware-upload and pending-removal guards (the corner button reaches closeEvent(), Esc and Ctrl+W reach done()). - Geometry persists under its old key (RadioSetupDialogGeometry); the minimum grows from 960x680 to 960x720 for the header. One sheet on the body (kRadioSetupStyle, canon/radioSetup scope) styles every page, modeled on Network Diagnostics' search / navigation / page sheet. About 290 per-widget sheets are gone; widgets carry a role instead (RadioSetupDialogCommon.h): - label tones (rsTone: caption, value, hint, heading, title, accent for a status in progress, ok, warn, error) and a status chip (rsChip); - button roles (rsButton): a plain checkable button is an on/off toggle, filled with the toggle success tokens when on; Select is one of a set, filled canon cyan; Mute reads danger when engaged; Danger and Success mark reboot / remove and firmware upload; - a disabled control takes color.control.unavailable, checked or not, so an engaged Mute on a radio that cannot mute shows no engaged red. Status lines that changed colour by string (firmware upload, calibration, oscillator lock, KiwiSDR and peripheral status) set a tone, and setTone re-polishes a live widget so the change paints at once. The remaining hex literals (copy icon, USB cable list, slice swatch borders) are tokens. The groups' caption sweeps skip a label that already has a tone. Tests: the three tests that read per-widget sheet strings now read the polished palette or the rendered pixels across a Dark -> Light -> Dark switch (radio_setup_label_theme_token_test, radio_setup_region_field_test, radio_setup_headphone_output_gate_test), and the token test adds a tone change on a shown label. Each goes red when the sheet is frozen, when a caption keeps its own sheet, when the unavailable rules are dropped, or when setTone stops re-polishing. peripheral_auth_dialog_test pins the new default size. Test targets that compile RadioSetupDialog.cpp now compile CanonWindow.cpp. Docs: the Radio Setup page describes the window and the toggle colours; docs/style lists Radio Setup among the canon windows and documents the role convention. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/style/aethersdr-style-guide.md | 2 +- docs/style/dialog-patterns.md | 9 +- docs/style/theme-style-guide.md | 16 +- docs/user/docs/radio-setup.md | 4 + src/gui/MainWindow.cpp | 19 +- src/gui/RadioSetupDialog.cpp | 1369 +++++++---------- src/gui/RadioSetupDialog.h | 7 +- src/gui/RadioSetupDialogCommon.h | 96 +- src/gui/RadioSetupDialog_Peripherals.cpp | 104 +- tests/peripheral_auth_dialog_test.cpp | 4 +- ...radio_setup_headphone_output_gate_test.cpp | 7 +- tests/radio_setup_label_theme_token_test.cpp | 284 ++-- tests/radio_setup_region_field_test.cpp | 93 +- tests/tests.cmake | 9 + 14 files changed, 865 insertions(+), 1158 deletions(-) diff --git a/docs/style/aethersdr-style-guide.md b/docs/style/aethersdr-style-guide.md index b6b5d14437..9638483032 100644 --- a/docs/style/aethersdr-style-guide.md +++ b/docs/style/aethersdr-style-guide.md @@ -113,7 +113,7 @@ windows" lists what it keeps (the `FramelessMoveHelper` drag, Escape and Which windows the canon covers: short-lived, informational windows (About, Remote Access, and the candidates listed on #6239, once the maintainer picks them), and the tool windows the maintainer has moved to it (Waveforms, -Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Connect to Radio, AGC-T Calibration). Other tool and workspace dialogs keep +Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Radio Setup, Connect to Radio, AGC-T Calibration). Other tool and workspace dialogs keep `PersistentDialog` and `FramelessWindowTitleBar` as `docs/agents/gui.md` describes. For windows the canon covers, `FramelessWindowTitleBar` is deprecated: those dialogs move to diff --git a/docs/style/dialog-patterns.md b/docs/style/dialog-patterns.md index b023ed7d75..3aae173ae6 100644 --- a/docs/style/dialog-patterns.md +++ b/docs/style/dialog-patterns.md @@ -225,7 +225,6 @@ replace these with a single `QSet>` walk. - `src/gui/MemoryDialog.{h,cpp}` - `src/gui/AetherDspDialog.{h,cpp}` -- `src/gui/DxClusterDialog.{h,cpp}` (SpotHub) - `src/gui/MultiFlexDialog.{h,cpp}` - `src/gui/MidiMappingDialog.{h,cpp}` - `src/gui/PanLayoutDialog.{h,cpp}` @@ -236,12 +235,12 @@ replace these with a single `QSet>` walk. A window built on `CanonWindow` (`src/gui/CanonWindow.{h,cpp}`, RFC #6226) deliberately opts out of frameless chrome integration, and by default of geometry persistence too. About AetherSDR, Remote Access (Tailscale), -Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Connect to Radio (`ConnectionPanel`), AGC-T Calibration and the AetherRX / AetherTX Settings (profile library) dialogs are built on it. +Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Radio Setup, Connect to Radio (`ConnectionPanel`), AGC-T Calibration and the AetherRX / AetherTX Settings (profile library) dialogs are built on it. | Concern | `CanonWindow` | Why | |---|---|---| | Frameless chrome integration | Always frameless; ignores `FramelessWindow` and is not tracked by `trackPersistentDialog()` | The style guide's rounded, title-bar-less window *is* the design; native chrome would put a title bar over it | -| Geometry persistence | None by default: asks to open centred on its parent every time (Wayland compositors place top-level windows themselves and may ignore it). `setGeometryKey()` opts in: size and position are saved under that AppSettings key on move and resize (in memory) and whenever the window hides (flushed — Escape and ⌘W / Ctrl+W hide it without a close event), and restored in place of the centring. Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS and SpotHub opt in. `setLaunchSize()` pins the size on every open while the position is still restored; AetherRX and AetherTX use it, for now | A short-lived window has nothing to restore; a workspace tool keeps the size its operator gave it | +| Geometry persistence | None by default: asks to open centred on its parent every time (Wayland compositors place top-level windows themselves and may ignore it). `setGeometryKey()` opts in: size and position are saved under that AppSettings key on move and resize (in memory) and whenever the window hides (flushed — Escape and ⌘W / Ctrl+W hide it without a close event), and restored in place of the centring. Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub and Radio Setup opt in. `setLaunchSize()` pins the size on every open while the position is still restored; AetherRX and AetherTX use it, for now | A short-lived window has nothing to restore; a workspace tool keeps the size its operator gave it | What it keeps: @@ -254,7 +253,7 @@ What it keeps: (⌘W, Ctrl+W) all close it. - **Kind:** `CanonWindow::Kind::Dialog` (the default) is a parented `Qt::Dialog` that sits over its parent. `Kind::Workspace` (AetherRX, - AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub) is an independent `Qt::Window`, with its own taskbar entry, that + AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Radio Setup) is an independent `Qt::Window`, with its own taskbar entry, that minimises on its own and does not keep the app alive (`WA_QuitOnClose` off). In a workspace, Return and Enter never click a default button: QDialog makes every push button an auto-default, so Return in a field would @@ -273,7 +272,7 @@ What it keeps: It is translucent, so the rounded corners need a compositor; an X11 session without one shows square black corners. Use `CanonWindow` only for windows the style guide covers: short-lived windows, and the tool windows the maintainer -has moved to it (Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub). A tool window on it also +has moved to it (Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Radio Setup). A tool window on it also installs `FramelessResizer`, since `CanonWindow` itself only moves. Other tool and workspace dialogs keep the persistent pattern above. diff --git a/docs/style/theme-style-guide.md b/docs/style/theme-style-guide.md index e8d67e0487..675410483a 100644 --- a/docs/style/theme-style-guide.md +++ b/docs/style/theme-style-guide.md @@ -42,13 +42,23 @@ a widget subtree (for example, the `applet/tx` scope turns the checked-toggle accent red where the root theme has it blue — scope paths are slash-joined, and a widget opts in by carrying the path in its `themeContainer` property). The `canon` scope does the same for -whole windows: AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Connect to -Radio and AGC-T Calibration sit under `canon/aetherRx`, `canon/aetherTx`, `canon/aetherModem`, -`canon/aetherMap`, `canon/aetherGps`, `canon/spotHub`, `canon/connection` and `canon/agcCalibration`, and it maps the base tokens onto the canon palette +whole windows: AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub, Radio Setup, +Connect to Radio and AGC-T Calibration sit under `canon/aetherRx`, `canon/aetherTx`, `canon/aetherModem`, +`canon/aetherMap`, `canon/aetherGps`, `canon/spotHub`, `canon/radioSetup`, `canon/connection` and `canon/agcCalibration`, and it maps the base tokens onto the canon palette there (see [`aethersdr-style-guide.md`](aethersdr-style-guide.md)). The widget-aware accessors below resolve scope automatically — prefer them inside applets and anything painted inside a scoped window. +A large canon window styles its pages from one sheet on its body rather than +a sheet per widget. Radio Setup's widgets carry a role the sheet selects on +(`RadioSetupDialogCommon.h`): a label's tone (`rsTone`: caption, value, hint, +heading, title, accent for a status in progress, and ok, warn, error), a status chip (`rsChip`) and a button role +(`rsButton`: select, mute, danger, success). A plain checkable button is an +on/off toggle, filled `color.toggle.success.*` when on; a Select button is one +of a set, filled canon cyan when chosen. A disabled control takes +`color.control.unavailable`, checked or not. Changing a role on a live widget +re-polishes it, so a status that turns to an error restyles at once. + ### Consuming tokens ```cpp diff --git a/docs/user/docs/radio-setup.md b/docs/user/docs/radio-setup.md index 6c958b9cc4..af8e7826be 100644 --- a/docs/user/docs/radio-setup.md +++ b/docs/user/docs/radio-setup.md @@ -25,6 +25,10 @@ Several menu items open Radio Setup straight at the right page: - **Settings → USB Cables...** → USB Cables - **Tools → Configure KiwiSDR...** → Antennas +The window has no title bar. Drag any empty part of it to move it, and drag an edge or corner to resize it; it reopens at the size and place you left it. Close it with the round **✕** in the top-right corner, **Esc** or **Ctrl+W** (**⌘W** on macOS). It's a window of its own, with its own taskbar entry, so you can minimize it from the taskbar or park it on another monitor. **View → Frameless Window** does not apply to it. + +On/off buttons (**Enabled** / **Disabled**) fill green when on. Where you pick one of a set (**DHCP** / **Static**, **Radio Side** / **Client Side**, **CWU** / **CWL**), the chosen one fills cyan. A control the connected radio cannot use is dimmed, never hidden. + ## Finding a setting Radio Setup is a searchable browser: a categorised tree of pages on the left and the selected page on the right. diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 132997a273..db1ad306e6 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -3589,11 +3589,20 @@ RadioSetupDialog* MainWindow::openRadioSetupPage(const QString& page) { const QString prevComp = m_radioModel.audioCompressionParam(); const bool wasFresh = !m_radioSetupDialog; - showOrRaisePersistent(m_radioSetupDialog, - &m_radioModel, m_audio, - &m_tgxlConn, &m_pgxlConn, &m_antennaGenius, - m_kiwiSdrManager, &m_acomConn, &m_speConn, &m_vkampConn, - &m_lpMeterConn, &m_kpa1500Conn); + // A canon workspace window: created directly rather than through + // showOrRaisePersistent(), which would apply View → Frameless Window. + if (wasFresh) { + auto* dlg = new RadioSetupDialog(&m_radioModel, m_audio, + &m_tgxlConn, &m_pgxlConn, &m_antennaGenius, + m_kiwiSdrManager, &m_acomConn, &m_speConn, + &m_vkampConn, &m_lpMeterConn, &m_kpa1500Conn, + this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + m_radioSetupDialog = dlg; + } + m_radioSetupDialog->show(); + m_radioSetupDialog->raise(); + m_radioSetupDialog->activateWindow(); if (wasFresh && m_radioSetupDialog) wireRadioSetupDialogSignals(m_radioSetupDialog, prevComp); if (m_radioSetupDialog && !page.isEmpty()) diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 9a5e33a887..48d6fc4209 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -68,7 +68,6 @@ #include #include #include -#include #include #include #include @@ -120,54 +119,21 @@ #include #include "core/ThemeManager.h" #include "RadioSetupDialogCommon.h" +#include "CanonIndicators.h" +#include "FramelessResizer.h" namespace AetherSDR { -// Value colour as the color.accent.bright token (tools/migrate_colours.py maps -// the old literals here); applied via ThemeManager::applyStyleSheet so it -// re-resolves on theme change. -static const QString kValueStyleTemplate = - "QLabel { color: {{color.accent.bright}}; font-size: 12px; font-weight: bold; }"; - -// The shared value style, paired with its QLabel once; every value label in -// this dialog goes through here so they are identical (#5507, #5857). -// kValueStyleTemplate should have exactly one reference, the call below. -// Also retires setStyleSheet() call sites counted by tools/audit_colours.py. -static QLabel* makeValueLabel(const QString& text, const QString& extraRules = {}) +// Every radio-reported value label in this dialog comes from here so they are +// identical (#5507, #5857): the canon sheet styles the Value tone, and a +// disabled value takes the unavailable color with it. +static QLabel* makeValueLabel(const QString& text) { auto* label = new QLabel(text); - ThemeManager::instance().applyStyleSheet(label, kValueStyleTemplate + extraRules); + setTone(label, Tone::Value); return label; } -// The unavailable look of a Radio Audio Outputs row (theme-style-guide.md, -// Three-state controls): a disabled label, value or Mute button takes the -// unavailable token; the enabled look is unchanged. An engaged Mute that is -// unavailable keeps the row's unchecked background, not the engaged red. -static const QString kUnavailableLabelRule = - "QLabel:disabled { color: {{color.control.unavailable}}; }"; -static const QString kUnavailableButtonRule = - "QPushButton:disabled { color: {{color.control.unavailable}}; }" - "QPushButton:checked:disabled { background: {{color.background.1}}; " - "color: {{color.control.unavailable}}; }"; - -static const QString kKiwiRowStyle = - "QFrame#kiwiAntennaRow { background: #101622; border: 1px solid #203040; " - "border-radius: 3px; }"; - -static const QString kKiwiActionButtonStyle = - "QPushButton { background: #183548; border: 1px solid #28506a; " - "border-radius: 4px; color: #d6e7f5; font-size: 12px; " - "font-weight: bold; padding: 4px 10px; }" - "QPushButton:hover { background: #20465e; }" - "QPushButton:pressed { background: #132c3d; }"; - -static const QString kKiwiIconButtonStyle = - "QPushButton { background: #183548; border: 1px solid #28506a; " - "border-radius: 4px; padding: 3px; }" - "QPushButton:hover { background: #20465e; }" - "QPushButton:pressed { background: #132c3d; }"; - static constexpr int kInfoLeftLabelWidth = 112; static constexpr int kInfoRightLabelWidth = 160; @@ -322,7 +288,6 @@ static QWidget* wrapTabInScrollArea(QWidget* content) auto* scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); - scroll->setStyleSheet("QScrollArea { background: transparent; border: none; }"); scroll->setWidget(content); return scroll; } @@ -436,13 +401,14 @@ class CopyValueButton final : public QToolButton { painter.drawRoundedRect(rect().adjusted(1, 1, -1, -1), 3, 3); } - QColor stroke = QColor(QStringLiteral("#8090a0")); + const auto& theme = ThemeManager::instance(); + QColor stroke = theme.color(QStringLiteral("color.canon.muted")); if (!hasCopyableValue) { - stroke = QColor(QStringLiteral("#405060")); + stroke = theme.color(QStringLiteral("color.canon.line")); } else if (isDown()) { - stroke = QColor(QStringLiteral("#00b4d8")); + stroke = theme.color(QStringLiteral("color.canon.cyan")); } else if (underMouse() || hasFocus()) { - stroke = QColor(QStringLiteral("#c8d8e8")); + stroke = theme.color(QStringLiteral("color.canon.ink")); } painter.setPen(QPen(stroke, 1.25)); @@ -454,7 +420,7 @@ class CopyValueButton final : public QToolButton { const QRectF front(left + 5.5, top + 4.0, 8.5, 11.0); painter.drawRoundedRect(back, 1.5, 1.5); - painter.fillRect(front.adjusted(0.8, 0.8, -0.8, -0.8), QColor(QStringLiteral("#0f0f1a"))); + painter.fillRect(front.adjusted(0.8, 0.8, -0.8, -0.8), theme.color(QStringLiteral("color.canon.raised"))); painter.drawRoundedRect(front, 1.5, 1.5); } @@ -496,8 +462,9 @@ static void showCopiedPopup(QWidget* anchor) Qt::ToolTip | Qt::FramelessWindowHint); popup->setAttribute(Qt::WA_DeleteOnClose); popup->setAttribute(Qt::WA_ShowWithoutActivating); - AetherSDR::ThemeManager::instance().applyStyleSheet(popup, "QLabel { background: {{color.background.0}}; border: 1px solid {{color.background.2}};" - " border-radius: 4px; color: {{color.text.primary}}; font-size: 11px;" + // A top-level popup, outside the window's canon sheet: canon tokens directly. + AetherSDR::ThemeManager::instance().applyStyleSheet(popup, "QLabel { background: {{color.canon.raised}}; border: 1px solid {{color.canon.lineHi}};" + " border-radius: 4px; color: {{color.canon.ink}}; font-size: 11px;" " font-weight: bold; padding: 4px 8px; }"); popup->adjustSize(); @@ -613,12 +580,11 @@ static QString oscillatorStatusText(const RadioModel* model) return text; } -static QString oscillatorStatusColor(const RadioModel* model) +static Tone oscillatorStatusTone(const RadioModel* model) { if (normalizedOscillatorValue(model->oscState()).isEmpty()) - return QStringLiteral("#8aa8c0"); - return model->oscLocked() ? QStringLiteral("#00c040") - : QStringLiteral("#c04040"); + return Tone::Hint; + return model->oscLocked() ? Tone::Ok : Tone::Error; } static void addOscillatorChoice(QComboBox* combo, const QString& label, @@ -658,6 +624,368 @@ static void refreshOscillatorSourceCombo(QComboBox* combo, const RadioModel* mod if (idx >= 0) combo->setCurrentIndex(idx); } +// The style guide's canon vocabulary (RFC #6226), as Network Diagnostics uses +// it for the same search / navigation / page shape: transparent over +// CanonWindow's ground except the surfaces that carry content. The navigation +// list and the group boxes are cards, fields sit on control, and section +// titles and focus are cyan. Every page is styled here; widgets carry a role +// (RadioSetupDialogCommon.h) rather than a sheet of their own. +static constexpr const char* kRadioSetupStyle = R"( +QWidget { + color: {{color.canon.inkSoft}}; + background: transparent; +} +QLabel { + background: transparent; +} +QLabel:disabled, +QCheckBox:disabled, +QRadioButton:disabled { + color: {{color.control.unavailable}}; +} +QLabel[rsTone="caption"] { + color: {{color.canon.inkSoft}}; + font-size: 12px; +} +QLabel[rsTone="value"] { + color: {{color.canon.ink}}; + font-size: 12px; + font-weight: 700; +} +QLabel[rsTone="hint"] { + color: {{color.canon.muted}}; + font-size: 11px; +} +QLabel[rsTone="heading"] { + color: {{color.canon.cyan}}; + font-size: 12px; + font-weight: 700; +} +QLabel[rsTone="title"] { + color: {{color.canon.ink}}; + font-size: 20px; + font-weight: 700; +} +QLabel[rsTone="accent"] { + color: {{color.canon.cyan}}; + font-size: 11px; +} +QLabel[rsTone="ok"] { + color: {{color.accent.success}}; + font-size: 11px; +} +QLabel[rsTone="warn"] { + color: {{color.accent.warning}}; + font-size: 11px; +} +QLabel[rsTone="error"] { + color: {{color.accent.danger}}; + font-size: 11px; +} +QLabel[rsTone="value"]:disabled, +QLabel[rsTone="caption"]:disabled, +QLabel[rsTone="hint"]:disabled { + color: {{color.control.unavailable}}; +} +QLabel[rsChip="off"], +QLabel[rsChip="on"] { + color: {{color.canon.inkSoft}}; + background: {{color.canon.control}}; + border: 1px solid {{color.canon.lineHi}}; + border-radius: 4px; + padding: 3px 10px; + font-size: 11px; + font-weight: 700; +} +QLabel[rsChip="on"] { + color: {{color.canon.ink}}; + background: {{color.toggle.success.background.checked}}; + border-color: {{color.toggle.success.border.checked}}; +} +QLineEdit#radioSetupSearch { + color: {{color.canon.ink}}; + background: {{color.canon.control}}; + border: 1px solid {{color.canon.lineHi}}; + border-radius: 8px; + padding: 8px 11px; + font-size: 13px; +} +QLineEdit#radioSetupSearch:focus { + border-color: {{color.canon.aqua}}; +} +QTreeWidget#radioSetupNavigation { + color: {{color.canon.inkSoft}}; + background: {{color.canon.nested}}; + border: 1px solid {{color.canon.line}}; + border-radius: 12px; + padding: 6px; + outline: none; +} +QTreeWidget#radioSetupNavigation::branch { + image: none; + border-image: none; + background: transparent; +} +QTreeWidget#radioSetupNavigation::branch:selected, +QTreeWidget#radioSetupNavigation::branch:hover { + background: {{color.canon.nested}}; +} +QTreeWidget#radioSetupNavigation::item { + min-height: 28px; + padding: 2px 9px; + border-radius: 6px; +} +QTreeWidget#radioSetupNavigation::item:selected { + color: {{color.canon.onAccent}}; + background: {{color.canon.cyan}}; +} +QTreeWidget#radioSetupNavigation::item:hover:!selected { + color: {{color.canon.ink}}; + background: {{color.canon.control}}; +} +QGroupBox { + color: {{color.canon.cyan}}; + background: {{color.canon.raised}}; + border: 1px solid {{color.canon.line}}; + border-radius: 8px; + margin-top: 14px; + padding-top: 10px; + font-weight: 700; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; +} +QGroupBox QGroupBox { + background: {{color.canon.nested}}; +} +QPushButton { + color: {{color.canon.cyan}}; + background: {{color.canon.control}}; + border: 1px solid {{color.canon.lineHi}}; + border-radius: 4px; + padding: 4px 10px; + font-weight: 600; +} +QPushButton:hover { + background: {{color.canon.nested}}; + color: {{color.canon.aqua}}; +} +QPushButton:focus { + border-color: {{color.canon.aqua}}; +} +QPushButton:checked { + color: {{color.canon.ink}}; + background: {{color.toggle.success.background.checked}}; + border-color: {{color.toggle.success.border.checked}}; +} +QPushButton[rsButton="select"]:checked { + color: {{color.canon.onAccent}}; + background: {{color.canon.cyan}}; + border-color: {{color.canon.cyan}}; +} +QPushButton[rsButton="mute"]:checked, +QPushButton[rsButton="danger"] { + color: {{color.accent.danger}}; + background: {{color.toggle.footer.danger.background.checked}}; + border-color: {{color.accent.danger}}; +} +QPushButton[rsButton="danger"]:hover { + background: {{color.toggle.footer.danger.background.hover}}; +} +QPushButton[rsButton="success"] { + color: {{color.accent.success}}; + background: {{color.toggle.footer.success.background.checked}}; + border-color: {{color.accent.success}}; +} +QPushButton[rsButton="success"]:hover { + background: {{color.toggle.footer.success.background.hover}}; +} +/* Unavailable reads the same checked or not: an engaged Mute on a radio that + cannot mute shows no engaged red (theme-style-guide.md, Three-state + controls). */ +QPushButton:disabled, +QPushButton:checked:disabled, +QPushButton[rsButton="mute"]:checked:disabled, +QPushButton[rsButton="select"]:checked:disabled, +QPushButton[rsButton="danger"]:disabled, +QPushButton[rsButton="success"]:disabled { + color: {{color.control.unavailable}}; + border-color: {{color.canon.line}}; + background: transparent; +} +QLineEdit, +QSpinBox, +QDoubleSpinBox, +QComboBox { + color: {{color.canon.ink}}; + background: {{color.canon.control}}; + border: 1px solid {{color.canon.lineHi}}; + border-radius: 4px; + padding: 3px 6px; + selection-background-color: {{color.canon.cyan}}; + selection-color: {{color.canon.onAccent}}; +} +QLineEdit:focus, +QSpinBox:focus, +QDoubleSpinBox:focus, +QComboBox:focus { + border-color: {{color.canon.aqua}}; +} +QLineEdit:disabled, +QSpinBox:disabled, +QDoubleSpinBox:disabled, +QComboBox:disabled { + color: {{color.control.unavailable}}; + border-color: {{color.canon.line}}; +} +QLineEdit[readOnly="true"] { + color: {{color.canon.inkSoft}}; + background: {{color.canon.nested}}; +} +QComboBox QAbstractItemView, +QMenu { + color: {{color.canon.inkSoft}}; + background: {{color.canon.nested}}; + border: 1px solid {{color.canon.lineHi}}; + selection-background-color: {{color.canon.cyan}}; + selection-color: {{color.canon.onAccent}}; +} +QMenu::item:selected { + color: {{color.canon.onAccent}}; + background: {{color.canon.cyan}}; +} +QMenu::item:disabled { + color: {{color.control.unavailable}}; +} +QCheckBox, +QRadioButton { + background: transparent; + color: {{color.canon.inkSoft}}; + spacing: 8px; +} +QSlider::groove:horizontal { + background: {{color.canon.control}}; + height: 6px; + border-radius: 3px; +} +QSlider::sub-page:horizontal { + background: {{color.canon.cyan}}; + border-radius: 3px; +} +QSlider::handle:horizontal { + background: {{color.canon.ink}}; + width: 14px; + margin: -5px 0; + border-radius: 7px; +} +QSlider::groove:horizontal:disabled, +QSlider::sub-page:horizontal:disabled { + background: {{color.canon.nested}}; +} +QSlider::handle:horizontal:disabled { + background: {{color.control.unavailable}}; +} +QProgressBar { + color: {{color.canon.ink}}; + background: {{color.canon.control}}; + border: 1px solid {{color.canon.lineHi}}; + border-radius: 4px; + text-align: center; + font-size: 11px; +} +QProgressBar::chunk { + background: {{color.canon.cyan}}; + border-radius: 3px; +} +QListWidget, +QTableWidget, +QPlainTextEdit, +QTextEdit { + color: {{color.canon.inkSoft}}; + background: {{color.canon.nested}}; + border: 1px solid {{color.canon.line}}; + border-radius: 6px; + selection-background-color: {{color.canon.control}}; + selection-color: {{color.canon.ink}}; +} +QTableWidget { + gridline-color: {{color.canon.line}}; +} +QHeaderView::section { + color: {{color.canon.cyan}}; + background: {{color.canon.raised}}; + border: none; + border-bottom: 1px solid {{color.canon.line}}; + padding: 4px 6px; + font-weight: 700; +} +QTabWidget::pane { + background: {{color.canon.raised}}; + border: 1px solid {{color.canon.line}}; + border-radius: 8px; + top: -1px; +} +QTabBar::tab { + color: {{color.canon.muted}}; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 5px 12px; + font-weight: 600; +} +QTabBar::tab:hover { + color: {{color.canon.ink}}; +} +QTabBar::tab:selected { + color: {{color.canon.cyan}}; + border-bottom-color: {{color.canon.cyan}}; +} +QFrame#kiwiAntennaRow { + background: {{color.canon.nested}}; + border: 1px solid {{color.canon.line}}; + border-radius: 6px; +} +QScrollArea, +QScrollArea > QWidget > QWidget, +QStackedWidget, +QSplitter::handle { + background: transparent; + border: none; +} +QScrollBar:vertical { + background: transparent; + width: 12px; + margin: 8px 2px 8px 2px; + border-radius: 6px; +} +QScrollBar::handle:vertical { + background: {{color.canon.lineHi}}; + border-radius: 5px; + min-height: 34px; +} +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical { + height: 0px; +} +QScrollBar:horizontal { + background: transparent; + height: 12px; + margin: 2px 8px 2px 8px; + border-radius: 6px; +} +QScrollBar::handle:horizontal { + background: {{color.canon.lineHi}}; + border-radius: 5px; + min-width: 34px; +} +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal { + width: 0px; +} +)"; + RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, TgxlConnection* tgxl, PgxlConnection* pgxl, AntennaGeniusModel* ag, @@ -668,18 +996,28 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, LpMeterConnection* lpMeter, Kpa1500Connection* kpa1500, QWidget* parent) - : PersistentDialog(QStringLiteral("Radio Setup"), - QStringLiteral("RadioSetupDialogGeometry"), parent), + : CanonWindow(QStringLiteral("Radio Setup"), parent, Kind::Workspace), m_model(model), m_audio(audio), m_tgxl(tgxl), m_pgxl(pgxl), m_ag(ag), m_kiwiSdrManager(kiwiSdrManager), m_acom(acom), m_spe(spe), m_vkamp(vkamp), m_lpMeter(lpMeter), m_kpa1500(kpa1500) { - theme::setContainer(this, QStringLiteral("dialog/radioSetup")); - setMinimumSize(960, 680); - AetherSDR::ThemeManager::instance().applyStyleSheet(this, "QDialog { background: {{color.background.0}}; }"); + theme::setContainer(this, QStringLiteral("canon/radioSetup")); + // Size and position persist under the key it used as a PersistentDialog, + // so saved geometry carries over; it resizes from every edge. 40 px taller + // than before for the canon header. + setGeometryKey(QStringLiteral("RadioSetupDialogGeometry")); + setMinimumSize(960, 720); + FramelessResizer::install(this); + bodyWidget()->setObjectName(QStringLiteral("radioSetupBody")); + applyCanonSheet(bodyWidget(), [] { + return QString::fromLatin1(kRadioSetupStyle) + canonIndicatorRules(); + }); auto* layout = new QVBoxLayout(bodyWidget()); + layout->setContentsMargins(22, 18, 22, 20); + layout->setSpacing(10); + layout->addWidget(makeCanonHeader(QStringLiteral("Radio Setup"))); auto* search = new QLineEdit; search->setObjectName(QStringLiteral("radioSetupSearch")); @@ -690,10 +1028,6 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, search->setAccessibleDescription( QStringLiteral("Type a feature, device, or setting name to filter the navigation list.")); search->setMinimumHeight(38); - AetherSDR::ThemeManager::instance().applyStyleSheet(search, - "QLineEdit { background: {{color.background.1}}; color: {{color.text.primary}}; " - "border: 2px solid {{color.background.2}}; border-radius: 6px; padding: 7px 10px; font-size: 13px; }" - "QLineEdit:focus { border-color: {{color.accent.bright}}; }"); layout->addWidget(search); auto* content = new QSplitter(Qt::Horizontal); @@ -710,13 +1044,6 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, m_navigation->setAccessibleName(QStringLiteral("Radio Setup categories")); m_navigation->setAccessibleDescription( QStringLiteral("Use the arrow keys to move between categories and settings pages.")); - AetherSDR::ThemeManager::instance().applyStyleSheet(m_navigation, - "QTreeWidget { background: {{color.background.1}}; color: {{color.text.primary}}; " - "border: 1px solid {{color.background.2}}; border-radius: 6px; padding: 6px; outline: none; }" - "QTreeWidget::branch { image: none; border-image: none; background: transparent; }" - "QTreeWidget::item { min-height: 36px; padding: 3px 8px; border-radius: 4px; }" - "QTreeWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }" - "QTreeWidget::item:hover:!selected { background: {{color.background.2}}; }"); content->addWidget(m_navigation); auto* pageHost = new QWidget; @@ -725,8 +1052,7 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, pageLayout->setSpacing(10); m_pageTitle = new QLabel; m_pageTitle->setAccessibleName(QStringLiteral("Current settings page")); - AetherSDR::ThemeManager::instance().applyStyleSheet(m_pageTitle, - "QLabel { color: {{color.text.primary}}; font-size: 20px; font-weight: 600; padding: 2px 0 8px 0; }"); + setTone(m_pageTitle, Tone::Title); pageLayout->addWidget(m_pageTitle); m_pages = new QStackedWidget; m_pages->setObjectName(QStringLiteral("radioSetupPages")); @@ -993,18 +1319,11 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, }); updateRadioCapabilityVisibility(); layout->addWidget(content, 1); - - auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close); - AetherSDR::ThemeManager::instance().applyStyleSheet(buttons, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; padding: 4px 16px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::close); - layout->addWidget(buttons); } void RadioSetupDialog::showEvent(QShowEvent* event) { - PersistentDialog::showEvent(event); + CanonWindow::showEvent(event); // This dialog is persistent and may have been hidden while the selected // radio or an optional Flex GPSDO changed. Re-evaluate every gated page and // group before presenting the cached widget tree again. @@ -1284,7 +1603,7 @@ void RadioSetupDialog::done(int result) // closeEvent. Keep those paths behind the same confirmation without // redirecting reject() to close() (which recurses during Qt's close path). if (!m_peripheralRemovalPending && confirmFirmwareClose()) { - PersistentDialog::done(result); + CanonWindow::done(result); } } @@ -1298,7 +1617,7 @@ void RadioSetupDialog::closeEvent(QCloseEvent* event) // tab before the base class flushes geometry to AppSettings. for (const auto& saver : m_peripheralRowSavers) saver(); - PersistentDialog::closeEvent(event); + CanonWindow::closeEvent(event); } // ── Radio tab ───────────────────────────────────────────────────────────────── @@ -1310,25 +1629,17 @@ QWidget* RadioSetupDialog::buildRadioTab() vbox->setSpacing(8); // Toggle button style: green when on, red when off - static const QString kToggleStyle = - "QPushButton { background: #1a2a3a; border: 1px solid #304050; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #1a5030; color: #00e060; " - "border: 1px solid #20a040; }"; auto makeToggle = [](bool checked) { auto* btn = new QPushButton(checked ? "Enabled" : "Disabled"); btn->setCheckable(true); btn->setChecked(checked); - btn->setStyleSheet(kToggleStyle); return btn; }; // Radio Information group { auto* group = new QGroupBox("Radio Information"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); grid->setColumnStretch(0, 1); @@ -1379,13 +1690,7 @@ QWidget* RadioSetupDialog::buildRadioTab() auto* fcLbl = new QLabel; auto updateFcLbl = [fcLbl](bool connected) { fcLbl->setText(connected ? "Enabled" : "Disabled"); - AetherSDR::ThemeManager::instance().applyStyleSheet(fcLbl, connected - ? "QLabel { background: #1a5030; border: 1px solid #20a040; " - "border-radius: 3px; color: {{color.accent.success}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - : "QLabel { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }"); + setChip(fcLbl, connected ? Chip::On : Chip::Off); }; updateFcLbl(m_model->isConnected()); fcLbl->setAlignment(Qt::AlignCenter); @@ -1404,11 +1709,7 @@ QWidget* RadioSetupDialog::buildRadioTab() grid->addWidget(m_multiFlexInfoField, 3, 1); auto* rebootBtn = new QPushButton(QStringLiteral("Reboot Radio")); - AetherSDR::ThemeManager::instance().applyStyleSheet(rebootBtn, - "QPushButton { background: #3a1a1a; color: #ffb080; border: 1px solid #6e3030;" - " border-radius: 3px; font-size: 11px; font-weight: bold; padding: 3px 10px; }" - "QPushButton:hover { background: #4a2020; }" - "QPushButton:disabled { background: {{color.button.danger.background.disabled}}; color: {{color.button.danger.foreground.disabled}}; border-color: {{color.button.danger.border.disabled}}; }"); + setButtonRole(rebootBtn, ButtonRole::Danger); // Only enable when actually connected; subscribe so disconnect/reconnect // disables/re-enables the button without the user having to reopen the // dialog. rebootRadio() also early-returns on disconnected, but the @@ -1470,7 +1771,7 @@ QWidget* RadioSetupDialog::buildRadioTab() }); for (auto* lbl : group->findChildren()) { - if (lbl->styleSheet().isEmpty()) + if (!hasTone(lbl)) applyLabelStyle(lbl); } @@ -1480,7 +1781,6 @@ QWidget* RadioSetupDialog::buildRadioTab() // Radio Identification group { auto* group = new QGroupBox("Radio Identification"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); grid->setColumnStretch(0, 1); @@ -1510,13 +1810,11 @@ QWidget* RadioSetupDialog::buildRadioTab() info.model.isEmpty() ? m_model->name() : info.model); } m_nicknameEdit = new QLineEdit(initialNickname); - applyEditStyle(m_nicknameEdit); grid->addWidget(makeInfoField(QStringLiteral("Nickname:"), m_nicknameEdit, kInfoRightLabelWidth), 0, 1); m_callsignEdit = new QLineEdit(m_model->callsign()); - applyEditStyle(m_callsignEdit); // Named for the screen reader and for the automation bridge, which // resolves controls by objectName / class / accessibleName. Both of // these fields were anonymous QLineEdits among many, so neither could @@ -1577,7 +1875,6 @@ QWidget* RadioSetupDialog::buildRadioTab() QString stationVal = AppSettings::instance().value("StationName", "").toString(); auto* stationEdit = new QLineEdit( stationVal.isEmpty() ? QSysInfo::machineHostName() : stationVal); - applyEditStyle(stationEdit); stationEdit->setToolTip("Identifies this client to other Multi-Flex stations.\n" "Defaults to OS hostname if empty."); grid->addWidget(makeInfoField(QStringLiteral("Station Name:"), stationEdit, @@ -1591,7 +1888,7 @@ QWidget* RadioSetupDialog::buildRadioTab() }); for (auto* lbl : group->findChildren()) { - if (lbl->styleSheet().isEmpty()) + if (!hasTone(lbl)) applyLabelStyle(lbl); } @@ -1602,7 +1899,6 @@ QWidget* RadioSetupDialog::buildRadioTab() { auto* group = new QGroupBox("License Info"); m_licenseInfoGroup = group; - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); grid->setColumnStretch(0, 1); @@ -1641,7 +1937,7 @@ QWidget* RadioSetupDialog::buildRadioTab() 1, 1); for (auto* lbl : group->findChildren()) { - if (lbl->styleSheet().isEmpty()) + if (!hasTone(lbl)) applyLabelStyle(lbl); } @@ -1664,7 +1960,6 @@ QWidget* RadioSetupDialog::buildRadioTab() { auto* group = new QGroupBox("Firmware Update"); m_firmwareUpdateGroup = group; - group->setStyleSheet(kGroupStyle); auto* vlay = new QVBoxLayout(group); vlay->setSpacing(6); @@ -1685,36 +1980,26 @@ QWidget* RadioSetupDialog::buildRadioTab() m_fwProgress->setValue(0); m_fwProgress->setTextVisible(true); m_fwProgress->setFixedHeight(20); - AetherSDR::ThemeManager::instance().applyStyleSheet(m_fwProgress, "QProgressBar { text-align: center; font-size: 11px; color: {{color.text.primary}};" - " background: {{color.background.1}}; border: 1px solid #2e4e6e; border-radius: 3px; }" - "QProgressBar::chunk { background: {{color.accent}}; }"); m_fwProgress->hide(); vlay->addWidget(m_fwProgress); // Status label (multi-line) m_fwStatusLabel = new QLabel(""); - m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Hint); m_fwStatusLabel->setWordWrap(true); vlay->addWidget(m_fwStatusLabel); // Button row auto* btnRow = new QHBoxLayout; auto* checkBtn = new QPushButton("Check for Update"); - AetherSDR::ThemeManager::instance().applyStyleSheet(checkBtn, "QPushButton { background: {{color.background.1}}; color: {{color.text.primary}}; border: 1px solid #2e4e6e;" - " border-radius: 3px; padding: 4px 8px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); btnRow->addWidget(checkBtn); auto* browseBtn = new QPushButton("Select Installer..."); - browseBtn->setStyleSheet(checkBtn->styleSheet()); btnRow->addWidget(browseBtn); m_fwUploadBtn = new QPushButton("Upload Firmware"); m_fwUploadBtn->setEnabled(false); - AetherSDR::ThemeManager::instance().applyStyleSheet(m_fwUploadBtn, "QPushButton { background: #1a3a1a; color: #80e080; border: 1px solid #2e6e2e;" - " border-radius: 3px; padding: 4px 8px; }" - "QPushButton:hover { background: #2a4a2a; }" - "QPushButton:disabled { background: {{color.button.background.disabled}}; color: {{color.button.foreground.disabled}}; border-color: {{color.button.border.disabled}}; }"); + setButtonRole(m_fwUploadBtn, ButtonRole::Success); btnRow->addWidget(m_fwUploadBtn); vlay->addLayout(btnRow); @@ -1723,7 +2008,7 @@ QWidget* RadioSetupDialog::buildRadioTab() connect(checkBtn, &QPushButton::clicked, this, [this, checkBtn] { checkBtn->setEnabled(false); - m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Hint); m_stager->checkForUpdate(m_model->softwareVersion()); }); @@ -1731,13 +2016,13 @@ QWidget* RadioSetupDialog::buildRadioTab() [this, checkBtn](const QString& latest, bool avail) { checkBtn->setEnabled(true); if (avail) { - m_fwStatusLabel->setStyleSheet("QLabel { color: #f0c040; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Warn); m_fwStatusLabel->setText(QString( "Update available: v%1\n" "Download the SmartSDR installer from flexradio.com,\n" "then click 'Select Installer...' to stage it.").arg(latest)); } else { - m_fwStatusLabel->setStyleSheet("QLabel { color: #80e080; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Ok); m_fwStatusLabel->setText("Firmware is up to date (v" + latest + ")."); } }); @@ -1745,14 +2030,14 @@ QWidget* RadioSetupDialog::buildRadioTab() connect(m_stager, &FirmwareStager::updateCheckFailed, this, [this, checkBtn](const QString& err) { checkBtn->setEnabled(true); - m_fwStatusLabel->setStyleSheet("QLabel { color: #e08080; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Error); m_fwStatusLabel->setText(err); }); connect(m_stager, &FirmwareStager::stageProgress, this, [this](int pct, const QString& status) { m_fwProgress->setValue(pct); - m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Hint); m_fwStatusLabel->setText(status); }); @@ -1760,13 +2045,13 @@ QWidget* RadioSetupDialog::buildRadioTab() [this](const QString& path, const QString&) { m_fwFilePath = path; m_fwUploadBtn->setEnabled(true); - m_fwStatusLabel->setStyleSheet("QLabel { color: #80e080; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Ok); }); connect(m_stager, &FirmwareStager::stageFailed, this, [this](const QString& err) { m_fwProgress->hide(); - m_fwStatusLabel->setStyleSheet("QLabel { color: #e08080; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Error); m_fwStatusLabel->setText(err); }); @@ -1792,7 +2077,7 @@ QWidget* RadioSetupDialog::buildRadioTab() m_fwUploadBtn->setEnabled(false); m_fwProgress->show(); m_fwProgress->setValue(0); - m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }"); + setTone(m_fwStatusLabel, Tone::Hint); m_fwStatusLabel->setText("Preparing firmware from " + QFileInfo(path).fileName() + "..."); // The stager emits stageProgress / stageComplete / stageFailed. @@ -1842,13 +2127,11 @@ QWidget* RadioSetupDialog::buildRadioTab() // colour — colour alone never states it (docs/a11y.md). m_fwStatusLabel->setText(msg); m_fwStatusLabel->setAccessibleDescription(msg); - // One setStyleSheet site for all three outcomes: the - // colour ratchet counts call sites, not just literals. - QString colour; + Tone tone = Tone::Hint; switch (outcome) { case FirmwareUploader::Outcome::Succeeded: m_fwProgress->setValue(100); - colour = QStringLiteral("#80e080"); + tone = Tone::Ok; m_fwUploadBtn->setEnabled(false); break; case FirmwareUploader::Outcome::Unconfirmed: @@ -1857,35 +2140,29 @@ QWidget* RadioSetupDialog::buildRadioTab() // reads as "nothing happened" — and do not offer a // retry: the uploader refuses one until reconnect. m_fwProgress->setValue(100); - colour = QStringLiteral("{{color.accent.warning}}"); + tone = Tone::Warn; m_fwUploadBtn->setEnabled(false); break; case FirmwareUploader::Outcome::Failed: m_fwProgress->hide(); - colour = QStringLiteral("#e08080"); + tone = Tone::Error; m_fwUploadBtn->setEnabled(true); break; } - // applyStyleSheet, not setStyleSheet: the latter does - // not expand {{tokens}} (ThemeManager.h:174), and it - // keeps the label repainting on themeChanged. - AetherSDR::ThemeManager::instance().applyStyleSheet( - m_fwStatusLabel, - QStringLiteral("QLabel { color: %1; font-size: 10px; }").arg(colour)); + setTone(m_fwStatusLabel, tone); }); } m_fwProgress->show(); m_fwProgress->setValue(0); m_fwUploadBtn->setEnabled(false); - AetherSDR::ThemeManager::instance().applyStyleSheet( - m_fwStatusLabel, QStringLiteral("QLabel { color: #6888a0; font-size: 10px; }")); + setTone(m_fwStatusLabel, Tone::Hint); m_uploader->upload(m_fwFilePath); }); for (auto* lbl : group->findChildren()) { - if (lbl->styleSheet().isEmpty()) + if (!hasTone(lbl)) applyLabelStyle(lbl); } @@ -1899,9 +2176,7 @@ QWidget* RadioSetupDialog::buildRadioTab() "via the SmartSDR Windows application."); disclaimer->setWordWrap(true); m_firmwareDisclaimer = disclaimer; - disclaimer->setStyleSheet( - "QLabel { color: #c08040; font-size: 11px; font-style: italic;" - " padding: 4px 8px; }"); + setTone(disclaimer, Tone::Warn); vbox->addWidget(disclaimer); vbox->addStretch(1); @@ -1920,7 +2195,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } @@ -1929,7 +2204,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() { auto* group = new QGroupBox("Network"); m_networkIdentityGroup = group; - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); grid->setColumnStretch(1, 1); @@ -1966,7 +2240,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() }); for (auto* lbl : group->findChildren()) - if (lbl->styleSheet().isEmpty()) applyLabelStyle(lbl); + if (!hasTone(lbl)) applyLabelStyle(lbl); vbox->addWidget(group); } @@ -1974,7 +2248,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() // Advanced group { auto* group = new QGroupBox("Advanced"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -1983,11 +2256,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() auto* enforceBtn = new QPushButton(m_model->enforcePrivateIp() ? "Enabled" : "Disabled"); enforceBtn->setCheckable(true); enforceBtn->setChecked(m_model->enforcePrivateIp()); - AetherSDR::ThemeManager::instance().applyStyleSheet(enforceBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; " - "border: 1px solid #20a040; }"); connect(enforceBtn, &QPushButton::toggled, this, [this, enforceBtn](bool on) { enforceBtn->setText(on ? "Enabled" : "Disabled"); m_model->sendCommand( @@ -2023,10 +2291,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() "Only a client holding this token can drive the radio.\n" "Stored in your OS secret store. See\n" + DocsLinks::automationBridge()); - AetherSDR::ThemeManager::instance().applyStyleSheet(tokenEdit, - "QLineEdit { background: {{color.background.0}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-family: monospace; " - "font-size: 11px; padding: 3px 6px; }"); auto tokenLoaded = std::make_shared(false); AutomationBridgeSettings::loadToken(this, [tokenEdit, tokenLoaded](const QString& tok) { @@ -2048,11 +2312,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() auto* mcpBtn = new QPushButton(bridgeOn ? "Enabled" : "Disabled"); mcpBtn->setCheckable(true); mcpBtn->setChecked(bridgeOn); - AetherSDR::ThemeManager::instance().applyStyleSheet(mcpBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; " - "border: 1px solid #20a040; }"); mcpBtn->setToolTip( "Enable the in-app automation bridge so an AI coding assistant " "(via the MCP server, tools/aether_mcp.py) can introspect and\n" @@ -2108,11 +2367,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() auto* copyBtn = new QPushButton("Copy"); auto* rotateBtn = new QPushButton("Rotate"); - for (auto* b : {copyBtn, rotateBtn}) - AetherSDR::ThemeManager::instance().applyStyleSheet(b, - "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; " - "padding: 3px 10px; }"); copyBtn->setToolTip("Copy the token to the clipboard."); rotateBtn->setToolTip( "Generate a new token and apply it immediately. Any client still\n" @@ -2155,9 +2409,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() "Bridge-originated TX is limited by a force-unkey watchdog. You are\n" "responsible for anything transmitted. See\n" + DocsLinks::automationBridge()); - AetherSDR::ThemeManager::instance().applyStyleSheet(txCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; }" - "QCheckBox::indicator { width: 14px; height: 14px; }"); if (envBlocksTx) { txCheck->setEnabled(false); txCheck->setToolTip(txCheck->toolTip() @@ -2233,9 +2484,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() "client cannot bypass it. Toggle takes effect immediately on the\n" "running bridge. See\n" + DocsLinks::automationBridge()); - AetherSDR::ThemeManager::instance().applyStyleSheet(roCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; }" - "QCheckBox::indicator { width: 14px; height: 14px; }"); if (envForcesRo) { roCheck->setEnabled(false); roCheck->setToolTip(roCheck->toolTip() @@ -2352,7 +2600,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() } for (auto* lbl : group->findChildren()) - if (lbl->styleSheet().isEmpty()) applyLabelStyle(lbl); + if (!hasTone(lbl)) applyLabelStyle(lbl); updateRadioCapabilityVisibility(); @@ -2371,7 +2619,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() QGroupBox* RadioSetupDialog::buildIpConfigGroup() { auto* group = new QGroupBox("IP Configuration"); - group->setStyleSheet(kGroupStyle); auto* gvbox = new QVBoxLayout(group); gvbox->setSpacing(6); @@ -2388,11 +2635,7 @@ QGroupBox* RadioSetupDialog::buildIpConfigGroup() dhcpBtn->setChecked(!isStatic); dhcpBtn->setEnabled(canConfigure); dhcpBtn->setToolTip(canConfigure ? QString() : unavailableTip); - AetherSDR::ThemeManager::instance().applyStyleSheet(dhcpBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 4px 16px; }" - "QPushButton:checked { background: {{color.background.2}}; color: {{color.text.primary}}; " - "border: 1px solid {{color.accent.dim}}; }"); + setButtonRole(dhcpBtn, ButtonRole::Select); btnRow->addWidget(dhcpBtn); auto* staticBtn = new QPushButton("Static"); @@ -2401,7 +2644,7 @@ QGroupBox* RadioSetupDialog::buildIpConfigGroup() staticBtn->setChecked(isStatic); staticBtn->setEnabled(canConfigure); staticBtn->setToolTip(dhcpBtn->toolTip()); - staticBtn->setStyleSheet(dhcpBtn->styleSheet()); + setButtonRole(staticBtn, ButtonRole::Select); btnRow->addWidget(staticBtn); btnRow->addStretch(1); @@ -2413,26 +2656,23 @@ QGroupBox* RadioSetupDialog::buildIpConfigGroup() fieldsGrid->addWidget(new QLabel("IP Address:"), 0, 0); auto* staticIp = new QLineEdit(isStatic ? m_model->staticIp() : m_model->ip()); m_staticIpEdit = staticIp; - applyEditStyle(staticIp); staticIp->setEnabled(canConfigure && isStatic); fieldsGrid->addWidget(staticIp, 0, 1); fieldsGrid->addWidget(new QLabel("Mask:"), 1, 0); auto* staticMask = new QLineEdit(isStatic ? m_model->staticNetmask() : m_model->netmask()); m_staticMaskEdit = staticMask; - applyEditStyle(staticMask); staticMask->setEnabled(canConfigure && isStatic); fieldsGrid->addWidget(staticMask, 1, 1); fieldsGrid->addWidget(new QLabel("Gateway:"), 2, 0); auto* staticGw = new QLineEdit(isStatic ? m_model->staticGateway() : m_model->gateway()); m_staticGatewayEdit = staticGw; - applyEditStyle(staticGw); staticGw->setEnabled(canConfigure && isStatic); fieldsGrid->addWidget(staticGw, 2, 1); for (auto* lbl : group->findChildren()) - if (lbl->styleSheet().isEmpty()) applyLabelStyle(lbl); + if (!hasTone(lbl)) applyLabelStyle(lbl); gvbox->addLayout(fieldsGrid); @@ -2445,10 +2685,6 @@ QGroupBox* RadioSetupDialog::buildIpConfigGroup() applyBtn->setEnabled(false); applyBtn->setToolTip(canConfigure ? QString() : unavailableTip); applyBtn->setAccessibleDescription(applyBtn->toolTip()); - AetherSDR::ThemeManager::instance().applyStyleSheet(applyBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 4px 16px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); gvbox->addWidget(applyBtn, 0, Qt::AlignLeft); connect(dhcpBtn, &QPushButton::clicked, this, @@ -2496,7 +2732,7 @@ QWidget* RadioSetupDialog::buildGpsTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } @@ -2505,9 +2741,7 @@ QWidget* RadioSetupDialog::buildGpsTab() { const bool installed = m_model->hasGpsSetupHardware(); auto* statusLbl = new QLabel(installed ? "GPS is installed" : "GPS is not installed"); - statusLbl->setStyleSheet(installed - ? "QLabel { color: #00c040; font-size: 16px; font-weight: bold; }" - : "QLabel { color: #c04040; font-size: 16px; font-weight: bold; }"); + setTone(statusLbl, installed ? Tone::Ok : Tone::Error); statusLbl->setAlignment(Qt::AlignCenter); vbox->addWidget(statusLbl); vbox->addSpacing(16); @@ -2555,7 +2789,7 @@ QWidget* RadioSetupDialog::buildTxTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } @@ -2563,7 +2797,6 @@ QWidget* RadioSetupDialog::buildTxTab() // Timings group { auto* group = new QGroupBox("Timings (in ms)"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -2573,9 +2806,6 @@ QWidget* RadioSetupDialog::buildTxTab() applyLabelStyle(lbl); grid->addWidget(lbl, row, col * 2); auto* edit = new QLineEdit(QString::number(value)); - AetherSDR::ThemeManager::instance().applyStyleSheet(edit, - kEditStyleTemplate - + "QLineEdit:disabled { color: {{color.control.unavailable}}; }"); edit->setFixedWidth(60); edit->setAccessibleName(QString(label).remove(QLatin1Char(':'))); grid->addWidget(edit, row, col * 2 + 1); @@ -2636,17 +2866,13 @@ QWidget* RadioSetupDialog::buildTxTab() // TX Band Settings button auto* bandSetBtn = new QPushButton("TX Band Settings"); - AetherSDR::ThemeManager::instance().applyStyleSheet(bandSetBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 4px 12px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); connect(bandSetBtn, &QPushButton::clicked, this, [this] { emit txBandSettingsRequested(); }); grid->addWidget(bandSetBtn, 3, 2, 1, 2); for (auto* lbl : group->findChildren()) - if (lbl->styleSheet().isEmpty()) applyLabelStyle(lbl); + if (!hasTone(lbl)) applyLabelStyle(lbl); // Last in the group: the lambda copies timingEdits, so every field // has to exist before this point. @@ -2672,7 +2898,6 @@ QWidget* RadioSetupDialog::buildTxTab() // Interlocks group { auto* group = new QGroupBox("Interlocks - TX REQ"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -2724,7 +2949,6 @@ QWidget* RadioSetupDialog::buildTxTab() maxPowerIsRatedWatts && !ratedWattsReported ? QString() : QString::number(tx.maxPowerLevel())); - applyEditStyle(mpEdit); mpEdit->setFixedWidth(50); mpEdit->setAccessibleName(tr("Max Power")); mpRow->addWidget(mpEdit); @@ -2767,11 +2991,6 @@ QWidget* RadioSetupDialog::buildTxTab() auto* swBtn = new QPushButton(tx.showTxInWaterfall() ? "Enabled" : "Disabled"); swBtn->setCheckable(true); swBtn->setChecked(tx.showTxInWaterfall()); - AetherSDR::ThemeManager::instance().applyStyleSheet(swBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; " - "border: 1px solid #20a040; }"); connect(swBtn, &QPushButton::toggled, this, [this, swBtn](bool on) { swBtn->setText(on ? "Enabled" : "Disabled"); m_model->sendCommand( @@ -2784,26 +3003,18 @@ QWidget* RadioSetupDialog::buildTxTab() applyLabelStyle(followLbl); grid->addWidget(followLbl, 2, 0); - const QString kFollowBtnStyle = - "QPushButton { background: #1a2a3a; border: 1px solid #304050; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; " - "padding: 3px 8px; }" - "QPushButton:checked { background: #1a5030; color: #00e060; " - "border: 1px solid #20a040; }"; bool txFollows = AppSettings::instance().value("TxFollowsActiveSlice", "False").toString() == "True"; auto* tfBtn = new QPushButton("TX Follows Active Slice"); tfBtn->setCheckable(true); tfBtn->setChecked(txFollows); tfBtn->setToolTip("TX follows the active slice.\nDisabled during Split operation."); - tfBtn->setStyleSheet(kFollowBtnStyle); bool activeFollows = AppSettings::instance().value("ActiveFollowsTxSlice", "False").toString() == "True"; auto* afBtn = new QPushButton("Active Slice Follows TX"); afBtn->setCheckable(true); afBtn->setChecked(activeFollows); afBtn->setToolTip("Switch active slice when TX moves externally\n(e.g. WSJT-X or CAT command)."); - afBtn->setStyleSheet(kFollowBtnStyle); auto* followRow = new QHBoxLayout; followRow->setSpacing(6); @@ -2850,30 +3061,23 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } - static const QString kTogBtnStyle = - "QPushButton { background: #1a2a3a; border: 1px solid #304050; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #0070c0; color: #ffffff; " - "border: 1px solid #0090e0; }"; auto mkTogBtn = [&](const QString& text, bool checked) { auto* btn = new QPushButton(text); btn->setCheckable(true); btn->setChecked(checked); - btn->setStyleSheet(kTogBtnStyle); + setButtonRole(btn, ButtonRole::Select); return btn; }; // Microphone group { auto* group = new QGroupBox("Microphone"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setHorizontalSpacing(8); grid->setVerticalSpacing(6); @@ -2914,7 +3118,6 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() // CW group { auto* group = new QGroupBox("CW"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -3011,7 +3214,6 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() // Digital group { auto* group = new QGroupBox("Digital"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -3019,7 +3221,6 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() applyLabelStyle(markLbl); grid->addWidget(markLbl, 0, 0); auto* markEdit = new QLineEdit(QString::number(m_model->rttyMarkDefault())); - applyEditStyle(markEdit); markEdit->setFixedWidth(60); connect(markEdit, &QLineEdit::editingFinished, this, [this, markEdit] { m_model->sendCommand( @@ -3035,8 +3236,7 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() // Decode RX/TX toggles above. MainWindow re-evaluates panel and // run state on dialog close via refreshRttyDecodeState(). auto* rttyDecodeLbl = new QLabel("RTTY Decode:"); - ThemeManager::instance().applyStyleSheet(rttyDecodeLbl, - "QLabel { color: {{color.text.primary}}; font-size: 12px; }"); + setTone(rttyDecodeLbl, Tone::Caption); grid->addWidget(rttyDecodeLbl, 1, 0); auto* rttyDecodeBtn = mkTogBtn( RttyDecodeSettings::enabled() ? "Enabled" : "Disabled", @@ -3067,31 +3267,22 @@ QWidget* RadioSetupDialog::buildRxTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } - static const QString kTogStyle = - "QPushButton { background: #1a2a3a; border: 1px solid #304050; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #1a5030; color: #00e060; " - "border: 1px solid #20a040; }"; // Frequency Offset group { auto* group = new QGroupBox("Frequency Offset"); - group->setStyleSheet(kGroupStyle); auto* gvb = new QVBoxLayout(group); gvb->setSpacing(4); auto* lbl = new QLabel(m_model->gpsdoPresent() ? "GPSDO installed. Manual frequency offset calibration available." : "Manual frequency offset calibration available."); - lbl->setStyleSheet(m_model->gpsdoPresent() - ? "QLabel { color: #00c040; font-size: 12px; }" - : "QLabel { color: #c0a000; font-size: 12px; }"); + setTone(lbl, m_model->gpsdoPresent() ? Tone::Ok : Tone::Warn); lbl->setWordWrap(true); gvb->addWidget(lbl); @@ -3103,7 +3294,6 @@ QWidget* RadioSetupDialog::buildRxTab() applyLabelStyle(calLbl); offsetGrid->addWidget(calLbl, 0, 0); auto* calEdit = new QLineEdit(QString::number(m_model->calFreqMhz(), 'f', 6)); - applyEditStyle(calEdit); calEdit->setFixedWidth(100); connect(calEdit, &QLineEdit::editingFinished, this, [this, calEdit] { m_model->sendCommand( @@ -3112,23 +3302,17 @@ QWidget* RadioSetupDialog::buildRxTab() offsetGrid->addWidget(calEdit, 0, 1); auto* startBtn = new QPushButton("Start"); - startBtn->setStyleSheet(kTogStyle); startBtn->setFixedWidth(60); auto* calStatus = new QLabel; - AetherSDR::ThemeManager::instance().applyStyleSheet(calStatus, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + setTone(calStatus, Tone::Hint); calStatus->setMinimumWidth(130); auto calibrationActive = std::make_shared(false); auto pllRunningSeen = std::make_shared(false); auto calibrationRun = std::make_shared(0); - // Takes a THEME TOKEN, not a colour literal. A raw setStyleSheet() with - // a token name in the colour slot is not merely ignored — Qt discards - // the whole rule, taking the font-size with it and leaving default - // black text, which is near-invisible on the dark theme. - auto setCalStatus = [calStatus](const QString& text, const QString& token) { + auto setCalStatus = [calStatus](const QString& text, Tone tone) { calStatus->setText(text); - AetherSDR::ThemeManager::instance().applyStyleSheet(calStatus, - QStringLiteral("QLabel { color: {{%1}}; font-size: 11px; }").arg(token)); + setTone(calStatus, tone); }; connect(startBtn, &QPushButton::clicked, this, @@ -3136,7 +3320,7 @@ QWidget* RadioSetupDialog::buildRxTab() calibrationRun, setCalStatus] { const QString calFreq = calEdit->text().trimmed(); if (calFreq.isEmpty()) { - setCalStatus("Enter cal frequency", "color.accent.warning"); + setCalStatus("Enter cal frequency", Tone::Warn); return; } @@ -3145,7 +3329,7 @@ QWidget* RadioSetupDialog::buildRxTab() *pllRunningSeen = false; startBtn->setEnabled(false); startBtn->setText("Busy"); - setCalStatus("Starting...", "color.text.secondary"); + setCalStatus("Starting...", Tone::Hint); qCDebug(lcProtocol) << "RadioSetupDialog: frequency calibration requested" << "cal_freq=" << calFreq @@ -3178,7 +3362,7 @@ QWidget* RadioSetupDialog::buildRxTab() } if (code == 0) { statusGuard->setText("Calibrating..."); - AetherSDR::ThemeManager::instance().applyStyleSheet(statusGuard, "QLabel { color: {{color.accent.bright}}; font-size: 11px; }"); + setTone(statusGuard, Tone::Accent); qCDebug(lcProtocol) << "RadioSetupDialog: radio pll_start accepted"; return; @@ -3189,8 +3373,7 @@ QWidget* RadioSetupDialog::buildRxTab() startGuard->setText("Start"); statusGuard->setText(QString("Error 0x%1") .arg(code, 0, 16).toUpper()); - statusGuard->setStyleSheet( - "QLabel { color: #ff7070; font-size: 11px; }"); + setTone(statusGuard, Tone::Error); qCWarning(lcProtocol) << "RadioSetupDialog: radio pll_start failed: code" << Qt::hex << code << "body:" << body; @@ -3206,8 +3389,7 @@ QWidget* RadioSetupDialog::buildRxTab() startGuard->setEnabled(true); startGuard->setText("Start"); statusGuard->setText("No response"); - statusGuard->setStyleSheet( - "QLabel { color: #ff7070; font-size: 11px; }"); + setTone(statusGuard, Tone::Error); qCWarning(lcProtocol) << "RadioSetupDialog: frequency calibration timed out waiting for pll_done=1" << "run_id=" << runId; @@ -3221,7 +3403,6 @@ QWidget* RadioSetupDialog::buildRxTab() applyLabelStyle(ppbLbl); offsetGrid->addWidget(ppbLbl, 1, 0); auto* ppbEdit = new QLineEdit(QString::number(m_model->freqErrorPpb())); - applyEditStyle(ppbEdit); ppbEdit->setFixedWidth(80); connect(ppbEdit, &QLineEdit::editingFinished, this, [this, ppbEdit] { m_model->sendCommand( @@ -3264,7 +3445,7 @@ QWidget* RadioSetupDialog::buildRxTab() if (pllDone == QStringLiteral("0")) { *pllRunningSeen = true; calStatus->setText("Calibrating..."); - AetherSDR::ThemeManager::instance().applyStyleSheet(calStatus, "QLabel { color: {{color.accent.bright}}; font-size: 11px; }"); + setTone(calStatus, Tone::Accent); return; } @@ -3283,7 +3464,7 @@ QWidget* RadioSetupDialog::buildRxTab() calStatus->setText(freqError.isEmpty() ? QStringLiteral("Complete") : QStringLiteral("Complete (%1 ppb)").arg(freqError)); - AetherSDR::ThemeManager::instance().applyStyleSheet(calStatus, "QLabel { color: {{color.accent.success}}; font-size: 11px; }"); + setTone(calStatus, Tone::Ok); qCDebug(lcProtocol) << "RadioSetupDialog: frequency calibration completed" << "freq_error_ppb=" << freqError @@ -3297,7 +3478,6 @@ QWidget* RadioSetupDialog::buildRxTab() // 10 MHz Reference group { auto* group = new QGroupBox("10 MHz Reference"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -3316,17 +3496,13 @@ QWidget* RadioSetupDialog::buildRxTab() // Lock status auto* lockLbl = new QLabel(oscillatorStatusText(m_model)); - lockLbl->setStyleSheet(QStringLiteral( - "QLabel { color: %1; font-size: 12px; font-weight: bold; }") - .arg(oscillatorStatusColor(m_model))); + setTone(lockLbl, oscillatorStatusTone(m_model)); grid->addWidget(lockLbl, 0, 2); // Live-update oscillator status when radio state changes (#967) connect(m_model, &RadioModel::oscillatorChanged, this, [this, srcCmb, lockLbl] { lockLbl->setText(oscillatorStatusText(m_model)); - lockLbl->setStyleSheet(QStringLiteral( - "QLabel { color: %1; font-size: 12px; font-weight: bold; }") - .arg(oscillatorStatusColor(m_model))); + setTone(lockLbl, oscillatorStatusTone(m_model)); const QString current = normalizedOscillatorValue(srcCmb->currentData().toString()); QSignalBlocker blocker(srcCmb); @@ -3349,7 +3525,6 @@ QWidget* RadioSetupDialog::buildRxTab() auto* btn = new QPushButton(checked ? "Enabled" : "Disabled"); btn->setCheckable(true); btn->setChecked(checked); - btn->setStyleSheet(kTogStyle); connect(btn, &QPushButton::toggled, this, [this, cmd, btn](bool on) { btn->setText(on ? "Enabled" : "Disabled"); m_model->sendCommand( @@ -3377,27 +3552,9 @@ QWidget* RadioSetupDialog::buildCalibrationTab() auto* vbox = new QVBoxLayout(page); vbox->setSpacing(8); - // Themed throughout — every style on this page goes through ThemeManager - // rather than a literal hex, so the page follows the active theme and the - // hardcoded-colour ratchet stays flat. - auto& theme = AetherSDR::ThemeManager::instance(); - auto themed = [&theme](QWidget* w, const QString& tpl) { theme.applyStyleSheet(w, tpl); }; - - static const QString kCalLabel = - QStringLiteral("QLabel { color: {{color.text.primary}}; font-size: 12px; }"); - static const QString kCalButton = - QStringLiteral("QPushButton { background: {{color.background.1}}; " - "border: 1px solid {{color.background.2}}; border-radius: 4px; " - "color: {{color.text.primary}}; font-size: 12px; font-weight: bold; " - "padding: 4px 10px; }" - "QPushButton:hover { background: {{color.background.2}}; }"); + auto* group = new QGroupBox("Frequency Calibration"); - themed(group, QStringLiteral( - "QGroupBox { border: 1px solid {{color.background.2}}; border-radius: 4px; " - "margin-top: 8px; padding-top: 12px; font-weight: bold; " - "color: {{color.text.secondary}}; }" - "QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; }")); auto* gvb = new QVBoxLayout(group); gvb->setSpacing(8); @@ -3406,14 +3563,13 @@ QWidget* RadioSetupDialog::buildCalibrationTab() "This radio tunes from a free-running crystal and cannot measure its own error, " "so the correction is applied here. Receive a signal of known frequency, tune " "until it zero-beats, and press Calibrate — or enter an error you already know."); - themed(intro, kCalLabel); + applyLabelStyle(intro); intro->setWordWrap(true); gvb->addWidget(intro); auto* warmup = new QLabel( "Let the radio warm up for 15 minutes first. The oscillator drifts as it heats."); - themed(warmup, QStringLiteral( - "QLabel { color: {{color.accent.warning}}; font-size: 12px; }")); + setTone(warmup, Tone::Warn); warmup->setWordWrap(true); gvb->addWidget(warmup); } @@ -3429,8 +3585,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() auto* noRadioLbl = new QLabel( "Connect the radio first. The calibration belongs to one physical radio, " "and there is no radio identity to store it against yet."); - themed(noRadioLbl, QStringLiteral( - "QLabel { color: {{color.accent.danger}}; font-size: 12px; font-weight: bold; }")); + setTone(noRadioLbl, Tone::Error); noRadioLbl->setWordWrap(true); noRadioLbl->setVisible(false); gvb->addWidget(noRadioLbl); @@ -3447,7 +3602,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() // ── Reference ──────────────────────────────────────────────────────────── auto* refLbl = new QLabel("Reference:"); - themed(refLbl, kCalLabel); + applyLabelStyle(refLbl); grid->addWidget(refLbl, row, 0); auto* refCombo = new QComboBox; @@ -3475,10 +3630,6 @@ QWidget* RadioSetupDialog::buildCalibrationTab() grid->addWidget(refCombo, row, 1); auto* customEdit = new QLineEdit(QStringLiteral("10.000000")); - themed(customEdit, QStringLiteral( - "QLineEdit { background: {{color.background.1}}; " - "border: 1px solid {{color.background.2}}; border-radius: 3px; " - "color: {{color.text.primary}}; font-size: 12px; padding: 2px 4px; }")); customEdit->setFixedWidth(110); customEdit->setValidator(new QDoubleValidator(0.1, 38.4, 6, customEdit)); customEdit->setToolTip(QStringLiteral("Reference frequency in MHz")); @@ -3506,7 +3657,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() // ── Error, in ppb — the ONE stored number ──────────────────────────────── auto* ppbLbl = new QLabel("Error:"); - themed(ppbLbl, kCalLabel); + applyLabelStyle(ppbLbl); grid->addWidget(ppbLbl, row, 0); auto* ppbSpin = new QSpinBox; @@ -3523,7 +3674,6 @@ QWidget* RadioSetupDialog::buildCalibrationTab() grid->addWidget(ppbSpin, row, 1); auto* resetBtn = new QPushButton("Reset"); - themed(resetBtn, kCalButton); resetBtn->setFixedWidth(70); resetBtn->setToolTip(QStringLiteral("Return this radio to uncalibrated (0 ppb)")); grid->addWidget(resetBtn, row, 2); @@ -3535,8 +3685,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() auto* readout = new QLabel; readout->setObjectName(QStringLiteral("hl2FreqCalReadout")); readout->setWordWrap(true); - AetherSDR::ThemeManager::instance().applyStyleSheet(readout, - "QLabel { color: {{color.accent.bright}}; font-size: 12px; font-weight: bold; }"); + setTone(readout, Tone::Value); auto activeSliceHz = [this]() -> double { for (SliceModel* s : m_model->slices()) { @@ -3643,7 +3792,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() // error is fractional, so a step that means 1 Hz on 10 MHz means 0.1 Hz on // 160 m, and storing Hz would be right on exactly one band. auto* trimLbl = new QLabel("Trim:"); - themed(trimLbl, kCalLabel); + applyLabelStyle(trimLbl); grid->addWidget(trimLbl, row, 0); auto* trimRow = new QWidget; @@ -3661,7 +3810,6 @@ QWidget* RadioSetupDialog::buildCalibrationTab() auto* downBtn = new QPushButton(QStringLiteral("−")); auto* upBtn = new QPushButton(QStringLiteral("+")); for (QPushButton* b : {downBtn, upBtn}) { - themed(b, kCalButton); b->setFixedWidth(44); b->setAutoRepeat(true); b->setAutoRepeatDelay(400); @@ -3695,31 +3843,27 @@ QWidget* RadioSetupDialog::buildCalibrationTab() calRow->setSpacing(8); auto* calBtn = new QPushButton("Calibrate from current VFO"); calBtn->setObjectName(QStringLiteral("hl2FreqCalCapture")); - themed(calBtn, kCalButton); auto* calStatus = new QLabel; calStatus->setWordWrap(true); calRow->addWidget(calBtn); calRow->addWidget(calStatus, 1); gvb->addLayout(calRow); - // Takes a THEME TOKEN, not a colour. Keeps the status line on the same - // palette as the rest of the dialog in every theme. - auto setStatus = [calStatus, &theme](const QString& text, const QString& token) { + auto setStatus = [calStatus](const QString& text, Tone tone) { calStatus->setText(text); - theme.applyStyleSheet(calStatus, - QStringLiteral("QLabel { color: {{%1}}; font-size: 11px; }").arg(token)); + setTone(calStatus, tone); }; connect(calBtn, &QPushButton::clicked, this, [this, apply, referenceHz, activeSliceHz, setStatus] { const double ref = referenceHz(); if (!(ref > 0.0)) { - setStatus(QStringLiteral("Enter a reference frequency."), "color.accent.warning"); + setStatus(QStringLiteral("Enter a reference frequency."), Tone::Warn); return; } const double dialled = activeSliceHz(); if (!(dialled > 0.0)) { - setStatus(QStringLiteral("No active slice to read."), "color.accent.warning"); + setStatus(QStringLiteral("No active slice to read."), Tone::Warn); return; } const int ppb = Hl2FreqCal::ppbFromZeroBeat(ref, dialled); @@ -3732,7 +3876,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() "reference) — check you are on the right signal.") .arg(dialled / 1.0e6, 0, 'f', 6) .arg(ref / 1.0e6, 0, 'f', 6), - "color.accent.danger"); + Tone::Error); return; } apply(ppb); @@ -3740,7 +3884,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() .arg(ref / 1.0e6, 0, 'f', 6) .arg(dialled / 1.0e6, 0, 'f', 6) .arg(ppb), - "color.accent.success"); + Tone::Ok); }); gvb->addWidget(readout); @@ -3898,21 +4042,12 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() auto* vbox = new QVBoxLayout(page); vbox->setSpacing(8); - auto& theme = AetherSDR::ThemeManager::instance(); - auto themed = [&theme](QWidget* w, const QString& tpl) { theme.applyStyleSheet(w, tpl); }; // kLabelStyleTemplate, NOT a local copy of it. #5898 made that constant this // file's single owner of the label style, and a retyped duplicate is how the // two drift apart on the next token rename — which is the failure the // constant's own comment describes. The other builders' local copies predate // #5898 and are not this change's to convert. - static const QString kHint = - QStringLiteral("QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); - static const QString kGroup = - QStringLiteral("QGroupBox { border: 1px solid {{color.background.2}}; border-radius: 4px; " - "margin-top: 8px; padding-top: 12px; font-weight: bold; " - "color: {{color.text.secondary}}; }" - "QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; }"); // Collected so the reseed lambda can dim the whole page in one pass when // there is no radio identity to store against — same shape, and the same @@ -3933,15 +4068,13 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() auto* noRadioLbl = new QLabel( "Connect the radio first. These settings describe one physical radio and are " "stored against its identity, which does not exist until it has been connected."); - themed(noRadioLbl, QStringLiteral( - "QLabel { color: {{color.accent.danger}}; font-size: 12px; font-weight: bold; }")); + setTone(noRadioLbl, Tone::Error); noRadioLbl->setWordWrap(true); noRadioLbl->setVisible(false); vbox->addWidget(noRadioLbl); // ── Local audio codec ──────────────────────────────────────────────────── auto* codecGroup = new QGroupBox("Local Audio"); - themed(codecGroup, kGroup); auto* cvb = new QVBoxLayout(codecGroup); cvb->setSpacing(6); @@ -3949,13 +4082,13 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() "A plain Hermes-Lite 2 has no audio codec: the slot the HPSDR protocol reserves for " "headphone audio is used by the gateware as an extended-address register, so nothing " "may be written there. Two variants do have a codec and do accept audio."); - themed(codecIntro, kHint); + setTone(codecIntro, Tone::Hint); codecIntro->setWordWrap(true); cvb->addWidget(codecIntro); auto* codecRow = new QHBoxLayout; auto* codecLbl = new QLabel("Codec:"); - themed(codecLbl, kLabelStyleTemplate); + applyLabelStyle(codecLbl); auto* codecCombo = new QComboBox; codecCombo->setObjectName(QStringLiteral("hl2HwCodec")); AetherSDR::applyComboStyle(codecCombo); @@ -3989,7 +4122,7 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() // happen to sum to. See Hl2HardwareOptions::speakerLevelPercent. auto* spkRow = new QHBoxLayout; auto* spkLbl = new QLabel("Speaker level:"); - themed(spkLbl, kLabelStyleTemplate); + applyLabelStyle(spkLbl); auto* spkSlider = new QSlider(Qt::Horizontal); spkSlider->setObjectName(QStringLiteral("hl2HwSpeakerLevel")); spkSlider->setRange(0, 100); @@ -4002,7 +4135,7 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() "Per-slice audio faders, balance and mute DO reach it — this rides on\n" "top of them. Set it to 0 to silence the radio's speaker.")); auto* spkValue = new QLabel; - themed(spkValue, kHint); + setTone(spkValue, Tone::Hint); spkValue->setMinimumWidth(36); spkRow->addWidget(spkLbl); spkRow->addWidget(spkSlider, 1); @@ -4036,26 +4169,21 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() // is the only way an operator can be expected to know what they are // switching. auto* ditherGroup = new QGroupBox("Dither Bit"); - themed(ditherGroup, kGroup); auto* dvb = new QVBoxLayout(ditherGroup); dvb->setSpacing(6); auto* ditherChk = new QCheckBox("Dither bit"); ditherChk->setObjectName(QStringLiteral("hl2HwDither")); - themed(ditherChk, QStringLiteral( - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; }")); dvb->addWidget(ditherChk); controls->append(ditherChk); auto* ditherHint = new QLabel; - themed(ditherHint, kHint); + setTone(ditherHint, Tone::Hint); ditherHint->setWordWrap(true); dvb->addWidget(ditherHint); auto* randomChk = new QCheckBox("Random bit"); randomChk->setObjectName(QStringLiteral("hl2HwRandom")); - themed(randomChk, QStringLiteral( - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; }")); randomChk->setToolTip(QStringLiteral( "The companion of the dither bit on the original openHPSDR hardware.\n" "No Hermes-Lite 2 variant is known to use it for anything; offered for\n" @@ -4108,7 +4236,6 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() // ── Companion filter board ─────────────────────────────────────────────── auto* filterGroup = new QGroupBox("Companion Filter Board"); - themed(filterGroup, kGroup); auto* fvb = new QVBoxLayout(filterGroup); fvb->setSpacing(6); @@ -4116,13 +4243,13 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() "The Hermes-Lite 2 has no filters of its own — it drives seven open-collector " "outputs that a board on J16 decodes. Where that board sits in the signal path " "decides whether receive is filtered too."); - themed(filterIntro, kHint); + setTone(filterIntro, Tone::Hint); filterIntro->setWordWrap(true); fvb->addWidget(filterIntro); auto* filterRow = new QHBoxLayout; auto* filterLbl = new QLabel("Board:"); - themed(filterLbl, kLabelStyleTemplate); + applyLabelStyle(filterLbl); auto* filterCombo = new QComboBox; filterCombo->setObjectName(QStringLiteral("hl2HwFilterBoard")); AetherSDR::applyComboStyle(filterCombo); @@ -4148,8 +4275,6 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() auto* hpfChk = new QCheckBox("Receive through the N2ADR 3 MHz high-pass"); hpfChk->setObjectName(QStringLiteral("hl2HwN2adrHpf")); - themed(hpfChk, QStringLiteral( - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; }")); hpfChk->setToolTip(QStringLiteral( "Engages the board's AM-broadcast high-pass on receive only, on 80 m through\n" "10 m. It stays out on 160 m — the radio's own switching supply couples spurs\n" @@ -4175,14 +4300,11 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() // ── Reference clock and tuner ──────────────────────────────────────────── auto* miscGroup = new QGroupBox("Reference Clock and Tuner"); - themed(miscGroup, kGroup); auto* mvb = new QVBoxLayout(miscGroup); mvb->setSpacing(6); auto* cl1Chk = new QCheckBox("External 10 MHz reference at CL1"); cl1Chk->setObjectName(QStringLiteral("hl2HwCl1")); - themed(cl1Chk, QStringLiteral( - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; }")); cl1Chk->setToolTip(QStringLiteral( "Reprograms the on-board VersaClock to lock to a 10 MHz reference fed into\n" "the CL1 jack — a GPSDO, typically — instead of the radio's own crystal.\n\n" @@ -4288,14 +4410,11 @@ QWidget* RadioSetupDialog::buildHl2HardwareTab() cl1Warn->setObjectName(QStringLiteral("hl2HwCl1Warning")); cl1Warn->setWordWrap(true); cl1Warn->setAccessibleName(QStringLiteral("CL1 input level warning")); - themed(cl1Warn, QStringLiteral( - "QLabel { color: {{color.accent.warning}}; font-size: 11px; }")); + setTone(cl1Warn, Tone::Warn); mvb->addWidget(cl1Warn); auto* atuChk = new QCheckBox("Antenna tuner driven by the HL2 gateware"); atuChk->setObjectName(QStringLiteral("hl2HwAtu")); - themed(atuChk, QStringLiteral( - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; }")); atuChk->setToolTip(QStringLiteral( "Raises the gateware's tune request while TUNE is running, for an ATU wired\n" "to the AH-4 protocol pins.\n\n" @@ -4500,25 +4619,9 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() auto* vbox = new QVBoxLayout(page); vbox->setSpacing(8); - auto& theme = AetherSDR::ThemeManager::instance(); - auto themed = [&theme](QWidget* w, const QString& tpl) { theme.applyStyleSheet(w, tpl); }; - static const QString kLabel = - QStringLiteral("QLabel { color: {{color.text.primary}}; font-size: 12px; }"); - static const QString kButton = - QStringLiteral("QPushButton { background: {{color.background.1}}; " - "border: 1px solid {{color.background.2}}; border-radius: 4px; " - "color: {{color.text.primary}}; font-size: 12px; font-weight: bold; " - "padding: 4px 10px; }" - "QPushButton:hover { background: {{color.background.2}}; }" - "QPushButton:disabled { color: {{color.text.secondary}}; }"); auto* group = new QGroupBox("DDC0 Droop Correction"); - themed(group, QStringLiteral( - "QGroupBox { border: 1px solid {{color.background.2}}; border-radius: 4px; " - "margin-top: 8px; padding-top: 12px; font-weight: bold; " - "color: {{color.text.secondary}}; }" - "QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; }")); auto* gvb = new QVBoxLayout(group); gvb->setSpacing(8); @@ -4532,7 +4635,7 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() "dummy load before starting — the sweep measures the receiver's own " "noise floor as a flat reference, and a live signal during the " "sweep will bias the correction for whichever rate it lands in."); - themed(intro, kLabel); + applyLabelStyle(intro); intro->setWordWrap(true); gvb->addWidget(intro); } @@ -4540,14 +4643,12 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() auto* noRadioLbl = new QLabel( "Connect the radio first. The calibration belongs to one physical " "radio, and there is no radio identity to store it against yet."); - themed(noRadioLbl, QStringLiteral( - "QLabel { color: {{color.accent.danger}}; font-size: 12px; font-weight: bold; }")); + setTone(noRadioLbl, Tone::Error); noRadioLbl->setWordWrap(true); noRadioLbl->setVisible(false); gvb->addWidget(noRadioLbl); auto* startStopBtn = new QPushButton("Start Sweep"); - themed(startStopBtn, kButton); startStopBtn->setFixedWidth(120); // Description, NOT accessibleName: this button's text toggles between // "Start Sweep" and "Stop", and a screen reader takes a button's name @@ -4574,7 +4675,7 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() gvb->addLayout(rowLayout); auto* statusLbl = new QLabel("Idle — no sweep has been run this session."); - themed(statusLbl, kLabel); + applyLabelStyle(statusLbl); statusLbl->setWordWrap(true); // Same reasoning as the Start button, for the same reason in reverse: a // QLabel's accessible name IS its text, and this label's text is the @@ -4584,16 +4685,13 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() gvb->addWidget(statusLbl); auto* summaryLbl = new QLabel; - themed(summaryLbl, QStringLiteral( - "QLabel { color: {{color.text.secondary}}; font-size: 11px; " - "font-family: monospace; }")); + setTone(summaryLbl, Tone::Hint); summaryLbl->setWordWrap(true); summaryLbl->setAccessibleDescription(QStringLiteral( "Measured correction per DDC0 rate")); gvb->addWidget(summaryLbl); auto* applyBtn = new QPushButton("Apply"); - themed(applyBtn, kButton); applyBtn->setFixedWidth(90); applyBtn->setEnabled(false); // Static text, so a name is safe here -- and needed: "Apply" and @@ -4603,7 +4701,6 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() "Push the measured tables live and save them for this radio")); auto* cancelBtn = new QPushButton("Discard"); - themed(cancelBtn, kButton); cancelBtn->setFixedWidth(90); cancelBtn->setEnabled(false); cancelBtn->setAccessibleName(QStringLiteral("Discard the measured droop correction")); @@ -4742,29 +4839,23 @@ QWidget* RadioSetupDialog::buildAudioTab() // ── Radio Audio Outputs ────────────────────────────────────────────── auto* outGroup = new QGroupBox("Radio Audio Outputs"); - outGroup->setStyleSheet(kGroupStyle); auto* outLayout = new QVBoxLayout(outGroup); // Line Out auto* lineoutRow = new QHBoxLayout; auto* lineoutLabel = new QLabel("Line Out:"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - lineoutLabel, kLabelStyleTemplate + kUnavailableLabelRule); + applyLabelStyle(lineoutLabel); lineoutLabel->setFixedWidth(90); auto* lineoutSlider = new GuardedSlider(Qt::Horizontal); lineoutSlider->setRange(0, 100); lineoutSlider->setValue(m_model->lineoutGain()); - auto* lineoutValue = makeValueLabel(QString::number(m_model->lineoutGain()), - kUnavailableLabelRule); + auto* lineoutValue = makeValueLabel(QString::number(m_model->lineoutGain())); lineoutValue->setFixedWidth(30); auto* lineoutMute = new QPushButton("Mute"); lineoutMute->setCheckable(true); lineoutMute->setChecked(m_model->lineoutMute()); lineoutMute->setFixedWidth(50); - AetherSDR::ThemeManager::instance().applyStyleSheet(lineoutMute, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 2px; }" - "QPushButton:checked { background: #8b0000; color: {{color.text.primary}}; }" - + kUnavailableButtonRule); + setButtonRole(lineoutMute, ButtonRole::Mute); lineoutRow->addWidget(lineoutLabel); lineoutRow->addWidget(lineoutSlider, 1); lineoutRow->addWidget(lineoutValue); @@ -4805,23 +4896,18 @@ QWidget* RadioSetupDialog::buildAudioTab() // Headphone auto* hpRow = new QHBoxLayout; auto* hpLabel = new QLabel("Headphone:"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - hpLabel, kLabelStyleTemplate + kUnavailableLabelRule); + applyLabelStyle(hpLabel); hpLabel->setFixedWidth(90); auto* hpSlider = new GuardedSlider(Qt::Horizontal); hpSlider->setRange(0, 100); hpSlider->setValue(m_model->headphoneGain()); - auto* hpValue = makeValueLabel(QString::number(m_model->headphoneGain()), - kUnavailableLabelRule); + auto* hpValue = makeValueLabel(QString::number(m_model->headphoneGain())); hpValue->setFixedWidth(30); auto* hpMute = new QPushButton("Mute"); hpMute->setCheckable(true); hpMute->setChecked(m_model->headphoneMute()); hpMute->setFixedWidth(50); - AetherSDR::ThemeManager::instance().applyStyleSheet(hpMute, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 2px; }" - "QPushButton:checked { background: #8b0000; color: {{color.text.primary}}; }" - + kUnavailableButtonRule); + setButtonRole(hpMute, ButtonRole::Mute); hpRow->addWidget(hpLabel); hpRow->addWidget(hpSlider, 1); hpRow->addWidget(hpValue); @@ -4873,9 +4959,7 @@ QWidget* RadioSetupDialog::buildAudioTab() spkMute->setCheckable(true); spkMute->setChecked(m_model->frontSpeakerMute()); spkMute->setFixedWidth(50); - AetherSDR::ThemeManager::instance().applyStyleSheet(spkMute, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 2px; }" - "QPushButton:checked { background: #8b0000; color: {{color.text.primary}}; }"); + setButtonRole(spkMute, ButtonRole::Mute); spkRow->addWidget(spkLabel); spkRow->addStretch(1); spkRow->addWidget(spkMute); @@ -4909,7 +4993,6 @@ QWidget* RadioSetupDialog::buildAudioTab() m_audioCompressionGroup = compGroup; compGroup->setVisible(!m_model->isConnected() || m_model->backendCapabilities().hasAudioCompression); - compGroup->setStyleSheet(kGroupStyle); auto* compLayout = new QHBoxLayout(compGroup); compLayout->setSpacing(4); @@ -4921,20 +5004,16 @@ QWidget* RadioSetupDialog::buildAudioTab() ? QStringLiteral("Opus") : QStringLiteral("None"); } - const QString btnStyle = - "QPushButton { background: #1a2a3a; color: #c8d8e8; border: 1px solid #304050; " - "border-radius: 3px; padding: 2px 10px; font-size: 11px; }" - "QPushButton:checked { background: #00607a; color: #e0f0ff; border-color: #00b4d8; }"; auto* autoBtn = new QPushButton("Auto"); autoBtn->setCheckable(true); autoBtn->setChecked(current == "Auto"); - autoBtn->setStyleSheet(btnStyle); + setButtonRole(autoBtn, ButtonRole::Select); auto* noneBtn = new QPushButton("Uncompressed"); noneBtn->setCheckable(true); noneBtn->setChecked(current == "None"); - noneBtn->setStyleSheet(btnStyle); + setButtonRole(noneBtn, ButtonRole::Select); auto* opusBtn = new QPushButton("Opus"); opusBtn->setCheckable(true); opusBtn->setChecked(current == "Opus"); - opusBtn->setStyleSheet(btnStyle); + setButtonRole(opusBtn, ButtonRole::Select); auto setComp = [autoBtn, noneBtn, opusBtn](const QString& val) { QSignalBlocker b1(autoBtn), b2(noneBtn), b3(opusBtn); @@ -4956,7 +5035,7 @@ QWidget* RadioSetupDialog::buildAudioTab() compLayout->addStretch(); auto* hint = new QLabel("Auto = Opus on SmartLink or a tailnet, uncompressed on LAN"); - AetherSDR::ThemeManager::instance().applyStyleSheet(hint, "QLabel { color: {{color.text.label}}; font-size: 10px; }"); + setTone(hint, Tone::Hint); compLayout->addWidget(hint); vbox->addWidget(compGroup); @@ -4969,9 +5048,6 @@ QWidget* RadioSetupDialog::buildAudioTab() { auto* plcCheck = new QCheckBox( "Smooth packet loss (conceal dropped audio packets)"); - AetherSDR::ThemeManager::instance().applyStyleSheet(plcCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }" - + kCheckBoxIndicator); plcCheck->setToolTip( "When the radio's audio stream loses a UDP packet, fade the gap\n" "to silence (uncompressed) or synthesize a perceptually smooth\n" @@ -5007,9 +5083,6 @@ QWidget* RadioSetupDialog::buildAudioTab() // lifetime but never blocks sleep. { auto* sleepCheck = new QCheckBox("Prevent system sleep while connected"); - AetherSDR::ThemeManager::instance().applyStyleSheet(sleepCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }" - + kCheckBoxIndicator); sleepCheck->setToolTip("Hold a system power assertion to prevent idle sleep\n" "while connected to a radio. Keeps TCP/UDP/audio\n" "streams alive during long sessions."); @@ -5022,7 +5095,6 @@ QWidget* RadioSetupDialog::buildAudioTab() // ── PC Audio Devices ──────────────────────────────────────────────── auto* pcGroup = new QGroupBox("PC Audio Devices"); - pcGroup->setStyleSheet(kGroupStyle); auto* pcLayout = new QVBoxLayout(pcGroup); // Input device @@ -5050,9 +5122,6 @@ QWidget* RadioSetupDialog::buildAudioTab() pcLayout->addLayout(outRow); auto* promptCheck = new QCheckBox("Prompt on Audio Device Changes"); - AetherSDR::ThemeManager::instance().applyStyleSheet(promptCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }" - + kCheckBoxIndicator); promptCheck->setToolTip("Show the Audio Device Detected dialog when a new PC audio device appears."); const bool suppressAudioDeviceNotifications = AppSettings::instance() @@ -5183,11 +5252,6 @@ QWidget* RadioSetupDialog::buildAudioTab() boostBtn->setChecked(boostOn); boostBtn->setToolTip("Apply 50% software gain boost to PC audio output.\n" "Compensates for lower levels with AGC-controlled audio."); - AetherSDR::ThemeManager::instance().applyStyleSheet(boostBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; " - "border: 1px solid #20a040; }"); connect(boostBtn, &QPushButton::toggled, this, [this, boostBtn](bool on) { boostBtn->setText(on ? "Enabled" : "Disabled"); auto& s = AppSettings::instance(); @@ -5213,12 +5277,11 @@ QWidget* RadioSetupDialog::buildAudioTab() bufLabel->setFixedWidth(90); int bufMs = AppSettings::instance().value("AudioBufferMs", "100").toInt(); auto* bufEdit = new QLineEdit(QString::number(bufMs)); - applyEditStyle(bufEdit); bufEdit->setFixedWidth(50); auto* bufUnit = new QLabel("ms"); applyLabelStyle(bufUnit); auto* bufHint = new QLabel("(50–1000, increase for VPN/SmartLink jitter)"); - AetherSDR::ThemeManager::instance().applyStyleSheet(bufHint, "QLabel { color: {{color.background.3}}; font-size: 10px; }"); + setTone(bufHint, Tone::Hint); connect(bufEdit, &QLineEdit::editingFinished, this, [this, bufEdit] { int val = qBound(50, bufEdit->text().toInt(), 1000); bufEdit->setText(QString::number(val)); @@ -5244,7 +5307,6 @@ QWidget* RadioSetupDialog::buildAudioTab() // ── Recording ─────────────────────────────────────────────────────── { auto* recGroup = new QGroupBox("Recording"); - recGroup->setStyleSheet(kGroupStyle); auto* recLayout = new QVBoxLayout(recGroup); auto& settings = AppSettings::instance(); @@ -5256,21 +5318,12 @@ QWidget* RadioSetupDialog::buildAudioTab() modeLabel->setFixedWidth(90); modeRow->addWidget(modeLabel); - const QString modeBtnStyle = - "QPushButton { background: #1a2a3a; color: #c8d8e8; border: 1px solid #304050; " - "border-radius: 3px; padding: 2px 10px; font-size: 11px; }" - "QPushButton:checked { background: #00607a; color: #e0f0ff; border-color: #00b4d8; }"; auto* radioSideBtn = new QPushButton("Radio Side"); radioSideBtn->setCheckable(true); - AetherSDR::ThemeManager::instance().applyStyleSheet(radioSideBtn, - modeBtnStyle - + "QPushButton:disabled { background: {{color.button.background.disabled}}; " - "color: {{color.control.unavailable}}; " - "border-color: {{color.button.border.disabled}}; }"); auto* clientSideBtn = new QPushButton("Client Side"); clientSideBtn->setCheckable(true); - clientSideBtn->setStyleSheet(modeBtnStyle); + setButtonRole(clientSideBtn, ButtonRole::Select); // A connected radio with no command plane has no radio-side recorder, so // Client Side is in effect there (recordsOnClient()). Shown, never @@ -5332,11 +5385,9 @@ QWidget* RadioSetupDialog::buildAudioTab() dirEdit->setText(settings.value("QsoRecordingDir", QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation) + "/AetherSDR/Recordings").toString()); - AetherSDR::ThemeManager::instance().applyStyleSheet(dirEdit, "QLineEdit { background: {{color.background.1}}; color: {{color.text.primary}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; padding: 2px 4px; font-size: 11px; }"); auto* browseBtn = new QPushButton("..."); browseBtn->setFixedWidth(30); - browseBtn->setStyleSheet(modeBtnStyle); + setButtonRole(browseBtn, ButtonRole::Select); connect(browseBtn, &QPushButton::clicked, this, [this, dirEdit]() { const QPointer self(this); const QPointer dirEditGuard(dirEdit); @@ -5362,9 +5413,6 @@ QWidget* RadioSetupDialog::buildAudioTab() // Auto-record on TX auto* autoRow = new QHBoxLayout; auto* autoCheck = new QCheckBox("Auto-record on TX"); - AetherSDR::ThemeManager::instance().applyStyleSheet(autoCheck, - "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }" - + kCheckBoxIndicator); autoCheck->setChecked(settings.value("QsoRecordingAutoRecord", "False").toString() == "True"); connect(autoCheck, &QCheckBox::toggled, this, [](bool on) { auto& s = AppSettings::instance(); @@ -5375,13 +5423,11 @@ QWidget* RadioSetupDialog::buildAudioTab() // Idle timeout auto* timeoutLabel = new QLabel("Idle timeout:"); - AetherSDR::ThemeManager::instance().applyStyleSheet(timeoutLabel, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + setTone(timeoutLabel, Tone::Hint); auto* timeoutSpin = new QSpinBox; timeoutSpin->setRange(10, 3600); timeoutSpin->setSuffix(" sec"); timeoutSpin->setValue(settings.value("QsoRecordingIdleTimeout", "120").toInt()); - AetherSDR::ThemeManager::instance().applyStyleSheet(timeoutSpin, "QSpinBox { background: {{color.background.1}}; color: {{color.text.primary}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; padding: 2px; font-size: 11px; }"); connect(timeoutSpin, QOverload::of(&QSpinBox::valueChanged), this, [](int v) { auto& s = AppSettings::instance(); s.setValue("QsoRecordingIdleTimeout", QString::number(v)); @@ -5412,32 +5458,21 @@ QWidget* RadioSetupDialog::buildFiltersTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } - static const QString kAutoBtn = - "QPushButton { background: #1a2a3a; border: 1px solid #304050; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #0070c0; color: #ffffff; " - "border: 1px solid #0090e0; }"; // Filter sharpness sliders — taller groove + wider handle than the // canonical slider for the radio-setup dialog's heavier visual style. // Sizes kept site-local (deliberate emphasis); colours routed through // color.slider.* so live theme switching works and per-applet // overrides could retint these in future without per-call-site work. - static const QString kFilterSlider = QStringLiteral( - "QSlider::groove:horizontal { background: {{color.slider.background}}; height: 6px; border-radius: 3px; }" - "QSlider::handle:horizontal { background: {{color.slider.handle}}; width: 14px; " - "margin: -5px 0; border-radius: 7px; }"); // Filter Options group { auto* group = new QGroupBox("Filter Options"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(8); @@ -5474,14 +5509,13 @@ QWidget* RadioSetupDialog::buildFiltersTab() auto* slider = new GuardedSlider(Qt::Horizontal); slider->setRange(0, 3); slider->setValue(r.level); - AetherSDR::ThemeManager::instance().applyStyleSheet(slider, kFilterSlider); slider->setEnabled(!r.autoOn); grid->addWidget(slider, row, 1, 1, 2); auto* autoBtn = new QPushButton("Auto"); autoBtn->setCheckable(true); autoBtn->setChecked(r.autoOn); - autoBtn->setStyleSheet(kAutoBtn); + setButtonRole(autoBtn, ButtonRole::Select); grid->addWidget(autoBtn, row, 3); QString mode = QString::fromLatin1(r.modeCmd); @@ -5502,14 +5536,10 @@ QWidget* RadioSetupDialog::buildFiltersTab() // Low Latency Digital checkbox { auto* group = new QGroupBox; - group->setStyleSheet(kGroupStyle); auto* hb = new QHBoxLayout(group); auto* chk = new QCheckBox("Use Low Latency Filters for Digital Modes"); chk->setChecked(m_model->lowLatencyDigital()); - AetherSDR::ThemeManager::instance().applyStyleSheet(chk, - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }" - + kCheckBoxIndicator); connect(chk, &QCheckBox::toggled, this, [this](bool on) { m_model->sendCommand( QString("radio set low_latency_digital_modes=%1").arg(on ? 1 : 0)); @@ -5533,18 +5563,13 @@ QWidget* RadioSetupDialog::buildXvtrTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } // Sub-tabs: one per XVTR + a "+" tab to add new auto* xvtrTabs = new QTabWidget; - AetherSDR::ThemeManager::instance().applyStyleSheet(xvtrTabs, "QTabWidget::pane { border: 1px solid {{color.background.2}}; background: {{color.background.0}}; }" - "QTabBar::tab { background: {{color.background.1}}; color: {{color.text.secondary}}; " - "border: 1px solid {{color.background.2}}; padding: 3px 10px; margin-right: 2px; }" - "QTabBar::tab:selected { background: {{color.background.0}}; color: {{color.text.primary}}; " - "border-bottom-color: {{color.background.0}}; }"); auto buildXvtrPage = [this, xvtrTabs](int idx, const RadioModel::XvtrInfo& x) { auto* pg = new QWidget; @@ -5557,7 +5582,6 @@ QWidget* RadioSetupDialog::buildXvtrTab() applyLabelStyle(lbl); grid->addWidget(lbl, row, col * 2); auto* edit = new QLineEdit(value); - applyEditStyle(edit); edit->setFixedWidth(100); edit->setReadOnly(!editable); grid->addWidget(edit, row, col * 2 + 1); @@ -5566,9 +5590,7 @@ QWidget* RadioSetupDialog::buildXvtrTab() auto* nameEdit = addField(0, 0, "Name:", x.name); auto* validLbl = new QLabel(x.isValid ? "Valid" : "Invalid"); - validLbl->setStyleSheet(x.isValid - ? "QLabel { color: #00c040; font-size: 12px; font-weight: bold; }" - : "QLabel { color: #c04040; font-size: 12px; font-weight: bold; }"); + setTone(validLbl, x.isValid ? Tone::Ok : Tone::Error); grid->addWidget(validLbl, 0, 3); auto* rfEdit = addField(1, 0, "RF Freq (MHz):", QString::number(x.rfFreq, 'f', 3)); @@ -5584,11 +5606,6 @@ QWidget* RadioSetupDialog::buildXvtrTab() auto* rxOnlyBtn = new QPushButton(x.rxOnly ? "Enabled" : "Disabled"); rxOnlyBtn->setCheckable(true); rxOnlyBtn->setChecked(x.rxOnly); - AetherSDR::ThemeManager::instance().applyStyleSheet(rxOnlyBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; " - "border: 1px solid #20a040; }"); connect(rxOnlyBtn, &QPushButton::toggled, this, [this, idx, rxOnlyBtn](bool on) { rxOnlyBtn->setText(on ? "Enabled" : "Disabled"); m_model->sendCommand( @@ -5600,11 +5617,7 @@ QWidget* RadioSetupDialog::buildXvtrTab() // Remove button auto* removeBtn = new QPushButton("Remove"); - removeBtn->setStyleSheet( - "QPushButton { background: #3a1a1a; border: 1px solid #504040; " - "border-radius: 3px; color: #ff6060; font-size: 11px; font-weight: bold; " - "padding: 4px 16px; }" - "QPushButton:hover { background: #502020; }"); + setButtonRole(removeBtn, ButtonRole::Danger); connect(removeBtn, &QPushButton::clicked, pg, [this, idx, xvtrTabs, pg] { m_model->sendCommand(QString("xvtr remove %1").arg(idx)); int tabIdx = xvtrTabs->indexOf(pg); @@ -5693,10 +5706,6 @@ QWidget* RadioSetupDialog::buildXvtrTab() auto* addPage = new QWidget; auto* addVb = new QVBoxLayout(addPage); auto* addBtn = new QPushButton("Create New Transverter"); - AetherSDR::ThemeManager::instance().applyStyleSheet(addBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 12px; font-weight: bold; " - "padding: 8px 20px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); connect(addBtn, &QPushButton::clicked, this, [this, xvtrTabs, buildXvtrPage] { m_model->sendCmdPublic("xvtr create", [this, xvtrTabs, buildXvtrPage](int code, const QString& /* body */) { @@ -5745,13 +5754,12 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } auto* group = new QGroupBox("Antenna Names"); - group->setStyleSheet(kGroupStyle); auto* groupLayout = new QVBoxLayout(group); groupLayout->setSpacing(6); @@ -5768,7 +5776,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto* scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); - scroll->setStyleSheet("QScrollArea { background: transparent; border: none; }"); scroll->setWidget(rowsWidget); groupLayout->addWidget(scroll); vbox->addWidget(group, 1); @@ -5798,7 +5805,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() if (m_kiwiSdrManager) { auto* kiwiGroup = new QGroupBox("KiwiSDR RX Antennas"); - kiwiGroup->setStyleSheet(kGroupStyle); kiwiLayout = new QVBoxLayout(kiwiGroup); kiwiLayout->setSpacing(6); @@ -5806,10 +5812,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() kiwiPasswordHelpText); kiwiHelp->setWordWrap(true); kiwiHelp->setAccessibleName("KiwiSDR receiver configuration help"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - kiwiHelp, - "QLabel { color: {{color.text.secondary}}; font-size: 11px; " - "padding: 0 4px 4px 4px; }"); + setTone(kiwiHelp, Tone::Hint); kiwiLayout->addWidget(kiwiHelp); auto* kiwiCredentialNotice = new QLabel; @@ -5817,10 +5820,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() kiwiCredentialNotice->setAccessibleName( "KiwiSDR credential storage notice"); kiwiCredentialNotice->setVisible(false); - AetherSDR::ThemeManager::instance().applyStyleSheet( - kiwiCredentialNotice, - "QLabel { color: {{color.accent.danger}}; font-size: 11px; " - "padding: 4px; }"); + setTone(kiwiCredentialNotice, Tone::Error); kiwiLayout->addWidget(kiwiCredentialNotice); connect( m_kiwiSdrManager, @@ -5848,7 +5848,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() kiwiScroll->setFrameShape(QFrame::NoFrame); kiwiScroll->setMinimumHeight(190); kiwiScroll->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding); - kiwiScroll->setStyleSheet("QScrollArea { background: transparent; border: none; }"); kiwiScroll->setAccessibleName("KiwiSDR RX antennas"); kiwiScroll->setAccessibleDescription( "Configured KiwiSDR receive-only antenna endpoints."); @@ -5898,7 +5897,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto addHeader = [grid](const QString& text, int col) { auto* lbl = new QLabel(text); - AetherSDR::ThemeManager::instance().applyStyleSheet(lbl, "QLabel { color: {{color.text.secondary}}; font-size: 11px; font-weight: bold; }"); + setTone(lbl, Tone::Heading); grid->addWidget(lbl, 0, col); }; addHeader("Port", 0); @@ -5908,7 +5907,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() const QStringList tokens = m_model->knownAntennaTokens(); if (tokens.isEmpty()) { auto* empty = new QLabel("Waiting for antenna ports from the radio."); - AetherSDR::ThemeManager::instance().applyStyleSheet(empty, "QLabel { color: {{color.text.label}}; font-size: 12px; padding: 8px; }"); + setTone(empty, Tone::Hint); grid->addWidget(empty, 1, 0, 1, 4); return; } @@ -5920,7 +5919,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto* edit = new QLineEdit(m_model->antennaAlias(token)); edit->setMaxLength(16); - applyEditStyle(edit); edit->setPlaceholderText(token); grid->addWidget(edit, row, 1); @@ -5931,10 +5929,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() grid->addWidget(preview, row, 2); auto* clearBtn = new QPushButton("Clear"); - AetherSDR::ThemeManager::instance().applyStyleSheet(clearBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; " - "font-weight: bold; padding: 3px 10px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); grid->addWidget(clearBtn, row, 3); connect(edit, &QLineEdit::textChanged, this, @@ -6031,7 +6025,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() }; auto styleKiwiEdit = [](QLineEdit* edit) { - applyEditStyle(edit); edit->setMinimumHeight(24); }; @@ -6060,13 +6053,11 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() }; auto styleKiwiButton = [](QPushButton* button) { - button->setStyleSheet(kKiwiActionButtonStyle); button->setMinimumWidth(96); button->setAutoDefault(false); }; auto styleKiwiIconButton = [](QPushButton* button) { - button->setStyleSheet(kKiwiIconButtonStyle); button->setFixedSize(30, 26); button->setAutoDefault(false); }; @@ -6087,19 +6078,13 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() m_kiwiSdrManager->profiles(); auto addSectionHeader = [kiwiRowsLayout](const QString& text) { auto* header = new QLabel(text); - AetherSDR::ThemeManager::instance().applyStyleSheet( - header, - "QLabel { color: {{color.accent.bright}}; font-size: 11px; " - "font-weight: bold; padding: 4px 2px 1px 2px; }"); + setTone(header, Tone::Heading); kiwiRowsLayout->addWidget(header); }; auto addFieldLabel = [](QGridLayout* layout, const QString& text, int column) { auto* label = new QLabel(text); - AetherSDR::ThemeManager::instance().applyStyleSheet( - label, - "QLabel { color: {{color.text.secondary}}; font-size: 10px; " - "font-weight: bold; }"); + setTone(label, Tone::Heading); layout->addWidget(label, 1, column); }; auto passwordPersistenceText = [](KiwiSdrPasswordPersistenceState state, @@ -6127,10 +6112,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() if (profiles.isEmpty()) { auto* empty = new QLabel("No KiwiSDR receivers configured."); empty->setAccessibleName("No configured KiwiSDR receivers"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - empty, - "QLabel { color: {{color.text.label}}; font-size: 12px; " - "padding: 8px; }"); + setTone(empty, Tone::Hint); kiwiRowsLayout->addWidget(empty); } @@ -6140,7 +6122,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() rowFrame->setObjectName("kiwiAntennaRow"); rowFrame->setAccessibleName( QStringLiteral("KiwiSDR receiver %1").arg(profile.name)); - rowFrame->setStyleSheet(kKiwiRowStyle); auto* rowLayout = new QGridLayout(rowFrame); rowLayout->setContentsMargins(10, 8, 10, 8); rowLayout->setHorizontalSpacing(8); @@ -6151,10 +6132,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto* title = new QLabel(profile.name); title->setAccessibleName("KiwiSDR receiver name"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - title, - "QLabel { color: {{color.text.primary}}; font-size: 14px; " - "font-weight: bold; }"); + setTone(title, Tone::Title); rowLayout->addWidget(title, 0, 0, 1, 2); const KiwiSdrClient::State kiwiState = @@ -6162,21 +6140,17 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto* status = new QLabel(stateText(profile.id)); status->setAccessibleName("KiwiSDR antenna status"); status->setAccessibleDescription(status->text()); - QString statusColor = QStringLiteral("{{color.text.secondary}}"); + Tone statusTone = Tone::Hint; if (kiwiState == KiwiSdrClient::State::Connected || kiwiState == KiwiSdrClient::State::Camping) { - statusColor = QStringLiteral("{{color.accent.success}}"); + statusTone = Tone::Ok; } else if (kiwiState == KiwiSdrClient::State::Error) { - statusColor = QStringLiteral("{{color.accent.danger}}"); + statusTone = Tone::Error; } else if (kiwiState == KiwiSdrClient::State::Connecting || kiwiState == KiwiSdrClient::State::Waiting) { - statusColor = QStringLiteral("{{color.accent.bright}}"); + statusTone = Tone::Accent; } - AetherSDR::ThemeManager::instance().applyStyleSheet( - status, - QStringLiteral("QLabel { color: %1; font-size: 12px; " - "padding-left: 6px; }") - .arg(statusColor)); + setTone(status, statusTone); status->setAlignment(Qt::AlignRight | Qt::AlignVCenter); status->setWordWrap(true); status->setToolTip(status->text()); @@ -6243,13 +6217,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() passwordStatus->setAccessibleDescription( passwordStatus->text()); passwordStatus->setWordWrap(true); - AetherSDR::ThemeManager::instance().applyStyleSheet( - passwordStatus, - persistenceState == KiwiSdrPasswordPersistenceState::Error - ? "QLabel { color: {{color.accent.danger}}; " - "font-size: 10px; padding: 0 2px; }" - : "QLabel { color: {{color.text.secondary}}; " - "font-size: 10px; padding: 0 2px; }"); + setTone(passwordStatus, persistenceState == KiwiSdrPasswordPersistenceState::Error ? Tone::Error : Tone::Hint); rowLayout->addWidget(passwordStatus, 3, 2, 1, 2); connect( m_kiwiSdrManager, @@ -6282,22 +6250,13 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() passwordPersistenceText(state, detail); passwordStatus->setText(text); passwordStatus->setAccessibleDescription(text); - AetherSDR::ThemeManager::instance().applyStyleSheet( - passwordStatus, - state == KiwiSdrPasswordPersistenceState::Error - ? "QLabel { color: {{color.accent.danger}}; " - "font-size: 10px; padding: 0 2px; }" - : "QLabel { color: {{color.text.secondary}}; " - "font-size: 10px; padding: 0 2px; }"); + setTone(passwordStatus, state == KiwiSdrPasswordPersistenceState::Error ? Tone::Error : Tone::Hint); }); auto* autoCheck = new QCheckBox; autoCheck->setText("Auto-connect"); autoCheck->setChecked(profile.autoConnect); autoCheck->setAccessibleName("Auto connect KiwiSDR antenna"); - AetherSDR::ThemeManager::instance().applyStyleSheet(autoCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }" - + kCheckBoxIndicator); rowLayout->addWidget(autoCheck, 4, 0, 1, 2, Qt::AlignLeft); auto* keepTxAudioCheck = new QCheckBox; @@ -6309,10 +6268,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() "Keep playing this receiver's audio while transmitting.\n" "Off: its audio is silenced during TX and resumes " "immediately at unkey."); - AetherSDR::ThemeManager::instance().applyStyleSheet( - keepTxAudioCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }" - + kCheckBoxIndicator); rowLayout->addWidget(keepTxAudioCheck, 5, 0, 1, 2, Qt::AlignLeft); @@ -6328,10 +6283,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() "your transmission ended instead of hearing your own " "delayed TX tail.\nNo effect while \"Keep audio during " "TX\" is on."); - AetherSDR::ThemeManager::instance().applyStyleSheet( - resumeDelayCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }" - + kCheckBoxIndicator); rowLayout->addWidget(resumeDelayCheck, 6, 0, 1, 2, Qt::AlignLeft); @@ -6428,7 +6379,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() addSectionHeader("ADD RECEIVER"); auto* rowFrame = new QFrame; rowFrame->setObjectName("kiwiAntennaRow"); - rowFrame->setStyleSheet(kKiwiRowStyle); auto* rowLayout = new QGridLayout(rowFrame); rowLayout->setContentsMargins(10, 8, 10, 8); rowLayout->setHorizontalSpacing(8); @@ -6482,9 +6432,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto* autoCheck = new QCheckBox; autoCheck->setText("Auto-connect"); autoCheck->setAccessibleName("Auto connect new KiwiSDR antenna"); - AetherSDR::ThemeManager::instance().applyStyleSheet(autoCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }" - + kCheckBoxIndicator); rowLayout->addWidget(autoCheck, 3, 0, Qt::AlignLeft); auto committed = std::make_shared(false); @@ -6752,7 +6699,7 @@ QWidget* RadioSetupDialog::buildApdTab() auto* hdr = new QHBoxLayout; hdr->addStretch(1); auto* modelLbl = new QLabel(m_model->model()); - AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }"); + setTone(modelLbl, Tone::Title); hdr->addWidget(modelLbl); vbox->addLayout(hdr); } @@ -6763,7 +6710,6 @@ QWidget* RadioSetupDialog::buildApdTab() // ANT2/XVTB on the bottom row, then the Reset button on its own row. { auto* group = new QGroupBox("External Sampler (per TX ANT)"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(8); @@ -6800,10 +6746,6 @@ QWidget* RadioSetupDialog::buildApdTab() grid->addWidget(resetLbl, 2, 0); auto* resetBtn = new QPushButton("Reset"); - AetherSDR::ThemeManager::instance().applyStyleSheet(resetBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; " - "font-weight: bold; padding: 3px 16px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); connect(resetBtn, &QPushButton::clicked, this, [this] { m_model->transmitModel().resetApdEqualizer(); }); @@ -6845,36 +6787,16 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() auto* cableModel = &m_model->usbCableModel(); // Style constants - static const QString kCombo = - "QComboBox { background: #1a2a3a; border: 1px solid #304050; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; padding: 2px 4px; }" - "QComboBox::drop-down { border: none; }" - "QComboBox QAbstractItemView { background: #1a2a3a; color: #c8d8e8; " - "selection-background-color: #00b4d8; }"; - static const QString kEdit = - "QLineEdit { background: #1a2a3a; border: 1px solid #304050; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; padding: 2px 4px; }"; - static const QString kSpin = - "QSpinBox { background: #1a2a3a; border: 1px solid #304050; " - "color: #c8d8e8; font-size: 11px; padding: 2px; }"; // Token template (applied via applyStyleSheet) so the indicator is visible // in dark mode, matching the other checkboxes in this dialog. #c8d8e8 is // exactly {{color.text.primary}}, so the text colour is unchanged (#4012). - static const QString kCheck = - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }" - + kCheckBoxIndicator; // ── Left: cable list ──────────────────────────────────────────────── auto* listGroup = new QGroupBox("Cables"); - listGroup->setStyleSheet(kGroupStyle); listGroup->setFixedWidth(180); auto* listLayout = new QVBoxLayout(listGroup); auto* cableList = new QListWidget; - AetherSDR::ThemeManager::instance().applyStyleSheet(cableList, "QListWidget { background: {{color.background.0}}; color: {{color.text.primary}}; border: 1px solid {{color.background.1}}; " - "font-size: 11px; }" - "QListWidget::item { padding: 4px; }" - "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); listLayout->addWidget(cableList); hbox->addWidget(listGroup); @@ -6888,7 +6810,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() auto* lbl = new QLabel("No USB cables detected.\n\nPlug a USB-serial adapter\n" "into the radio's rear USB port."); lbl->setAlignment(Qt::AlignCenter); - lbl->setStyleSheet("QLabel { color: #606880; font-size: 12px; }"); + setTone(lbl, Tone::Hint); emptyLayout->addWidget(lbl); stack->addWidget(empty); // index 0 } @@ -6901,7 +6823,6 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() auto* combo = new GuardedComboBox; combo->addItems({"None", "TX Pan", "TX Slice", "Active Slice", "TX Ant", "RX Ant", "Ordinal Slice"}); - combo->setStyleSheet(kCombo); combo->setAccessibleName("Cable source"); combo->setAccessibleDescription( "Signal source routed to this cable"); @@ -6961,7 +6882,6 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() for (const auto& entry : kCableTypes) { combo->addItem(entry.label); } - combo->setStyleSheet(kCombo); combo->setAccessibleName("Cable Type"); combo->setAccessibleDescription( "Selects this cable's protocol type: CAT, Bit, BCD, LDPA, or Passthrough"); @@ -6997,14 +6917,12 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() }; auto makeCableHeader = [makeTypeCombo](bool includeEnabled) -> CableHeaderWidgets { auto* group = new QGroupBox("Cable Settings"); - group->setStyleSheet(kGroupStyle); auto* hg = new QGridLayout(group); hg->setSpacing(4); int row = 0; hg->addWidget(new QLabel("Name:"), row, 0); auto* nameEdit = new QLineEdit; - nameEdit->setStyleSheet(kEdit); nameEdit->setAccessibleName("Cable name"); hg->addWidget(nameEdit, row, 1); ++row; @@ -7012,14 +6930,12 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() QCheckBox* enabledCheck = nullptr; if (includeEnabled) { enabledCheck = new QCheckBox("Enabled"); - AetherSDR::ThemeManager::instance().applyStyleSheet(enabledCheck, kCheck); hg->addWidget(enabledCheck, row, 0, 1, 2); ++row; } auto* statusLabel = new QLabel("Unplugged"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - statusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }"); + setTone(statusLabel, Tone::Hint); hg->addWidget(new QLabel("Status:"), row, 0); hg->addWidget(statusLabel, row, 1); ++row; @@ -7075,7 +6991,6 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() // Helper: serial parameter group (shared by CAT and Passthrough) auto makeSerialGroup = [](const QString& title) -> SerialWidgets { auto* group = new QGroupBox(title); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(4); @@ -7083,32 +6998,27 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() for (int s : {300,600,1200,2400,4800,9600,14400,19200,38400,57600,115200,230400,460800,921600}) speedCombo->addItem(QString::number(s)); speedCombo->setCurrentText("9600"); - speedCombo->setStyleSheet(kCombo); grid->addWidget(new QLabel("Speed:"), 0, 0); grid->addWidget(speedCombo, 0, 1); auto* dataCombo = new QComboBox; dataCombo->addItems({"7", "8"}); dataCombo->setCurrentText("8"); - dataCombo->setStyleSheet(kCombo); grid->addWidget(new QLabel("Data Bits:"), 1, 0); grid->addWidget(dataCombo, 1, 1); auto* parityCombo = new QComboBox; parityCombo->addItems({"none", "odd", "even", "mark", "space"}); - parityCombo->setStyleSheet(kCombo); grid->addWidget(new QLabel("Parity:"), 2, 0); grid->addWidget(parityCombo, 2, 1); auto* stopCombo = new QComboBox; stopCombo->addItems({"1", "2"}); - stopCombo->setStyleSheet(kCombo); grid->addWidget(new QLabel("Stop Bits:"), 3, 0); grid->addWidget(stopCombo, 3, 1); auto* flowCombo = new QComboBox; flowCombo->addItems({"none", "rts_cts", "dtr_dsr", "xon_xoff"}); - flowCombo->setStyleSheet(kCombo); grid->addWidget(new QLabel("Flow:"), 4, 0); grid->addWidget(flowCombo, 4, 1); @@ -7143,14 +7053,12 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() // CAT source auto* srcGroup = new QGroupBox("CAT Source"); - srcGroup->setStyleSheet(kGroupStyle); auto* sg = new QGridLayout(srcGroup); sg->setSpacing(4); sg->addWidget(new QLabel("Source:"), 0, 0); catSourceCombo = makeSourceCombo(); sg->addWidget(catSourceCombo, 0, 1); catAutoReportCheck = new QCheckBox("Auto Report"); - AetherSDR::ThemeManager::instance().applyStyleSheet(catAutoReportCheck, kCheck); sg->addWidget(catAutoReportCheck, 1, 0, 1, 2); vbox->addWidget(srcGroup); @@ -7180,7 +7088,6 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() vbox->addWidget(bcdHeader.group); auto* bcdGroup = new QGroupBox("BCD Settings"); - bcdGroup->setStyleSheet(kGroupStyle); auto* bg = new QGridLayout(bcdGroup); bg->setSpacing(4); bg->addWidget(new QLabel("BCD Type:"), 0, 0); @@ -7193,13 +7100,11 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() // If that invariant ever changes, route this through sendCableType. bcdTypeCombo = new GuardedComboBox; bcdTypeCombo->addItems({"HF (bcd)", "VHF (vbcd)", "HF+VHF (bcd_vbcd)"}); - bcdTypeCombo->setStyleSheet(kCombo); bcdTypeCombo->setAccessibleName("BCD sub-type"); bg->addWidget(bcdTypeCombo, 0, 1); bg->addWidget(new QLabel("Polarity:"), 1, 0); bcdPolarityCombo = new GuardedComboBox; // destructive polarity= on change bcdPolarityCombo->addItems({"Active High", "Active Low"}); - bcdPolarityCombo->setStyleSheet(kCombo); bcdPolarityCombo->setAccessibleName("BCD polarity"); bg->addWidget(bcdPolarityCombo, 1, 1); bg->addWidget(new QLabel("Source:"), 2, 0); @@ -7256,22 +7161,15 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() auto* bitSplit = new QHBoxLayout; auto* bitListGroup = new QGroupBox("Bits"); - bitListGroup->setStyleSheet(kGroupStyle); bitListGroup->setFixedWidth(90); auto* bitListVbox = new QVBoxLayout(bitListGroup); bitIndexList = new QListWidget; - AetherSDR::ThemeManager::instance().applyStyleSheet(bitIndexList, - "QListWidget { background: {{color.background.0}}; color: {{color.text.primary}}; border: 1px solid {{color.background.1}}; " - "font-size: 11px; }" - "QListWidget::item { padding: 4px; }" - "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); for (int b = 0; b < 8; ++b) bitIndexList->addItem(QString("Bit %1").arg(b)); bitListVbox->addWidget(bitIndexList); bitSplit->addWidget(bitListGroup); auto* bitDetailGroup = new QGroupBox("Bit Settings"); - bitDetailGroup->setStyleSheet(kGroupStyle); auto* bd = new QGridLayout(bitDetailGroup); bd->setSpacing(6); @@ -7285,7 +7183,6 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() }; bitWidgets.enabled = new QCheckBox; - AetherSDR::ThemeManager::instance().applyStyleSheet(bitWidgets.enabled, kCheck); addRow("Enabled:", bitWidgets.enabled); bitWidgets.source = makeSourceCombo(); @@ -7293,50 +7190,41 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() bitWidgets.sourceDetail = new QComboBox; bitWidgets.sourceDetail->setEditable(true); - bitWidgets.sourceDetail->setStyleSheet(kCombo); bitWidgets.sourceDetailLabel = addRow("Antenna/Slice:", bitWidgets.sourceDetail); bitWidgets.output = new QComboBox; bitWidgets.output->addItems({"band", "freq_range"}); - bitWidgets.output->setStyleSheet(kCombo); addRow("Output:", bitWidgets.output); bitWidgets.band = new QLineEdit; bitWidgets.band->setPlaceholderText("e.g. 20"); - bitWidgets.band->setStyleSheet(kEdit); bitWidgets.bandLabel = addRow("Band:", bitWidgets.band); bitWidgets.lowFreq = new QLineEdit; bitWidgets.lowFreq->setPlaceholderText("e.g. 0.100"); - bitWidgets.lowFreq->setStyleSheet(kEdit); bitWidgets.lowFreqLabel = addRow("Low Freq (MHz):", bitWidgets.lowFreq); bitWidgets.highFreq = new QLineEdit; bitWidgets.highFreq->setPlaceholderText("e.g. 54.000"); - bitWidgets.highFreq->setStyleSheet(kEdit); bitWidgets.highFreqLabel = addRow("High Freq (MHz):", bitWidgets.highFreq); bitWidgets.polarity = new QComboBox; bitWidgets.polarity->addItems({"High", "Low"}); - bitWidgets.polarity->setStyleSheet(kCombo); addRow("Polarity:", bitWidgets.polarity); bitWidgets.pttDependent = new QCheckBox; - AetherSDR::ThemeManager::instance().applyStyleSheet(bitWidgets.pttDependent, kCheck); addRow("PTT Dependent:", bitWidgets.pttDependent); bitWidgets.pttDelay = new QSpinBox; bitWidgets.pttDelay->setRange(0, 10000); bitWidgets.pttDelay->setSingleStep(5); bitWidgets.pttDelay->setSuffix(" ms"); - bitWidgets.pttDelay->setStyleSheet(kSpin); addRow("PTT Delay:", bitWidgets.pttDelay); bitWidgets.txDelay = new QSpinBox; bitWidgets.txDelay->setRange(0, 10000); bitWidgets.txDelay->setSingleStep(5); bitWidgets.txDelay->setSuffix(" ms"); - bitWidgets.txDelay->setStyleSheet(kSpin); addRow("TX Delay:", bitWidgets.txDelay); bd->setRowStretch(row, 1); @@ -7464,19 +7352,16 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() vbox->addWidget(ldpaHeader.group); auto* ldpaGroup = new QGroupBox("LDPA Settings"); - ldpaGroup->setStyleSheet(kGroupStyle); auto* lg = new QGridLayout(ldpaGroup); lg->setSpacing(4); lg->addWidget(new QLabel("Band:"), 0, 0); // GuardedComboBox: scroll-wrapped tab + destructive `band=` on change. ldpaBandCombo = new GuardedComboBox; ldpaBandCombo->addItems({"2m", "4m"}); - ldpaBandCombo->setStyleSheet(kCombo); ldpaBandCombo->setAccessibleName("LDPA band"); ldpaBandCombo->setAccessibleDescription("LDPA amplifier band (2m or 4m)"); lg->addWidget(ldpaBandCombo, 0, 1); ldpaPreampCheck = new QCheckBox("Preamp"); - AetherSDR::ThemeManager::instance().applyStyleSheet(ldpaPreampCheck, kCheck); lg->addWidget(ldpaPreampCheck, 1, 0, 1, 2); lg->addWidget(new QLabel("Source:"), 2, 0); ldpaSourceCombo = makeSourceCombo(); @@ -7508,7 +7393,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() vbox->addWidget(unconfiguredHeader.group); auto* note = new QLabel("Select a cable type to configure this device."); - note->setStyleSheet("QLabel { color: #606880; font-size: 12px; }"); + setTone(note, Tone::Hint); note->setWordWrap(true); vbox->addWidget(note); @@ -7536,12 +7421,13 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() label += " (unplugged)"; auto* item = new QListWidgetItem(label); item->setData(Qt::UserRole, cable.serialNumber); + const auto& theme = ThemeManager::instance(); if (cable.enabled && cable.present) - item->setForeground(QColor("#30d050")); + item->setForeground(theme.color(QStringLiteral("color.accent.success"))); else if (cable.enabled) - item->setForeground(QColor("#d0d030")); + item->setForeground(theme.color(QStringLiteral("color.accent.warning"))); else - item->setForeground(QColor("#808080")); + item->setForeground(theme.color(QStringLiteral("color.canon.muted"))); cableList->addItem(item); if (cable.serialNumber == prevSn) cableList->setCurrentItem(item); @@ -7567,9 +7453,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() catNameEdit->setText(cable.name); catEnabledCheck->setChecked(cable.enabled); catStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged"); - catStatusLabel->setStyleSheet(cable.present - ? "QLabel { color: #30d050; font-size: 11px; }" - : "QLabel { color: #808080; font-size: 11px; }"); + setTone(catStatusLabel, cable.present ? Tone::Ok : Tone::Hint); catSourceCombo->setCurrentIndex(protoToSource(cable.source)); catAutoReportCheck->setChecked(cable.autoReport); catTypeCombo->setCurrentIndex(protoToTypeIndex(t)); @@ -7586,9 +7470,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() bcdNameEdit->setText(cable.name); bcdEnabledCheck->setChecked(cable.enabled); bcdStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged"); - bcdStatusLabel->setStyleSheet(cable.present - ? "QLabel { color: #30d050; font-size: 11px; }" - : "QLabel { color: #808080; font-size: 11px; }"); + setTone(bcdStatusLabel, cable.present ? Tone::Ok : Tone::Hint); bcdSourceCombo->setCurrentIndex(protoToSource(cable.source)); if (t == "vbcd") bcdTypeCombo->setCurrentIndex(1); else if (t == "bcd_vbcd") bcdTypeCombo->setCurrentIndex(2); @@ -7601,9 +7483,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() bitNameEdit->setText(cable.name); bitEnabledCheck->setChecked(cable.enabled); bitStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged"); - bitStatusLabel->setStyleSheet(cable.present - ? "QLabel { color: #30d050; font-size: 11px; }" - : "QLabel { color: #808080; font-size: 11px; }"); + setTone(bitStatusLabel, cable.present ? Tone::Ok : Tone::Hint); bitTypeCombo->setCurrentIndex(protoToTypeIndex(t)); if (bitIndexList->currentRow() < 0) { QSignalBlocker blk(bitIndexList); @@ -7619,9 +7499,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() ptNameEdit->setText(cable.name); ptEnabledCheck->setChecked(cable.enabled); ptStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged"); - ptStatusLabel->setStyleSheet(cable.present - ? "QLabel { color: #30d050; font-size: 11px; }" - : "QLabel { color: #808080; font-size: 11px; }"); + setTone(ptStatusLabel, cable.present ? Tone::Ok : Tone::Hint); ptTypeCombo->setCurrentIndex(protoToTypeIndex(t)); ptSerialWidgets.speed->setCurrentText(QString::number(cable.speed)); @@ -7636,9 +7514,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() ldpaNameEdit->setText(cable.name); ldpaEnabledCheck->setChecked(cable.enabled); ldpaStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged"); - ldpaStatusLabel->setStyleSheet(cable.present - ? "QLabel { color: #30d050; font-size: 11px; }" - : "QLabel { color: #808080; font-size: 11px; }"); + setTone(ldpaStatusLabel, cable.present ? Tone::Ok : Tone::Hint); ldpaTypeCombo->setCurrentIndex(protoToTypeIndex(t)); ldpaBandCombo->setCurrentIndex(cable.ldpaBand == "4" ? 1 : 0); ldpaPreampCheck->setChecked(cable.preamp); @@ -7652,9 +7528,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() QSignalBlocker b1(unconfiguredNameEdit), b2(unconfiguredTypeCombo); unconfiguredNameEdit->setText(cable.name); unconfiguredStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged"); - unconfiguredStatusLabel->setStyleSheet(cable.present - ? "QLabel { color: #30d050; font-size: 11px; }" - : "QLabel { color: #808080; font-size: 11px; }"); + setTone(unconfiguredStatusLabel, cable.present ? Tone::Ok : Tone::Hint); unconfiguredTypeCombo->setCurrentIndex(-1); } }; @@ -7915,14 +7789,6 @@ QWidget* RadioSetupDialog::buildSerialTab() vbox->setSpacing(8); vbox->setContentsMargins(8, 8, 8, 8); - // Literal on purpose: #8898a8 has no token (no migrate_colours.py mapping, - // no canonical-tokens.md row), and the nearest, color.text.secondary - // (#8ea8c0 on Dark), would change the dark appearance (#5896). - const QString kLabelStyle = "QLabel { color: #8898a8; font-size: 11px; }"; - const QString kGroupStyle = "QGroupBox { color: #00b4d8; font-size: 12px; border: 1px solid #203040; " - "border-radius: 4px; margin-top: 6px; padding-top: 14px; } " - "QGroupBox::title { subcontrol-origin: margin; left: 8px; }"; - auto& settings = AppSettings::instance(); // ── USB control surfaces (Ulanzi Dial, StreamDeck+) (#3257) ────────── @@ -7935,7 +7801,6 @@ QWidget* RadioSetupDialog::buildSerialTab() // use the hardware. { auto* group = new QGroupBox("USB Control Surfaces"); - group->setStyleSheet(kGroupStyle); auto* gvbox = new QVBoxLayout(group); gvbox->setSpacing(6); @@ -7946,13 +7811,10 @@ QWidget* RadioSetupDialog::buildSerialTab() "asks for Input Monitoring permission the first time it claims " "one of these devices."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); gvbox->addWidget(note); auto* ulanziEnable = new QCheckBox("Use a Ulanzi Dial when detected"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - ulanziEnable, "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }" - + kCheckBoxIndicator); ulanziEnable->setChecked(UlanziDialMappings::enabled()); connect(ulanziEnable, &QCheckBox::toggled, this, [this](bool on) { UlanziDialMappings::setEnabled(on); @@ -7963,9 +7825,6 @@ QWidget* RadioSetupDialog::buildSerialTab() #ifdef HAVE_HIDAPI auto* hidEnable = new QCheckBox( "Enable HID encoders / StreamDeck+ (RC-28, PowerMate, ShuttleXpress, …)"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - hidEnable, "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }" - + kCheckBoxIndicator); hidEnable->setChecked( settings.value("HidEncoderEnabled", "False").toString() == "True"); connect(hidEnable, &QCheckBox::toggled, this, [this](bool on) { @@ -7983,7 +7842,6 @@ QWidget* RadioSetupDialog::buildSerialTab() // ── Port Configuration ─────────────────────────────────────────────── { auto* group = new QGroupBox("Port Configuration"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(4); @@ -8099,16 +7957,15 @@ QWidget* RadioSetupDialog::buildSerialTab() // ── Pin Assignment ─────────────────────────────────────────────────── { auto* group = new QGroupBox("Pin Assignment"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(4); auto* headerPin = new QLabel("Pin"); auto* headerFn = new QLabel("Function"); auto* headerPol = new QLabel("Polarity"); - headerPin->setStyleSheet(kLabelStyle); - headerFn->setStyleSheet(kLabelStyle); - headerPol->setStyleSheet(kLabelStyle); + setTone(headerPin, Tone::Hint); + setTone(headerFn, Tone::Hint); + setTone(headerPol, Tone::Hint); grid->addWidget(headerPin, 0, 0); grid->addWidget(headerFn, 0, 1); grid->addWidget(headerPol, 0, 2); @@ -8175,14 +8032,14 @@ QWidget* RadioSetupDialog::buildSerialTab() // CTS row (input) auto* ctsLabel = new QLabel("CTS"); - ctsLabel->setStyleSheet("QLabel { color: #60a0c0; }"); + applyLabelStyle(ctsLabel); grid->addWidget(ctsLabel, 3, 0); grid->addWidget(makeInputFnCombo("SerialCtsFunction"), 3, 1); grid->addWidget(makePolCombo("SerialCtsPolarity"), 3, 2); // DSR row (input) auto* dsrLabel = new QLabel("DSR"); - dsrLabel->setStyleSheet("QLabel { color: #60a0c0; }"); + applyLabelStyle(dsrLabel); grid->addWidget(dsrLabel, 4, 0); grid->addWidget(makeInputFnCombo("SerialDsrFunction"), 4, 1); grid->addWidget(makePolCombo("SerialDsrPolarity"), 4, 2); @@ -8191,16 +8048,13 @@ QWidget* RadioSetupDialog::buildSerialTab() // chip's DCD# pin (e.g. HaliKey Serial from Halibut Electronics, // whose TRS Ring is wired to both DSR and DCD). auto* dcdLabel = new QLabel("DCD"); - dcdLabel->setStyleSheet("QLabel { color: #60a0c0; }"); + applyLabelStyle(dcdLabel); grid->addWidget(dcdLabel, 5, 0); grid->addWidget(makeInputFnCombo("SerialDcdFunction"), 5, 1); grid->addWidget(makePolCombo("SerialDcdPolarity"), 5, 2); // Paddle swap auto* swapCb = new QCheckBox("Paddle Swap (swap dit/dah)"); - AetherSDR::ThemeManager::instance().applyStyleSheet(swapCb, - "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }" - + kCheckBoxIndicator); swapCb->setChecked(AppSettings::instance().value("SerialPaddleSwap", "False").toString() == "True"); connect(swapCb, &QCheckBox::toggled, this, [](bool on) { auto& s = AppSettings::instance(); @@ -8217,23 +8071,17 @@ QWidget* RadioSetupDialog::buildSerialTab() auto* row = new QHBoxLayout; row->setSpacing(8); - const QString btnStyle = - "QPushButton { background: #00b4d8; color: #0f0f1a; font-weight: bold; " - "border: 1px solid #008ba8; padding: 3px; border-radius: 3px; }" - "QPushButton:hover { background: #00c8f0; }" - "QPushButton:disabled { background: {{color.button.background.disabled}}; color: {{color.button.foreground.disabled}}; border-color: {{color.button.border.disabled}}; }"; auto* openBtn = new QPushButton("Open"); // Min-width rather than fixed-width: macOS native button metrics need // more horizontal room than the 80 px Windows/Fusion baseline. openBtn->setMinimumWidth(80); - AetherSDR::ThemeManager::instance().applyStyleSheet(openBtn, btnStyle); + setButtonRole(openBtn, ButtonRole::Select); auto* closeBtn = new QPushButton("Close"); closeBtn->setMinimumWidth(80); - AetherSDR::ThemeManager::instance().applyStyleSheet(closeBtn, btnStyle); + setButtonRole(closeBtn, ButtonRole::Select); auto* statusLabel = new QLabel; - statusLabel->setStyleSheet("QLabel { font-size: 11px; }"); row->addWidget(openBtn); row->addWidget(closeBtn); @@ -8246,10 +8094,10 @@ QWidget* RadioSetupDialog::buildSerialTab() closeBtn->setEnabled(open); if (open) { statusLabel->setText("Open"); - AetherSDR::ThemeManager::instance().applyStyleSheet(statusLabel, "QLabel { color: {{color.accent.success}}; font-size: 11px; }"); + setTone(statusLabel, Tone::Ok); } else { statusLabel->setText("Closed"); - AetherSDR::ThemeManager::instance().applyStyleSheet(statusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }"); + setTone(statusLabel, Tone::Hint); } }; @@ -8273,9 +8121,6 @@ QWidget* RadioSetupDialog::buildSerialTab() vbox->addLayout(row); auto* autoOpen = new QCheckBox("Auto-open serial port on startup"); - AetherSDR::ThemeManager::instance().applyStyleSheet(autoOpen, - "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }" - + kCheckBoxIndicator); autoOpen->setChecked(settings.value("SerialAutoOpen", "False").toString() == "True"); connect(autoOpen, &QCheckBox::toggled, this, [](bool on) { auto& s = AppSettings::instance(); @@ -8288,7 +8133,6 @@ QWidget* RadioSetupDialog::buildSerialTab() // ── FlexControl tuning knob ──────────────────────────────────────── { auto* group = new QGroupBox("FlexControl Tuning Knob"); - group->setStyleSheet(kGroupStyle); m_flexControlGroup = group; // Host peripheral, not a radio capability -- see updateRadioCapabilityVisibility (#5778). group->setVisible(true); @@ -8297,7 +8141,7 @@ QWidget* RadioSetupDialog::buildSerialTab() // Status auto* fcStatusLabel = new QLabel("Not detected"); - AetherSDR::ThemeManager::instance().applyStyleSheet(fcStatusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }"); + setTone(fcStatusLabel, Tone::Hint); m_flexControlStatusLabel = fcStatusLabel; grid->addWidget(new QLabel("Status:"), 0, 0); grid->addWidget(fcStatusLabel, 0, 1); @@ -8306,12 +8150,8 @@ QWidget* RadioSetupDialog::buildSerialTab() auto* fcDetectBtn = new QPushButton("Detect"); // Same macOS-native-metrics consideration as the Open/Close pair above. fcDetectBtn->setMinimumWidth(80); - AetherSDR::ThemeManager::instance().applyStyleSheet(fcDetectBtn, "QPushButton { background: {{color.accent}}; color: {{color.background.0}}; font-weight: bold; " - "border: 1px solid {{color.accent.dim}}; padding: 3px; border-radius: 3px; }" - "QPushButton:hover { background: {{color.accent.bright}}; }"); auto* fcCloseBtn = new QPushButton("Close"); fcCloseBtn->setMinimumWidth(80); - fcCloseBtn->setStyleSheet(fcDetectBtn->styleSheet()); fcCloseBtn->setEnabled(false); m_flexControlDetectButton = fcDetectBtn; m_flexControlCloseButton = fcCloseBtn; @@ -8386,8 +8226,6 @@ QWidget* RadioSetupDialog::buildSerialTab() row->addWidget(new QLabel(actLabels[a])); auto* combo = new QComboBox; combo->addItems(actions); - AetherSDR::ThemeManager::instance().applyStyleSheet( - combo, QString(kEditStyleTemplate).replace("QLineEdit", "QComboBox")); QString key = QString("FlexControlBtn%1Action%2").arg(b + 1).arg(a); QString current = settings.value(key, defaultActions[b][a]).toString(); int idx = actions.indexOf(current); @@ -8408,9 +8246,6 @@ QWidget* RadioSetupDialog::buildSerialTab() // Auto-detect checkbox auto* autoDetect = new QCheckBox("Auto-detect on startup"); - AetherSDR::ThemeManager::instance().applyStyleSheet(autoDetect, - "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }" - + kCheckBoxIndicator); autoDetect->setChecked(settings.value("FlexControlAutoDetect", "True").toString() == "True"); connect(autoDetect, &QCheckBox::toggled, this, [this](bool on) { auto& s = AppSettings::instance(); @@ -8421,9 +8256,6 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->addWidget(autoDetect, 5, 0, 1, 3); auto* invertDir = new QCheckBox("Invert tuning direction"); - AetherSDR::ThemeManager::instance().applyStyleSheet(invertDir, - "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }" - + kCheckBoxIndicator); invertDir->setChecked(settings.value("FlexControlInvertDir", "False").toString() == "True"); m_flexControlInvertCheck = invertDir; connect(invertDir, &QCheckBox::toggled, this, [this](bool on) { @@ -8441,7 +8273,6 @@ QWidget* RadioSetupDialog::buildSerialTab() #ifdef HAVE_HIDAPI { auto* group = new QGroupBox("StreamDeck+ LCD Button Actions"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -8449,7 +8280,7 @@ QWidget* RadioSetupDialog::buildSerialTab() "Assign an action to each of the 8 LCD buttons. " "The button label updates on the device to match."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 4); static const struct { const char* id; const char* label; } kKeyActions[] = { @@ -8484,8 +8315,6 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->addWidget(new QLabel(QString("Key %1:").arg(i + 1)), row, col); auto* combo = new QComboBox; - AetherSDR::ThemeManager::instance().applyStyleSheet( - combo, QString(kEditStyleTemplate).replace("QLineEdit", "QComboBox")); for (const auto& act : kKeyActions) combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id)); @@ -8513,7 +8342,6 @@ QWidget* RadioSetupDialog::buildSerialTab() // ── HID Encoder — per-encoder action mapping (#1510) ───────────────────── { auto* group = new QGroupBox("HID Encoder / StreamDeck+ Encoders"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -8521,7 +8349,7 @@ QWidget* RadioSetupDialog::buildSerialTab() "Assign an action to each encoder dial. " "Single-encoder devices (RC-28, PowerMate, ShuttleXpress) use Encoder 1 only."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 3); static const struct { const char* id; const char* label; } kEncoderActions[] = { @@ -8546,8 +8374,6 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->addWidget(new QLabel(QString("Encoder %1:").arg(i + 1)), i + 1, 0); auto* combo = new QComboBox; - AetherSDR::ThemeManager::instance().applyStyleSheet( - combo, QString(kEditStyleTemplate).replace("QLineEdit", "QComboBox")); for (const auto& act : kEncoderActions) combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id)); @@ -8574,7 +8400,6 @@ QWidget* RadioSetupDialog::buildSerialTab() // ── HID Encoder — per-encoder push-button action mapping (#1510) ───────── { auto* group = new QGroupBox("StreamDeck+ Encoder Push Actions"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -8582,7 +8407,7 @@ QWidget* RadioSetupDialog::buildSerialTab() "Action when each encoder dial is pressed. " "Defaults: Enc 1 = Cycle Tuning Step, Enc 2 = Toggle RIT, Enc 3 = Toggle XIT."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 3); static const struct { const char* id; const char* label; } kPushActions[] = { @@ -8603,8 +8428,6 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->addWidget(new QLabel(QString("Encoder %1 push:").arg(i + 1)), i + 1, 0); auto* combo = new QComboBox; - AetherSDR::ThemeManager::instance().applyStyleSheet( - combo, QString(kEditStyleTemplate).replace("QLineEdit", "QComboBox")); for (const auto& act : kPushActions) combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id)); @@ -8631,13 +8454,12 @@ QWidget* RadioSetupDialog::buildSerialTab() // --- TMate 2 device actions, encoders, and backlight --------------------- { auto* group = new QGroupBox("TMate 2 Key Actions"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); auto* note = new QLabel("Assign actions to the six TMate 2 function keys."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 4); static const struct { const char* id; const char* label; } kTMate2KeyActions[] = { @@ -8674,8 +8496,6 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->addWidget(new QLabel(QString("F%1:").arg(i + 1)), row, col); auto* combo = new QComboBox; - AetherSDR::ThemeManager::instance().applyStyleSheet( - combo, QString(kEditStyleTemplate).replace("QLineEdit", "QComboBox")); for (const auto& act : kTMate2KeyActions) combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id)); @@ -8703,13 +8523,12 @@ QWidget* RadioSetupDialog::buildSerialTab() { auto* group = new QGroupBox("TMate 2 Encoder Actions"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); auto* note = new QLabel("Assign actions to the three TMate 2 encoder dials."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 3); static const struct { const char* id; const char* label; } kTMate2EncoderActions[] = { @@ -8734,8 +8553,6 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->addWidget(new QLabel(QString("Encoder %1:").arg(i + 1)), i + 1, 0); auto* combo = new QComboBox; - AetherSDR::ThemeManager::instance().applyStyleSheet( - combo, QString(kEditStyleTemplate).replace("QLineEdit", "QComboBox")); for (const auto& act : kTMate2EncoderActions) combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id)); @@ -8762,13 +8579,12 @@ QWidget* RadioSetupDialog::buildSerialTab() { auto* group = new QGroupBox("TMate 2 Encoder Push Actions"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); auto* note = new QLabel("Action when each TMate 2 encoder is pressed."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 3); static const struct { const char* id; const char* label; } kTMate2PushActions[] = { @@ -8789,8 +8605,6 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->addWidget(new QLabel(QString("Encoder %1 push:").arg(i + 1)), i + 1, 0); auto* combo = new QComboBox; - AetherSDR::ThemeManager::instance().applyStyleSheet( - combo, QString(kEditStyleTemplate).replace("QLineEdit", "QComboBox")); for (const auto& act : kTMate2PushActions) combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id)); @@ -8817,7 +8631,6 @@ QWidget* RadioSetupDialog::buildSerialTab() { auto* group = new QGroupBox("TMate 2 Display"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -8825,7 +8638,7 @@ QWidget* RadioSetupDialog::buildSerialTab() "Backlight colours and temporary display feedback. " "Overlay duration controls how long changed values are shown on the TMate 2 LCD."); note->setWordWrap(true); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 7); static const struct { @@ -8843,20 +8656,18 @@ QWidget* RadioSetupDialog::buildSerialTab() static const char* kLabels[3] = {"R:", "G:", "B:"}; for (int row = 0; row < 2; ++row) { auto* rowLbl = new QLabel(QString::fromLatin1(kRows[row].rowLabel)); - rowLbl->setStyleSheet(kLabelStyle); + setTone(rowLbl, Tone::Hint); grid->addWidget(rowLbl, row + 1, 0); const char* keys[3] = {kRows[row].rKey, kRows[row].gKey, kRows[row].bKey}; const char* dflts[3] = {kRows[row].rDflt, kRows[row].gDflt, kRows[row].bDflt}; for (int ch = 0; ch < 3; ++ch) { auto* lbl = new QLabel(QString::fromLatin1(kLabels[ch])); - lbl->setStyleSheet(kLabelStyle); + setTone(lbl, Tone::Hint); grid->addWidget(lbl, row + 1, 1 + ch * 2); auto* spin = new QSpinBox; spin->setRange(0, 255); spin->setValue(settings.value(keys[ch], dflts[ch]).toInt()); - AetherSDR::ThemeManager::instance().applyStyleSheet( - spin, QString(kEditStyleTemplate).replace("QLineEdit", "QSpinBox")); grid->addWidget(spin, row + 1, 2 + ch * 2); m_tmate2BacklightSpins[kRows[row].spinOffset + ch] = spin; @@ -8874,7 +8685,7 @@ QWidget* RadioSetupDialog::buildSerialTab() auto addTimingSpin = [&](int row, const QString& label, const QString& key, int dflt, int min, int max, int step) -> QSpinBox* { auto* lbl = new QLabel(label); - lbl->setStyleSheet(kLabelStyle); + setTone(lbl, Tone::Hint); grid->addWidget(lbl, row, 0, 1, 2); auto* spin = new QSpinBox; @@ -8882,8 +8693,6 @@ QWidget* RadioSetupDialog::buildSerialTab() spin->setSingleStep(step); spin->setSuffix(" ms"); spin->setValue(settings.value(key, QString::number(dflt)).toInt()); - AetherSDR::ThemeManager::instance().applyStyleSheet( - spin, QString(kEditStyleTemplate).replace("QLineEdit", "QSpinBox")); grid->addWidget(spin, row, 2, 1, 2); connect(spin, QOverload::of(&QSpinBox::valueChanged), @@ -9076,10 +8885,10 @@ void RadioSetupDialog::setFlexControlConnectionStatus(bool connected, const QStr ? AppSettings::instance().value("FlexControlPort").toString() : port; m_flexControlStatusLabel->setText(QString("Connected (%1)").arg(displayPort)); - AetherSDR::ThemeManager::instance().applyStyleSheet(m_flexControlStatusLabel, "QLabel { color: {{color.accent.success}}; font-size: 11px; }"); + setTone(m_flexControlStatusLabel, Tone::Ok); } else { m_flexControlStatusLabel->setText("Not detected"); - AetherSDR::ThemeManager::instance().applyStyleSheet(m_flexControlStatusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }"); + setTone(m_flexControlStatusLabel, Tone::Hint); } } if (m_flexControlCloseButton) @@ -9127,10 +8936,11 @@ void RadioSetupDialog::reportAutomationBridgeStartResult(bool ok) QWidget* RadioSetupDialog::buildUiEnhancementsTab() { + // A swatch: the slice's own color, in a canon hairline. static const QString kBtnBase = - "QPushButton { border: 1px solid #304050; border-radius: 3px; " + "QPushButton { border: 1px solid {{color.canon.lineHi}}; border-radius: 3px; " "font-weight: bold; font-size: 13px; min-width: 36px; min-height: 36px; }" - "QPushButton:hover { border: 2px solid #60a0c0; }"; + "QPushButton:hover { border: 2px solid {{color.canon.cyan}}; }"; auto* page = new QWidget; auto* vbox = new QVBoxLayout(page); @@ -9144,7 +8954,6 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() // commands, settings keys and routing stay global. { auto* letterGrp = new QGroupBox("Slice Letter Display"); - letterGrp->setStyleSheet(kGroupStyle); auto* letterLayout = new QVBoxLayout(letterGrp); letterLayout->setSpacing(8); @@ -9152,8 +8961,6 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() auto* globalRadio = new QRadioButton("Global slot index (A=0, B=1, …)"); auto* radioIdxRadio = new QRadioButton("Radio-assigned letter with global subscript (A₂)"); - AetherSDR::ThemeManager::instance().applyStyleSheet(globalRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }"); - AetherSDR::ThemeManager::instance().applyStyleSheet(radioIdxRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }"); radioRow->addWidget(globalRadio); radioRow->addWidget(radioIdxRadio); radioRow->addStretch(); @@ -9164,7 +8971,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() "applet labels. Multi-Flex sessions with multiple clients only: " "Radio-assigned letters match SmartSDR's behaviour; the global " "subscript preserves which physical slot you're on."); - letterDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(letterDesc, Tone::Hint); letterDesc->setWordWrap(true); letterLayout->addWidget(letterDesc); @@ -9195,15 +9002,12 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() // ── Slice color group ──────────────────────────────────────────────────── auto* grp = new QGroupBox("Slice Colors"); - grp->setStyleSheet(kGroupStyle); auto* grpLayout = new QVBoxLayout(grp); grpLayout->setSpacing(10); auto* modeLayout = new QHBoxLayout; auto* defaultsRadio = new QRadioButton("Use Aether defaults"); auto* customRadio = new QRadioButton("Custom colors"); - AetherSDR::ThemeManager::instance().applyStyleSheet(defaultsRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }"); - AetherSDR::ThemeManager::instance().applyStyleSheet(customRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }"); modeLayout->addWidget(defaultsRadio); modeLayout->addWidget(customRadio); modeLayout->addStretch(); @@ -9211,7 +9015,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() auto* desc = new QLabel( "Customize the color used for each slice marker, filter band, and badge."); - desc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(desc, Tone::Hint); desc->setWordWrap(true); grpLayout->addWidget(desc); @@ -9228,10 +9032,10 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() auto* lbl = new QLabel(QString(kLetters[i])); lbl->setAlignment(Qt::AlignCenter); - AetherSDR::ThemeManager::instance().applyStyleSheet(lbl, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + setTone(lbl, Tone::Hint); auto* btn = new QPushButton(QString(kLetters[i])); - btn->setStyleSheet(kBtnBase); + ThemeManager::instance().applyStyleSheet(btn, kBtnBase); colorBtns.append(btn); col->addWidget(lbl); @@ -9244,9 +9048,6 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() // Reset-all button auto* resetRow = new QHBoxLayout; auto* resetBtn = new QPushButton("Reset All to Defaults"); - AetherSDR::ThemeManager::instance().applyStyleSheet(resetBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 3px 12px; }" - "QPushButton:hover { background: {{color.background.1}}; }"); resetRow->addWidget(resetBtn); resetRow->addStretch(); grpLayout->addLayout(resetRow); @@ -9262,7 +9063,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() QColor c = pMgr->activeColor(idx); bool light = (c.red() * 299 + c.green() * 587 + c.blue() * 114) > 128000; QString textColor = light ? "#000000" : "#ffffff"; - colorBtns[idx]->setStyleSheet( + ThemeManager::instance().applyStyleSheet(colorBtns[idx], kBtnBase + QStringLiteral("QPushButton { background: %1; color: %2; }") .arg(c.name(), textColor)); @@ -9346,7 +9147,6 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() // mute-all keyboard shortcut still works for the most common use. { auto* clickGrp = new QGroupBox("Single-click delay"); - clickGrp->setStyleSheet(kGroupStyle); auto* clickLayout = new QVBoxLayout(clickGrp); clickLayout->setSpacing(8); @@ -9354,7 +9154,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() row->setSpacing(8); auto* clickLabel = new QLabel("Delay (ms):"); - AetherSDR::ThemeManager::instance().applyStyleSheet(clickLabel, "QLabel { color: {{color.text.primary}}; font-size: 12px; }"); + setTone(clickLabel, Tone::Caption); row->addWidget(clickLabel); auto& s = AppSettings::instance(); @@ -9368,14 +9168,9 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() clickSpin->setSuffix(" ms"); clickSpin->setValue(qBound(0, current, 1000)); clickSpin->setFixedWidth(110); - AetherSDR::ThemeManager::instance().applyStyleSheet(clickSpin, "QSpinBox { background: {{color.background.1}}; color: {{color.text.primary}}; " - "border: 1px solid {{color.background.2}}; border-radius: 3px; padding: 2px 4px; }"); row->addWidget(clickSpin); auto* resetBtn = new QPushButton("Reset"); - AetherSDR::ThemeManager::instance().applyStyleSheet(resetBtn, "QPushButton { background: {{color.background.1}}; color: {{color.text.primary}}; " - "border: 1px solid {{color.background.2}}; border-radius: 3px; padding: 4px 12px; }" - "QPushButton:hover { border-color: #60a0c0; }"); resetBtn->setToolTip(QString("Reset to platform default (%1 ms)") .arg(platformDefault)); row->addWidget(resetBtn); @@ -9390,7 +9185,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() "%1 ms. Set to 0 to fire single-click actions instantly — " "this also disables the double-click affordances on those " "widgets.").arg(platformDefault)); - clickDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(clickDesc, Tone::Hint); clickDesc->setWordWrap(true); clickLayout->addWidget(clickDesc); @@ -9417,14 +9212,10 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() // zoom on the panadapter is intentionally not reversed. { auto* wheelGrp = new QGroupBox("Mouse wheel"); - wheelGrp->setStyleSheet(kGroupStyle); auto* wheelLayout = new QVBoxLayout(wheelGrp); wheelLayout->setSpacing(8); auto* reverseChk = new QCheckBox("Reverse mouse-wheel tuning direction"); - AetherSDR::ThemeManager::instance().applyStyleSheet(reverseChk, - "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }" - + kCheckBoxIndicator); { auto& s = AppSettings::instance(); reverseChk->setChecked(s.value("ReverseMouseWheel", false).toBool()); @@ -9437,7 +9228,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() "the natural scroll direction feels inverted. Affects the VFO " "frequency display and the panadapter / waterfall; the " "Ctrl+wheel bandwidth zoom is not reversed."); - wheelDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(wheelDesc, Tone::Hint); wheelDesc->setWordWrap(true); wheelLayout->addWidget(wheelDesc); @@ -9469,7 +9260,6 @@ QWidget* RadioSetupDialog::buildSmartLinkTab() root->setSpacing(12); auto* grp = new QGroupBox("Pinned SmartLink Certificates"); - grp->setStyleSheet(kGroupStyle); auto* grpLay = new QVBoxLayout(grp); grpLay->setSpacing(8); @@ -9481,7 +9271,7 @@ QWidget* RadioSetupDialog::buildSmartLinkTab() "Forget a pin when you deliberately change a radio's certificate " "(firmware update, hardware replacement) so the next connect can " "re-pin silently. See GHSA-wfx7-w6p8-4jr2 for the threat model."); - desc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(desc, Tone::Hint); desc->setWordWrap(true); grpLay->addWidget(desc); @@ -9498,24 +9288,12 @@ QWidget* RadioSetupDialog::buildSmartLinkTab() table->verticalHeader()->setVisible(false); table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setSelectionBehavior(QAbstractItemView::SelectRows); - AetherSDR::ThemeManager::instance().applyStyleSheet(table, "QTableWidget { background: {{color.background.0}}; color: {{color.text.primary}};" - " gridline-color: {{color.background.1}}; }" - "QHeaderView::section { background: {{color.background.1}}; color: #b0c4d6;" - " padding: 4px; border: none; }"); grpLay->addWidget(table); auto* btnRow = new QHBoxLayout; btnRow->setSpacing(8); auto* forgetSel = new QPushButton("Forget selected"); auto* forgetAll = new QPushButton("Forget all"); - static const QString kPinnedBtnStyle = - "QPushButton { background: #1a2230; border: 1px solid #2a3744;" - " border-radius: 3px; color: #c8d8e8; font-size: 11px;" - " padding: 4px 12px; }" - "QPushButton:hover { background: #243044; color: #e8e8e8; }" - "QPushButton:pressed { background: #2a3a52; }"; - forgetSel->setStyleSheet(kPinnedBtnStyle); - forgetAll->setStyleSheet(kPinnedBtnStyle); btnRow->addWidget(forgetSel); btnRow->addStretch(); btnRow->addWidget(forgetAll); @@ -9586,7 +9364,6 @@ QWidget* RadioSetupDialog::buildQrzTab() // ---- Account group ------------------------------------------------- auto* acct = new QGroupBox("QRZ.com Account"); - acct->setStyleSheet(kGroupStyle); auto* acctLay = new QVBoxLayout(acct); acctLay->setSpacing(8); @@ -9598,14 +9375,13 @@ QWidget* RadioSetupDialog::buildQrzTab() "subscription returns full details; a free account returns limited " "fields. Your password is stored in the operating system keychain, " "never in the settings file."); - desc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(desc, Tone::Hint); desc->setWordWrap(true); acctLay->addWidget(desc); auto* enableCheck = new QCheckBox("Enable QRZ callsign lookups"); enableCheck->setObjectName("qrzEnableCheck"); enableCheck->setAccessibleName("Enable QRZ callsign lookups"); - enableCheck->setStyleSheet("QCheckBox { color: #c8d8e8; font-size: 12px; }"); enableCheck->setChecked(QrzLookupSettings::enabled()); connect(enableCheck, &QCheckBox::toggled, this, [](bool on) { QrzLookupSettings::setEnabled(on); @@ -9622,7 +9398,6 @@ QWidget* RadioSetupDialog::buildQrzTab() auto* userEdit = new QLineEdit(QrzLookupSettings::username()); userEdit->setObjectName("qrzUsernameEdit"); userEdit->setAccessibleName("QRZ username"); - applyEditStyle(userEdit); userEdit->setMaxLength(64); grid->addWidget(userEdit, 0, 1); @@ -9632,7 +9407,6 @@ QWidget* RadioSetupDialog::buildQrzTab() auto* passEdit = new QLineEdit; passEdit->setObjectName("qrzPasswordEdit"); passEdit->setAccessibleName("QRZ password"); - applyEditStyle(passEdit); passEdit->setEchoMode(QLineEdit::Password); passEdit->setMaxLength(128); grid->addWidget(passEdit, 1, 1); @@ -9666,15 +9440,10 @@ QWidget* RadioSetupDialog::buildQrzTab() auto* testBtn = new QPushButton("Test Login"); testBtn->setObjectName("qrzTestLoginBtn"); testBtn->setAccessibleName("Test QRZ login"); - testBtn->setStyleSheet( - "QPushButton { background: #183548; border: 1px solid #28506a; " - "border-radius: 3px; color: #c8d8e8; font-size: 11px; padding: 4px 12px; }" - "QPushButton:hover { background: #1f4258; }" - "QPushButton:disabled { color: #506070; }"); testRow->addWidget(testBtn); auto* testStatus = new QLabel; testStatus->setObjectName("qrzTestStatus"); - testStatus->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(testStatus, Tone::Hint); testStatus->setWordWrap(true); testRow->addWidget(testStatus, 1); acctLay->addLayout(testRow); @@ -9698,23 +9467,20 @@ QWidget* RadioSetupDialog::buildQrzTab() ? (message.isEmpty() ? QStringLiteral("Login OK.") : QStringLiteral("Login OK — %1").arg(message)) : QStringLiteral("Login failed: %1").arg(message)); - testStatus->setStyleSheet(ok - ? "QLabel { color: #4dd87a; font-size: 11px; }" - : "QLabel { color: #ff6060; font-size: 11px; }"); + setTone(testStatus, ok ? Tone::Ok : Tone::Error); }); root->addWidget(acct); // ---- Cache group ---------------------------------------------------- auto* cache = new QGroupBox("Lookup Cache"); - cache->setStyleSheet(kGroupStyle); auto* cacheLay = new QVBoxLayout(cache); cacheLay->setSpacing(8); auto* cacheDesc = new QLabel( "Looked-up callsigns are cached for 7 days so a busy net never asks " "QRZ twice for the same station. Station photos are cached alongside."); - cacheDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(cacheDesc, Tone::Hint); cacheDesc->setWordWrap(true); cacheLay->addWidget(cacheDesc); @@ -9733,7 +9499,6 @@ QWidget* RadioSetupDialog::buildQrzTab() auto* clearBtn = new QPushButton("Clear Cache"); clearBtn->setObjectName("qrzClearCacheBtn"); clearBtn->setAccessibleName("Clear QRZ lookup cache"); - clearBtn->setStyleSheet(testBtn->styleSheet()); connect(clearBtn, &QPushButton::clicked, this, [refreshCacheCount] { CallsignLookupService::instance().clearCache(); refreshCacheCount(); diff --git a/src/gui/RadioSetupDialog.h b/src/gui/RadioSetupDialog.h index cfad98bfe8..7ba68b93a5 100644 --- a/src/gui/RadioSetupDialog.h +++ b/src/gui/RadioSetupDialog.h @@ -1,6 +1,6 @@ #pragma once -#include "PersistentDialog.h" +#include "CanonWindow.h" #include "PeripheralConnectionSource.h" #include "RadioSetupIpConfigPresentation.h" @@ -42,8 +42,9 @@ class Kpa1500Connection; class LpMeterConnection; struct PeripheralDeviceUi; -// Radio Setup dialog — searchable, category-based configuration window. -class RadioSetupDialog : public PersistentDialog { +// Radio Setup: the searchable, category-based configuration window, a canon +// workspace window styled by one sheet (see RadioSetupDialogCommon.h). +class RadioSetupDialog : public CanonWindow { Q_OBJECT public: diff --git a/src/gui/RadioSetupDialogCommon.h b/src/gui/RadioSetupDialogCommon.h index 1920d7c26d..f1e7e3fa7b 100644 --- a/src/gui/RadioSetupDialogCommon.h +++ b/src/gui/RadioSetupDialogCommon.h @@ -12,52 +12,80 @@ #include #include #include +#include +#include #ifdef HAVE_SERIALPORT #include #endif namespace AetherSDR { -inline const QString kGroupStyle = - "QGroupBox { border: 1px solid #304050; border-radius: 4px; " - "margin-top: 8px; padding-top: 12px; font-weight: bold; color: #8aa8c0; }" - "QGroupBox::title { subcontrol-origin: margin; left: 10px; " - "padding: 0 4px; }"; - -// Caption label style (#5896): tokens, applied only through -// ThemeManager::applyStyleSheet, which tracks widgets for re-resolution on -// themeChanged. Named *Template because a {{token}} string must never reach -// setStyleSheet(), which does not expand it. -inline const QString kLabelStyleTemplate = - "QLabel { color: {{color.text.primary}}; font-size: 12px; }"; - -// Line-edit style as tokens; each literal it replaced was that token's Default -// Dark value (resources/themes/default-dark.json), so only Light changes. -inline const QString kEditStyleTemplate = - "QLineEdit { background: {{color.background.1}}; " - "border: 1px solid {{color.background.2}}; border-radius: 3px; " - "color: {{color.text.primary}}; font-size: 12px; padding: 2px 4px; }"; - -// One call shape for every caption label and line edit routed through the two -// templates above, so the widget is tracked for re-resolution as well as styled. -inline void applyLabelStyle(QWidget* widget) +// Radio Setup is a canon window: one sheet on its body (kRadioSetupStyle in +// RadioSetupDialog.cpp) styles every page. Widgets carry a role for it rather +// than a sheet of their own. + +// Text tones, styled by QLabel[rsTone="..."]: +// Caption a field's name ("Callsign:") Value a radio-reported value +// Hint help and secondary text Heading a section or sub-group title +// Title a page's device name Accent a status in progress +// Ok / Warn / Error a settled status +enum class Tone { Caption, Value, Hint, Heading, Title, Ok, Warn, Error, Accent }; + +// A status chip: a small bordered label that reads on (green) or off. +enum class Chip { Off, On }; + +// Button roles, styled by QPushButton[rsButton="..."]. A plain checkable +// button is an on/off toggle (green when on); Select is one of a set (cyan +// when chosen); Mute reads red when engaged; Danger and Success are actions +// with consequences (reboot, remove, upload firmware). +enum class ButtonRole { Select, Mute, Danger, Success }; + +namespace detail { +inline void setRoleProperty(QWidget* widget, const char* name, const char* value) +{ + if (widget->property(name).toByteArray() == value) { + return; + } + widget->setProperty(name, QByteArray(value)); + // A property selector is matched at polish time: re-polish so a role + // changed on a live widget (a status that turns to an error) restyles. + if (widget->style()) { + widget->style()->unpolish(widget); + widget->style()->polish(widget); + } + widget->update(); +} +} // namespace detail + +inline void setTone(QWidget* widget, Tone tone) { - ThemeManager::instance().applyStyleSheet(widget, kLabelStyleTemplate); + static constexpr const char* kNames[] = { + "caption", "value", "hint", "heading", "title", "ok", "warn", "error", "accent"}; + detail::setRoleProperty(widget, "rsTone", kNames[static_cast(tone)]); } -inline void applyEditStyle(QWidget* widget) +inline void setChip(QWidget* widget, Chip chip) { - ThemeManager::instance().applyStyleSheet(widget, kEditStyleTemplate); + detail::setRoleProperty(widget, "rsChip", chip == Chip::On ? "on" : "off"); } -// Uses ThemeManager tokens (Low Latency architecture) with hover + disabled -// pseudo-states (FreeDV Reporter pattern) so boxes are visible in dark mode. -inline const QString kCheckBoxIndicator = - "QCheckBox::indicator { width: 14px; height: 14px; " - "border: 2px solid {{color.background.3}}; border-radius: 3px; background: {{color.background.0}}; }" - "QCheckBox::indicator:hover { border-color: {{color.accent}}; background: {{color.background.1}}; }" - "QCheckBox::indicator:checked { border: 2px solid {{color.accent}}; background: {{color.background.2}}; }" - "QCheckBox::indicator:disabled { border-color: {{color.background.2}}; background: {{color.background.0}}; }"; +inline void setButtonRole(QWidget* button, ButtonRole role) +{ + static constexpr const char* kNames[] = {"select", "mute", "danger", "success"}; + detail::setRoleProperty(button, "rsButton", kNames[static_cast(role)]); +} + +// Whether a label already has a tone; the groups' caption sweeps leave those. +inline bool hasTone(const QWidget* widget) +{ + return widget->property("rsTone").isValid() || widget->property("rsChip").isValid(); +} + +// The caption every label beside a field takes. +inline void applyLabelStyle(QWidget* widget) +{ + setTone(widget, Tone::Caption); +} // The control port the ShackSwitch connect path connects on and saves its // credential under. Removal derives its endpoint from the same value. diff --git a/src/gui/RadioSetupDialog_Peripherals.cpp b/src/gui/RadioSetupDialog_Peripherals.cpp index 4facbf55db..baa61cc1b4 100644 --- a/src/gui/RadioSetupDialog_Peripherals.cpp +++ b/src/gui/RadioSetupDialog_Peripherals.cpp @@ -174,25 +174,6 @@ struct PeripheralDeviceUi { namespace { -const QString kButtonStyle = - "QPushButton { background: {{color.background.1}}; " - "border: 1px solid {{color.background.2}}; border-radius: 3px; " - "color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 3px 10px; }" - "QPushButton:hover { background: {{color.background.2}}; }"; -const QString kComboStyle = - "QComboBox { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 12px; padding: 2px 4px; }" - "QComboBox::drop-down { border: none; }"; -const QString kSpinStyle = - "QSpinBox { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; " - "border-radius: 3px; color: {{color.text.primary}}; font-size: 12px; padding: 2px 4px; }"; -const QString kGroupBoxStyle = - "QGroupBox { border: 1px solid {{color.background.2}}; border-radius: 4px; " - "margin-top: 8px; padding-top: 12px; color: {{color.text.secondary}}; " - "font-weight: bold; } QGroupBox::title { subcontrol-origin: margin; " - "left: 10px; padding: 0 4px; }"; - QString peripheralTr(const char* text) { return RadioSetupDialog::tr(text); @@ -257,25 +238,24 @@ QString statusAccessibleName(const PeripheralDeviceUi& ui) ui.state.source, ui.shortName, !ui.radioRelay).accessibleName; } -const char* statusColorToken(const PeripheralDeviceUi& ui) +Tone statusTone(const PeripheralDeviceUi& ui) { switch (ui.state.attention) { case Attention::InvalidCode: case Attention::AuthBlocked: case Attention::ConnectError: case Attention::CredentialError: - return "{{color.accent.danger}}"; + return Tone::Error; case Attention::CredentialWarning: case Attention::RemovalPending: - return "{{color.accent.warning}}"; + return Tone::Warn; case Attention::None: break; } if (!ui.state.note.isEmpty() || ui.state.discardedAuthCode || ui.state.codeNotSaved) { - return "{{color.accent.warning}}"; + return Tone::Warn; } - return ui.state.source == Source::Direct ? "{{color.accent.success}}" - : "{{color.text.secondary}}"; + return ui.state.source == Source::Direct ? Tone::Ok : Tone::Hint; } // Name and description follow the visible state, and assistive technology is @@ -288,15 +268,10 @@ void applyStatusLabel(PeripheralDeviceUi& ui) } const QString text = statusText(ui); const QString name = statusAccessibleName(ui); - const QString color = QString::fromLatin1(statusColorToken(ui)); const bool textChanged = label->text() != text; const bool nameChanged = label->accessibleName() != name; const bool descriptionChanged = label->accessibleDescription() != text; - if (label->property("peripheralStatusColor").toString() != color) { - label->setProperty("peripheralStatusColor", color); - ThemeManager::instance().applyStyleSheet(label, - QStringLiteral("QLabel { color: %1; font-size: 11px; }").arg(color)); - } + setTone(label, statusTone(ui)); if (!textChanged && !nameChanged && !descriptionChanged) { return; } @@ -376,17 +351,13 @@ DetailPage beginDetailPage(PeripheralDeviceUi& ui, QWidget* stackParent) DetailPage detail; detail.page = new QWidget(stackParent); detail.page->setObjectName(QStringLiteral("peripheralDetail_%1").arg(ui.id)); - ThemeManager::instance().applyStyleSheet(detail.page, - QStringLiteral("QWidget#%1 { background: {{color.background.0}}; }") - .arg(detail.page->objectName())); detail.layout = new QVBoxLayout(detail.page); detail.layout->setContentsMargins(8, 12, 0, 0); detail.layout->setSpacing(10); auto* heading = new QHBoxLayout; auto* title = new QLabel(ui.label, detail.page); - ThemeManager::instance().applyStyleSheet(title, - "QLabel { color: {{color.text.primary}}; font-size: 15px; font-weight: bold; }"); + setTone(title, Tone::Title); heading->addWidget(title); heading->addStretch(); detail.layout->addLayout(heading); @@ -399,7 +370,6 @@ DetailPage beginDetailPage(PeripheralDeviceUi& ui, QWidget* stackParent) detail.layout->addWidget(ui.statusLabel); detail.group = new QGroupBox(RadioSetupDialog::tr("Connection Settings"), detail.page); - ThemeManager::instance().applyStyleSheet(detail.group, kGroupBoxStyle); detail.form = new QGridLayout(detail.group); detail.form->setHorizontalSpacing(12); detail.form->setVerticalSpacing(10); @@ -447,9 +417,6 @@ void finishDetailPage(DetailPage& detail, PeripheralDeviceUi& ui, : RadioSetupDialog::tr( "Connect to %1 on startup, when discovery reports it, and after a drop. " "Turn off to connect only when you click Connect.").arg(ui.label)); - ThemeManager::instance().applyStyleSheet(autoConnect, - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }" - + kCheckBoxIndicator); autoConnect->setChecked(PeripheralSettings::autoConnect(ui.id)); // Written at once. The connections read it when they decide to // reconnect, so nothing needs to be pushed to them here. @@ -462,8 +429,7 @@ void finishDetailPage(DetailPage& detail, PeripheralDeviceUi& ui, ui.editHint = new QLabel(detail.page); ui.editHint->setObjectName(QStringLiteral("peripheralEditHint_%1").arg(ui.id)); ui.editHint->setWordWrap(true); - ThemeManager::instance().applyStyleSheet(ui.editHint, - "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + setTone(ui.editHint, Tone::Hint); auto* buttonRow = new QHBoxLayout; buttonRow->addWidget(ui.connectButton); @@ -492,7 +458,6 @@ QPushButton* makeConnectButton(PeripheralDeviceUi& ui) : QStringLiteral("Connect")); button->setObjectName(QStringLiteral("peripheralConnect_%1").arg(ui.id)); button->setAccessibleName(RadioSetupDialog::tr("Connect or disconnect %1").arg(ui.label)); - ThemeManager::instance().applyStyleSheet(button, kButtonStyle); return button; } @@ -501,7 +466,6 @@ QSpinBox* makePortSpin(const PeripheralDeviceUi& ui) auto* spin = new QSpinBox; spin->setObjectName(QStringLiteral("peripheralPort_%1").arg(ui.id)); spin->setRange(1, 65535); - ThemeManager::instance().applyStyleSheet(spin, kSpinStyle); spin->setAccessibleName(RadioSetupDialog::tr("%1 port").arg(ui.label)); return spin; } @@ -648,7 +612,6 @@ void RadioSetupDialog::buildAuthNetworkDevice(PeripheralDeviceUi& ui, QWidget* s ui.addressEdit->setObjectName(QStringLiteral("peripheralAddress_%1").arg(ui.id)); ui.addressEdit->setPlaceholderText(QStringLiteral("e.g. 192.168.1.100")); ui.addressEdit->setAccessibleName(tr("%1 address").arg(ui.label)); - applyEditStyle(ui.addressEdit); ui.addressEdit->setMinimumWidth(140); const QString savedIp = settings.value(ui.ipKey, "").toString(); if (!savedIp.isEmpty()) { @@ -770,12 +733,10 @@ void RadioSetupDialog::buildAuthNetworkDevice(PeripheralDeviceUi& ui, QWidget* s ? edit->property("authBlankDescription").toString() : RadioSetupDialog::tr("A code has been entered. It replaces the saved code only after the device accepts it.")); }); - applyEditStyle(edit); auto* show = new QPushButton(tr("Show")); ui.showButton = show; show->setObjectName(QStringLiteral("peripheralAuth_%1_5").arg(ui.id)); - ThemeManager::instance().applyStyleSheet(show, kButtonStyle); show->setAccessibleName(tr("%1 show authorization code").arg(label)); auto concealSavedCode = [edit, show, label]() { edit->setProperty("authRevealGeneration", @@ -854,7 +815,6 @@ void RadioSetupDialog::buildAuthNetworkDevice(PeripheralDeviceUi& ui, QWidget* s auto* clear = new QPushButton(tr("Clear code")); ui.clearButton = clear; clear->setObjectName(QStringLiteral("peripheralAuth_%1_6").arg(ui.id)); - ThemeManager::instance().applyStyleSheet(clear, kButtonStyle); clear->setAccessibleName(tr("%1 clear saved authorization code").arg(label)); connect(clear, &QPushButton::clicked, edit, concealSavedCode); connect(clear, &QPushButton::clicked, this, [this, u, edit, authDevice, refresh]() { @@ -901,7 +861,6 @@ void RadioSetupDialog::buildAuthNetworkDevice(PeripheralDeviceUi& ui, QWidget* s ui.webUiButton = new QPushButton(QStringLiteral("⚙ Web UI")); ui.webUiButton->setObjectName(QStringLiteral("peripheralWebUi_%1").arg(ui.id)); ui.webUiButton->setAccessibleName(tr("Open the %1 web interface").arg(ui.label)); - ThemeManager::instance().applyStyleSheet(ui.webUiButton, kButtonStyle); ui.webUiButton->setToolTip(tr("Open ShackSwitch web interface")); connect(ui.webUiButton, &QPushButton::clicked, this, [u]() { u->openWebUi(); }); extras.append(ui.webUiButton); @@ -921,7 +880,6 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget* ui.modeCombo = new QComboBox; ui.modeCombo->setObjectName(QStringLiteral("peripheralMode_%1").arg(ui.id)); - ThemeManager::instance().applyStyleSheet(ui.modeCombo, kComboStyle); ui.modeCombo->setAccessibleName(tr("%1 connection type").arg(ui.label)); #ifdef HAVE_SERIALPORT ui.modeCombo->addItem("Serial", "Serial"); @@ -945,12 +903,10 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget* auto* lay = new QHBoxLayout(serialPage); lay->setContentsMargins(0, 0, 0, 0); serialCombo = new QComboBox; - ThemeManager::instance().applyStyleSheet(serialCombo, kComboStyle); serialCombo->setAccessibleName(tr("%1 serial port").arg(ui.label)); serialCustomEdit = new QLineEdit; serialCustomEdit->setPlaceholderText("/dev/ttyUSB0"); serialCustomEdit->setAccessibleName(tr("%1 custom serial port").arg(ui.label)); - applyEditStyle(serialCustomEdit); populateSerialPortCombo(serialCombo, serialCustomEdit, PeripheralSettings::deviceString(group, "SerialPort")); serialCustomEdit->setVisible(serialCombo->currentData().toString() == "__custom__"); @@ -982,7 +938,6 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget* netIpEdit->setPlaceholderText(ui.networkPlaceholder); netIpEdit->setAccessibleName(tr("%1 network address").arg(ui.label)); netIpEdit->setAccessibleDescription(ui.networkDescription); - applyEditStyle(netIpEdit); netIpEdit->setText(PeripheralSettings::deviceString(group, "ManualIp")); if (!ui.networkTooltip.isEmpty()) { netIpEdit->setToolTip(ui.networkTooltip); @@ -998,8 +953,7 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget* #ifdef HAVE_SERIALPORT { auto* fixedLbl = new QLabel(ui.fixedBaud); - ThemeManager::instance().applyStyleSheet(fixedLbl, - "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + setTone(fixedLbl, Tone::Hint); serialBaudIdx = ui.portStack->addWidget(fixedLbl); } #endif @@ -1007,7 +961,6 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget* netPortSpin->setRange(1, 65535); netPortSpin->setValue(PeripheralSettings::deviceInt(group, "ManualPort", ui.defaultPort)); netPortSpin->setAccessibleName(tr("%1 network port").arg(ui.label)); - ThemeManager::instance().applyStyleSheet(netPortSpin, kSpinStyle); const int netPortIdx = ui.portStack->addWidget(netPortSpin); auto applyMode = [stack = ui.addressStack, ports = ui.portStack, @@ -1097,7 +1050,6 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget* ui.serialRefreshButton->setObjectName(QStringLiteral("peripheralSerialRefresh_%1").arg(ui.id)); ui.serialRefreshButton->setAutoDefault(false); ui.serialRefreshButton->setAccessibleName(tr("%1 refresh serial ports").arg(ui.label)); - ThemeManager::instance().applyStyleSheet(ui.serialRefreshButton, kButtonStyle); connect(ui.serialRefreshButton, &QPushButton::clicked, this, [pageReseeds]() { for (const auto& reseed : *pageReseeds) { reseed(); @@ -1123,7 +1075,6 @@ void RadioSetupDialog::buildVkampDevice(PeripheralDeviceUi& ui, QWidget* stackPa ui.addressEdit = new QLineEdit; ui.addressEdit->setObjectName(QStringLiteral("peripheralAddress_%1").arg(ui.id)); ui.addressEdit->setPlaceholderText(QStringLiteral("e.g. 192.168.1.50")); - applyEditStyle(ui.addressEdit); ui.addressEdit->setText(PeripheralSettings::deviceString("Vkamp", "ManualIp")); // Name answers "what is this?", description answers "what do I type?" // (docs/a11y.md section 2). @@ -1169,7 +1120,6 @@ void RadioSetupDialog::buildVkampDevice(PeripheralDeviceUi& ui, QWidget* stackPa // defaults to W2000 (the originally-confirmed unit) rather than blocking. ui.modelCombo = new QComboBox; ui.modelCombo->setObjectName(QStringLiteral("peripheralAmplifierModel")); - ThemeManager::instance().applyStyleSheet(ui.modelCombo, kComboStyle); ui.modelCombo->setAccessibleName(tr("VK3AMP amplifier model")); ui.modelCombo->setAccessibleDescription( tr("Rated output of your unit. Sets the power meter's full scale; it does not change " @@ -1219,7 +1169,6 @@ void RadioSetupDialog::buildKpa1500Device(PeripheralDeviceUi& ui, QWidget* stack ui.addressEdit = new QLineEdit; ui.addressEdit->setObjectName(QStringLiteral("peripheralAddress_%1").arg(ui.id)); ui.addressEdit->setPlaceholderText(QStringLiteral("e.g. 192.168.1.60")); - applyEditStyle(ui.addressEdit); ui.addressEdit->setText(PeripheralSettings::deviceString("Kpa1500", "ManualIp")); ui.addressEdit->setAccessibleName(tr("KPA1500 address")); ui.addressEdit->setAccessibleDescription( @@ -1301,8 +1250,6 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() { auto* page = new QWidget; page->setObjectName(QStringLiteral("peripheralsPage")); - ThemeManager::instance().applyStyleSheet(page, - "QWidget#peripheralsPage { background: {{color.background.0}}; }"); auto* vbox = new QVBoxLayout(page); vbox->setSpacing(8); @@ -1341,8 +1288,6 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() auto* content = new QWidget(page); content->setObjectName(QStringLiteral("peripheralContent")); - ThemeManager::instance().applyStyleSheet(content, - "QWidget#peripheralContent { background: {{color.background.0}}; }"); auto* columns = new QHBoxLayout(content); columns->setContentsMargins(0, 0, 0, 0); columns->setSpacing(12); @@ -1354,8 +1299,7 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() auto* emptyText = new QLabel( tr("No peripherals added.\n\nChoose Add to configure a device."), emptyPage); emptyText->setAlignment(Qt::AlignCenter); - ThemeManager::instance().applyStyleSheet(emptyText, - "QLabel { color: {{color.text.secondary}}; font-size: 12px; }"); + setTone(emptyText, Tone::Hint); emptyLayout->addWidget(emptyText); detailStack->addWidget(emptyPage); @@ -1652,9 +1596,6 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() auto* reconnectCheck = new QCheckBox(tr("Reconnect automatically")); reconnectCheck->setObjectName(QStringLiteral("peripheralAutoReconnect")); reconnectCheck->setAccessibleDescription(tr("Reconnect peripherals after a connection drops.")); - ThemeManager::instance().applyStyleSheet(reconnectCheck, - "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }" - + kCheckBoxIndicator); reconnectCheck->setChecked(PeripheralSettings::autoReconnect()); connect(reconnectCheck, &QCheckBox::toggled, this, [this](bool on) { PeripheralSettings::setAutoReconnect(on); @@ -1687,42 +1628,22 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() auto* listGroup = new QGroupBox(tr("Devices"), content); listGroup->setMinimumWidth(250); listGroup->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - ThemeManager::instance().applyStyleSheet(listGroup, kGroupBoxStyle); auto* listLayout = new QVBoxLayout(listGroup); auto* deviceList = new QListWidget(listGroup); deviceList->setObjectName(QStringLiteral("peripheralDeviceList")); deviceList->setAccessibleName(tr("Configured peripherals")); deviceList->setAlternatingRowColors(true); deviceList->setSpacing(3); - ThemeManager::instance().applyStyleSheet(deviceList, - "QListWidget { background: {{color.background.0}}; alternate-background-color: {{color.background.0}}; " - "color: {{color.text.primary}}; " - "border: 1px solid {{color.background.1}}; font-size: 11px; }" - "QListWidget::item { background: {{color.background.1}}; padding: 8px 7px; margin-right: 6px; " - "border: 1px solid {{color.background.2}}; border-radius: 0px; }" - "QListWidget::item:alternate { background: {{color.background.2}}; }" - "QListWidget::item:selected, QListWidget::item:alternate:selected { background: palette(highlight); " - "color: palette(highlighted-text); }"); deviceList->setMinimumHeight(200); deviceList->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); listLayout->addWidget(deviceList); - const QString actionStyle = - "QPushButton { background: {{color.background.1}}; " - "border: 1px solid {{color.background.2}}; border-radius: 3px; " - "color: {{color.text.primary}}; font-size: 11px; font-weight: bold; " - "padding: 5px 8px; }" - "QPushButton:hover { background: {{color.background.2}}; }" - "QPushButton:disabled { color: {{color.text.disabled}}; }"; auto* listActions = new QHBoxLayout; auto* addButton = new QPushButton(tr("Add"), listGroup); addButton->setObjectName(QStringLiteral("peripheralAddButton")); addButton->setAccessibleName(tr("Add peripheral device")); addButton->setAccessibleDescription(tr("Choose a device type to configure")); - ThemeManager::instance().applyStyleSheet(addButton, actionStyle); auto* addMenu = new QMenu(addButton); - ThemeManager::instance().applyStyleSheet(addMenu, - "QMenu::item:disabled { color: {{color.text.disabled}}; }"); addButton->setMenu(addMenu); auto* removeButton = new QPushButton(tr("Remove"), listGroup); removeButton->setObjectName(QStringLiteral("peripheralRemoveButton")); @@ -1730,7 +1651,6 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() removeButton->setAccessibleDescription(tr("Select a configured peripheral to remove it")); removeButton->setToolTip(tr("Remove this device and clear its saved connection details")); removeButton->setEnabled(false); - ThemeManager::instance().applyStyleSheet(removeButton, actionStyle); listActions->addWidget(addButton, 1); listActions->addWidget(removeButton, 1); listLayout->addLayout(listActions); @@ -1739,8 +1659,7 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() removalNotice->setAccessibleName(tr("Peripheral removal status")); removalNotice->setWordWrap(true); removalNotice->hide(); - ThemeManager::instance().applyStyleSheet(removalNotice, - "QLabel { color: {{color.accent.warning}}; font-size: 11px; }"); + setTone(removalNotice, Tone::Warn); listLayout->addWidget(removalNotice); listLayout->addSpacing(8); listLayout->addWidget(reconnectCheck); @@ -1749,7 +1668,6 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() helpButton->setObjectName(QStringLiteral("peripheralConnectionHelp")); helpButton->setAutoDefault(false); helpButton->setAccessibleDescription(tr("Explain local and remote peripheral connections.")); - ThemeManager::instance().applyStyleSheet(helpButton, actionStyle); connect(helpButton, &QPushButton::clicked, this, [this]() { QMessageBox::information(this, tr("Peripheral connections"), tr("Add a device and enter its address for a direct connection. " diff --git a/tests/peripheral_auth_dialog_test.cpp b/tests/peripheral_auth_dialog_test.cpp index 099b565b57..4e2c9b0900 100644 --- a/tests/peripheral_auth_dialog_test.cpp +++ b/tests/peripheral_auth_dialog_test.cpp @@ -1901,7 +1901,7 @@ int checkInteraction(QApplication& app) LpMeterConnection meter; RadioSetupDialog dialog(&model, nullptr, &tgxl, &pgxl, &ag, nullptr, &acom, &spe, &vkamp, &meter); - dialog.resize(960, 680); + dialog.resize(960, 720); dialog.show(); dialog.selectTab(QStringLiteral("Peripherals")); QCoreApplication::processEvents(); @@ -1963,7 +1963,7 @@ int checkInteraction(QApplication& app) return false; }; QCoreApplication::processEvents(); - if (dialog.size() != QSize(960, 680) || !fitsViewport(clearButton) || !fitsViewport(status)) { + if (dialog.size() != QSize(960, 720) || !fitsViewport(clearButton) || !fitsViewport(status)) { std::fprintf(stderr, "Clear code or Status is clipped at the default dialog size\n"); // The page's width depends on the host: each serial-port combo is as // wide as its longest "port — description" label. Name the widest one. diff --git a/tests/radio_setup_headphone_output_gate_test.cpp b/tests/radio_setup_headphone_output_gate_test.cpp index 775aede7da..f5ed7650a3 100644 --- a/tests/radio_setup_headphone_output_gate_test.cpp +++ b/tests/radio_setup_headphone_output_gate_test.cpp @@ -313,12 +313,13 @@ private slots: { auto& tm = ThemeManager::instance(); const ThemeRestore restoreTheme; - const QColor engagedRed(0x8b, 0x00, 0x00); QStringList failures; for (const char* theme : {"Default Dark", "Default Light"}) { QVERIFY2(tm.setActiveTheme(QLatin1String(theme)), theme); const QString unavailable = tm.color(QStringLiteral("color.control.unavailable")).name(); + // The engaged Mute's border is drawn in the danger token, solid. + const QColor engagedRed = tm.color(QStringLiteral("color.accent.danger")); RadioModel hl2; NoCommandPlaneBackend* backend = installConnected(hl2, QStringLiteral("hl2")); @@ -371,7 +372,9 @@ private slots: failures << where + QStringLiteral(": regions differ in size"); else if (diff == 0) failures << where + QStringLiteral(": dimmed renders as live"); - if (!p.dimmed->styleSheet().contains(unavailable, Qt::CaseInsensitive)) + // The window's canon sheet paints a disabled label, value and + // Mute in the unavailable token: its colour is in the render. + if (pixelsOfColour(dimmed, QColor(unavailable)) <= 0) failures << where + QStringLiteral(": not the unavailable token ") + unavailable; } diff --git a/tests/radio_setup_label_theme_token_test.cpp b/tests/radio_setup_label_theme_token_test.cpp index d48d0e02c3..b376aeda21 100644 --- a/tests/radio_setup_label_theme_token_test.cpp +++ b/tests/radio_setup_label_theme_token_test.cpp @@ -1,67 +1,38 @@ -// #5896 — RadioSetupDialog's caption labels and line edits. +// #5896 — Radio Setup's caption labels and line edits follow a LIVE theme switch. // -// `kLabelStyle` pinned `#c8d8e8` and `kEditStyle` pinned `#1a2a3a` / `#304050` / -// `#c8d8e8`, and both were painted with a plain `QWidget::setStyleSheet`. Only -// `ThemeManager::applyStyleSheet` records a widget in `m_trackedWidgets` for -// re-resolution on `themeChanged`, so the literals survived View ▸ Theme -// unchanged: under Default Light the captions rendered Default Dark's -// `text.primary` on a `#f5f5f8` ground, and the line edits kept a near-black -// fill. +// Radio Setup is a canon window: its body carries one sheet built from +// color.canon.* tokens, re-applied on themeChanged (applyCanonSheet), and a +// caption is a QLabel with the Caption tone (`rsTone`) and no sheet of its +// own. A widget that kept its own sheet — a literal, or a token template that +// pins a colour — would override the window's and could survive View ▸ Theme +// unchanged, which is the defect: under Default Light the captions once +// rendered Default Dark's text on a light ground, and the line edits kept a +// near-black fill. // -// THE SWITCH IS THE ONLY DISCRIMINATOR, and that is the whole shape of this -// file. Every literal this change removed IS the value its replacement token -// resolves to under Default Dark — that exactness is the reason the conversion -// is safe, and it is also the reason a construction-time assertion cannot see -// the defect: before and after, a freshly built dialog under Default Dark is -// byte-identical. A test that only inspected a new widget would pass against -// the broken tree. So the colour is read back ACROSS a theme switch, and the +// THE SWITCH IS THE ONLY DISCRIMINATOR. A freshly built dialog under Default +// Dark looks right whether or not its colours can move, so every colour here is +// read back from the polished palette ACROSS a theme switch, and the // dark→light→dark round trip is asserted rather than a single reading. // -// Nothing is collected by name. Widgets are gathered by the STYLE SHAPE with -// the colour left open — `QLabel { color: ; -// font-size: 12px; }` — so the same collector runs against both trees, and a -// named floor then pins that the collection is not empty and not accidentally -// narrowed to the one label that happens to be right. +// Nothing is collected by name. Captions are gathered by their role and line +// edits by type, and a named floor then pins that the collection is not empty +// and not accidentally narrowed. // // No hardware and no transport: a bare `RadioModel`, never connected. Only the // Radio page is built eagerly (#1776 keeps the hardware probes out of the -// constructor), and that page alone carries the three line edits and the -// makeInfoField captions the floor below names, so no deferred page has to be -// opened and no probe is triggered. -// -// AND THAT LAZINESS IS LOAD-BEARING FOR THE COLLECTOR, not just for the probes. -// `captionShape()` leaves the colour open, so it matches an UNTRACKED literal -// just as happily as a resolved token — and the registration loop then demands -// `color.text.primary` in `tokensForWidget()` for everything it collected. An -// untracked caption of that exact shape on the EAGER page would therefore fail -// this test for a reason that has nothing to do with the defect it pins. -// -// Four untracked labels of that exact shape survive in `RadioSetupDialog.cpp`, -// and all four sit on deferred pages: -// -// - `buildRxTab`, Frequency Offset group — the GPSDO-present note, both -// branches (`#00c040` / `#c0a000`). -// - `buildUsbCablesTab` — the "No USB cables detected." placeholder and the -// "Select a cable type to configure this device." note (`#606880`, twice). -// -// Inside `buildRadioTab` every surviving raw-hex label is 10px or 11px — the -// `m_fwStatusLabel` group (#5896's stated deferral: `#6888a0` is not any -// token's Default Dark value) at 10px, and the licence note at 11px — so none -// of them is collected. Make another page eager, or move one of those four -// labels onto the Radio page, and this test goes red with nothing broken. The -// fix then is to route that label through `applyLabelStyle`, NOT to loosen the -// regex: narrowing the collector to dodge a real untracked widget is how this -// test would start agreeing with itself. +// constructor), and that page alone carries the line edits and captions the +// floor below names, so no deferred page has to be opened and no probe runs. +// A tone changed on a live label (a status line) repaints at once. #include "TestSettingsProfile.h" #include "core/ThemeManager.h" #include "gui/RadioSetupDialog.h" +#include "gui/RadioSetupDialogCommon.h" #include "models/RadioModel.h" #include #include #include -#include #include using namespace AetherSDR; @@ -71,46 +42,34 @@ namespace { const char* const kDarkTheme = "Default Dark"; const char* const kLightTheme = "Default Light"; -// Colour left open on purpose: the same regex has to match the literal the -// broken tree pastes in AND the value the token resolves to. Anchored, so a -// value label (`font-weight: bold`) or the 11px serial-tab caption cannot drift -// into the set. -const QRegularExpression& captionShape() -{ - static const QRegularExpression re( - QStringLiteral("^QLabel \\{ color: #[0-9a-fA-F]{6}; font-size: 12px; \\}$")); - return re; -} - -const QRegularExpression& editShape() -{ - static const QRegularExpression re( - QStringLiteral("^QLineEdit \\{ background: #[0-9a-fA-F]{6}; " - "border: 1px solid #[0-9a-fA-F]{6}; border-radius: 3px; " - "color: #[0-9a-fA-F]{6}; font-size: 12px; padding: 2px 4px; \\}$")); - return re; -} - QList captionLabels(QWidget* root) { QList out; for (QLabel* l : root->findChildren()) { - if (captionShape().match(l->styleSheet()).hasMatch()) + if (l->property("rsTone").toByteArray() == "caption") out.append(l); } return out; } -QList styledEdits(QWidget* root) +// The page's own fields: the search box above the navigation has a sheet rule +// of its own and is not a settings field. +QList fieldEdits(QWidget* root) { QList out; for (QLineEdit* e : root->findChildren()) { - if (editShape().match(e->styleSheet()).hasMatch()) + if (e->objectName() != QLatin1String("radioSetupSearch") && e->isVisibleTo(root)) out.append(e); } return out; } +QColor polished(QWidget* w, QPalette::ColorRole role) +{ + w->ensurePolished(); + return w->palette().color(role); +} + } // namespace class RadioSetupLabelThemeTokenTest : public QObject { @@ -122,8 +81,7 @@ private slots: // construction-time reading succeeds — but availableThemes() is empty, // setActiveTheme() has nothing to switch to, and every "the colour moved" // assertion would be comparing a value against itself. That failure passes - // silently, which is the same class of defect this file is about one level - // up, so it is asserted first and on its own. + // silently, so it is asserted first and on its own. void theThemesAreReachableAndDisagree() { auto& tm = ThemeManager::instance(); @@ -132,52 +90,28 @@ private slots: QVERIFY2(themes.contains(QLatin1String(kLightTheme)), qPrintable(themes.join(", "))); QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); - const QColor darkText = tm.color(QStringLiteral("color.text.primary")); - const QColor darkBg1 = tm.color(QStringLiteral("color.background.1")); - const QColor darkBg2 = tm.color(QStringLiteral("color.background.2")); + const QColor darkCaption = tm.color(QStringLiteral("color.canon.inkSoft")); + const QColor darkField = tm.color(QStringLiteral("color.canon.control")); + const QColor darkInk = tm.color(QStringLiteral("color.canon.ink")); QVERIFY(tm.setActiveTheme(QLatin1String(kLightTheme))); - const QColor lightText = tm.color(QStringLiteral("color.text.primary")); - const QColor lightBg1 = tm.color(QStringLiteral("color.background.1")); - const QColor lightBg2 = tm.color(QStringLiteral("color.background.2")); + const QColor lightCaption = tm.color(QStringLiteral("color.canon.inkSoft")); + const QColor lightField = tm.color(QStringLiteral("color.canon.control")); + const QColor lightInk = tm.color(QStringLiteral("color.canon.ink")); - QVERIFY(darkText.isValid() && lightText.isValid()); + QVERIFY(darkCaption.isValid() && lightCaption.isValid()); // If the two themes ever agreed about a token, "the colour followed the // switch" would be unobservable and the slots below would pass without // measuring anything. - QVERIFY(darkText != lightText); - QVERIFY(darkBg1 != lightBg1); - QVERIFY(darkBg2 != lightBg2); - - QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); - } + QVERIFY(darkCaption != lightCaption); + QVERIFY(darkField != lightField); + QVERIFY(darkInk != lightInk); - // The equivalence claim this change rests on, pinned rather than asserted in - // a commit message: each literal removed from RadioSetupDialog.cpp IS the - // value its replacement token resolves to under Default Dark, so Radio - // Setup's dark appearance did not move. The hex below is the value the - // dialog USED to carry; it no longer appears in the production file for - // these constants, so this cannot agree with itself. - // - // If this ever fails, Default Dark's palette has been retuned and Radio - // Setup's dark appearance has moved with it. That is a decision someone - // made, not a bug in this test — but it should be a visible one. - void theTokensResolveToExactlyTheLiteralsTheyReplaced() - { - auto& tm = ThemeManager::instance(); QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); - QCOMPARE(tm.color(QStringLiteral("color.text.primary")).name(), - QStringLiteral("#c8d8e8")); // was kLabelStyle / kEditStyle's colour - QCOMPARE(tm.color(QStringLiteral("color.background.1")).name(), - QStringLiteral("#1a2a3a")); // was kEditStyle's background - QCOMPARE(tm.color(QStringLiteral("color.background.2")).name(), - QStringLiteral("#304050")); // was kEditStyle's border } - // The named floor. A shape collector that matched nothing would make every - // "for each collected widget" loop below vacuously true, and one that - // matched only the labels that were already right would be worse than - // empty. These captions and these edits are on the eagerly built Radio page - // and are named one by one. + // The named floor. A collector that matched nothing would make every "for + // each collected widget" loop below vacuously true. These captions and + // these edits are on the eagerly built Radio page and are named one by one. void theCollectionHasANamedFloor() { auto& tm = ThemeManager::instance(); @@ -197,7 +131,7 @@ private slots: QVERIFY2(texts.size() >= 8, qPrintable(QString::number(texts.size()))); QStringList editNames; - for (QLineEdit* e : styledEdits(&dialog)) + for (QLineEdit* e : fieldEdits(&dialog)) editNames << e->accessibleName(); for (const char* wanted : {"Radio nickname", "Station callsign"}) QVERIFY2(editNames.contains(QLatin1String(wanted)), @@ -205,9 +139,28 @@ private slots: QVERIFY2(editNames.size() >= 3, qPrintable(QString::number(editNames.size()))); } + // No caption and no field carries a sheet of its own: one would override + // the window's, and a colour it pinned would not follow the switch. + void captionsAndFieldsTakeTheWindowSheet() + { + auto& tm = ThemeManager::instance(); + QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); + + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.show(); + + for (QLabel* l : captionLabels(&dialog)) + QVERIFY2(l->styleSheet().isEmpty(), + qPrintable(QStringLiteral("%1 | qss=%2").arg(l->text(), l->styleSheet()))); + for (QLineEdit* e : fieldEdits(&dialog)) + QVERIFY2(e->styleSheet().isEmpty(), + qPrintable(QStringLiteral("%1 | qss=%2") + .arg(e->accessibleName(), e->styleSheet()))); + } + // THE SLOT THE FIX IS FOR. Dark → Light → Dark, reading the painted colour - // back each time. Against the pre-#5896 tree the first pass passes (the - // literal IS the dark value) and the Light pass fails on every caption. + // back each time. void captionLabelColoursFollowAThemeSwitch() { auto& tm = ThemeManager::instance(); @@ -220,42 +173,60 @@ private slots: const QList labels = captionLabels(&dialog); QVERIFY(!labels.isEmpty()); - // Registration. A widget painted with a plain setStyleSheet is not in - // m_trackedWidgets at all, so this is the construction-time half of the - // defect and it fails on the broken tree without any switch. - for (QLabel* l : labels) { - QVERIFY2(tm.tokensForWidget(l).contains(QStringLiteral("color.text.primary")), - qPrintable(QStringLiteral("not registered against the token: %1 | qss=%2") - .arg(l->text(), l->styleSheet()))); - } - - auto assertPainted = [&labels](const char* theme, const QColor& expected) { + auto assertPainted = [&labels](const char* theme) { auto& tm = ThemeManager::instance(); QVERIFY(tm.setActiveTheme(QLatin1String(theme))); - const QString want = expected.name(); + const QColor want = tm.color(QStringLiteral("color.canon.inkSoft")); for (QLabel* l : labels) { - QVERIFY2(l->styleSheet().contains(want, Qt::CaseInsensitive), - qPrintable(QStringLiteral("expected %1 under %2: %3 | qss=%4") - .arg(want, QLatin1String(theme), l->text(), l->styleSheet()))); + const QColor got = polished(l, QPalette::WindowText); + QVERIFY2(got == want, + qPrintable(QStringLiteral("expected %1 under %2: %3 is %4") + .arg(want.name(), QLatin1String(theme), l->text(), + got.name()))); } }; - QVERIFY(tm.setActiveTheme(QLatin1String(kLightTheme))); - const QColor light = tm.color(QStringLiteral("color.text.primary")); - QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); - const QColor dark = tm.color(QStringLiteral("color.text.primary")); + assertPainted(kDarkTheme); + assertPainted(kLightTheme); // the fix + assertPainted(kDarkTheme); // and back, so it is not a one-way latch - assertPainted(kDarkTheme, dark); - assertPainted(kLightTheme, light); // the fix - assertPainted(kDarkTheme, dark); // and back, so it is not a one-way latch + QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); + } + // A status line changes tone while the window is open (an upload that + // fails, a calibration that settles). setTone re-polishes the live widget, + // so the new colour is painted at once rather than on the next theme + // change or reopen. + void aToneChangedOnAShownLabelRepaints() + { + auto& tm = ThemeManager::instance(); QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); + + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + + const QList labels = captionLabels(&dialog); + QVERIFY(!labels.isEmpty()); + QLabel* label = labels.first(); + const QColor caption = tm.color(QStringLiteral("color.canon.inkSoft")); + const QColor error = tm.color(QStringLiteral("color.accent.danger")); + const QColor ok = tm.color(QStringLiteral("color.accent.success")); + // CONTROL: the three colours are distinguishable. + QVERIFY(caption != error && error != ok && caption != ok); + QCOMPARE(polished(label, QPalette::WindowText), caption); + + setTone(label, Tone::Error); + QCOMPARE(polished(label, QPalette::WindowText), error); + setTone(label, Tone::Ok); + QCOMPARE(polished(label, QPalette::WindowText), ok); + setTone(label, Tone::Caption); + QCOMPARE(polished(label, QPalette::WindowText), caption); } - // The same, for the line edits. Three tokens rather than one, and the - // background is the one an operator sees: #1a2a3a under a light theme is a - // near-black box, and it is simultaneously Default Light's text.primary, so - // the text the theme did reach landed black on black. + // The same, for the line edits: the fill an operator sees, and the text on + // it. void lineEditColoursFollowAThemeSwitch() { auto& tm = ThemeManager::instance(); @@ -265,35 +236,28 @@ private slots: RadioSetupDialog dialog(&model); dialog.show(); - const QList edits = styledEdits(&dialog); + const QList edits = fieldEdits(&dialog); QVERIFY(!edits.isEmpty()); - for (QLineEdit* e : edits) { - const QStringList tokens = tm.tokensForWidget(e); - for (const char* token : {"color.background.1", "color.background.2", - "color.text.primary"}) { - QVERIFY2(tokens.contains(QLatin1String(token)), - qPrintable(QStringLiteral("%1 not registered on %2 | qss=%3") - .arg(QLatin1String(token), e->accessibleName(), - e->styleSheet()))); - } - } - auto assertPainted = [&edits](const char* theme) { auto& tm = ThemeManager::instance(); QVERIFY(tm.setActiveTheme(QLatin1String(theme))); - const QStringList want{ - tm.color(QStringLiteral("color.background.1")).name(), - tm.color(QStringLiteral("color.background.2")).name(), - tm.color(QStringLiteral("color.text.primary")).name()}; + const QColor fill = tm.color(QStringLiteral("color.canon.control")); + const QColor ink = tm.color(QStringLiteral("color.canon.ink")); + int checked = 0; for (QLineEdit* e : edits) { - for (const QString& value : want) { - QVERIFY2(e->styleSheet().contains(value, Qt::CaseInsensitive), - qPrintable(QStringLiteral("expected %1 under %2: %3 | qss=%4") - .arg(value, QLatin1String(theme), - e->accessibleName(), e->styleSheet()))); - } + // A read-only field sits nested rather than on control. + if (e->isReadOnly() || !e->isEnabled()) + continue; + ++checked; + const QColor gotFill = polished(e, QPalette::Base); + const QColor gotInk = polished(e, QPalette::Text); + QVERIFY2(gotFill == fill && gotInk == ink, + qPrintable(QStringLiteral("expected %1 on %2 under %3: %4 is %5 on %6") + .arg(ink.name(), fill.name(), QLatin1String(theme), + e->accessibleName(), gotInk.name(), gotFill.name()))); } + QVERIFY2(checked >= 2, qPrintable(QString::number(checked))); }; assertPainted(kDarkTheme); diff --git a/tests/radio_setup_region_field_test.cpp b/tests/radio_setup_region_field_test.cpp index c2a051e66e..5407fb46bb 100644 --- a/tests/radio_setup_region_field_test.cpp +++ b/tests/radio_setup_region_field_test.cpp @@ -27,6 +27,11 @@ // fails a post-switch one, so the slot below switches the theme and reads the // colour back rather than only inspecting the freshly built dialog. // +// Radio Setup is now a canon window: a value label carries the Value tone +// (`rsTone`) and the window's one sheet colours it with color.canon.ink, so +// the colour is read back from the label's polished palette, not from a sheet +// of its own (it has none). +// // No hardware and no transport: the model is constructed bare and never // connected, and the region arrives the way a Flex's does — as a RadioDelta over // IRadioBackend::radioChanged, the seam RadioModel::setupBackend wires to @@ -82,7 +87,7 @@ namespace { // wrong. struct ThemeProbe { QString name; - QColor accentBright; + QColor ink; }; ThemeProbe readThemeFile(const QString& path) @@ -93,42 +98,40 @@ ThemeProbe readThemeFile(const QString& path) const QJsonObject root = QJsonDocument::fromJson(f.readAll()).object(); probe.name = root.value(QStringLiteral("name")).toString(); - // scopes.root.tokens.color["accent.bright"], then one {primitive} hop. + // scopes.root.tokens.color.canon.ink, then one {primitive} hop. QString value = root.value(QStringLiteral("scopes")).toObject() .value(QStringLiteral("root")).toObject() .value(QStringLiteral("tokens")).toObject() .value(QStringLiteral("color")).toObject() - .value(QStringLiteral("accent.bright")).toString(); + .value(QStringLiteral("canon")).toObject() + .value(QStringLiteral("ink")).toString(); if (value.startsWith(QLatin1Char('{')) && value.endsWith(QLatin1Char('}'))) { value = root.value(QStringLiteral("primitives")).toObject() .value(value.mid(1, value.size() - 2)).toString(); } - probe.accentBright = QColor(value); + probe.ink = QColor(value); return probe; } -// The value style's SHAPE, with the colour left open. Matching on shape rather -// than on a colour is what lets the set be collected before anything is -// asserted about it: a label that kept a literal is still in the set, and is -// then caught by the colour assertion instead of quietly dropping out of it. -const QRegularExpression& valueSheetPattern() +// A value label is one carrying the Value tone; collecting by the role rather +// than by a list of names keeps the set in step with the file. +bool isValueLabel(const QLabel* label) { - static const QRegularExpression re( - QStringLiteral("^QLabel \\{ color: ([^;]+); font-size: 12px; font-weight: bold; \\}$")); - return re; + return label->property("rsTone").toByteArray() == "value"; } -QColor valueSheetColour(const QLabel* label) +// The colour the window's sheet gave the label, as polished into its palette. +QColor renderedColour(QLabel* label) { - const auto m = valueSheetPattern().match(label->styleSheet()); - return m.hasMatch() ? QColor(m.captured(1).trimmed()) : QColor(); + label->ensurePolished(); + return label->palette().color(QPalette::WindowText); } QList collectValueLabels(QWidget& dialog) { QList out; for (QLabel* l : dialog.findChildren()) { - if (valueSheetPattern().match(l->styleSheet()).hasMatch()) out.append(l); + if (isValueLabel(l)) out.append(l); } return out; } @@ -147,7 +150,7 @@ QString describe(const QLabel* label) } } parts << QStringLiteral("text=\"%1\"").arg(label->text()); - parts << QStringLiteral("qss=%1").arg(label->styleSheet()); + parts << QStringLiteral("tone=%1").arg(QString::fromLatin1(label->property("rsTone").toByteArray())); return parts.join(QStringLiteral(" | ")); } @@ -340,14 +343,12 @@ private slots: QVERIFY(region); QVERIFY(hwVersion); - // CONTROL: the peer carries a real stylesheet, so the comparison below - // is between two set stylesheets and not two empty strings. (The group - // sweeps kLabelStyle onto any label left with an empty stylesheet, so - // "empty" would not even survive construction — but an assertion that - // depends on that sweep to be non-vacuous is not one worth having.) - QVERIFY(!hwVersion->styleSheet().isEmpty()); + // CONTROL: the peer carries a real role, so the comparison below is + // between two set roles and not two empty ones. + QVERIFY(isValueLabel(hwVersion)); - QCOMPARE(region->styleSheet(), hwVersion->styleSheet()); + QCOMPARE(region->property("rsTone"), hwVersion->property("rsTone")); + QCOMPARE(renderedColour(region), renderedColour(hwVersion)); // Centre alignment was the other half of the control look. QCOMPARE(region->alignment(), hwVersion->alignment()); } @@ -411,17 +412,15 @@ private slots: QVERIFY(hwVersion); const QColor token = - ThemeManager::instance().color(region, QStringLiteral("color.accent.bright")); + ThemeManager::instance().color(region, QStringLiteral("color.canon.ink")); QVERIFY(token.isValid()); // CONTROL: the token does not resolve to the retired literal, so the // assertion below can tell a tokenised label from a hardcoded one. If // these ever converge this fails loudly rather than passing vacuously. QVERIFY(token.name().compare(QStringLiteral("#00c8ff"), Qt::CaseInsensitive) != 0); - QVERIFY2(region->styleSheet().contains(token.name(), Qt::CaseInsensitive), - qPrintable(region->styleSheet())); - QVERIFY2(hwVersion->styleSheet().contains(token.name(), Qt::CaseInsensitive), - qPrintable(hwVersion->styleSheet())); + QCOMPARE(renderedColour(region).name(), token.name()); + QCOMPARE(renderedColour(hwVersion).name(), token.name()); } // #5857 — the rest of this dialog's value labels, and the only assertion @@ -447,8 +446,8 @@ private slots: { const ThemeProbe dark = readThemeFile(QStringLiteral(":/themes/default-dark.json")); const ThemeProbe light = readThemeFile(QStringLiteral(":/themes/default-light.json")); - QVERIFY2(dark.accentBright.isValid(), "default-dark.json: color.accent.bright unreadable"); - QVERIFY2(light.accentBright.isValid(), "default-light.json: color.accent.bright unreadable"); + QVERIFY2(dark.ink.isValid(), "default-dark.json: color.canon.ink unreadable"); + QVERIFY2(light.ink.isValid(), "default-light.json: color.canon.ink unreadable"); QVERIFY(!dark.name.isEmpty()); QVERIFY(!light.name.isEmpty()); @@ -457,10 +456,10 @@ private slots: // assertion below would pass on a label that never re-resolved; and if // the dark value were the old literal, a label that had not been // converted at all would pass the first pass. - QVERIFY2(dark.accentBright != light.accentBright, - qPrintable(QStringLiteral("themes agree on color.accent.bright: %1") - .arg(dark.accentBright.name()))); - QVERIFY(dark.accentBright.name().compare(QStringLiteral("#00c8ff"), + QVERIFY2(dark.ink != light.ink, + qPrintable(QStringLiteral("themes agree on color.canon.ink: %1") + .arg(dark.ink.name()))); + QVERIFY(dark.ink.name().compare(QStringLiteral("#00c8ff"), Qt::CaseInsensitive) != 0); ThemeManager& tm = ThemeManager::instance(); @@ -532,17 +531,15 @@ private slots: qPrintable(QStringLiteral("no value label matching %1").arg(field))); } - // Under Default Dark every one of them carries the token's value — not - // the literal, which is one step off it in the blue channel — and is - // registered against the token, which is what makes the switch below - // capable of moving it. + // Under Default Dark every one of them carries the token's value, and + // takes it from the window's sheet: no label has a sheet of its own + // that could pin a colour against the switch below. for (QLabel* l : values) { - QVERIFY2(valueSheetColour(l) == dark.accentBright, + QVERIFY2(renderedColour(l) == dark.ink, qPrintable(QStringLiteral("expected %1 under %2: %3") - .arg(dark.accentBright.name(), dark.name, describe(l)))); - QVERIFY2(tm.tokensForWidget(l).contains(QStringLiteral("color.accent.bright")), - qPrintable(QStringLiteral("not registered against the token: %1") - .arg(describe(l)))); + .arg(dark.ink.name(), dark.name, describe(l)))); + QVERIFY2(l->styleSheet().isEmpty(), + qPrintable(QStringLiteral("carries its own sheet: %1").arg(describe(l)))); } // THE SWITCH. This is the assertion the construction-time one cannot @@ -550,18 +547,18 @@ private slots: // unchanged. QVERIFY2(tm.setActiveTheme(light.name), qPrintable(light.name)); for (QLabel* l : values) { - QVERIFY2(valueSheetColour(l) == light.accentBright, + QVERIFY2(renderedColour(l) == light.ink, qPrintable(QStringLiteral("expected %1 under %2: %3") - .arg(light.accentBright.name(), light.name, describe(l)))); + .arg(light.ink.name(), light.name, describe(l)))); } // And back, so the slot pins a colour that FOLLOWS the theme rather // than one that changed once in one direction. QVERIFY2(tm.setActiveTheme(dark.name), qPrintable(dark.name)); for (QLabel* l : values) { - QVERIFY2(valueSheetColour(l) == dark.accentBright, + QVERIFY2(renderedColour(l) == dark.ink, qPrintable(QStringLiteral("expected %1 back under %2: %3") - .arg(dark.accentBright.name(), dark.name, describe(l)))); + .arg(dark.ink.name(), dark.name, describe(l)))); } } }; diff --git a/tests/tests.cmake b/tests/tests.cmake index cfc1358dd1..14373ed878 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -3557,6 +3557,7 @@ add_executable(firmware_close_dialog_test tests/firmware_close_dialog_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -3580,6 +3581,7 @@ add_executable(flex_control_visibility_test tests/flex_control_visibility_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -3614,6 +3616,7 @@ add_executable(radio_setup_region_field_test tests/radio_setup_region_field_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -3642,6 +3645,7 @@ add_executable(radio_setup_label_theme_token_test tests/radio_setup_label_theme_token_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -3670,6 +3674,7 @@ add_executable(radio_setup_max_power_field_test tests/radio_setup_max_power_field_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -3695,6 +3700,7 @@ add_executable(radio_setup_tx_timing_fields_test tests/radio_setup_tx_timing_fields_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -3721,6 +3727,7 @@ add_executable(radio_setup_recording_mode_dim_test tests/radio_setup_recording_mode_dim_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -3748,6 +3755,7 @@ add_executable(radio_setup_headphone_output_gate_test tests/radio_setup_headphone_output_gate_test.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp @@ -4912,6 +4920,7 @@ add_executable(peripheral_auth_dialog_test tests/fakes/PeripheralAuthStoreFake.cpp src/gui/DragValuePopup.cpp src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp src/gui/RtlReceiverSettingsWidget.cpp src/gui/ControlAvailabilityRegistry.cpp src/gui/RadioSetupDialog_Peripherals.cpp From f754567bf1474943b07233f69a48d281dc972f8e Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Sun, 11 Oct 2026 20:47:10 -0700 Subject: [PATCH 2/7] fix(gui): Radio Setup buttons take their text's width and pack with their labels, and the peripheral source dot is green, yellow or red. Principle VIII. Buttons and layout. Qt's default horizontal policy for a push button (Minimum) let every Radio Setup button grow to fill its grid cell or stretched row. Every page now goes through ButtonsFitContent (installed by wrapTabInScrollArea, re-run on each layout request so buttons added later are covered): - a button left at the default policy becomes Fixed and starts at its cell's left edge; a button given any other policy or a fixed width is left alone; - a grid or a row with nothing left that really grows (an expanding widget not held to one width, a container whose contents grow, or an expanding spacer) gets a trailing expanding spacer, so its contents pack to the left instead of spreading the spare width between columns. A layout that grows or has its own stretch is left as it was; - makeInfoField() keeps a button or status chip at its own width. The CW group puts each row's related buttons in one cell (Iambic Enabled / A / B, CWU / CWL, RX / TX decode) so they stay together, and the Peripherals Add / Remove pair sits together. Source dot. PeripheralConnectionSource::colorToken() gives the indicator dot's colour: DIRECT color.accent.success, RADIO color.accent.warning, OFFLINE color.accent.danger. - The Setup Peripherals list paints only the dot in that colour (a delegate; the item carries its source), the word keeps the list's text colour, and Connecting... has no dot. - The Tuner, Amplifier and Antenna Genius applets colour their indicator from the same helper, so OFFLINE reads red there instead of the disabled grey. - peripheral_auth_dialog_test reads the painted list: OFFLINE red, RADIO yellow, DIRECT green, each with the other two absent. It fails when the delegate is removed. Docs: the Peripherals and TGXL pages give each indicator's colour. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/user/docs/peripherals.md | 6 +- docs/user/docs/tgxl-tuner-control.md | 6 +- src/gui/AmpApplet.cpp | 9 +- src/gui/AntennaGeniusApplet.cpp | 6 +- src/gui/PeripheralConnectionSource.h | 12 ++ src/gui/RadioSetupDialog.cpp | 187 +++++++++++++++++++++-- src/gui/RadioSetupDialog_Peripherals.cpp | 76 ++++++++- src/gui/TunerApplet.cpp | 9 +- tests/peripheral_auth_dialog_test.cpp | 46 ++++++ 9 files changed, 326 insertions(+), 31 deletions(-) diff --git a/docs/user/docs/peripherals.md b/docs/user/docs/peripherals.md index 644cec87cc..a210609970 100644 --- a/docs/user/docs/peripherals.md +++ b/docs/user/docs/peripherals.md @@ -39,9 +39,9 @@ coming from: | Word | Meaning | |---|---| -| `● DIRECT` | Connected directly to the device | -| `● RADIO` | Not connected directly, but the radio relays the device (TGXL and PGXL only) | -| `● OFFLINE` | No connection | +| `● DIRECT` | Connected directly to the device (green dot) | +| `● RADIO` | Not connected directly, but the radio relays the device (TGXL and PGXL only; yellow dot) | +| `● OFFLINE` | No connection (red dot) | | `Connecting…` | A connection attempt is in progress | The TGXL, PGXL and Antenna Genius applets use the same words. A device that diff --git a/docs/user/docs/tgxl-tuner-control.md b/docs/user/docs/tgxl-tuner-control.md index d093c3390a..0027af8f5a 100644 --- a/docs/user/docs/tgxl-tuner-control.md +++ b/docs/user/docs/tgxl-tuner-control.md @@ -16,9 +16,9 @@ using with an indicator at the end of its readout row: | Indicator | Path | |---|---| -| `● DIRECT` | A direct TCP connection to the tuner on port 9010 | -| `● RADIO` | Through the FlexRadio, which relays the tuner's status and commands | -| `● OFFLINE` | Neither is available | +| `● DIRECT` | A direct TCP connection to the tuner on port 9010 (green) | +| `● RADIO` | Through the FlexRadio, which relays the tuner's status and commands (yellow) | +| `● OFFLINE` | Neither is available (red) | When a TGXL is connected to your FlexRadio, the radio reports its address and AetherSDR opens the direct connection automatically. For a tuner the radio diff --git a/src/gui/AmpApplet.cpp b/src/gui/AmpApplet.cpp index 4bc58f38bb..424ec11651 100644 --- a/src/gui/AmpApplet.cpp +++ b/src/gui/AmpApplet.cpp @@ -965,10 +965,11 @@ void AmpApplet::applyTelemetryStyles(qreal scale) } theme.applyStyleSheet(m_sourceLabel, QStringLiteral( - "QLabel { color: %1; font-size: %2px; }") - .arg(m_directConnected ? QStringLiteral("{{color.accent.success}}") - : hasRadioRelay() ? QStringLiteral("{{color.accent.warning}}") - : QStringLiteral("{{color.text.disabled}}")) + "QLabel { color: {{%1}}; font-size: %2px; }") + .arg(PeripheralConnectionSource::colorToken( + m_directConnected ? PeripheralConnectionSource::Source::Direct + : hasRadioRelay() ? PeripheralConnectionSource::Source::Radio + : PeripheralConnectionSource::Source::Offline)) .arg(f ? px(11) : 9)); // Docked, the indicator shares a row with Vdd, both centred. Its smaller diff --git a/src/gui/AntennaGeniusApplet.cpp b/src/gui/AntennaGeniusApplet.cpp index 998b19d519..f28c403bd2 100644 --- a/src/gui/AntennaGeniusApplet.cpp +++ b/src/gui/AntennaGeniusApplet.cpp @@ -303,9 +303,9 @@ void AntennaGeniusApplet::setConnectionSource(bool connected) m_sourceLabel->setAccessibleName(text.accessibleName); m_sourceLabel->setAccessibleDescription(text.description); AetherSDR::ThemeManager::instance().applyStyleSheet(m_sourceLabel, - QStringLiteral("QLabel { color: %1; font-size: 9px; }") - .arg(connected ? QStringLiteral("{{color.accent.success}}") - : QStringLiteral("{{color.text.disabled}}"))); + QStringLiteral("QLabel { color: {{%1}}; font-size: 9px; }") + .arg(PeripheralConnectionSource::colorToken( + connected ? Source::Direct : Source::Offline))); } void AntennaGeniusApplet::setModel(AntennaGeniusModel* model) diff --git a/src/gui/PeripheralConnectionSource.h b/src/gui/PeripheralConnectionSource.h index 813e0b48af..5d4de5ffef 100644 --- a/src/gui/PeripheralConnectionSource.h +++ b/src/gui/PeripheralConnectionSource.h @@ -53,6 +53,18 @@ inline Text describe(Source source, const QString& device, bool directOnly = fal return out; } +// The indicator dot's colour token: a direct connection is green, the radio +// relay yellow, no connection red. +inline QString colorToken(Source source) +{ + switch (source) { + case Source::Direct: return QStringLiteral("color.accent.success"); + case Source::Radio: return QStringLiteral("color.accent.warning"); + case Source::Offline: break; + } + return QStringLiteral("color.accent.danger"); +} + } // namespace PeripheralConnectionSource // What Setup shows for one peripheral. Carried explicitly: nothing reads the diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 48d6fc4209..ee380c9548 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -56,6 +56,7 @@ #include #include #include +#include #include #include #include @@ -283,8 +284,153 @@ static void settleNavigationLayout(QTreeWidget* tree, bool changed) // past the screen edge (#3345). setWidgetResizable(true) keeps horizontal // expansion intact and hides the scrollbar when content already fits — users // on wide screens see no visual change. +// A push button takes the width its text needs, never its row's. Qt's default +// horizontal policy (Minimum) lets a button grow to fill a grid cell or a +// stretched row; this turns that default to Fixed on every button of a page, +// including buttons added later (a KiwiSDR row, a transverter tab), by +// re-running on each layout request. A button given any other policy, or a +// fixed width, is left alone. +class ButtonsFitContent : public QObject { +public: + explicit ButtonsFitContent(QWidget* page) : QObject(page), m_page(page) + { + fit(); + page->installEventFilter(this); + } + +protected: + bool eventFilter(QObject* watched, QEvent* event) override + { + if (watched == m_page && event->type() == QEvent::LayoutRequest) { + fit(); + } + return QObject::eventFilter(watched, event); + } + +private: + void fit() + { + fitButtons(); + // With buttons at their own width, a grid or a row with nothing left + // that can grow would spread its spare width between its columns and + // push related buttons apart. Give that width to the end instead, so + // the contents pack to the left as they read. A layout that can grow + // (a field, a slider), or that already has its own stretch, is left + // as it was. + packLayout(m_page->layout()); + for (QWidget* child : m_page->findChildren()) { + packLayout(child->layout()); + } + } + + void packLayout(QLayout* layout) + { + if (!layout || layout->property("rsPacked").toBool()) { + return; + } + for (int i = 0; i < layout->count(); ++i) { + packLayout(layout->itemAt(i)->layout()); + } + if (canGrowHorizontally(layout)) { + return; + } + if (auto* grid = qobject_cast(layout)) { + for (int c = 0; c < grid->columnCount(); ++c) { + if (grid->columnStretch(c) > 0) { + return; + } + } + layout->setProperty("rsPacked", true); + // A real spacer, not a stretch on an empty column: QGridLayout + // gives no width to a column with nothing in it. + grid->addItem(new QSpacerItem(0, 0, QSizePolicy::Expanding, QSizePolicy::Minimum), + 0, grid->columnCount()); + } else if (auto* box = qobject_cast(layout)) { + if (box->direction() != QBoxLayout::LeftToRight) { + return; + } + for (int i = 0; i < box->count(); ++i) { + if (box->stretch(i) > 0) { + return; + } + } + layout->setProperty("rsPacked", true); + box->addStretch(1); + } + } + + // Whether anything in `layout` really takes more width: an expanding + // widget that is not held to one width (a field given setFixedWidth keeps + // its Expanding policy), or an expanding spacer. + static bool canGrowHorizontally(QLayout* layout) + { + for (int i = 0; i < layout->count(); ++i) { + QLayoutItem* item = layout->itemAt(i); + if (QWidget* w = item->widget()) { + if (w->isHidden() || w->maximumWidth() <= w->minimumWidth()) { + continue; + } + // A plain container (a field with its buttons) grows when + // what it holds does. + if ((w->sizePolicy().expandingDirections() & Qt::Horizontal) + || (w->layout() && !qobject_cast(w) + && canGrowHorizontally(w->layout()))) { + return true; + } + } else if (QLayout* inner = item->layout()) { + if (canGrowHorizontally(inner)) { + return true; + } + } else if (item->expandingDirections() & Qt::Horizontal) { + return true; + } + } + return false; + } + + void fitButtons() + { + for (QPushButton* button : m_page->findChildren()) { + QSizePolicy policy = button->sizePolicy(); + if (policy.horizontalPolicy() == QSizePolicy::Minimum) { + policy.setHorizontalPolicy(QSizePolicy::Fixed); + button->setSizePolicy(policy); + // A fixed button in a wider cell would centre; it starts at + // the cell's left edge like the field beside it would. + if (QWidget* parent = button->parentWidget()) { + alignLeftIfUnset(parent->layout(), button); + } + } + } + } + + static bool alignLeftIfUnset(QLayout* layout, QWidget* widget) + { + if (!layout) { + return false; + } + for (int i = 0; i < layout->count(); ++i) { + QLayoutItem* item = layout->itemAt(i); + if (item->widget() == widget) { + if (!(item->alignment() & Qt::AlignHorizontal_Mask)) { + item->setAlignment(item->alignment() | Qt::AlignLeft); + layout->invalidate(); + } + return true; + } + if (alignLeftIfUnset(item->layout(), widget)) { + return true; + } + } + return false; + } + + QWidget* m_page; +}; + static QWidget* wrapTabInScrollArea(QWidget* content) { + new ButtonsFitContent(content); auto* scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); @@ -504,6 +650,12 @@ static QWidget* makeInfoField(const QString& labelText, QWidget* valueWidget, applyLabelStyle(label); label->setFixedWidth(labelWidth); layout->addWidget(label); + if (qobject_cast(valueWidget) || valueWidget->property("rsChip").isValid()) { + // A button or a status chip takes its text's width, at the left. + layout->addWidget(valueWidget); + layout->addStretch(1); + return wrapper; + } QSizePolicy policy = valueWidget->sizePolicy(); if (policy.horizontalPolicy() != QSizePolicy::Fixed) { policy.setHorizontalPolicy(QSizePolicy::Expanding); @@ -3135,7 +3287,12 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() iamBtn->setText(on ? "Enabled" : "Disabled"); m_model->transmitModel().setCwIambic(on); }); - grid->addWidget(iamBtn, 0, 1); + // Each row's related buttons share one cell, so they sit together + // whatever their widths; the second label column starts after them. + auto* iambicRow = new QHBoxLayout; + iambicRow->setSpacing(6); + iambicRow->addWidget(iamBtn); + grid->addLayout(iambicRow, 0, 1); auto* modeA = mkTogBtn("A", tx.cwIambicMode() == 0); auto* modeB = mkTogBtn("B", tx.cwIambicMode() == 1); connect(modeA, &QPushButton::clicked, this, [this, modeA, modeB] { @@ -3146,18 +3303,18 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() modeA->setChecked(false); modeB->setChecked(true); m_model->transmitModel().setCwIambicMode(1); }); - grid->addWidget(modeA, 0, 2); - grid->addWidget(modeB, 0, 3); + iambicRow->addWidget(modeA); + iambicRow->addWidget(modeB); // Swap: Dot/Dash button auto* swapLbl = new QLabel("Swap:"); applyLabelStyle(swapLbl); - grid->addWidget(swapLbl, 0, 4); + grid->addWidget(swapLbl, 0, 2); auto* swapBtn = mkTogBtn("Dot/Dash", tx.cwSwapPaddles()); connect(swapBtn, &QPushButton::toggled, this, [this](bool on) { m_model->transmitModel().setCwSwapPaddles(on); }); - grid->addWidget(swapBtn, 0, 5); + grid->addWidget(swapBtn, 0, 3); // Sideband: CWU | CWL auto* sbLbl = new QLabel("Sideband:"); @@ -3173,18 +3330,21 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() cwuBtn->setChecked(false); cwlBtn->setChecked(true); m_model->transmitModel().setCwlEnabled(true); }); - grid->addWidget(cwuBtn, 1, 1); - grid->addWidget(cwlBtn, 1, 2); + auto* sidebandRow = new QHBoxLayout; + sidebandRow->setSpacing(6); + sidebandRow->addWidget(cwuBtn); + sidebandRow->addWidget(cwlBtn); + grid->addLayout(sidebandRow, 1, 1); // CWX: Sync auto* cwxLbl = new QLabel("CWX:"); applyLabelStyle(cwxLbl); - grid->addWidget(cwxLbl, 1, 4); + grid->addWidget(cwxLbl, 1, 2); auto* syncBtn = mkTogBtn("Sync", tx.syncCwx()); connect(syncBtn, &QPushButton::toggled, this, [this](bool on) { m_model->sendCommand(QString("cw synccwx %1").arg(on ? 1 : 0)); }); - grid->addWidget(syncBtn, 1, 5); + grid->addWidget(syncBtn, 1, 3); // CW Decode — independent RX / TX toggles (#2417). RX keeps the // legacy behaviour of decoding the received CW slice; TX decodes @@ -3194,7 +3354,7 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() // dialog close via refreshCwDecodeState(). auto* decodeLbl = new QLabel("Decode:"); applyLabelStyle(decodeLbl); - grid->addWidget(decodeLbl, 2, 4); + grid->addWidget(decodeLbl, 2, 2); auto* rxDecodeBtn = mkTogBtn("RX", CwDecodeSettings::rxEnabled()); auto* txDecodeBtn = mkTogBtn("TX", CwDecodeSettings::txEnabled()); connect(rxDecodeBtn, &QPushButton::toggled, this, [](bool on) { @@ -3203,8 +3363,11 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() connect(txDecodeBtn, &QPushButton::toggled, this, [](bool on) { CwDecodeSettings::setTxEnabled(on); }); - grid->addWidget(rxDecodeBtn, 2, 5); - grid->addWidget(txDecodeBtn, 2, 6); + auto* decodeRow = new QHBoxLayout; + decodeRow->setSpacing(6); + decodeRow->addWidget(rxDecodeBtn); + decodeRow->addWidget(txDecodeBtn); + grid->addLayout(decodeRow, 2, 3); diff --git a/src/gui/RadioSetupDialog_Peripherals.cpp b/src/gui/RadioSetupDialog_Peripherals.cpp index baa61cc1b4..fd70425405 100644 --- a/src/gui/RadioSetupDialog_Peripherals.cpp +++ b/src/gui/RadioSetupDialog_Peripherals.cpp @@ -34,6 +34,9 @@ #include "models/TunerModel.h" #include +#include +#include +#include #include #include #include @@ -174,6 +177,72 @@ struct PeripheralDeviceUi { namespace { +// The device list's line three starts with the connection-source dot. The item +// carries its source under kSourceRole (-1 while connecting, when there is no +// dot), and this delegate paints that dot in the source's colour token; the +// rest of the line keeps the list's text colour. +constexpr int kSourceRole = Qt::UserRole + 1; + +class PeripheralListDelegate : public QStyledItemDelegate { +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override + { + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + const QString text = opt.text; + const QVariant source = index.data(kSourceRole); + const QString dot = QStringLiteral("● "); + // initStyleOption() has turned the item's '\n' breaks into line + // separators (U+2028), which is what the style lays out. + auto isBreak = [](QChar c) { + return c == QLatin1Char('\n') || c == QChar(QChar::LineSeparator); + }; + int lineStart = 0; + int lines = 1; + for (int i = 0; i < text.size(); ++i) { + if (isBreak(text.at(i))) { + lineStart = i + 1; + ++lines; + } + } + if (!source.isValid() || source.toInt() < 0 + || text.mid(lineStart, dot.size()) != dot) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + // The item as usual with the dot's glyph blanked to a space of the + // same advance, then the dot drawn in its colour at that position. + QString blanked = text; + blanked[lineStart] = QChar(0x2007); // figure space + opt.text = blanked; + const QWidget* widget = opt.widget; + QStyle* style = widget ? widget->style() : QApplication::style(); + style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, widget); + + const QRect textRect = style->subElementRect(QStyle::SE_ItemViewItemText, &opt, widget); + const QFontMetrics fm(opt.font); + const int blockHeight = lines * fm.lineSpacing(); + int top = textRect.top(); + if (opt.displayAlignment & Qt::AlignVCenter) { + top += (textRect.height() - blockHeight) / 2; + } + const int margin = style->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, widget) + 1; + const QPoint at(textRect.left() + margin, + top + (lines - 1) * fm.lineSpacing() + fm.ascent()); + using PeripheralConnectionSource::Source; + const QColor colour = ThemeManager::instance().color( + PeripheralConnectionSource::colorToken(static_cast(source.toInt()))); + painter->save(); + painter->setFont(opt.font); + painter->setPen(colour); + painter->drawText(at, QStringLiteral("●")); + painter->restore(); + } +}; + QString peripheralTr(const char* text) { return RadioSetupDialog::tr(text); @@ -1634,6 +1703,7 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() deviceList->setAccessibleName(tr("Configured peripherals")); deviceList->setAlternatingRowColors(true); deviceList->setSpacing(3); + deviceList->setItemDelegate(new PeripheralListDelegate(deviceList)); deviceList->setMinimumHeight(200); deviceList->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); listLayout->addWidget(deviceList); @@ -1651,8 +1721,9 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() removeButton->setAccessibleDescription(tr("Select a configured peripheral to remove it")); removeButton->setToolTip(tr("Remove this device and clear its saved connection details")); removeButton->setEnabled(false); - listActions->addWidget(addButton, 1); - listActions->addWidget(removeButton, 1); + listActions->addWidget(addButton); + listActions->addWidget(removeButton); + listActions->addStretch(1); listLayout->addLayout(listActions); auto* removalNotice = new QLabel(listGroup); removalNotice->setObjectName(QStringLiteral("peripheralRemovalNotice")); @@ -2197,6 +2268,7 @@ QWidget* RadioSetupDialog::buildPeripheralsTab() continue; } item->setText(ui.label + QLatin1Char('\n') + address + QLatin1Char('\n') + stateWord); + item->setData(kSourceRole, connecting ? -1 : static_cast(ui.state.source)); item->setToolTip(address + QLatin1Char('\n') + statusText(ui)); item->setData(Qt::AccessibleTextRole, ui.label + QStringLiteral(": ") + address + QStringLiteral(", ") + spokenWord); diff --git a/src/gui/TunerApplet.cpp b/src/gui/TunerApplet.cpp index a970cfb940..d706e2387f 100644 --- a/src/gui/TunerApplet.cpp +++ b/src/gui/TunerApplet.cpp @@ -701,10 +701,11 @@ void TunerApplet::applyDensityAtScale(qreal scale) void TunerApplet::applySourceIndicatorStyle(qreal scale) { AetherSDR::ThemeManager::instance().applyStyleSheet(m_sourceLabel, - QStringLiteral("QLabel { color: %1; font-size: %2px; }") - .arg(m_directConnected ? QStringLiteral("{{color.accent.success}}") - : hasRadioRelay() ? QStringLiteral("{{color.accent.warning}}") - : QStringLiteral("{{color.text.disabled}}")) + QStringLiteral("QLabel { color: {{%1}}; font-size: %2px; }") + .arg(PeripheralConnectionSource::colorToken( + m_directConnected ? PeripheralConnectionSource::Source::Direct + : hasRadioRelay() ? PeripheralConnectionSource::Source::Radio + : PeripheralConnectionSource::Source::Offline)) .arg(m_floating ? qMax(1, qRound(11 * scale)) : 9)); } diff --git a/tests/peripheral_auth_dialog_test.cpp b/tests/peripheral_auth_dialog_test.cpp index 4e2c9b0900..768000f0da 100644 --- a/tests/peripheral_auth_dialog_test.cpp +++ b/tests/peripheral_auth_dialog_test.cpp @@ -8,6 +8,8 @@ #include "core/LpMeterConnection.h" #include "core/AppSettings.h" #include "core/PeripheralSettings.h" +#include "core/ThemeManager.h" +#include "gui/PeripheralConnectionSource.h" #include "gui/RadioSetupDialog.h" #include "gui/AntennaGeniusApplet.h" #include "gui/ShackSwitchApplet.h" @@ -1809,6 +1811,23 @@ bool checkConnectAutomaticallyGates() // What Setup shows follows the device's explicit status, never text, and the // page is signal-driven (no presentation timer). +// Pixels of exactly the colour a theme token resolves to, in the list as +// painted: the connection-source dot is drawn in its token, solid at its core. +int pixelsOfToken(QWidget* widget, const char* token) +{ + const QColor colour = AetherSDR::ThemeManager::instance().color(QString::fromLatin1(token)); + const QImage image = widget->grab().toImage().convertToFormat(QImage::Format_ARGB32); + int n = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (image.pixel(x, y) == colour.rgb()) { + ++n; + } + } + } + return n; +} + bool checkStatusPresentation() { AppSettings::instance().remove(QStringLiteral("Peripherals")); @@ -1835,6 +1854,33 @@ bool checkStatusPresentation() std::fprintf(stderr, "Idle status not shown on the detail page\n"); return false; } + // The dot before the source word follows the connection: OFFLINE red, + // RADIO yellow, DIRECT green. Each state is read against the other two. + { + using PeripheralConnectionSource::Source; + struct Expect { Source source; const char* on; const char* off1; const char* off2; }; + for (const Expect e : {Expect{Source::Offline, "color.accent.danger", + "color.accent.success", "color.accent.warning"}, + Expect{Source::Radio, "color.accent.warning", + "color.accent.success", "color.accent.danger"}, + Expect{Source::Direct, "color.accent.success", + "color.accent.warning", "color.accent.danger"}}) { + // The refresh derives the source from the live connection, which a + // bare model has none of: OFFLINE arrives that way, and the other + // two are set on the item the refresh writes them to. + if (e.source != Source::Offline) { + list->item(0)->setData(Qt::UserRole + 1, static_cast(e.source)); + } + QCoreApplication::processEvents(); + const int on = pixelsOfToken(list, e.on); + if (on <= 0 || pixelsOfToken(list, e.off1) != 0 || pixelsOfToken(list, e.off2) != 0) { + std::fprintf(stderr, "Source dot not drawn in %s (%d pixels): %s\n", + e.on, on, qPrintable(list->item(0)->text())); + return false; + } + } + pokeRefresh(model); + } // A message that happens to start with "Error:" or hold a dash changes nothing // by itself; only the explicit attention does. dialog.editPeripheralStatusForTest(QStringLiteral("tgxl"), [](PeripheralDeviceStatus& state) { From a05eebe45b47c8f08260056363c0e25160fd3ddf Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Mon, 12 Oct 2026 07:46:34 -0700 Subject: [PATCH 3/7] fix(gui): Radio Setup is a fixed 960 px wide; only its height resizes. Principle VIII. - RadioSetupDialog: setFixedWidth(960) and a 720 minimum height replace the 960x720 minimum, so a wider saved geometry restores at 960. - FramelessResizer::withoutLockedAxes(): an axis whose minimum equals its maximum is not a resize zone, so a fixed-width window shows no horizontal cursor at its sides and its corners resize vertically. - radio_setup_window_size_test pins the fixed width, a resize keeping 960 and a wider saved geometry restoring at 960; frameless_resizer_test pins withoutLockedAxes(). Both go red with the change reverted. - radio-setup.md: the window's height resizes, its width is fixed. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/user/docs/radio-setup.md | 2 +- src/gui/FramelessResizer.cpp | 17 +++++- src/gui/FramelessResizer.h | 7 +++ src/gui/RadioSetupDialog.cpp | 5 +- tests/frameless_resizer_test.cpp | 25 +++++++++ tests/radio_setup_window_size_test.cpp | 78 ++++++++++++++++++++++++++ tests/tests.cmake | 24 ++++++++ 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tests/radio_setup_window_size_test.cpp diff --git a/docs/user/docs/radio-setup.md b/docs/user/docs/radio-setup.md index af8e7826be..e3d05765dd 100644 --- a/docs/user/docs/radio-setup.md +++ b/docs/user/docs/radio-setup.md @@ -25,7 +25,7 @@ Several menu items open Radio Setup straight at the right page: - **Settings → USB Cables...** → USB Cables - **Tools → Configure KiwiSDR...** → Antennas -The window has no title bar. Drag any empty part of it to move it, and drag an edge or corner to resize it; it reopens at the size and place you left it. Close it with the round **✕** in the top-right corner, **Esc** or **Ctrl+W** (**⌘W** on macOS). It's a window of its own, with its own taskbar entry, so you can minimize it from the taskbar or park it on another monitor. **View → Frameless Window** does not apply to it. +The window has no title bar. Drag any empty part of it to move it, and drag its top or bottom edge to make it taller or shorter; its width is fixed. It reopens at the height and place you left it. Close it with the round **✕** in the top-right corner, **Esc** or **Ctrl+W** (**⌘W** on macOS). It's a window of its own, with its own taskbar entry, so you can minimize it from the taskbar or park it on another monitor. **View → Frameless Window** does not apply to it. On/off buttons (**Enabled** / **Disabled**) fill green when on. Where you pick one of a set (**DHCP** / **Static**, **Radio Side** / **Client Side**, **CWU** / **CWL**), the chosen one fills cyan. A control the connected radio cannot use is dimmed, never hidden. diff --git a/src/gui/FramelessResizer.cpp b/src/gui/FramelessResizer.cpp index 5fdf8500d0..079e5613ed 100644 --- a/src/gui/FramelessResizer.cpp +++ b/src/gui/FramelessResizer.cpp @@ -109,10 +109,23 @@ bool FramelessResizer::shouldEndOnUngrabbedLeave(bool manualResizeActive, return manualResizeActive && !manualResizeGrabbed; } +// static +Qt::Edges FramelessResizer::withoutLockedAxes(Qt::Edges edges, const QSize& minimum, + const QSize& maximum) +{ + if (minimum.width() == maximum.width()) + edges &= ~(Qt::LeftEdge | Qt::RightEdge); + if (minimum.height() == maximum.height()) + edges &= ~(Qt::TopEdge | Qt::BottomEdge); + return edges; +} + Qt::Edges FramelessResizer::edgesAt(const QPoint& p) const { - return computeEdges(m_window->rect(), p, m_margin, m_topMoveReserve, - m_window->isMaximized() || m_window->isFullScreen()); + return withoutLockedAxes( + computeEdges(m_window->rect(), p, m_margin, m_topMoveReserve, + m_window->isMaximized() || m_window->isFullScreen()), + m_window->minimumSize(), m_window->maximumSize()); } // Known limitation: setOverrideCursor()/restoreOverrideCursor() is one global diff --git a/src/gui/FramelessResizer.h b/src/gui/FramelessResizer.h index 1a974ff64d..40055a0106 100644 --- a/src/gui/FramelessResizer.h +++ b/src/gui/FramelessResizer.h @@ -65,6 +65,13 @@ class FramelessResizer : public QObject { static Qt::Edges computeEdges(const QRect& rect, const QPoint& p, int margin, int topMoveReserve, bool maximizedOrFullscreen); + // `edges` without the ones on an axis the window cannot resize along: a + // fixed width (minimum == maximum) drops Left/Right, a fixed height drops + // Top/Bottom. Without it a fixed-width window still shows a horizontal + // resize cursor at its sides and starts a drag that cannot move. + static Qt::Edges withoutLockedAxes(Qt::Edges edges, const QSize& minimum, + const QSize& maximum); + // The decision QEvent::Leave applies when a manual resize is active but // never got a real mouse grab: true means end the drag right there, // because without a grab we stop receiving any further events for it — diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index ee380c9548..473fc760d7 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -1159,7 +1159,10 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, // so saved geometry carries over; it resizes from every edge. 40 px taller // than before for the canon header. setGeometryKey(QStringLiteral("RadioSetupDialogGeometry")); - setMinimumSize(960, 720); + // The width is fixed: the pages are laid out for it. The height is the + // operator's, from 720 up. + setFixedWidth(960); + setMinimumHeight(720); FramelessResizer::install(this); bodyWidget()->setObjectName(QStringLiteral("radioSetupBody")); applyCanonSheet(bodyWidget(), [] { diff --git a/tests/frameless_resizer_test.cpp b/tests/frameless_resizer_test.cpp index ccceba41b1..6febab4687 100644 --- a/tests/frameless_resizer_test.cpp +++ b/tests/frameless_resizer_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -226,6 +227,29 @@ void testComputeEdges() == Qt::Edges{}); } +// A window with a fixed width (Radio Setup) resizes only vertically: its side +// edges and the horizontal half of each corner are not resize zones. +void testWithoutLockedAxes() +{ + const Qt::Edges all = Qt::LeftEdge | Qt::RightEdge | Qt::TopEdge | Qt::BottomEdge; + const QSize unbounded(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); + report("withoutLockedAxes: a free window keeps every edge", + FramelessResizer::withoutLockedAxes(all, QSize(100, 100), unbounded) == all); + report("withoutLockedAxes: fixed width drops Left and Right", + FramelessResizer::withoutLockedAxes(all, QSize(960, 720), + QSize(960, QWIDGETSIZE_MAX)) + == (Qt::TopEdge | Qt::BottomEdge)); + report("withoutLockedAxes: fixed width turns the bottom-right corner vertical", + FramelessResizer::withoutLockedAxes(Qt::BottomEdge | Qt::RightEdge, + QSize(960, 720), + QSize(960, QWIDGETSIZE_MAX)) + == Qt::BottomEdge); + report("withoutLockedAxes: fixed height drops Top and Bottom", + FramelessResizer::withoutLockedAxes(all, QSize(100, 300), + QSize(QWIDGETSIZE_MAX, 300)) + == (Qt::LeftEdge | Qt::RightEdge)); +} + // The decision behind the #4829 review-round-3 fix: an active manual resize // with no real grab must end itself on Leave, or it strands m_manualResizeActive // true until (at best) a buttonless move eventually arrives. @@ -279,6 +303,7 @@ int main(int argc, char** argv) testCeilingClamp(); testNoEdges(); testComputeEdges(); + testWithoutLockedAxes(); testShouldEndOnUngrabbedLeave(); testWindowOwnsChain(); diff --git a/tests/radio_setup_window_size_test.cpp b/tests/radio_setup_window_size_test.cpp new file mode 100644 index 0000000000..57f64b6b65 --- /dev/null +++ b/tests/radio_setup_window_size_test.cpp @@ -0,0 +1,78 @@ +// Radio Setup is 960 px wide, always; the operator sets only its height. +// +// Pinned here, each with its control: +// 1. the width is fixed at 960 (minimum == maximum) and the height runs from +// 720 up; +// 2. a resize asking for another width keeps 960 and takes the height; +// 3. a geometry saved at another width (before the width was fixed) is +// restored at 960, with its height. +// +// No hardware and no transport: a bare RadioModel, never connected. + +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "gui/RadioSetupDialog.h" +#include "models/RadioModel.h" + +#include +#include + +using namespace AetherSDR; + +namespace { +const char* const kGeometryKey = "RadioSetupDialogGeometry"; +} + +class RadioSetupWindowSizeTest : public QObject { + Q_OBJECT +private slots: + + void theWidthIsFixedAndTheHeightIsFree() + { + RadioModel model; + RadioSetupDialog dialog(&model); + QCOMPARE(dialog.minimumWidth(), 960); + QCOMPARE(dialog.maximumWidth(), 960); + QCOMPARE(dialog.minimumHeight(), 720); + QCOMPARE(dialog.maximumHeight(), QWIDGETSIZE_MAX); + } + + void aResizeKeepsTheWidthAndTakesTheHeight() + { + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + dialog.resize(1300, 900); + QTRY_COMPARE(dialog.size(), QSize(960, 900)); + dialog.resize(700, 800); + QTRY_COMPARE(dialog.size(), QSize(960, 800)); + } + + void aWiderSavedGeometryRestoresAt960() + { + auto& settings = AppSettings::instance(); + // Saved by the window when its width was free: x,y,w,h. + settings.setValue(QLatin1String(kGeometryKey), QStringLiteral("100,100,1400,850")); + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + // CONTROL: the restore ran (the saved height arrived), so the width + // below is the clamp, not a restore that never happened. + QTRY_COMPARE(dialog.height(), 850); + QCOMPARE(dialog.width(), 960); + dialog.hide(); + settings.remove(QLatin1String(kGeometryKey)); + } +}; + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("radio-setup-window-size")); + QApplication app(argc, argv); + app.setQuitOnLastWindowClosed(false); + RadioSetupWindowSizeTest test; + return QTest::qExec(&test, argc, argv); +} +#include "radio_setup_window_size_test.moc" diff --git a/tests/tests.cmake b/tests/tests.cmake index 14373ed878..eefc7bf4cd 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -3664,6 +3664,29 @@ add_test(NAME radio_setup_label_theme_token_test COMMAND radio_setup_label_theme set_tests_properties(radio_setup_label_theme_token_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) +add_executable(radio_setup_window_size_test + tests/radio_setup_window_size_test.cpp + src/gui/DragValuePopup.cpp + src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp + src/gui/RtlReceiverSettingsWidget.cpp + src/gui/ControlAvailabilityRegistry.cpp + src/gui/RadioSetupDialog_Peripherals.cpp + src/gui/PersistentDialog.cpp + src/gui/FramelessResizer.cpp + src/gui/FramelessWindowTitleBar.cpp + src/gui/SliceColorManager.cpp + src/gui/KiwiPublicReceiverPicker.cpp + src/gui/GuardedSlider.h + ${THEME_TEST_RESOURCES} +) +target_include_directories(radio_setup_window_size_test PRIVATE src tests) +target_link_libraries(radio_setup_window_size_test PRIVATE + aetherdesktop_support Qt6::Widgets Qt6::Test) +add_test(NAME radio_setup_window_size_test COMMAND radio_setup_window_size_test) +set_tests_properties(radio_setup_window_size_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) + # #5637 §3: Radio Setup's Max Power field. A watt ceiling from the backend's # txPowerBands (HL2) reads W once reported (nothing before), read-only with an # accessible reason where there is no command plane to write it; the Flex path @@ -8454,6 +8477,7 @@ set(AETHER_SETTINGS_CONSUMERS radio_setup_region_field_test radio_setup_tx_timing_fields_test radio_setup_label_theme_token_test + radio_setup_window_size_test radio_setup_max_power_field_test radio_setup_recording_mode_dim_test radio_setup_headphone_output_gate_test From b7f9139cb2b2d214315f09a948d002eaaec9cb13 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Mon, 12 Oct 2026 07:57:31 -0700 Subject: [PATCH 4/7] fix(gui): Radio Setup spreads side-by-side caption: value pairs across the window. Principle VIII. A grid of two or more caption: value pairs side by side (GPS, Transmit Timings and Interlocks, Serial Port Configuration, Phone & CW's CW) packed against the left edge, leaving the right of the page empty. Its value columns now share the spare width equally, so each pair starts its own column across the page, and each value keeps its own width at the left of its cell, beside its caption. A grid is a pair grid when it has an even number of columns, at least four, and every even column starts with a caption, nothing, or a value spanning the caption's place. Serial's Refresh button sits beside the port combo it rescans rather than in the second pair column. radio_setup_pair_grid_test pins GPS and Transmit Timings: the second pair starts in the page's right half, the pair columns line up, and a caption sits within 24 px of its value. It goes red with the spreading disabled. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/gui/RadioSetupDialog.cpp | 48 ++++++++- tests/radio_setup_pair_grid_test.cpp | 147 +++++++++++++++++++++++++++ tests/tests.cmake | 24 +++++ 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 tests/radio_setup_pair_grid_test.cpp diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 473fc760d7..c206436494 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -341,6 +341,24 @@ class ButtonsFitContent : public QObject { } } layout->setProperty("rsPacked", true); + // Side-by-side caption: value pairs share the width: each value + // column takes an equal part of it, so every pair starts its own + // column across the page and a caption stays beside its value. + if (isPairGrid(grid)) { + for (int c = 1; c < grid->columnCount(); c += 2) { + grid->setColumnStretch(c, 1); + } + // A value keeps its own width at the left of its wider cell. + for (int i = 0; i < grid->count(); ++i) { + QLayoutItem* item = grid->itemAt(i); + if (!qobject_cast(item->widget()) + && !(item->alignment() & Qt::AlignHorizontal_Mask)) { + item->setAlignment(item->alignment() | Qt::AlignLeft); + } + } + grid->invalidate(); + return; + } // A real spacer, not a stretch on an empty column: QGridLayout // gives no width to a column with nothing in it. grid->addItem(new QSpacerItem(0, 0, QSizePolicy::Expanding, QSizePolicy::Minimum), @@ -359,6 +377,26 @@ class ButtonsFitContent : public QObject { } } + // Two or more caption: value pairs side by side: an even number of + // columns, at least four, and at the start of every even column a caption + // (a QLabel), nothing, or a value spanning the caption's place as well. + static bool isPairGrid(QGridLayout* grid) + { + const int columns = grid->columnCount(); + if (columns < 4 || columns % 2 != 0) { + return false; + } + for (int i = 0; i < grid->count(); ++i) { + int row = 0, column = 0, rowSpan = 0, columnSpan = 0; + grid->getItemPosition(i, &row, &column, &rowSpan, &columnSpan); + QLayoutItem* item = grid->itemAt(i); + if (column % 2 == 0 && columnSpan < 2 && !qobject_cast(item->widget())) { + return false; + } + } + return true; + } + // Whether anything in `layout` really takes more width: an expanding // widget that is not held to one width (a field given setFixedWidth keeps // its Expanding policy), or an expanding spacer. @@ -8019,13 +8057,19 @@ QWidget* RadioSetupDialog::buildSerialTab() auto* customEdit = new QLineEdit; customEdit->setPlaceholderText("/dev/ttyr0"); bool isCustom = populateSerialPortCombo(portCombo, customEdit, savedPort); - grid->addWidget(portCombo, 0, 1); + // Refresh sits beside the port it rescans. The row keeps its own + // width (rsPacked: no trailing stretch), so the grid still spreads + // its caption: value pairs. + auto* portRow = new QHBoxLayout; + portRow->setProperty("rsPacked", true); + portRow->addWidget(portCombo); + grid->addLayout(portRow, 0, 1, 1, 3); auto* refreshBtn = new QPushButton("Refresh"); // Let native style (notably macOS) drive the button height; a fixed // 24 px clipped the macOS button bezel and label baseline. refreshBtn->setMinimumHeight(24); - grid->addWidget(refreshBtn, 0, 3); + portRow->addWidget(refreshBtn); // Custom port row — hidden unless "Custom..." selected or saved port is custom auto* customLabel = new QLabel("Path:"); diff --git a/tests/radio_setup_pair_grid_test.cpp b/tests/radio_setup_pair_grid_test.cpp new file mode 100644 index 0000000000..7452635c55 --- /dev/null +++ b/tests/radio_setup_pair_grid_test.cpp @@ -0,0 +1,147 @@ +// Radio Setup spreads side-by-side caption: value pairs across the window's +// width, and keeps each caption beside its value. +// +// The GPS page is the case it was found on: its five rows of two pairs were +// packed against the left edge, leaving the right half of the page empty. +// Transmit → Timings is the same grid with a value spanning the caption's +// place (the TX profile and TX Band Settings), and it must spread too. +// +// No hardware and no transport: a bare RadioModel, never connected, so the +// GPS page is listed (it is hidden only for a connected radio without GPS). +// Set AETHER_PAIR_GRID_GRAB to a directory to save a grab of each page. + +#include "TestSettingsProfile.h" +#include "gui/RadioSetupDialog.h" +#include "models/RadioModel.h" + +#include +#include +#include +#include + +using namespace AetherSDR; + +namespace { + +QLabel* caption(QWidget* root, const char* text) +{ + for (QLabel* l : root->findChildren()) { + if (l->text() == QLatin1String(text) && l->isVisible()) + return l; + } + return nullptr; +} + +int leftIn(QWidget* w, QWidget* root) { return w->mapTo(root, QPoint(0, 0)).x(); } +int rightIn(QWidget* w, QWidget* root) { return leftIn(w, root) + w->width(); } + +// The grid, at any depth under `layout`, that holds `w`. +QGridLayout* gridHolding(QLayout* layout, QWidget* w) +{ + if (!layout) + return nullptr; + auto* grid = qobject_cast(layout); + if (grid && grid->indexOf(w) >= 0) + return grid; + for (int i = 0; i < layout->count(); ++i) { + if (QGridLayout* found = gridHolding(layout->itemAt(i)->layout(), w)) + return found; + } + return nullptr; +} + +// The widget in the same grid row, just right of `label`: its value. +QWidget* valueOf(QLabel* label) +{ + QGridLayout* grid = gridHolding(label->parentWidget()->layout(), label); + if (!grid) + return nullptr; + const int index = grid->indexOf(label); + int row = 0, column = 0, rowSpan = 0, columnSpan = 0; + grid->getItemPosition(index, &row, &column, &rowSpan, &columnSpan); + QLayoutItem* item = grid->itemAtPosition(row, column + 1); + return item ? item->widget() : nullptr; +} + +void grab(QWidget* dialog, const char* name) +{ + const QByteArray dir = qgetenv("AETHER_PAIR_GRID_GRAB"); + if (!dir.isEmpty()) + dialog->grab().save(QString::fromLocal8Bit(dir) + QLatin1Char('/') + QLatin1String(name)); +} + +} // namespace + +class RadioSetupPairGridTest : public QObject { + Q_OBJECT +private slots: + + void gpsPairsSpreadAndStayTogether() + { + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.resize(960, 760); + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + dialog.selectTab(QStringLiteral("GPS")); + QCoreApplication::processEvents(); + + QLabel* lat = caption(&dialog, "Latitude:"); + QLabel* lon = caption(&dialog, "Longitude:"); + QLabel* time = caption(&dialog, "UTC Time:"); + QVERIFY(lat && lon && time); + QWidget* page = lat->parentWidget(); + grab(&dialog, "gps.png"); + + // The second pair starts in the page's right half... + const int pageLeft = leftIn(lat, page); + const int pageWidth = page->width() - pageLeft; + QVERIFY2(leftIn(lon, page) - pageLeft >= pageWidth * 2 / 5, + qPrintable(QStringLiteral("Longitude at %1 of %2") + .arg(leftIn(lon, page) - pageLeft).arg(pageWidth))); + // ...and both columns of pairs line up. + QCOMPARE(leftIn(time, page), leftIn(lon, page)); + + // Each caption is beside its value, not across a gap. + for (QLabel* label : {lat, lon}) { + QWidget* value = valueOf(label); + QVERIFY2(value, qPrintable(label->text())); + const int gap = leftIn(value, page) - rightIn(label, page); + QVERIFY2(gap >= 0 && gap <= 24, qPrintable(QStringLiteral("%1 gap %2") + .arg(label->text()).arg(gap))); + } + } + + void transmitTimingsSpread() + { + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.resize(960, 760); + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + dialog.selectTab(QStringLiteral("Transmit")); + QCoreApplication::processEvents(); + + QLabel* acc = caption(&dialog, "ACC TX:"); + QLabel* delay = caption(&dialog, "TX Delay:"); + QVERIFY(acc && delay); + QWidget* group = acc->parentWidget(); + grab(&dialog, "transmit.png"); + QVERIFY2(leftIn(delay, group) >= group->width() * 2 / 5, + qPrintable(QStringLiteral("TX Delay at %1 of %2") + .arg(leftIn(delay, group)).arg(group->width()))); + QWidget* value = valueOf(delay); + QVERIFY(value); + QVERIFY(leftIn(value, group) - rightIn(delay, group) <= 24); + } +}; + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("radio-setup-pair-grid")); + QApplication app(argc, argv); + app.setQuitOnLastWindowClosed(false); + RadioSetupPairGridTest test; + return QTest::qExec(&test, argc, argv); +} +#include "radio_setup_pair_grid_test.moc" diff --git a/tests/tests.cmake b/tests/tests.cmake index eefc7bf4cd..47068512fe 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -3687,6 +3687,29 @@ add_test(NAME radio_setup_window_size_test COMMAND radio_setup_window_size_test) set_tests_properties(radio_setup_window_size_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) +add_executable(radio_setup_pair_grid_test + tests/radio_setup_pair_grid_test.cpp + src/gui/DragValuePopup.cpp + src/gui/RadioSetupDialog.cpp + src/gui/CanonWindow.cpp + src/gui/RtlReceiverSettingsWidget.cpp + src/gui/ControlAvailabilityRegistry.cpp + src/gui/RadioSetupDialog_Peripherals.cpp + src/gui/PersistentDialog.cpp + src/gui/FramelessResizer.cpp + src/gui/FramelessWindowTitleBar.cpp + src/gui/SliceColorManager.cpp + src/gui/KiwiPublicReceiverPicker.cpp + src/gui/GuardedSlider.h + ${THEME_TEST_RESOURCES} +) +target_include_directories(radio_setup_pair_grid_test PRIVATE src tests) +target_link_libraries(radio_setup_pair_grid_test PRIVATE + aetherdesktop_support Qt6::Widgets Qt6::Test) +add_test(NAME radio_setup_pair_grid_test COMMAND radio_setup_pair_grid_test) +set_tests_properties(radio_setup_pair_grid_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) + # #5637 §3: Radio Setup's Max Power field. A watt ceiling from the backend's # txPowerBands (HL2) reads W once reported (nothing before), read-only with an # accessible reason where there is no command plane to write it; the Flex path @@ -8478,6 +8501,7 @@ set(AETHER_SETTINGS_CONSUMERS radio_setup_tx_timing_fields_test radio_setup_label_theme_token_test radio_setup_window_size_test + radio_setup_pair_grid_test radio_setup_max_power_field_test radio_setup_recording_mode_dim_test radio_setup_headphone_output_gate_test From 081eea771312fde01a862401f8bf4fdf3c95a5b1 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Mon, 12 Oct 2026 08:52:21 -0700 Subject: [PATCH 5/7] fix(gui): a UX pass over every Radio Setup page. Principle VIII. Every page: - Spin boxes draw their steppers (theme-colored triangles, regenerated per theme like the combo arrow); they rendered as two specks. - Controls that sat loose between groups are in one: Audio's packet-loss and sleep switches (Connection), Transmit's Max Power, Show TX, Slice/TX Follow, TX Profile and TX Band Settings (Options), Receive's mute and binaural toggles (Audio), Serial's Open / Close / Auto-open (Connection). - US English: "behavior", "colors"; group titles in title case (Single-Click Delay, Mouse Wheel). Radio: Callsign, Nickname and Station Name end at the copy column instead of running under it, and read from their start; the FlexControl chip is the size of the buttons beside it; FW Version lines up with the fields above, the empty status line takes no room, and the CAUTION sits inside the Firmware Update group under its buttons. Network: Network Name's copy button is in the left column with the others; Network MTU is its own width; IP Configuration captions its mode and sizes the address fields for an address. Transmit: the TX profile has a caption and is no longer in Timings. Phone & CW: Microphone captions align left like every other page. Receive: "Freq Offset:" with its ppb unit beside the field (was the unit twice, one of them a column away). Filters: Low Latency and Sharp Filters sit over the slider's two ends; the digital-modes checkbox is in Filter Options, not an untitled box. Antennas: the names list is as tall as its rows, not the page. Appearance: the slack is under the last group, not a gap between two; the swatches lose the letter row that repeated their own letters. SmartLink: table headings align left over their columns. QRZ: the callsign and password fields are 260 px and Test Login sits under them. Serial: TMate 2 Display's RGB and timing fields are their own width with caption-toned labels. Peripherals: a serial port combo is no longer as wide as the longest port description, which pushed the page past the window with a long-named device plugged in (peripheral_auth_dialog_interaction_test). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/user/docs/aetherial-audio.md | 2 +- docs/user/docs/audio-settings.md | 2 +- docs/user/docs/radio-setup.md | 2 +- docs/user/docs/rx-controls.md | 2 +- docs/user/docs/slice-colors.md | 2 +- src/gui/RadioSetupDialog.cpp | 296 ++++++++++++++++------- src/gui/RadioSetupDialog_Peripherals.cpp | 6 + 7 files changed, 222 insertions(+), 90 deletions(-) diff --git a/docs/user/docs/aetherial-audio.md b/docs/user/docs/aetherial-audio.md index b6c6c19aa3..c311497014 100644 --- a/docs/user/docs/aetherial-audio.md +++ b/docs/user/docs/aetherial-audio.md @@ -233,7 +233,7 @@ compact companion to the two windows: - The chain strip shows each stage as a box. Single-click a box to switch the stage on or off, double-click it to open that stage's editor, and drag it to reorder. The delay used to tell a single click from a double click is - **Settings → Radio Setup... → Appearance & Behavior → Single-click delay**. + **Settings → Radio Setup... → Appearance & Behavior → Single-Click Delay**. - On the TX side, ⏺ / ▶ drive the same transmit monitor as AetherTX's REC / PLAY. - Compact tiles for individual stages sit below the strip. diff --git a/docs/user/docs/audio-settings.md b/docs/user/docs/audio-settings.md index eec3bda1e6..681c63c12d 100644 --- a/docs/user/docs/audio-settings.md +++ b/docs/user/docs/audio-settings.md @@ -44,7 +44,7 @@ When the operating system adds or removes an audio device, an **Audio Device Det ## Volume and mute - **AF slider** (VFO flag or RX applet): the slice's audio level, 0–100. -- **Mute button:** single-click mutes or unmutes this slice; double-click mutes or unmutes all of your slices. **Radio Setup → Appearance & Behavior → Single-click delay** sets how long AetherSDR waits to tell a single click from a double click (0 makes single clicks instant and turns off the double-click action). +- **Mute button:** single-click mutes or unmutes this slice; double-click mutes or unmutes all of your slices. **Radio Setup → Appearance & Behavior → Single-Click Delay** sets how long AetherSDR waits to tell a single click from a double click (0 makes single clicks instant and turns off the double-click action). - Slice mute belongs to the radio, so it is not restored by AetherSDR on reconnect. - **Title-bar speaker icon and master slider** always show the path you are actually hearing. With **PC Audio** on, they show and control the computer's mute and level. With PC Audio off, they show the radio's Line Out mute and level, including changes made by another Multi-Flex client. - The title-bar headphone controls follow the radio's headphone output, and are dimmed on radios that have none. diff --git a/docs/user/docs/radio-setup.md b/docs/user/docs/radio-setup.md index e3d05765dd..6d32afcc12 100644 --- a/docs/user/docs/radio-setup.md +++ b/docs/user/docs/radio-setup.md @@ -79,7 +79,7 @@ Radio Setup is a searchable browser: a categorised tree of pages on the left and | Page | What it covers | |------|----------------| -| **Appearance & Behavior** | Slice Letter Display (see [Multi-Flex](./multi-flex.md)), slice colours (see [Slice Colors](./slice-colors.md)), Single-click delay and mouse-wheel behaviour. Themes are under **View → Theme**. See [Themes and Theme Editor](./themes-and-theme-editor.md). | +| **Appearance & Behavior** | Slice Letter Display (see [Multi-Flex](./multi-flex.md)), slice colors (see [Slice Colors](./slice-colors.md)), Single-Click Delay and mouse-wheel behavior. Themes are under **View → Theme**. See [Themes and Theme Editor](./themes-and-theme-editor.md). | | **SmartLink** | Pinned SmartLink certificates. See [SmartLink Setup](./smartlink-setup.md). | | **QRZ & Callsigns** | QRZ.com account and lookup cache. See [Callsign Lookup](./callsign-lookup.md). | diff --git a/docs/user/docs/rx-controls.md b/docs/user/docs/rx-controls.md index a3aa90f421..8d9bffe493 100644 --- a/docs/user/docs/rx-controls.md +++ b/docs/user/docs/rx-controls.md @@ -208,7 +208,7 @@ when you reconnect, so the slice keeps the mute state the radio reports. The single-click delay is set to 0, which disables double-click actions. 1. Open **Settings → Radio Setup... → Appearance & Behavior**. -2. Raise **Single-click delay** above 0, or press **Reset** for the platform +2. Raise **Single-Click Delay** above 0, or press **Reset** for the platform default. ### Squelch switched off when you changed to RTTY diff --git a/docs/user/docs/slice-colors.md b/docs/user/docs/slice-colors.md index 2e3e29115f..6cb1dd8160 100644 --- a/docs/user/docs/slice-colors.md +++ b/docs/user/docs/slice-colors.md @@ -10,7 +10,7 @@ Each slice (A–H) has its own colour, used for its frequency marker, filter pas Open **Settings → Radio Setup... → Appearance & Behavior** and find the **Slice Colors** group: -Appearance & Behavior page. Slice Letter Display offers Global slot index (selected) or Radio-assigned letter with global subscript. Slice Colors offers Use Aether defaults or Custom colors (selected), with eight colour buttons A to H in cyan, magenta, green, yellow, orange, teal, pink and purple and a Reset All to Defaults button. Further down, Single-click delay is 400 ms with a Reset button, and Mouse wheel has a Reverse mouse-wheel tuning direction checkbox. +Appearance & Behavior page. Slice Letter Display offers Global slot index (selected) or Radio-assigned letter with global subscript. Slice Colors offers Use Aether defaults or Custom colors (selected), with eight color buttons A to H in cyan, magenta, green, yellow, orange, teal, pink and purple and a Reset All to Defaults button. Further down, Single-Click Delay is 400 ms with a Reset button, and Mouse Wheel has a Reverse mouse-wheel tuning direction checkbox. *Radio Setup, Appearance & Behavior page with Custom colors chosen, so each slice letter's colour can be changed.* diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index c206436494..42bbfd8ab2 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -122,6 +122,7 @@ #include "RadioSetupDialogCommon.h" #include "CanonIndicators.h" #include "FramelessResizer.h" +#include "ThemedImageCache.h" namespace AetherSDR { @@ -136,7 +137,9 @@ static QLabel* makeValueLabel(const QString& text) } static constexpr int kInfoLeftLabelWidth = 112; -static constexpr int kInfoRightLabelWidth = 160; +// Room for the right column's longest caption ("Licensed version:") and no +// more, so its fields keep their width. +static constexpr int kInfoRightLabelWidth = 128; static QString kiwiSetupApiPolicyText(KiwiSdrProtocol::ApiPolicy policy) { @@ -529,6 +532,8 @@ static QString radioOptionsText(const RadioModel* model) static void showCopiedPopup(QWidget* anchor); +static constexpr int kCopyButtonSize = 20; + class CopyValueButton final : public QToolButton { public: explicit CopyValueButton(QString fieldName, @@ -541,7 +546,7 @@ class CopyValueButton final : public QToolButton { setAutoRaise(true); setCursor(Qt::PointingHandCursor); setFocusPolicy(Qt::TabFocus); - setFixedSize(20, 20); + setFixedSize(kCopyButtonSize, kCopyButtonSize); setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); setStyleSheet( "QToolButton { background: transparent; border: 0; padding: 0; margin: 0; }" @@ -676,6 +681,13 @@ static void showCopiedPopup(QWidget* anchor) QTimer::singleShot(1000, popup, &QLabel::close); } +// A status line that takes no room while it has nothing to say. +static void setShownText(QLabel* label, const QString& text) +{ + label->setText(text); + label->setVisible(!text.isEmpty()); +} + static QWidget* makeInfoField(const QString& labelText, QWidget* valueWidget, int labelWidth = kInfoLeftLabelWidth) { @@ -700,6 +712,9 @@ static QWidget* makeInfoField(const QString& labelText, QWidget* valueWidget, valueWidget->setSizePolicy(policy); } layout->addWidget(valueWidget, 1); + // The copy column of the fields above and below: a field ends where their + // values do, rather than running under their copy buttons. + layout->addSpacing(kCopyButtonSize); return wrapper; } @@ -883,9 +898,8 @@ QLabel[rsChip="on"] { background: {{color.canon.control}}; border: 1px solid {{color.canon.lineHi}}; border-radius: 4px; - padding: 3px 10px; - font-size: 11px; - font-weight: 700; + padding: 4px 10px; + font-weight: 600; } QLabel[rsChip="on"] { color: {{color.canon.ink}}; @@ -1176,6 +1190,56 @@ QScrollBar::sub-line:horizontal { } )"; +// A spin box with a styled frame draws its steppers from the sheet or not at +// all: without these rules Qt falls back to two specks. Each stepper is a +// column at the field's right with a painted triangle in the canon ink, +// regenerated per theme like the combo arrow (ComboStyle.h). Left out if an +// arrow could not be written, so the field keeps Qt's own. +static QString spinArrowRules() +{ + const QColor color = ThemeManager::instance().color(QStringLiteral("color.canon.inkSoft")); + const QString key = color.name(QColor::HexRgb).remove(QLatin1Char('#')); + auto arrow = [&](const QString& name, bool up) { + return themedImagePath(QStringLiteral("spin-arrows"), name, key, QSize(8, 5), 1, + [color, up](QPainter& p) { + p.setRenderHint(QPainter::Antialiasing); + p.setPen(Qt::NoPen); + p.setBrush(color); + const QPointF upTri[] = {{0, 5}, {8, 5}, {4, 0}}; + const QPointF downTri[] = {{0, 0}, {8, 0}, {4, 5}}; + p.drawPolygon(up ? upTri : downTri, 3); + }); + }; + const QString up = arrow(QStringLiteral("up"), true); + const QString down = arrow(QStringLiteral("down"), false); + if (up.isEmpty() || down.isEmpty()) { + return {}; + } + return QStringLiteral( + "QSpinBox, QDoubleSpinBox { padding-right: 16px; }" + "QSpinBox::up-button, QDoubleSpinBox::up-button," + " QSpinBox::down-button, QDoubleSpinBox::down-button {" + " subcontrol-origin: border; width: 16px; border: none;" + " border-left: 1px solid {{color.canon.line}}; background: transparent; }" + "QSpinBox::up-button, QDoubleSpinBox::up-button {" + " subcontrol-position: top right; border-top-right-radius: 4px; }" + "QSpinBox::down-button, QDoubleSpinBox::down-button {" + " subcontrol-position: bottom right; border-bottom-right-radius: 4px; }" + "QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover," + " QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover {" + " background: {{color.canon.nested}}; }" + "QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {" + " image: url(\"%1\"); width: 8px; height: 5px; }" + "QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {" + " image: url(\"%2\"); width: 8px; height: 5px; }" + "QSpinBox::up-arrow:disabled, QDoubleSpinBox::up-arrow:disabled," + " QSpinBox::down-arrow:disabled, QDoubleSpinBox::down-arrow:disabled," + " QSpinBox::up-arrow:off, QDoubleSpinBox::up-arrow:off," + " QSpinBox::down-arrow:off, QDoubleSpinBox::down-arrow:off { image: none; }") + .arg(up, down); +} + + RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, TgxlConnection* tgxl, PgxlConnection* pgxl, AntennaGeniusModel* ag, @@ -1204,7 +1268,7 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, FramelessResizer::install(this); bodyWidget()->setObjectName(QStringLiteral("radioSetupBody")); applyCanonSheet(bodyWidget(), [] { - return QString::fromLatin1(kRadioSetupStyle) + canonIndicatorRules(); + return QString::fromLatin1(kRadioSetupStyle) + spinArrowRules() + canonIndicatorRules(); }); auto* layout = new QVBoxLayout(bodyWidget()); @@ -2016,6 +2080,9 @@ QWidget* RadioSetupDialog::buildRadioTab() m_callsignEdit->setAccessibleDescription( tr("Your callsign, used for PSK Reporter, WSPR and spotting")); m_nicknameEdit->setAccessibleName(tr("Radio nickname")); + // Read from the start, not scrolled to the cursor at the end. + m_nicknameEdit->setCursorPosition(0); + m_callsignEdit->setCursorPosition(0); grid->addWidget(makeInfoField(QStringLiteral("Callsign:"), m_callsignEdit), 1, 0); @@ -2068,6 +2135,7 @@ QWidget* RadioSetupDialog::buildRadioTab() QString stationVal = AppSettings::instance().value("StationName", "").toString(); auto* stationEdit = new QLineEdit( stationVal.isEmpty() ? QSysInfo::machineHostName() : stationVal); + stationEdit->setCursorPosition(0); stationEdit->setToolTip("Identifies this client to other Multi-Flex stations.\n" "Defaults to OS hostname if empty."); grid->addWidget(makeInfoField(QStringLiteral("Station Name:"), stationEdit, @@ -2157,10 +2225,15 @@ QWidget* RadioSetupDialog::buildRadioTab() vlay->setSpacing(6); // Current version row - auto* infoRow = new QHBoxLayout; - infoRow->addWidget(new QLabel("FW Version:")); + // Laid out like the info fields above it, so its value lines up + // with theirs; half the group's width, as theirs are. + auto* infoRow = new QGridLayout; + infoRow->setSpacing(6); + infoRow->setColumnStretch(0, 1); + infoRow->setColumnStretch(1, 1); auto* curFw = makeValueLabel(displayOrDash(m_model->softwareVersion())); - infoRow->addWidget(makeCopyableValueLabel(QStringLiteral("FW Version"), curFw), 1); + infoRow->addWidget(makeCopyableInfoField(QStringLiteral("FW Version"), + QStringLiteral("FW Version:"), curFw), 0, 0); vlay->addLayout(infoRow); connect(m_model, &RadioModel::infoChanged, this, [this, curFw] { @@ -2180,6 +2253,9 @@ QWidget* RadioSetupDialog::buildRadioTab() m_fwStatusLabel = new QLabel(""); setTone(m_fwStatusLabel, Tone::Hint); m_fwStatusLabel->setWordWrap(true); + // Hidden until there is something to say, so an idle group has no + // blank line between the version and the buttons. + m_fwStatusLabel->hide(); vlay->addWidget(m_fwStatusLabel); // Button row @@ -2194,8 +2270,18 @@ QWidget* RadioSetupDialog::buildRadioTab() m_fwUploadBtn->setEnabled(false); setButtonRole(m_fwUploadBtn, ButtonRole::Success); btnRow->addWidget(m_fwUploadBtn); + btnRow->addStretch(1); vlay->addLayout(btnRow); + auto* disclaimer = new QLabel( + "⚠ CAUTION: Firmware update is currently a highly experimental feature. " + "Use at your own risk. At this time we still recommend updating " + "via the SmartSDR Windows application."); + disclaimer->setWordWrap(true); + m_firmwareDisclaimer = disclaimer; + setTone(disclaimer, Tone::Warn); + vlay->addWidget(disclaimer); + // ── Stager wiring ───────────────────────────────────────────── m_stager = new FirmwareStager(this); @@ -2210,13 +2296,13 @@ QWidget* RadioSetupDialog::buildRadioTab() checkBtn->setEnabled(true); if (avail) { setTone(m_fwStatusLabel, Tone::Warn); - m_fwStatusLabel->setText(QString( + setShownText(m_fwStatusLabel, QString( "Update available: v%1\n" "Download the SmartSDR installer from flexradio.com,\n" "then click 'Select Installer...' to stage it.").arg(latest)); } else { setTone(m_fwStatusLabel, Tone::Ok); - m_fwStatusLabel->setText("Firmware is up to date (v" + latest + ")."); + setShownText(m_fwStatusLabel, "Firmware is up to date (v" + latest + ")."); } }); @@ -2224,14 +2310,14 @@ QWidget* RadioSetupDialog::buildRadioTab() [this, checkBtn](const QString& err) { checkBtn->setEnabled(true); setTone(m_fwStatusLabel, Tone::Error); - m_fwStatusLabel->setText(err); + setShownText(m_fwStatusLabel, err); }); connect(m_stager, &FirmwareStager::stageProgress, this, [this](int pct, const QString& status) { m_fwProgress->setValue(pct); setTone(m_fwStatusLabel, Tone::Hint); - m_fwStatusLabel->setText(status); + setShownText(m_fwStatusLabel, status); }); connect(m_stager, &FirmwareStager::stageComplete, this, @@ -2245,7 +2331,7 @@ QWidget* RadioSetupDialog::buildRadioTab() [this](const QString& err) { m_fwProgress->hide(); setTone(m_fwStatusLabel, Tone::Error); - m_fwStatusLabel->setText(err); + setShownText(m_fwStatusLabel, err); }); // ── Browse / select installer manually ──────────────────────── @@ -2271,7 +2357,7 @@ QWidget* RadioSetupDialog::buildRadioTab() m_fwProgress->show(); m_fwProgress->setValue(0); setTone(m_fwStatusLabel, Tone::Hint); - m_fwStatusLabel->setText("Preparing firmware from " + QFileInfo(path).fileName() + "..."); + setShownText(m_fwStatusLabel, "Preparing firmware from " + QFileInfo(path).fileName() + "..."); // The stager emits stageProgress / stageComplete / stageFailed. // Existing slots wire to those (set above) so we just kick it off. @@ -2307,7 +2393,7 @@ QWidget* RadioSetupDialog::buildRadioTab() connect(m_uploader, &FirmwareUploader::progressChanged, this, [this](int pct, const QString& status) { m_fwProgress->setValue(pct); - m_fwStatusLabel->setText(status); + setShownText(m_fwStatusLabel, status); m_fwStatusLabel->setAccessibleDescription(status); }); connect(m_uploader, &FirmwareUploader::finished, this, @@ -2318,7 +2404,7 @@ QWidget* RadioSetupDialog::buildRadioTab() // radio's own `file update failed=` settles it. The // message carries the distinction in words as well as in // colour — colour alone never states it (docs/a11y.md). - m_fwStatusLabel->setText(msg); + setShownText(m_fwStatusLabel, msg); m_fwStatusLabel->setAccessibleDescription(msg); Tone tone = Tone::Hint; switch (outcome) { @@ -2362,16 +2448,6 @@ QWidget* RadioSetupDialog::buildRadioTab() vbox->addWidget(group); } - // Firmware disclaimer - auto* disclaimer = new QLabel( - "⚠ CAUTION: Firmware update is currently a highly experimental feature. " - "Use at your own risk. At this time we still recommend updating " - "via the SmartSDR Windows application."); - disclaimer->setWordWrap(true); - m_firmwareDisclaimer = disclaimer; - setTone(disclaimer, Tone::Warn); - vbox->addWidget(disclaimer); - vbox->addStretch(1); return page; } @@ -2420,8 +2496,9 @@ QWidget* RadioSetupDialog::buildNetworkTab() grid->addWidget(new QLabel("Network Name:"), 2, 0); auto* networkNameLbl = makeValueLabel(displayOrDash(m_model->networkName())); + // In the left pair's column, so its copy button lines up with theirs. grid->addWidget(makeCopyableValueLabel(QStringLiteral("Network Name"), networkNameLbl), - 2, 1, 1, 3); + 2, 1); connect(m_model, &RadioModel::infoChanged, this, [this, ipLbl, maskLbl, macLbl, gatewayLbl, networkNameLbl] { @@ -2709,7 +2786,9 @@ QWidget* RadioSetupDialog::buildNetworkTab() .arg(m_model->networkMtuParam(val))); }); m_networkMtuControl = mtuSpin; - grid->addWidget(mtuSpin, 5, 1); + // A four-digit value: its own width, not the token field's. + mtuSpin->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + grid->addWidget(mtuSpin, 5, 1, Qt::AlignLeft); // VITA-49 UDP receive buffer (SO_RCVBUF). Snap-to-preset slider; the // kernel clamps the grant at net.core.rmem_max, so we show the granted @@ -2841,34 +2920,39 @@ QGroupBox* RadioSetupDialog::buildIpConfigGroup() btnRow->addWidget(staticBtn); btnRow->addStretch(1); - gvbox->addLayout(btnRow); + // Mode, then the three addresses, captioned in one column like every + // other group; an address field is sized for 255.255.255.255. auto* fieldsGrid = new QGridLayout; - fieldsGrid->setSpacing(4); + fieldsGrid->setHorizontalSpacing(8); + fieldsGrid->setVerticalSpacing(6); + fieldsGrid->addWidget(new QLabel("Mode:"), 0, 0); + fieldsGrid->addLayout(btnRow, 0, 1); - fieldsGrid->addWidget(new QLabel("IP Address:"), 0, 0); + fieldsGrid->addWidget(new QLabel("IP Address:"), 1, 0); auto* staticIp = new QLineEdit(isStatic ? m_model->staticIp() : m_model->ip()); m_staticIpEdit = staticIp; staticIp->setEnabled(canConfigure && isStatic); - fieldsGrid->addWidget(staticIp, 0, 1); + fieldsGrid->addWidget(staticIp, 1, 1); - fieldsGrid->addWidget(new QLabel("Mask:"), 1, 0); + fieldsGrid->addWidget(new QLabel("Mask:"), 2, 0); auto* staticMask = new QLineEdit(isStatic ? m_model->staticNetmask() : m_model->netmask()); m_staticMaskEdit = staticMask; staticMask->setEnabled(canConfigure && isStatic); - fieldsGrid->addWidget(staticMask, 1, 1); + fieldsGrid->addWidget(staticMask, 2, 1); - fieldsGrid->addWidget(new QLabel("Gateway:"), 2, 0); + fieldsGrid->addWidget(new QLabel("Gateway:"), 3, 0); auto* staticGw = new QLineEdit(isStatic ? m_model->staticGateway() : m_model->gateway()); m_staticGatewayEdit = staticGw; staticGw->setEnabled(canConfigure && isStatic); - fieldsGrid->addWidget(staticGw, 2, 1); + fieldsGrid->addWidget(staticGw, 3, 1); + for (QLineEdit* edit : {staticIp, staticMask, staticGw}) + edit->setFixedWidth(180); + gvbox->addLayout(fieldsGrid); for (auto* lbl : group->findChildren()) if (!hasTone(lbl)) applyLabelStyle(lbl); - gvbox->addLayout(fieldsGrid); - auto* applyBtn = new QPushButton("Apply"); m_ipApplyButton = applyBtn; m_ipConfigPresentation.sessionKey = m_model->isConnected() @@ -2878,7 +2962,7 @@ QGroupBox* RadioSetupDialog::buildIpConfigGroup() applyBtn->setEnabled(false); applyBtn->setToolTip(canConfigure ? QString() : unavailableTip); applyBtn->setAccessibleDescription(applyBtn->toolTip()); - gvbox->addWidget(applyBtn, 0, Qt::AlignLeft); + fieldsGrid->addWidget(applyBtn, 4, 1, Qt::AlignLeft); connect(dhcpBtn, &QPushButton::clicked, this, [dhcpBtn, staticBtn, staticIp, staticMask, staticGw, applyBtn] { @@ -2987,6 +3071,11 @@ QWidget* RadioSetupDialog::buildTxTab() vbox->addLayout(hdr); } + // The TX profile and the band settings are not timings: built with the + // Timings group as before, but laid out in Options below. + QComboBox* profCmb = nullptr; + QPushButton* bandSetBtn = nullptr; + // Timings group { auto* group = new QGroupBox("Timings (in ms)"); @@ -3045,11 +3134,10 @@ QWidget* RadioSetupDialog::buildTxTab() connectTimingField(tx2Edit, "tx2_delay"); // TX Profile dropdown (below Timeout, right column) - auto* profCmb = new QComboBox; + profCmb = new QComboBox; profCmb->addItems(tx.profileList()); profCmb->setCurrentText(tx.activeProfile()); AetherSDR::applyComboStyle(profCmb); - grid->addWidget(profCmb, 2, 2, 1, 2); connect(profCmb, &QComboBox::currentTextChanged, this, [this](const QString& name) { m_model->transmitModel().loadProfile(name); }); @@ -3058,11 +3146,10 @@ QWidget* RadioSetupDialog::buildTxTab() connectTimingField(tx3Edit, "tx3_delay"); // TX Band Settings button - auto* bandSetBtn = new QPushButton("TX Band Settings"); + bandSetBtn = new QPushButton("TX Band Settings"); connect(bandSetBtn, &QPushButton::clicked, this, [this] { emit txBandSettingsRequested(); }); - grid->addWidget(bandSetBtn, 3, 2, 1, 2); for (auto* lbl : group->findChildren()) if (!hasTone(lbl)) applyLabelStyle(lbl); @@ -3236,7 +3323,17 @@ QWidget* RadioSetupDialog::buildTxTab() s.save(); }); - vbox->addLayout(grid); + auto* profLbl = new QLabel("TX Profile:"); + applyLabelStyle(profLbl); + grid->addWidget(profLbl, 3, 0); + profCmb->setAccessibleName(QStringLiteral("TX profile")); + profCmb->setMinimumWidth(180); + grid->addWidget(profCmb, 3, 1, Qt::AlignLeft); + grid->addWidget(bandSetBtn, 4, 1, Qt::AlignLeft); + + auto* optionsGroup = new QGroupBox("Options"); + optionsGroup->setLayout(grid); + vbox->addWidget(optionsGroup); } vbox->addStretch(1); @@ -3280,7 +3377,7 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() auto addMicRow = [&](int row, const QString& labelText, QPushButton* button) { auto* label = new QLabel(labelText); applyLabelStyle(label); - label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); button->setMinimumWidth(kMicControlButtonWidth); button->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); grid->addWidget(label, row, 0); @@ -3603,7 +3700,7 @@ QWidget* RadioSetupDialog::buildRxTab() offsetGrid->addWidget(calStatus, 0, 3); // Freq Error PPB row - auto* ppbLbl = new QLabel("Freq Offset (ppb):"); + auto* ppbLbl = new QLabel("Freq Offset:"); applyLabelStyle(ppbLbl); offsetGrid->addWidget(ppbLbl, 1, 0); auto* ppbEdit = new QLineEdit(QString::number(m_model->freqErrorPpb())); @@ -3612,10 +3709,16 @@ QWidget* RadioSetupDialog::buildRxTab() m_model->sendCommand( "radio set freq_error_ppb=" + ppbEdit->text()); }); - offsetGrid->addWidget(ppbEdit, 1, 1); + ppbEdit->setAccessibleName(QStringLiteral("Frequency offset in ppb")); + // The unit follows the field, as "ms" does on the Audio page. + auto* ppbRow = new QHBoxLayout; + ppbRow->setProperty("rsPacked", true); + ppbRow->setSpacing(6); + ppbRow->addWidget(ppbEdit); auto* ppbUnitLbl = new QLabel("ppb"); applyLabelStyle(ppbUnitLbl); - offsetGrid->addWidget(ppbUnitLbl, 1, 2); + ppbRow->addWidget(ppbUnitLbl); + offsetGrid->addLayout(ppbRow, 1, 1, Qt::AlignLeft); offsetGrid->setColumnStretch(3, 1); gvb->addLayout(offsetGrid); @@ -3742,7 +3845,9 @@ QWidget* RadioSetupDialog::buildRxTab() addToggle(1, "Binaural audio:", m_model->binauralRx(), "radio set binaural_rx"); - vbox->addLayout(grid); + auto* audioGroup = new QGroupBox("Audio"); + audioGroup->setLayout(grid); + vbox->addWidget(audioGroup); } vbox->addStretch(1); @@ -5245,6 +5350,13 @@ QWidget* RadioSetupDialog::buildAudioTab() vbox->addWidget(compGroup); } + // The two connection-wide switches share a group rather than sitting + // loose between groups. + auto* connectionGroup = new QGroupBox("Connection"); + auto* connectionBox = new QVBoxLayout(connectionGroup); + connectionBox->setSpacing(6); + vbox->addWidget(connectionGroup); + // ── Packet-Loss Concealment ───────────────────────────────────────── // Fades dropped VITA-49 audio packets to silence (uncompressed) or // calls libopus native PLC (Opus) instead of splicing the next packet @@ -5278,7 +5390,7 @@ QWidget* RadioSetupDialog::buildAudioTab() Qt::QueuedConnection); } }); - vbox->addWidget(plcCheck); + connectionBox->addWidget(plcCheck); } // ── Prevent Sleep ─────────────────────────────────────────────────── @@ -5294,7 +5406,7 @@ QWidget* RadioSetupDialog::buildAudioTab() connect(sleepCheck, &QCheckBox::toggled, this, [this](bool on) { m_model->setSleepInhibitWhileConnected(on); }); - vbox->addWidget(sleepCheck); + connectionBox->addWidget(sleepCheck); } // ── PC Audio Devices ──────────────────────────────────────────────── @@ -5679,15 +5791,19 @@ QWidget* RadioSetupDialog::buildFiltersTab() auto* group = new QGroupBox("Filter Options"); auto* grid = new QGridLayout(group); grid->setSpacing(8); + // The sliders take the width; the two headings sit at their ends. + grid->setColumnStretch(1, 1); + grid->setColumnStretch(2, 1); // Column headers auto* lowLbl = new QLabel("Low Latency"); applyLabelStyle(lowLbl); - lowLbl->setAlignment(Qt::AlignCenter); + // Over the slider's two ends, which they name. + lowLbl->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); grid->addWidget(lowLbl, 0, 1); auto* sharpLbl = new QLabel("Sharp Filters"); applyLabelStyle(sharpLbl); - sharpLbl->setAlignment(Qt::AlignCenter); + sharpLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); grid->addWidget(sharpLbl, 0, 2); struct FilterRow { @@ -5734,21 +5850,15 @@ QWidget* RadioSetupDialog::buildFiltersTab() }); } - vbox->addWidget(group); - } - - // Low Latency Digital checkbox - { - auto* group = new QGroupBox; - auto* hb = new QHBoxLayout(group); - + // Low Latency Digital checkbox: a filter option, in its group + // rather than a box of its own with no title. auto* chk = new QCheckBox("Use Low Latency Filters for Digital Modes"); chk->setChecked(m_model->lowLatencyDigital()); connect(chk, &QCheckBox::toggled, this, [this](bool on) { m_model->sendCommand( QString("radio set low_latency_digital_modes=%1").arg(on ? 1 : 0)); }); - hb->addWidget(chk); + grid->addWidget(chk, 4, 0, 1, 4); vbox->addWidget(group); } @@ -5981,8 +6091,11 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); scroll->setWidget(rowsWidget); + // As tall as its rows (a scroll area caps that), not the rest of the + // page: one antenna is one row, not a box down to the window's foot. + scroll->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); groupLayout->addWidget(scroll); - vbox->addWidget(group, 1); + vbox->addWidget(group, 0); QVBoxLayout* kiwiRowsLayout = nullptr; QVBoxLayout* kiwiLayout = nullptr; @@ -6051,7 +6164,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() kiwiScroll->setWidgetResizable(true); kiwiScroll->setFrameShape(QFrame::NoFrame); kiwiScroll->setMinimumHeight(190); - kiwiScroll->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding); + kiwiScroll->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum); kiwiScroll->setAccessibleName("KiwiSDR RX antennas"); kiwiScroll->setAccessibleDescription( "Configured KiwiSDR receive-only antenna endpoints."); @@ -6880,6 +6993,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() kiwiLayout->addLayout(kiwiTransferRow); } + vbox->addStretch(1); (*refresh)(); if (refreshKiwi && *refreshKiwi) { (*refreshKiwi)(); @@ -8328,7 +8442,13 @@ QWidget* RadioSetupDialog::buildSerialTab() }); updatePortStatus(); - vbox->addLayout(row); + // In a group like everything else on the page, not loose between + // Pin Assignment and the FlexControl knob. + auto* connectionGroup = new QGroupBox("Connection"); + auto* connectionBox = new QVBoxLayout(connectionGroup); + connectionBox->setSpacing(6); + connectionBox->addLayout(row); + vbox->addWidget(connectionGroup); auto* autoOpen = new QCheckBox("Auto-open serial port on startup"); autoOpen->setChecked(settings.value("SerialAutoOpen", "False").toString() == "True"); @@ -8337,7 +8457,7 @@ QWidget* RadioSetupDialog::buildSerialTab() s.setValue("SerialAutoOpen", on ? "True" : "False"); s.save(); }); - vbox->addWidget(autoOpen); + connectionBox->addWidget(autoOpen); } // ── FlexControl tuning knob ──────────────────────────────────────── @@ -8845,7 +8965,7 @@ QWidget* RadioSetupDialog::buildSerialTab() grid->setSpacing(6); auto* note = new QLabel( - "Backlight colours and temporary display feedback. " + "Backlight colors and temporary display feedback. " "Overlay duration controls how long changed values are shown on the TMate 2 LCD."); note->setWordWrap(true); setTone(note, Tone::Hint); @@ -8866,18 +8986,20 @@ QWidget* RadioSetupDialog::buildSerialTab() static const char* kLabels[3] = {"R:", "G:", "B:"}; for (int row = 0; row < 2; ++row) { auto* rowLbl = new QLabel(QString::fromLatin1(kRows[row].rowLabel)); - setTone(rowLbl, Tone::Hint); + applyLabelStyle(rowLbl); grid->addWidget(rowLbl, row + 1, 0); const char* keys[3] = {kRows[row].rKey, kRows[row].gKey, kRows[row].bKey}; const char* dflts[3] = {kRows[row].rDflt, kRows[row].gDflt, kRows[row].bDflt}; for (int ch = 0; ch < 3; ++ch) { auto* lbl = new QLabel(QString::fromLatin1(kLabels[ch])); - setTone(lbl, Tone::Hint); + applyLabelStyle(lbl); grid->addWidget(lbl, row + 1, 1 + ch * 2); auto* spin = new QSpinBox; spin->setRange(0, 255); spin->setValue(settings.value(keys[ch], dflts[ch]).toInt()); + // 0–255: three digits and the steppers. + spin->setFixedWidth(76); grid->addWidget(spin, row + 1, 2 + ch * 2); m_tmate2BacklightSpins[kRows[row].spinOffset + ch] = spin; @@ -8895,7 +9017,7 @@ QWidget* RadioSetupDialog::buildSerialTab() auto addTimingSpin = [&](int row, const QString& label, const QString& key, int dflt, int min, int max, int step) -> QSpinBox* { auto* lbl = new QLabel(label); - setTone(lbl, Tone::Hint); + applyLabelStyle(lbl); grid->addWidget(lbl, row, 0, 1, 2); auto* spin = new QSpinBox; @@ -8903,7 +9025,8 @@ QWidget* RadioSetupDialog::buildSerialTab() spin->setSingleStep(step); spin->setSuffix(" ms"); spin->setValue(settings.value(key, QString::number(dflt)).toInt()); - grid->addWidget(spin, row, 2, 1, 2); + spin->setFixedWidth(120); + grid->addWidget(spin, row, 2, 1, 2, Qt::AlignLeft); connect(spin, QOverload::of(&QSpinBox::valueChanged), this, [this, key](int val) { @@ -8920,8 +9043,8 @@ QWidget* RadioSetupDialog::buildSerialTab() m_tmate2UserInteractionTimeoutSpin = addTimingSpin( 4, "User interaction timeout:", "TMate2UserInteractionTimeoutMs", 2000, 0, 60000, 100); - for (int col = 2; col <= 6; col += 2) - grid->setColumnStretch(col, 1); + // The fields keep their widths; the group's spare width is at its end. + grid->setColumnStretch(7, 1); vbox->addWidget(group); } @@ -9179,7 +9302,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() auto* letterDesc = new QLabel( "Choose how slice letters are rendered in badges, faders, and " "applet labels. Multi-Flex sessions with multiple clients only: " - "Radio-assigned letters match SmartSDR's behaviour; the global " + "Radio-assigned letters match SmartSDR's behavior; the global " "subscript preserves which physical slot you're on."); setTone(letterDesc, Tone::Hint); letterDesc->setWordWrap(true); @@ -9240,15 +9363,11 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() auto* col = new QVBoxLayout; col->setSpacing(4); - auto* lbl = new QLabel(QString(kLetters[i])); - lbl->setAlignment(Qt::AlignCenter); - setTone(lbl, Tone::Hint); - + // The swatch carries its own letter; no caption above it. auto* btn = new QPushButton(QString(kLetters[i])); ThemeManager::instance().applyStyleSheet(btn, kBtnBase); colorBtns.append(btn); - col->addWidget(lbl); col->addWidget(btn); colorGrid->addLayout(col); } @@ -9263,7 +9382,6 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() grpLayout->addLayout(resetRow); vbox->addWidget(grp); - vbox->addStretch(); // ── Helpers (capture manager by pointer, buttons by value) ──────────────── SliceColorManager* pMgr = &SliceColorManager::instance(); @@ -9356,7 +9474,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() // double-click affordances become unreachable in that case but the // mute-all keyboard shortcut still works for the most common use. { - auto* clickGrp = new QGroupBox("Single-click delay"); + auto* clickGrp = new QGroupBox("Single-Click Delay"); auto* clickLayout = new QVBoxLayout(clickGrp); clickLayout->setSpacing(8); @@ -9421,7 +9539,7 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() // effect immediately with no signal plumbing. The Ctrl+wheel bandwidth // zoom on the panadapter is intentionally not reversed. { - auto* wheelGrp = new QGroupBox("Mouse wheel"); + auto* wheelGrp = new QGroupBox("Mouse Wheel"); auto* wheelLayout = new QVBoxLayout(wheelGrp); wheelLayout->setSpacing(8); @@ -9451,6 +9569,8 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() vbox->addWidget(wheelGrp); } + // The page's slack goes under its last group, not between two of them. + vbox->addStretch(); return page; } @@ -9496,6 +9616,8 @@ QWidget* RadioSetupDialog::buildSmartLinkTab() table->horizontalHeader()->setSectionResizeMode( 2, QHeaderView::ResizeToContents); table->verticalHeader()->setVisible(false); + // Headings sit over the start of their column, as the cells do. + table->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setSelectionBehavior(QAbstractItemView::SelectRows); grpLay->addWidget(table); @@ -9620,7 +9742,10 @@ QWidget* RadioSetupDialog::buildQrzTab() passEdit->setEchoMode(QLineEdit::Password); passEdit->setMaxLength(128); grid->addWidget(passEdit, 1, 1); - grid->setColumnStretch(1, 1); + // A callsign and a password: fields sized for them, not the page. + userEdit->setFixedWidth(260); + passEdit->setFixedWidth(260); + grid->setColumnStretch(2, 1); acctLay->addLayout(grid); // Populate the password field from the keychain (async). @@ -9656,7 +9781,8 @@ QWidget* RadioSetupDialog::buildQrzTab() setTone(testStatus, Tone::Hint); testStatus->setWordWrap(true); testRow->addWidget(testStatus, 1); - acctLay->addLayout(testRow); + // Under the fields it tests, not under their captions. + grid->addLayout(testRow, 2, 1, 1, 2); connect(testBtn, &QPushButton::clicked, this, [testBtn, testStatus, userEdit, passEdit] { diff --git a/src/gui/RadioSetupDialog_Peripherals.cpp b/src/gui/RadioSetupDialog_Peripherals.cpp index fd70425405..0275c102f0 100644 --- a/src/gui/RadioSetupDialog_Peripherals.cpp +++ b/src/gui/RadioSetupDialog_Peripherals.cpp @@ -973,6 +973,12 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget* lay->setContentsMargins(0, 0, 0, 0); serialCombo = new QComboBox; serialCombo->setAccessibleName(tr("%1 serial port").arg(ui.label)); + // A port's description can be long ("ttyACM1 — USB JTAG serial debug + // unit"); sized by its content, one plugged-in device would widen the + // page past the window. The open list still shows the whole name. + serialCombo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + serialCombo->setMinimumContentsLength(14); + serialCombo->view()->setTextElideMode(Qt::ElideNone); serialCustomEdit = new QLineEdit; serialCustomEdit->setPlaceholderText("/dev/ttyUSB0"); serialCustomEdit->setAccessibleName(tr("%1 custom serial port").arg(ui.label)); From 2f04ce19357055a1b0772a53ad033f581e6ff488 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Mon, 12 Oct 2026 09:12:55 -0700 Subject: [PATCH 6/7] fix(gui): Radio Setup headings are Title Case and sit clear of their boxes. Principle VIII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A group's title is a heading on its own line above the box, in line with its left edge and clear of it, as Add Receiver sits above its box; it was a label cut into the box's top border. - Title Case for every heading, column heading and button, matching the group titles: Configured Receivers, Add Receiver, Name / Server / Password / Type, Custom Name, SHA-256 Fingerprint, Add Receiver, Browse Public…, Forget Selected / Forget All, Clear Code, Calibrate from Current VFO. - Serial's Pin / Function / Polarity column headings take the heading tone, as the Antennas page's do. - The user docs that name those controls follow. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/user/docs/hermes-lite-2.md | 2 +- docs/user/docs/kiwisdr-and-web-888.md | 8 ++-- docs/user/docs/peripherals.md | 2 +- docs/user/docs/smartlink-setup.md | 6 +-- src/gui/RadioSetupDialog.cpp | 54 +++++++++++++----------- src/gui/RadioSetupDialog_Peripherals.cpp | 2 +- 6 files changed, 40 insertions(+), 34 deletions(-) diff --git a/docs/user/docs/hermes-lite-2.md b/docs/user/docs/hermes-lite-2.md index aefd0074db..15db325bdb 100644 --- a/docs/user/docs/hermes-lite-2.md +++ b/docs/user/docs/hermes-lite-2.md @@ -58,7 +58,7 @@ The HL2 IO board's amplifier control follows the band. 1. Let the radio warm up for 15 minutes. 2. Pick a **Reference:** (WWV/WWVH 2.5–25 MHz, CHU 3.330 / 7.850 / 14.670 MHz, or **GPSDO / signal generator (custom)** with its frequency). Attenuate a GPSDO by at least 30 dB. -3. Zero-beat the reference with normal tuning, then click **Calibrate from current VFO**. +3. Zero-beat the reference with normal tuning, then click **Calibrate from Current VFO**. 4. Fine-tune with **Trim:** − / + (steps of 0.1, 1 or 10 Hz at 10 MHz), or type the **Error:** in ppb (±50,000 ppb). **Reset** returns to 0. One value corrects every band, receive and transmit. It is stored per radio (by MAC), so a second HL2 does not inherit it. While **External 10 MHz reference at CL1** is on, the manual calibration is zeroed and disabled. diff --git a/docs/user/docs/kiwisdr-and-web-888.md b/docs/user/docs/kiwisdr-and-web-888.md index 48de828034..be492030dd 100644 --- a/docs/user/docs/kiwisdr-and-web-888.md +++ b/docs/user/docs/kiwisdr-and-web-888.md @@ -23,7 +23,7 @@ Open **Tools → Configure KiwiSDR...**. It opens **Settings → Radio Setup... Each receiver has: -KiwiSDR RX Antennas group. A note says passwords are kept only for the current session. Configured Receivers reads No KiwiSDR receivers configured. Under Add Receiver, the Name field holds Example Kiwi, the Server field kiwi.example.org:8073, the Password field is empty and Type is KiwiSDR, with an Auto-connect checkbox and Browse public… and Add receiver buttons. Import… and Export… buttons sit at the bottom right. +KiwiSDR RX Antennas group. A note says passwords are kept only for the current session. Configured Receivers reads No KiwiSDR receivers configured. Under Add Receiver, the Name field holds Example Kiwi, the Server field kiwi.example.org:8073, the Password field is empty and Type is KiwiSDR, with an Auto-connect checkbox and Browse Public… and Add Receiver buttons. Import… and Export… buttons sit at the bottom right. *The KiwiSDR RX Antennas group on the Antennas page, with a receiver typed in but not yet added.* @@ -37,7 +37,7 @@ Each receiver has: | **Keep audio during TX** | Off (default): the receiver's audio is silenced while you transmit and resumes immediately at unkey. The stream stays warm, so there is no gap or re-sync. On: keep listening while you transmit. | | **Resume audio after TX delay** | After unkey, wait out this receiver's stream delay before unmuting, so you don't hear your own delayed transmission. No effect while **Keep audio during TX** is on. | -Each row also has **Connect** / **Disconnect** and a **Remove** (bin) button. To add a receiver by hand, fill in the empty row at the bottom (name, `host:8073`, optional password and type) and click **Add receiver**. +Each row also has **Connect** / **Disconnect** and a **Remove** (bin) button. To add a receiver by hand, fill in the empty row at the bottom (name, `host:8073`, optional password and type) and click **Add Receiver**. ### Import and export @@ -45,7 +45,7 @@ Each row also has **Connect** / **Disconnect** and a **Remove** (bin) button. To ### Browsing public KiwiSDRs -Click **Browse public…** to open **Browse public KiwiSDR receivers**: +Click **Browse Public…** to open **Browse public KiwiSDR receivers**: - A filter box ("Filter by name, location, or host…") and **Refresh list**. - Columns **Receiver**, **Location**, **Users**, **API** and **Limits**. @@ -109,7 +109,7 @@ Every channel on the receiver is in use. 1. Leave the slice on the receiver: AetherSDR waits for a free slot and connects when one opens. 2. Accept a monitor (camp) session if one is offered. -3. Or choose another receiver with **Browse public…**. +3. Or choose another receiver with **Browse Public…**. ### The receiver shows Error diff --git a/docs/user/docs/peripherals.md b/docs/user/docs/peripherals.md index a210609970..2dddf2a66c 100644 --- a/docs/user/docs/peripherals.md +++ b/docs/user/docs/peripherals.md @@ -110,7 +110,7 @@ device accepts it. ("Code for this session only"). - The code is hidden unless you click **Show**, is concealed again when you change page, and is kept out of logs, the automation bridge and widget - grabs. **Clear code** deletes the saved code. + grabs. **Clear Code** deletes the saved code. - After repeated failures with a saved code, the device is blocked: "Authorization blocked; enter a code and click Connect to retry". - If direct authentication fails, a TGXL or PGXL falls back to the radio diff --git a/docs/user/docs/smartlink-setup.md b/docs/user/docs/smartlink-setup.md index 5a26327043..666aad8205 100644 --- a/docs/user/docs/smartlink-setup.md +++ b/docs/user/docs/smartlink-setup.md @@ -61,10 +61,10 @@ AetherSDR pins each SmartLink radio's TLS certificate on first use, to protect y ### Managing pins -**Settings → Radio Setup... → SmartLink → Pinned SmartLink Certificates** lists each pinned radio with its **Host**, **SHA-256 fingerprint** and the date it was **Pinned**. +**Settings → Radio Setup... → SmartLink → Pinned SmartLink Certificates** lists each pinned radio with its **Host**, **SHA-256 Fingerprint** and the date it was **Pinned**. -- **Forget selected** removes one pin. -- **Forget all** clears every pin after confirmation. Each radio is pinned again, silently, on its next connection. +- **Forget Selected** removes one pin. +- **Forget All** clears every pin after confirmation. Each radio is pinned again, silently, on its next connection. Forget a pin only when you have deliberately changed a radio's certificate. diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 42bbfd8ab2..6a7c0a3731 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -947,19 +947,24 @@ QTreeWidget#radioSetupNavigation::item:hover:!selected { color: {{color.canon.ink}}; background: {{color.canon.control}}; } +/* A group's title is a heading on its own line above the box, in line with + the box's left edge and a few pixels clear of it, as a section heading + (Add Receiver) is above its box; not a label cut into the top border. */ QGroupBox { color: {{color.canon.cyan}}; background: {{color.canon.raised}}; border: 1px solid {{color.canon.line}}; border-radius: 8px; - margin-top: 14px; - padding-top: 10px; + margin-top: 24px; + padding-top: 0px; font-weight: 700; } QGroupBox::title { subcontrol-origin: margin; - left: 10px; - padding: 0 4px; + subcontrol-position: top left; + left: 1px; + top: 0px; + padding: 0; } QGroupBox QGroupBox { background: {{color.canon.nested}}; @@ -4150,7 +4155,7 @@ QWidget* RadioSetupDialog::buildCalibrationTab() // ── Calibrate from the current VFO ─────────────────────────────────────── auto* calRow = new QHBoxLayout; calRow->setSpacing(8); - auto* calBtn = new QPushButton("Calibrate from current VFO"); + auto* calBtn = new QPushButton("Calibrate from Current VFO"); calBtn->setObjectName(QStringLiteral("hl2FreqCalCapture")); auto* calStatus = new QLabel; calStatus->setWordWrap(true); @@ -6218,7 +6223,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() grid->addWidget(lbl, 0, col); }; addHeader("Port", 0); - addHeader("Custom name", 1); + addHeader("Custom Name", 1); addHeader("Preview", 2); const QStringList tokens = m_model->knownAntennaTokens(); @@ -6425,7 +6430,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() return QString(); }; - addSectionHeader("CONFIGURED RECEIVERS"); + addSectionHeader("Configured Receivers"); if (profiles.isEmpty()) { auto* empty = new QLabel("No KiwiSDR receivers configured."); empty->setAccessibleName("No configured KiwiSDR receivers"); @@ -6473,10 +6478,10 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() status->setToolTip(status->text()); rowLayout->addWidget(status, 0, 2, 1, 2); - addFieldLabel(rowLayout, "NAME", 0); - addFieldLabel(rowLayout, "SERVER", 1); - addFieldLabel(rowLayout, "PASSWORD", 2); - addFieldLabel(rowLayout, "TYPE", 3); + addFieldLabel(rowLayout, "Name", 0); + addFieldLabel(rowLayout, "Server", 1); + addFieldLabel(rowLayout, "Password", 2); + addFieldLabel(rowLayout, "Type", 3); auto* nameEdit = new QLineEdit(profile.name); nameEdit->setMaxLength(16); @@ -6693,7 +6698,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() }); } - addSectionHeader("ADD RECEIVER"); + addSectionHeader("Add Receiver"); auto* rowFrame = new QFrame; rowFrame->setObjectName("kiwiAntennaRow"); auto* rowLayout = new QGridLayout(rowFrame); @@ -6704,10 +6709,10 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() rowLayout->setColumnStretch(1, 1); rowLayout->setColumnStretch(2, 1); - addFieldLabel(rowLayout, "NAME", 0); - addFieldLabel(rowLayout, "SERVER", 1); - addFieldLabel(rowLayout, "PASSWORD", 2); - addFieldLabel(rowLayout, "TYPE", 3); + addFieldLabel(rowLayout, "Name", 0); + addFieldLabel(rowLayout, "Server", 1); + addFieldLabel(rowLayout, "Password", 2); + addFieldLabel(rowLayout, "Type", 3); auto* nameEdit = new QLineEdit; nameEdit->setMaxLength(16); @@ -6781,7 +6786,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() // Browse the public KiwiSDR directory to fill in a receiver. Only // API-permitting receivers are listed (web-only operators honored). // Picking one fills the fields; Add receiver commits the profile. - auto* browseButton = new QPushButton("Browse public…"); + auto* browseButton = new QPushButton("Browse Public…"); browseButton->setAccessibleName("Browse public KiwiSDR receivers"); browseButton->setAccessibleDescription( "Choose from the public KiwiSDR directory; receivers whose " @@ -6813,7 +6818,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() } }); - auto* addButton = new QPushButton("Add receiver"); + auto* addButton = new QPushButton("Add Receiver"); addButton->setAccessibleName("Add KiwiSDR receiver"); styleKiwiButton(addButton); rowLayout->addWidget(addButton, 3, 2); @@ -8287,9 +8292,10 @@ QWidget* RadioSetupDialog::buildSerialTab() auto* headerPin = new QLabel("Pin"); auto* headerFn = new QLabel("Function"); auto* headerPol = new QLabel("Polarity"); - setTone(headerPin, Tone::Hint); - setTone(headerFn, Tone::Hint); - setTone(headerPol, Tone::Hint); + // Column headings in the heading tone, as on the Antennas page. + setTone(headerPin, Tone::Heading); + setTone(headerFn, Tone::Heading); + setTone(headerPol, Tone::Heading); grid->addWidget(headerPin, 0, 0); grid->addWidget(headerFn, 0, 1); grid->addWidget(headerPol, 0, 2); @@ -9607,7 +9613,7 @@ QWidget* RadioSetupDialog::buildSmartLinkTab() auto* table = new QTableWidget(0, 3, grp); m_pinnedCertsTable = table; - table->setHorizontalHeaderLabels({"Host", "SHA-256 fingerprint", "Pinned"}); + table->setHorizontalHeaderLabels({"Host", "SHA-256 Fingerprint", "Pinned"}); table->horizontalHeader()->setStretchLastSection(false); table->horizontalHeader()->setSectionResizeMode( 0, QHeaderView::ResizeToContents); @@ -9624,8 +9630,8 @@ QWidget* RadioSetupDialog::buildSmartLinkTab() auto* btnRow = new QHBoxLayout; btnRow->setSpacing(8); - auto* forgetSel = new QPushButton("Forget selected"); - auto* forgetAll = new QPushButton("Forget all"); + auto* forgetSel = new QPushButton("Forget Selected"); + auto* forgetAll = new QPushButton("Forget All"); btnRow->addWidget(forgetSel); btnRow->addStretch(); btnRow->addWidget(forgetAll); diff --git a/src/gui/RadioSetupDialog_Peripherals.cpp b/src/gui/RadioSetupDialog_Peripherals.cpp index 0275c102f0..03049a01c5 100644 --- a/src/gui/RadioSetupDialog_Peripherals.cpp +++ b/src/gui/RadioSetupDialog_Peripherals.cpp @@ -881,7 +881,7 @@ void RadioSetupDialog::buildAuthNetworkDevice(PeripheralDeviceUi& ui, QWidget* s connect(ui.addressEdit, &QLineEdit::textChanged, edit, concealSavedCode); connect(ui.portSpin, &QSpinBox::valueChanged, edit, concealSavedCode); - auto* clear = new QPushButton(tr("Clear code")); + auto* clear = new QPushButton(tr("Clear Code")); ui.clearButton = clear; clear->setObjectName(QStringLiteral("peripheralAuth_%1_6").arg(ui.id)); clear->setAccessibleName(tr("%1 clear saved authorization code").arg(label)); From 3b5186c269a9512b532cacb1d472c708aae1fff3 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Mon, 12 Oct 2026 11:22:18 -0700 Subject: [PATCH 7/7] fix(gui): Radio Setup review fixes; a peripheral OFFLINE dot is orange. Principle VIII. From the #6413 reviews: - OFFLINE takes a new color.accent.offline token (orange: #ff7a1a dark, #c4560a light) in the Radio Setup device list and the Tuner, Amplifier and Antenna Genius applets, so it is not read as transmit red. - Escape keeps the uncommitted Peripherals address clears, as the corner button does: done() runs the row savers too. - The USB cable list re-resolves its item colors on a theme switch, leaving the list and the cable being edited as they are. - Radio Side takes the Select role like Client Side. - The device list's source dot is drawn in its own cell with the item clipped around it, instead of over a figure space whose width differs. - US spellings in comments and docs (color, center). Tests: - peripheral_auth_dialog_test drives the dot through the link probes (connected, relayed, neither), not by setting the item's role. - external-ag closes with Escape as well. - radio_setup_label_theme_token_test pins the cable colors across a switch and the record-mode roles. - radio_setup_window_size_test restores a wider legacy saveGeometry() blob at 960. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/style/dialog-patterns.md | 2 +- docs/style/theme-style-guide.md | 1 + docs/user/docs/peripherals.md | 2 +- docs/user/docs/tgxl-tuner-control.md | 2 +- resources/themes/default-dark.json | 2 + resources/themes/default-light.json | 2 + src/core/ThemeSeedGenerated.cpp | 1 + src/gui/PeripheralConnectionSource.h | 6 +- src/gui/RadioSetupDialog.cpp | 39 ++++++++--- src/gui/RadioSetupDialog.h | 8 +++ src/gui/RadioSetupDialog_Peripherals.cpp | 47 +++++++++---- tests/peripheral_auth_dialog_test.cpp | 66 ++++++++++++------- ...radio_setup_headphone_output_gate_test.cpp | 2 +- tests/radio_setup_label_theme_token_test.cpp | 62 +++++++++++++++-- tests/radio_setup_region_field_test.cpp | 8 +-- tests/radio_setup_window_size_test.cpp | 31 ++++++++- 16 files changed, 219 insertions(+), 62 deletions(-) diff --git a/docs/style/dialog-patterns.md b/docs/style/dialog-patterns.md index 3aae173ae6..90d2f62076 100644 --- a/docs/style/dialog-patterns.md +++ b/docs/style/dialog-patterns.md @@ -240,7 +240,7 @@ Waveforms, Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, Aeth | Concern | `CanonWindow` | Why | |---|---|---| | Frameless chrome integration | Always frameless; ignores `FramelessWindow` and is not tracked by `trackPersistentDialog()` | The style guide's rounded, title-bar-less window *is* the design; native chrome would put a title bar over it | -| Geometry persistence | None by default: asks to open centred on its parent every time (Wayland compositors place top-level windows themselves and may ignore it). `setGeometryKey()` opts in: size and position are saved under that AppSettings key on move and resize (in memory) and whenever the window hides (flushed — Escape and ⌘W / Ctrl+W hide it without a close event), and restored in place of the centring. Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub and Radio Setup opt in. `setLaunchSize()` pins the size on every open while the position is still restored; AetherRX and AetherTX use it, for now | A short-lived window has nothing to restore; a workspace tool keeps the size its operator gave it | +| Geometry persistence | None by default: asks to open centered on its parent every time (Wayland compositors place top-level windows themselves and may ignore it). `setGeometryKey()` opts in: size and position are saved under that AppSettings key on move and resize (in memory) and whenever the window hides (flushed — Escape and ⌘W / Ctrl+W hide it without a close event), and restored in place of the centering. Network Diagnostics, AetherRX, AetherTX, AetherModem, AetherMap, AetherGPS, SpotHub and Radio Setup opt in. `setLaunchSize()` pins the size on every open while the position is still restored; AetherRX and AetherTX use it, for now | A short-lived window has nothing to restore; a workspace tool keeps the size its operator gave it | What it keeps: diff --git a/docs/style/theme-style-guide.md b/docs/style/theme-style-guide.md index 675410483a..527acf2413 100644 --- a/docs/style/theme-style-guide.md +++ b/docs/style/theme-style-guide.md @@ -97,6 +97,7 @@ a token for a role it does not name. |---|---| | **Error / fault / danger / destructive action** | `color.accent.danger` (foreground); `color.button.danger.*.disabled` for a destructive button's disabled state — enabled-state button colours are not tokenised yet, so an enabled destructive button takes its colour from `color.accent.danger` | | **Warning / caution / approaching a limit** | `color.accent.warning` (foreground); `color.background.warning` (panel tint); `color.toggle.warning.*` (checked warning toggles) | +| **A link that is down, not failed (a peripheral OFFLINE)** | `color.accent.offline` — orange, apart from the warning amber and from red, which reads as transmit | | **Success / OK / enabled-and-healthy** | `color.accent.success` (foreground); `color.background.success` (panel tint); `color.toggle.success.*` (checked success toggles) | | **Notification / message / attention ping** | `color.highlight.message`, with `color.highlight.fg` for text drawn on top | | **Transmit-active** | `color.background.tx` + `color.border.tx` (panel), `color.highlight.tx` (marker), `color.slice.tx` (slice flag), `color.tx.mox.*` (MOX button) | diff --git a/docs/user/docs/peripherals.md b/docs/user/docs/peripherals.md index 2dddf2a66c..513d2c3633 100644 --- a/docs/user/docs/peripherals.md +++ b/docs/user/docs/peripherals.md @@ -41,7 +41,7 @@ coming from: |---|---| | `● DIRECT` | Connected directly to the device (green dot) | | `● RADIO` | Not connected directly, but the radio relays the device (TGXL and PGXL only; yellow dot) | -| `● OFFLINE` | No connection (red dot) | +| `● OFFLINE` | No connection (orange dot) | | `Connecting…` | A connection attempt is in progress | The TGXL, PGXL and Antenna Genius applets use the same words. A device that diff --git a/docs/user/docs/tgxl-tuner-control.md b/docs/user/docs/tgxl-tuner-control.md index 0027af8f5a..3aad153211 100644 --- a/docs/user/docs/tgxl-tuner-control.md +++ b/docs/user/docs/tgxl-tuner-control.md @@ -18,7 +18,7 @@ using with an indicator at the end of its readout row: |---|---| | `● DIRECT` | A direct TCP connection to the tuner on port 9010 (green) | | `● RADIO` | Through the FlexRadio, which relays the tuner's status and commands (yellow) | -| `● OFFLINE` | Neither is available (red) | +| `● OFFLINE` | Neither is available (orange) | When a TGXL is connected to your FlexRadio, the radio reports its address and AetherSDR opens the direct connection automatically. For a tuner the radio diff --git a/resources/themes/default-dark.json b/resources/themes/default-dark.json index 4ee3f819f3..d11d3314f0 100644 --- a/resources/themes/default-dark.json +++ b/resources/themes/default-dark.json @@ -10,6 +10,7 @@ "color.blue.700": "#0070c0", "color.red.500": "#ff4d4d", "color.amber.500": "#ffb84d", + "color.orange.500": "#ff7a1a", "color.green.500": "#4dd87a", "color.gray.50": "#e6f0fa", "color.gray.200": "#c8d8e8", @@ -47,6 +48,7 @@ "accent.dim": "{color.blue.700}", "accent.warning": "{color.amber.500}", "accent.danger": "{color.red.500}", + "accent.offline": "{color.orange.500}", "accent.success": "{color.green.500}", "waterfall.timeMarker.foreground": "#c8d8e8", "waterfall.timeMarker.background": "#dc0f0f1a", diff --git a/resources/themes/default-light.json b/resources/themes/default-light.json index 6a878d432d..db70281529 100644 --- a/resources/themes/default-light.json +++ b/resources/themes/default-light.json @@ -10,6 +10,7 @@ "color.blue.700": "#005080", "color.red.500": "#c02020", "color.amber.500": "#d08010", + "color.orange.500": "#c4560a", "color.green.500": "#1a8040", "color.gray.50": "#1a2a3a", "color.gray.100": "#506070", @@ -48,6 +49,7 @@ "accent.dim": "{color.blue.700}", "accent.warning": "{color.amber.500}", "accent.danger": "{color.red.500}", + "accent.offline": "{color.orange.500}", "accent.success": "{color.green.500}", "waterfall.timeMarker.foreground": "#c8d8e8", "waterfall.timeMarker.background": "#dc0f0f1a", diff --git a/src/core/ThemeSeedGenerated.cpp b/src/core/ThemeSeedGenerated.cpp index f32f826ba5..cb34b5b33b 100644 --- a/src/core/ThemeSeedGenerated.cpp +++ b/src/core/ThemeSeedGenerated.cpp @@ -26,6 +26,7 @@ void ThemeManager::seedGeneratedDefaults() m_tokens.insert("color.accent.bright", QString("#00c8f0")); m_tokens.insert("color.accent.danger", QString("#ff4d4d")); m_tokens.insert("color.accent.dim", QString("#0070c0")); + m_tokens.insert("color.accent.offline", QString("#ff7a1a")); m_tokens.insert("color.accent.success", QString("#4dd87a")); m_tokens.insert("color.accent.warning", QString("#ffb84d")); m_tokens.insert("color.accessory.dial.face", QString("#0070c0")); diff --git a/src/gui/PeripheralConnectionSource.h b/src/gui/PeripheralConnectionSource.h index 5d4de5ffef..f40df1d790 100644 --- a/src/gui/PeripheralConnectionSource.h +++ b/src/gui/PeripheralConnectionSource.h @@ -53,8 +53,8 @@ inline Text describe(Source source, const QString& device, bool directOnly = fal return out; } -// The indicator dot's colour token: a direct connection is green, the radio -// relay yellow, no connection red. +// The indicator dot's color token: a direct connection is green, the radio +// relay amber, no connection orange (red is kept for transmit). inline QString colorToken(Source source) { switch (source) { @@ -62,7 +62,7 @@ inline QString colorToken(Source source) case Source::Radio: return QStringLiteral("color.accent.warning"); case Source::Offline: break; } - return QStringLiteral("color.accent.danger"); + return QStringLiteral("color.accent.offline"); } } // namespace PeripheralConnectionSource diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 6a7c0a3731..245a75ecdd 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -436,7 +436,7 @@ class ButtonsFitContent : public QObject { if (policy.horizontalPolicy() == QSizePolicy::Minimum) { policy.setHorizontalPolicy(QSizePolicy::Fixed); button->setSizePolicy(policy); - // A fixed button in a wider cell would centre; it starts at + // A fixed button in a wider cell would center; it starts at // the cell's left edge like the field beside it would. if (QWidget* parent = button->parentWidget()) { alignLeftIfUnset(parent->layout(), button); @@ -1865,6 +1865,10 @@ void RadioSetupDialog::done(int result) // closeEvent. Keep those paths behind the same confirmation without // redirecting reject() to close() (which recurses during Qt's close path). if (!m_peripheralRemovalPending && confirmFirmwareClose()) { + // Escape is a close like the corner button: it keeps the + // uncommitted Peripherals edits too. + for (const auto& saver : m_peripheralRowSavers) + saver(); CanonWindow::done(result); } } @@ -5642,6 +5646,7 @@ QWidget* RadioSetupDialog::buildAudioTab() auto* radioSideBtn = new QPushButton("Radio Side"); radioSideBtn->setCheckable(true); + setButtonRole(radioSideBtn, ButtonRole::Select); auto* clientSideBtn = new QPushButton("Client Side"); clientSideBtn->setCheckable(true); setButtonRole(clientSideBtn, ButtonRole::Select); @@ -7731,7 +7736,22 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() hbox->addWidget(stack, 1); // ── Populate cable list from model ────────────────────────────────── - auto refreshList = [cableList, cableModel]() { + // A cable's color says whether it is enabled and plugged in. + auto colorItem = [cableModel](QListWidgetItem* item) { + const auto& cables = cableModel->cables(); + const auto it = cables.constFind(item->data(Qt::UserRole).toString()); + if (it == cables.constEnd()) { + return; + } + const auto& theme = ThemeManager::instance(); + if (it->enabled && it->present) + item->setForeground(theme.color(QStringLiteral("color.accent.success"))); + else if (it->enabled) + item->setForeground(theme.color(QStringLiteral("color.accent.warning"))); + else + item->setForeground(theme.color(QStringLiteral("color.canon.muted"))); + }; + auto refreshList = [cableList, cableModel, colorItem]() { QString prevSn; if (cableList->currentItem()) prevSn = cableList->currentItem()->data(Qt::UserRole).toString(); @@ -7744,13 +7764,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() label += " (unplugged)"; auto* item = new QListWidgetItem(label); item->setData(Qt::UserRole, cable.serialNumber); - const auto& theme = ThemeManager::instance(); - if (cable.enabled && cable.present) - item->setForeground(theme.color(QStringLiteral("color.accent.success"))); - else if (cable.enabled) - item->setForeground(theme.color(QStringLiteral("color.accent.warning"))); - else - item->setForeground(theme.color(QStringLiteral("color.canon.muted"))); + colorItem(item); cableList->addItem(item); if (cable.serialNumber == prevSn) cableList->setCurrentItem(item); @@ -8100,6 +8114,13 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() // Initial populate refreshList(); + // The item colors are resolved tokens: re-resolve them on a theme switch, + // leaving the list and the cable being edited as they are. + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, cableList, + [cableList, colorItem]() { + for (int i = 0; i < cableList->count(); ++i) + colorItem(cableList->item(i)); + }); return page; } diff --git a/src/gui/RadioSetupDialog.h b/src/gui/RadioSetupDialog.h index 7ba68b93a5..60dedd5304 100644 --- a/src/gui/RadioSetupDialog.h +++ b/src/gui/RadioSetupDialog.h @@ -120,6 +120,14 @@ class RadioSetupDialog : public CanonWindow { PeripheralDeviceStatus peripheralStatusForTest(const QString& id) const; void editPeripheralStatusForTest(const QString& id, const std::function& edit); + // Test seam: replaces a peripheral's link probes (connected directly, + // relayed by the radio) and returns the ones it had, so the next refresh + // derives the source as it would from a live link. + struct PeripheralLinkProbes { + std::function isConnected; + std::function hasRadioRelay; + }; + PeripheralLinkProbes swapPeripheralLinkForTest(const QString& id, PeripheralLinkProbes probes); static void setRemovalConfirmationHookForTest( std::function hook); diff --git a/src/gui/RadioSetupDialog_Peripherals.cpp b/src/gui/RadioSetupDialog_Peripherals.cpp index 03049a01c5..080cd54fc9 100644 --- a/src/gui/RadioSetupDialog_Peripherals.cpp +++ b/src/gui/RadioSetupDialog_Peripherals.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -179,8 +180,8 @@ namespace { // The device list's line three starts with the connection-source dot. The item // carries its source under kSourceRole (-1 while connecting, when there is no -// dot), and this delegate paints that dot in the source's colour token; the -// rest of the line keeps the list's text colour. +// dot), and this delegate paints that dot in the source's color token; the +// rest of the line keeps the list's text color. constexpr int kSourceRole = Qt::UserRole + 1; class PeripheralListDelegate : public QStyledItemDelegate { @@ -213,15 +214,12 @@ class PeripheralListDelegate : public QStyledItemDelegate { QStyledItemDelegate::paint(painter, option, index); return; } - // The item as usual with the dot's glyph blanked to a space of the - // same advance, then the dot drawn in its colour at that position. - QString blanked = text; - blanked[lineStart] = QChar(0x2007); // figure space - opt.text = blanked; + // The item as usual, except in the dot's own cell, which keeps only + // the item's fill and selection; the dot is drawn there in its color. + // Each pixel is painted once, so a translucent selection is not + // blended twice. const QWidget* widget = opt.widget; QStyle* style = widget ? widget->style() : QApplication::style(); - style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, widget); - const QRect textRect = style->subElementRect(QStyle::SE_ItemViewItemText, &opt, widget); const QFontMetrics fm(opt.font); const int blockHeight = lines * fm.lineSpacing(); @@ -232,12 +230,27 @@ class PeripheralListDelegate : public QStyledItemDelegate { const int margin = style->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, widget) + 1; const QPoint at(textRect.left() + margin, top + (lines - 1) * fm.lineSpacing() + fm.ascent()); + const QRegion dotCell(at.x(), at.y() - fm.ascent(), + fm.horizontalAdvance(dot.left(1)), fm.height()); + + painter->save(); + painter->setClipRegion(QRegion(opt.rect).subtracted(dotCell), Qt::IntersectClip); + style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, widget); + painter->restore(); + + QStyleOptionViewItem ground = opt; + ground.text.clear(); + painter->save(); + painter->setClipRegion(dotCell, Qt::IntersectClip); + style->drawControl(QStyle::CE_ItemViewItem, &ground, painter, widget); + painter->restore(); + using PeripheralConnectionSource::Source; - const QColor colour = ThemeManager::instance().color( + const QColor color = ThemeManager::instance().color( PeripheralConnectionSource::colorToken(static_cast(source.toInt()))); painter->save(); painter->setFont(opt.font); - painter->setPen(colour); + painter->setPen(color); painter->drawText(at, QStringLiteral("●")); painter->restore(); } @@ -669,6 +682,18 @@ void RadioSetupDialog::editPeripheralStatusForTest( } } +RadioSetupDialog::PeripheralLinkProbes RadioSetupDialog::swapPeripheralLinkForTest( + const QString& id, PeripheralLinkProbes probes) +{ + PeripheralDeviceUi* ui = peripheralDevice(id); + if (!ui) { + return probes; + } + std::swap(ui->isConnected, probes.isConnected); + std::swap(ui->hasRadioRelay, probes.hasRadioRelay); + return probes; +} + void RadioSetupDialog::buildAuthNetworkDevice(PeripheralDeviceUi& ui, QWidget* stackParent, const std::function& refresh) { diff --git a/tests/peripheral_auth_dialog_test.cpp b/tests/peripheral_auth_dialog_test.cpp index 768000f0da..9c1f4b9ada 100644 --- a/tests/peripheral_auth_dialog_test.cpp +++ b/tests/peripheral_auth_dialog_test.cpp @@ -1318,8 +1318,9 @@ bool checkExternalAgConfiguration() bool checkAgCloseAfterExternalConfiguration() { // Add, saved row, Remove/reconfigure, close before refresh, deliberate clear, - // stale clear, and port-only edit with an externally configured address. - for (int scenario = 0; scenario < 8; ++scenario) { + // stale clear, port-only edit with an externally configured address, and + // the deliberate clear again closed with Escape (reject()) instead. + for (int scenario = 0; scenario < 9; ++scenario) { AppSettings& settings = AppSettings::instance(); settings.remove("Peripherals"); settings.remove("AG_ManualIp"); @@ -1328,7 +1329,7 @@ bool checkAgCloseAfterExternalConfiguration() ? QStringList{} : QStringList{QStringLiteral("ag")}); const QString oldHost = QStringLiteral("192.0.2.90"); const QString newHost = QStringLiteral("192.0.2.91"); - if (scenario == 2 || scenario == 4 || scenario == 5 || scenario == 7) { + if (scenario == 2 || scenario == 4 || scenario == 5 || scenario == 7 || scenario == 8) { settings.setValue("AG_ManualIp", oldHost); settings.setValue("AG_ManualPort", 19007); } @@ -1363,7 +1364,7 @@ bool checkAgCloseAfterExternalConfiguration() if (!address->text().isEmpty() || port->value() != 9007) { return false; } - } else if (scenario == 4 || scenario == 5) { + } else if (scenario == 4 || scenario == 5 || scenario == 8) { address->selectAll(); address->backspace(); // Deliberate user edit, unlike a programmatic reset. if (!address->isModified()) { @@ -1374,12 +1375,12 @@ bool checkAgCloseAfterExternalConfiguration() } else if (scenario == 7) { address->setText(QString()); // Programmatic clear carries no user intent. } - if (scenario != 4 && scenario != 7) { + if (scenario != 4 && scenario != 7 && scenario != 8) { settings.setValue("AG_ManualIp", newHost); settings.setValue("AG_ManualPort", 9007); } // Inject a connected LAN prologue; no socket or firmware peer is used. - const QString connectedHost = (scenario == 4 || scenario == 7) ? oldHost : newHost; + const QString connectedHost = (scenario == 4 || scenario == 7 || scenario == 8) ? oldHost : newHost; if (!QMetaObject::invokeMethod(&ag, "beginAttemptAt", Qt::DirectConnection, Q_ARG(QString, connectedHost), Q_ARG(quint16, quint16(9007))) || !QMetaObject::invokeMethod(&ag, "processTcpBytes", Qt::DirectConnection, @@ -1403,9 +1404,14 @@ bool checkAgCloseAfterExternalConfiguration() return false; } } - dialog.close(); // Exercise the production closeEvent/savers, not destruction. + // Exercise the production close paths and their savers, not destruction. + if (scenario == 8) { + dialog.reject(); // what Escape calls + } else { + dialog.close(); + } const bool cleared = !settings.contains("AG_ManualIp") && !settings.contains("AG_ManualPort"); - if (scenario == 4) { + if (scenario == 4 || scenario == 8) { if (!cleared || ag.isConnected()) { std::fprintf(stderr, "Intentional AG clear was not saved on close\n"); return false; @@ -1811,16 +1817,16 @@ bool checkConnectAutomaticallyGates() // What Setup shows follows the device's explicit status, never text, and the // page is signal-driven (no presentation timer). -// Pixels of exactly the colour a theme token resolves to, in the list as +// Pixels of exactly the color a theme token resolves to, in the list as // painted: the connection-source dot is drawn in its token, solid at its core. int pixelsOfToken(QWidget* widget, const char* token) { - const QColor colour = AetherSDR::ThemeManager::instance().color(QString::fromLatin1(token)); + const QColor color = AetherSDR::ThemeManager::instance().color(QString::fromLatin1(token)); const QImage image = widget->grab().toImage().convertToFormat(QImage::Format_ARGB32); int n = 0; for (int y = 0; y < image.height(); ++y) { for (int x = 0; x < image.width(); ++x) { - if (image.pixel(x, y) == colour.rgb()) { + if (image.pixel(x, y) == color.rgb()) { ++n; } } @@ -1854,31 +1860,41 @@ bool checkStatusPresentation() std::fprintf(stderr, "Idle status not shown on the detail page\n"); return false; } - // The dot before the source word follows the connection: OFFLINE red, - // RADIO yellow, DIRECT green. Each state is read against the other two. + // The dot before the source word follows the connection: OFFLINE orange, + // RADIO amber, DIRECT green. Each state is read against the other two. { using PeripheralConnectionSource::Source; - struct Expect { Source source; const char* on; const char* off1; const char* off2; }; - for (const Expect e : {Expect{Source::Offline, "color.accent.danger", + struct Expect { + Source source; const char* word; const char* on; const char* off1; const char* off2; + }; + RadioSetupDialog::PeripheralLinkProbes original; + bool saved = false; + for (const Expect e : {Expect{Source::Offline, "OFFLINE", "color.accent.offline", "color.accent.success", "color.accent.warning"}, - Expect{Source::Radio, "color.accent.warning", - "color.accent.success", "color.accent.danger"}, - Expect{Source::Direct, "color.accent.success", - "color.accent.warning", "color.accent.danger"}}) { - // The refresh derives the source from the live connection, which a - // bare model has none of: OFFLINE arrives that way, and the other - // two are set on the item the refresh writes them to. - if (e.source != Source::Offline) { - list->item(0)->setData(Qt::UserRole + 1, static_cast(e.source)); + Expect{Source::Radio, "RADIO", "color.accent.warning", + "color.accent.success", "color.accent.offline"}, + Expect{Source::Direct, "DIRECT", "color.accent.success", + "color.accent.warning", "color.accent.offline"}}) { + // The refresh derives the source from the link probes: connected + // is DIRECT, relayed by the radio is RADIO, neither is OFFLINE. + auto previous = dialog.swapPeripheralLinkForTest( + QStringLiteral("tgxl"), + {[e] { return e.source == Source::Direct; }, + [e] { return e.source == Source::Radio; }}); + if (!saved) { + original = std::move(previous); + saved = true; } + pokeRefresh(model); QCoreApplication::processEvents(); const int on = pixelsOfToken(list, e.on); - if (on <= 0 || pixelsOfToken(list, e.off1) != 0 || pixelsOfToken(list, e.off2) != 0) { + if (!list->item(0)->text().endsWith(QLatin1String(e.word)) || on <= 0 || pixelsOfToken(list, e.off1) != 0 || pixelsOfToken(list, e.off2) != 0) { std::fprintf(stderr, "Source dot not drawn in %s (%d pixels): %s\n", e.on, on, qPrintable(list->item(0)->text())); return false; } } + dialog.swapPeripheralLinkForTest(QStringLiteral("tgxl"), std::move(original)); pokeRefresh(model); } // A message that happens to start with "Error:" or hold a dash changes nothing diff --git a/tests/radio_setup_headphone_output_gate_test.cpp b/tests/radio_setup_headphone_output_gate_test.cpp index f5ed7650a3..08df7246b5 100644 --- a/tests/radio_setup_headphone_output_gate_test.cpp +++ b/tests/radio_setup_headphone_output_gate_test.cpp @@ -373,7 +373,7 @@ private slots: else if (diff == 0) failures << where + QStringLiteral(": dimmed renders as live"); // The window's canon sheet paints a disabled label, value and - // Mute in the unavailable token: its colour is in the render. + // Mute in the unavailable token: its color is in the render. if (pixelsOfColour(dimmed, QColor(unavailable)) <= 0) failures << where + QStringLiteral(": not the unavailable token ") + unavailable; diff --git a/tests/radio_setup_label_theme_token_test.cpp b/tests/radio_setup_label_theme_token_test.cpp index b376aeda21..60c6e9e6bc 100644 --- a/tests/radio_setup_label_theme_token_test.cpp +++ b/tests/radio_setup_label_theme_token_test.cpp @@ -4,13 +4,13 @@ // color.canon.* tokens, re-applied on themeChanged (applyCanonSheet), and a // caption is a QLabel with the Caption tone (`rsTone`) and no sheet of its // own. A widget that kept its own sheet — a literal, or a token template that -// pins a colour — would override the window's and could survive View ▸ Theme +// pins a color — would override the window's and could survive View ▸ Theme // unchanged, which is the defect: under Default Light the captions once // rendered Default Dark's text on a light ground, and the line edits kept a // near-black fill. // // THE SWITCH IS THE ONLY DISCRIMINATOR. A freshly built dialog under Default -// Dark looks right whether or not its colours can move, so every colour here is +// Dark looks right whether or not its colors can move, so every color here is // read back from the polished palette ACROSS a theme switch, and the // dark→light→dark round trip is asserted rather than a single reading. // @@ -33,6 +33,8 @@ #include #include #include +#include +#include #include using namespace AetherSDR; @@ -140,7 +142,7 @@ private slots: } // No caption and no field carries a sheet of its own: one would override - // the window's, and a colour it pinned would not follow the switch. + // the window's, and a color it pinned would not follow the switch. void captionsAndFieldsTakeTheWindowSheet() { auto& tm = ThemeManager::instance(); @@ -195,7 +197,7 @@ private slots: // A status line changes tone while the window is open (an upload that // fails, a calibration that settles). setTone re-polishes the live widget, - // so the new colour is painted at once rather than on the next theme + // so the new color is painted at once rather than on the next theme // change or reopen. void aToneChangedOnAShownLabelRepaints() { @@ -213,7 +215,7 @@ private slots: const QColor caption = tm.color(QStringLiteral("color.canon.inkSoft")); const QColor error = tm.color(QStringLiteral("color.accent.danger")); const QColor ok = tm.color(QStringLiteral("color.accent.success")); - // CONTROL: the three colours are distinguishable. + // CONTROL: the three colors are distinguishable. QVERIFY(caption != error && error != ok && caption != ok); QCOMPARE(polished(label, QPalette::WindowText), caption); @@ -266,6 +268,56 @@ private slots: QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); } + + // A USB cable's list color is a resolved token, so it is re-resolved on a + // switch rather than kept from the theme the list was filled under. + void usbCableColorsFollowAThemeSwitch() + { + auto& tm = ThemeManager::instance(); + QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); + + RadioModel model; + // A disabled CAT cable, injected as the radio's status would report it. + model.usbCableModel().applyStatus(QStringLiteral("SNTHEME1"), + {{QStringLiteral("type"), QStringLiteral("cat")}, + {QStringLiteral("enable"), QStringLiteral("0")}, + {QStringLiteral("plugged_in"), QStringLiteral("1")}}); + RadioSetupDialog dialog(&model); + dialog.selectTab(QStringLiteral("USB Cables")); + dialog.show(); + + QListWidgetItem* cable = nullptr; + for (QListWidget* list : dialog.findChildren()) { + for (int i = 0; i < list->count() && !cable; ++i) { + if (list->item(i)->data(Qt::UserRole).toString() == QLatin1String("SNTHEME1")) + cable = list->item(i); + } + } + QVERIFY(cable); + + for (const char* theme : {kDarkTheme, kLightTheme, kDarkTheme}) { + QVERIFY(tm.setActiveTheme(QLatin1String(theme))); + QCOMPARE(cable->foreground().color(), tm.color(QStringLiteral("color.canon.muted"))); + } + } + + // Radio Side and Client Side are one choice between two: both take the + // Select role, so whichever is chosen reads the same. + void recordModeButtonsAreOneSelectSet() + { + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.selectTab(QStringLiteral("Audio")); + + QStringList roles; + for (QPushButton* b : dialog.findChildren()) { + if (b->text() == QLatin1String("Radio Side") || b->text() == QLatin1String("Client Side")) + roles << b->text() + QLatin1Char('=') + b->property("rsButton").toString(); + } + roles.sort(); + QCOMPARE(roles, QStringList({QStringLiteral("Client Side=select"), + QStringLiteral("Radio Side=select")})); + } }; int main(int argc, char** argv) diff --git a/tests/radio_setup_region_field_test.cpp b/tests/radio_setup_region_field_test.cpp index 5407fb46bb..265f5f9807 100644 --- a/tests/radio_setup_region_field_test.cpp +++ b/tests/radio_setup_region_field_test.cpp @@ -28,8 +28,8 @@ // colour back rather than only inspecting the freshly built dialog. // // Radio Setup is now a canon window: a value label carries the Value tone -// (`rsTone`) and the window's one sheet colours it with color.canon.ink, so -// the colour is read back from the label's polished palette, not from a sheet +// (`rsTone`) and the window's one sheet colors it with color.canon.ink, so +// the color is read back from the label's polished palette, not from a sheet // of its own (it has none). // // No hardware and no transport: the model is constructed bare and never @@ -120,7 +120,7 @@ bool isValueLabel(const QLabel* label) return label->property("rsTone").toByteArray() == "value"; } -// The colour the window's sheet gave the label, as polished into its palette. +// The color the window's sheet gave the label, as polished into its palette. QColor renderedColour(QLabel* label) { label->ensurePolished(); @@ -533,7 +533,7 @@ private slots: // Under Default Dark every one of them carries the token's value, and // takes it from the window's sheet: no label has a sheet of its own - // that could pin a colour against the switch below. + // that could pin a color against the switch below. for (QLabel* l : values) { QVERIFY2(renderedColour(l) == dark.ink, qPrintable(QStringLiteral("expected %1 under %2: %3") diff --git a/tests/radio_setup_window_size_test.cpp b/tests/radio_setup_window_size_test.cpp index 57f64b6b65..963bc769d9 100644 --- a/tests/radio_setup_window_size_test.cpp +++ b/tests/radio_setup_window_size_test.cpp @@ -5,7 +5,8 @@ // 720 up; // 2. a resize asking for another width keeps 960 and takes the height; // 3. a geometry saved at another width (before the width was fixed) is -// restored at 960, with its height. +// restored at 960, with its height, in both saved forms: "x,y,w,h" and +// the base64 saveGeometry() blob a PersistentDialog wrote. // // No hardware and no transport: a bare RadioModel, never connected. @@ -65,6 +66,34 @@ private slots: dialog.hide(); settings.remove(QLatin1String(kGeometryKey)); } + + void aWiderLegacyBlobRestoresAt960() + { + auto& settings = AppSettings::instance(); + // What the PersistentDialog saved before the move: saveGeometry() as + // base64, here from a free-width window at 1400x750. (restoreGeometry() + // fits a blob to the screen, so the height is one the offscreen + // screen holds.) + QByteArray blob; + { + QWidget wide; + wide.setGeometry(100, 100, 1400, 750); + wide.show(); + QVERIFY(QTest::qWaitForWindowExposed(&wide)); + QCOMPARE(wide.width(), 1400); // CONTROL: the blob is wider than 960 + blob = wide.saveGeometry(); + } + settings.setValue(QLatin1String(kGeometryKey), QString::fromLatin1(blob.toBase64())); + RadioModel model; + RadioSetupDialog dialog(&model); + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + // CONTROL: the blob was applied (its height arrived). + QTRY_COMPARE(dialog.height(), 750); + QCOMPARE(dialog.width(), 960); + dialog.hide(); + settings.remove(QLatin1String(kGeometryKey)); + } }; int main(int argc, char** argv)