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..90d2f62076 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 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: @@ -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..527acf2413 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 @@ -87,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/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/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 644cec87cc..513d2c3633 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 (orange dot) | | `Connecting…` | A connection attempt is in progress | The TGXL, PGXL and Antenna Genius applets use the same words. A device that @@ -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/radio-setup.md b/docs/user/docs/radio-setup.md index 6c958b9cc4..6d32afcc12 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 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. + ## Finding a setting Radio Setup is a searchable browser: a categorised tree of pages on the left and the selected page on the right. @@ -75,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/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/docs/user/docs/tgxl-tuner-control.md b/docs/user/docs/tgxl-tuner-control.md index d093c3390a..3aad153211 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 (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/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/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/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/PeripheralConnectionSource.h b/src/gui/PeripheralConnectionSource.h index 813e0b48af..f40df1d790 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 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) { + case Source::Direct: return QStringLiteral("color.accent.success"); + case Source::Radio: return QStringLiteral("color.accent.warning"); + case Source::Offline: break; + } + return QStringLiteral("color.accent.offline"); +} + } // 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 9a5e33a887..245a75ecdd 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -56,6 +56,7 @@ #include #include #include +#include #include #include #include @@ -68,7 +69,6 @@ #include #include #include -#include #include #include #include @@ -120,56 +120,26 @@ #include #include "core/ThemeManager.h" #include "RadioSetupDialogCommon.h" +#include "CanonIndicators.h" +#include "FramelessResizer.h" +#include "ThemedImageCache.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; +// 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) { @@ -317,12 +287,194 @@ 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); + // 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), + 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); + } + } + + // 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. + 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 center; 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); - scroll->setStyleSheet("QScrollArea { background: transparent; border: none; }"); scroll->setWidget(content); return scroll; } @@ -380,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, @@ -392,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; }" @@ -436,13 +590,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 +609,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 +651,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(); @@ -525,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) { @@ -537,12 +700,21 @@ 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); 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; } @@ -613,12 +785,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 +829,422 @@ 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: 4px 10px; + font-weight: 600; +} +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}}; +} +/* 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: 24px; + padding-top: 0px; + font-weight: 700; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 1px; + top: 0px; + padding: 0; +} +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; +} +)"; + +// 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, @@ -668,18 +1255,31 @@ 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")); + // 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(), [] { + return QString::fromLatin1(kRadioSetupStyle) + spinArrowRules() + 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 +1290,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 +1306,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 +1314,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 +1581,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 +1865,11 @@ 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); + // 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); } } @@ -1298,7 +1883,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 +1895,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 +1956,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 +1975,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 +2037,7 @@ QWidget* RadioSetupDialog::buildRadioTab() }); for (auto* lbl : group->findChildren()) { - if (lbl->styleSheet().isEmpty()) + if (!hasTone(lbl)) applyLabelStyle(lbl); } @@ -1480,7 +2047,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 +2076,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 @@ -1525,6 +2089,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); @@ -1577,7 +2144,7 @@ QWidget* RadioSetupDialog::buildRadioTab() QString stationVal = AppSettings::instance().value("StationName", "").toString(); auto* stationEdit = new QLineEdit( stationVal.isEmpty() ? QSysInfo::machineHostName() : stationVal); - applyEditStyle(stationEdit); + 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, @@ -1591,7 +2158,7 @@ QWidget* RadioSetupDialog::buildRadioTab() }); for (auto* lbl : group->findChildren()) { - if (lbl->styleSheet().isEmpty()) + if (!hasTone(lbl)) applyLabelStyle(lbl); } @@ -1602,7 +2169,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 +2207,7 @@ QWidget* RadioSetupDialog::buildRadioTab() 1, 1); for (auto* lbl : group->findChildren()) { - if (lbl->styleSheet().isEmpty()) + if (!hasTone(lbl)) applyLabelStyle(lbl); } @@ -1664,15 +2230,19 @@ QWidget* RadioSetupDialog::buildRadioTab() { auto* group = new QGroupBox("Firmware Update"); m_firmwareUpdateGroup = group; - group->setStyleSheet(kGroupStyle); auto* vlay = new QVBoxLayout(group); 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] { @@ -1685,45 +2255,48 @@ 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); + // 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 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); + 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); 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,43 +2304,43 @@ QWidget* RadioSetupDialog::buildRadioTab() [this, checkBtn](const QString& latest, bool avail) { checkBtn->setEnabled(true); if (avail) { - m_fwStatusLabel->setStyleSheet("QLabel { color: #f0c040; font-size: 10px; }"); - m_fwStatusLabel->setText(QString( + setTone(m_fwStatusLabel, Tone::Warn); + 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 { - m_fwStatusLabel->setStyleSheet("QLabel { color: #80e080; font-size: 10px; }"); - m_fwStatusLabel->setText("Firmware is up to date (v" + latest + ")."); + setTone(m_fwStatusLabel, Tone::Ok); + setShownText(m_fwStatusLabel, "Firmware is up to date (v" + latest + ")."); } }); connect(m_stager, &FirmwareStager::updateCheckFailed, this, [this, checkBtn](const QString& err) { checkBtn->setEnabled(true); - m_fwStatusLabel->setStyleSheet("QLabel { color: #e08080; font-size: 10px; }"); - m_fwStatusLabel->setText(err); + setTone(m_fwStatusLabel, Tone::Error); + setShownText(m_fwStatusLabel, 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; }"); - m_fwStatusLabel->setText(status); + setTone(m_fwStatusLabel, Tone::Hint); + setShownText(m_fwStatusLabel, status); }); connect(m_stager, &FirmwareStager::stageComplete, this, [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; }"); - m_fwStatusLabel->setText(err); + setTone(m_fwStatusLabel, Tone::Error); + setShownText(m_fwStatusLabel, err); }); // ── Browse / select installer manually ──────────────────────── @@ -1792,8 +2365,8 @@ QWidget* RadioSetupDialog::buildRadioTab() m_fwUploadBtn->setEnabled(false); m_fwProgress->show(); m_fwProgress->setValue(0); - m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }"); - m_fwStatusLabel->setText("Preparing firmware from " + QFileInfo(path).fileName() + "..."); + setTone(m_fwStatusLabel, Tone::Hint); + 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. @@ -1829,7 +2402,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, @@ -1840,15 +2413,13 @@ 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); - // 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,53 +2428,35 @@ 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); } 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; - disclaimer->setStyleSheet( - "QLabel { color: #c08040; font-size: 11px; font-style: italic;" - " padding: 4px 8px; }"); - vbox->addWidget(disclaimer); - vbox->addStretch(1); return page; } @@ -1920,7 +2473,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 +2482,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); @@ -1953,8 +2505,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] { @@ -1966,7 +2519,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 +2527,6 @@ QWidget* RadioSetupDialog::buildNetworkTab() // Advanced group { auto* group = new QGroupBox("Advanced"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -1983,11 +2535,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 +2570,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 +2591,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 +2646,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 +2688,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 +2763,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() @@ -2268,7 +2795,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 @@ -2352,7 +2881,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() } for (auto* lbl : group->findChildren()) - if (lbl->styleSheet().isEmpty()) applyLabelStyle(lbl); + if (!hasTone(lbl)) applyLabelStyle(lbl); updateRadioCapabilityVisibility(); @@ -2371,7 +2900,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 +2916,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,40 +2925,42 @@ 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); - 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; - applyEditStyle(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; - applyEditStyle(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; - applyEditStyle(staticGw); staticGw->setEnabled(canConfigure && isStatic); - fieldsGrid->addWidget(staticGw, 2, 1); - - for (auto* lbl : group->findChildren()) - if (lbl->styleSheet().isEmpty()) applyLabelStyle(lbl); + 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); auto* applyBtn = new QPushButton("Apply"); m_ipApplyButton = applyBtn; @@ -2445,11 +2971,7 @@ 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); + fieldsGrid->addWidget(applyBtn, 4, 1, Qt::AlignLeft); connect(dhcpBtn, &QPushButton::clicked, this, [dhcpBtn, staticBtn, staticIp, staticMask, staticGw, applyBtn] { @@ -2496,7 +3018,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 +3027,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,15 +3075,19 @@ 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); } + // 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)"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -2573,9 +3097,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); @@ -2622,11 +3143,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); }); @@ -2635,18 +3155,13 @@ QWidget* RadioSetupDialog::buildTxTab() connectTimingField(tx3Edit, "tx3_delay"); // 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}}; }"); + 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 (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 +3187,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 +3238,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 +3280,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 +3292,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); @@ -2832,7 +3332,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); @@ -2850,30 +3360,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); @@ -2883,7 +3386,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); @@ -2914,7 +3417,6 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() // CW group { auto* group = new QGroupBox("CW"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -2932,7 +3434,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] { @@ -2943,18 +3450,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:"); @@ -2970,18 +3477,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 @@ -2991,7 +3501,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) { @@ -3000,8 +3510,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); @@ -3011,7 +3524,6 @@ QWidget* RadioSetupDialog::buildPhoneCwTab() // Digital group { auto* group = new QGroupBox("Digital"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(6); @@ -3019,7 +3531,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 +3546,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 +3577,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 +3604,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 +3612,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 +3630,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 +3639,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 +3672,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 +3683,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 +3699,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; @@ -3217,20 +3709,25 @@ 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())); - applyEditStyle(ppbEdit); ppbEdit->setFixedWidth(80); connect(ppbEdit, &QLineEdit::editingFinished, this, [this, ppbEdit] { 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); @@ -3264,7 +3761,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 +3780,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 +3794,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 +3812,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 +3841,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( @@ -3363,7 +3854,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); @@ -3377,27 +3870,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 +3881,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 +3903,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 +3920,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 +3948,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 +3975,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 +3992,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 +4003,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 +4110,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 +4128,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); @@ -3693,33 +4159,29 @@ 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")); - 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 +4194,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 +4202,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 +4360,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 +4386,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 +4400,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 +4440,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 +4453,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 +4487,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 +4554,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 +4561,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 +4593,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 +4618,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 +4728,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 +4937,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 +4953,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 +4961,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 +4993,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 +5003,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 +5019,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 +5157,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 +5214,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 +5277,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 +5311,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 +5322,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,12 +5353,19 @@ 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); } + // 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 @@ -4969,9 +5373,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" @@ -4998,7 +5399,7 @@ QWidget* RadioSetupDialog::buildAudioTab() Qt::QueuedConnection); } }); - vbox->addWidget(plcCheck); + connectionBox->addWidget(plcCheck); } // ── Prevent Sleep ─────────────────────────────────────────────────── @@ -5007,9 +5408,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."); @@ -5017,12 +5415,11 @@ QWidget* RadioSetupDialog::buildAudioTab() connect(sleepCheck, &QCheckBox::toggled, this, [this](bool on) { m_model->setSleepInhibitWhileConnected(on); }); - vbox->addWidget(sleepCheck); + connectionBox->addWidget(sleepCheck); } // ── PC Audio Devices ──────────────────────────────────────────────── auto* pcGroup = new QGroupBox("PC Audio Devices"); - pcGroup->setStyleSheet(kGroupStyle); auto* pcLayout = new QVBoxLayout(pcGroup); // Input device @@ -5050,9 +5447,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 +5577,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 +5602,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 +5632,6 @@ QWidget* RadioSetupDialog::buildAudioTab() // ── Recording ─────────────────────────────────────────────────────── { auto* recGroup = new QGroupBox("Recording"); - recGroup->setStyleSheet(kGroupStyle); auto* recLayout = new QVBoxLayout(recGroup); auto& settings = AppSettings::instance(); @@ -5256,21 +5643,13 @@ 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}}; }"); + setButtonRole(radioSideBtn, ButtonRole::Select); 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 +5711,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 +5739,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 +5749,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,43 +5784,36 @@ 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); + // 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 { @@ -5474,14 +5839,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); @@ -5496,25 +5860,15 @@ QWidget* RadioSetupDialog::buildFiltersTab() }); } - vbox->addWidget(group); - } - - // Low Latency Digital checkbox - { - auto* group = new QGroupBox; - group->setStyleSheet(kGroupStyle); - 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()); - 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)); }); - hb->addWidget(chk); + grid->addWidget(chk, 4, 0, 1, 4); vbox->addWidget(group); } @@ -5533,18 +5887,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 +5906,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 +5914,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 +5930,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 +5941,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 +6030,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 +6078,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,10 +6100,12 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() auto* scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); - scroll->setStyleSheet("QScrollArea { background: transparent; border: none; }"); 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; @@ -5798,7 +6132,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 +6139,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 +6147,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, @@ -5847,8 +6174,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() kiwiScroll->setWidgetResizable(true); kiwiScroll->setFrameShape(QFrame::NoFrame); kiwiScroll->setMinimumHeight(190); - kiwiScroll->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding); - kiwiScroll->setStyleSheet("QScrollArea { background: transparent; border: none; }"); + kiwiScroll->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum); kiwiScroll->setAccessibleName("KiwiSDR RX antennas"); kiwiScroll->setAccessibleDescription( "Configured KiwiSDR receive-only antenna endpoints."); @@ -5898,17 +6224,17 @@ 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); - addHeader("Custom name", 1); + addHeader("Custom Name", 1); addHeader("Preview", 2); 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 +6246,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 +6256,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 +6352,6 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() }; auto styleKiwiEdit = [](QLineEdit* edit) { - applyEditStyle(edit); edit->setMinimumHeight(24); }; @@ -6060,13 +6380,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 +6405,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, @@ -6123,14 +6435,11 @@ 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"); - AetherSDR::ThemeManager::instance().applyStyleSheet( - empty, - "QLabel { color: {{color.text.label}}; font-size: 12px; " - "padding: 8px; }"); + setTone(empty, Tone::Hint); kiwiRowsLayout->addWidget(empty); } @@ -6140,7 +6449,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 +6459,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,30 +6467,26 @@ 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()); 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); @@ -6243,13 +6544,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 +6577,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 +6595,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 +6610,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); @@ -6425,10 +6703,9 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() }); } - addSectionHeader("ADD RECEIVER"); + 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); @@ -6437,10 +6714,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); @@ -6482,9 +6759,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); @@ -6517,7 +6791,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 " @@ -6549,7 +6823,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); @@ -6729,6 +7003,7 @@ QWidget* RadioSetupDialog::buildAntennaNamesTab() kiwiLayout->addLayout(kiwiTransferRow); } + vbox->addStretch(1); (*refresh)(); if (refreshKiwi && *refreshKiwi) { (*refreshKiwi)(); @@ -6752,7 +7027,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 +7038,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 +7074,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 +7115,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 +7138,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 +7151,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 +7210,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 +7245,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 +7258,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 +7319,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 +7326,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 +7381,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 +7416,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 +7428,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 +7489,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 +7511,6 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() }; bitWidgets.enabled = new QCheckBox; - AetherSDR::ThemeManager::instance().applyStyleSheet(bitWidgets.enabled, kCheck); addRow("Enabled:", bitWidgets.enabled); bitWidgets.source = makeSourceCombo(); @@ -7293,50 +7518,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 +7680,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 +7721,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); @@ -7523,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(); @@ -7536,12 +7764,7 @@ QWidget* RadioSetupDialog::buildUsbCablesTab() label += " (unplugged)"; auto* item = new QListWidgetItem(label); item->setData(Qt::UserRole, cable.serialNumber); - if (cable.enabled && cable.present) - item->setForeground(QColor("#30d050")); - else if (cable.enabled) - item->setForeground(QColor("#d0d030")); - else - item->setForeground(QColor("#808080")); + colorItem(item); cableList->addItem(item); if (cable.serialNumber == prevSn) cableList->setCurrentItem(item); @@ -7567,9 +7790,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 +7807,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 +7820,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 +7836,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 +7851,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 +7865,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); } }; @@ -7903,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; } @@ -7915,14 +8133,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 +8145,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 +8155,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 +8169,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 +8186,6 @@ QWidget* RadioSetupDialog::buildSerialTab() // ── Port Configuration ─────────────────────────────────────────────── { auto* group = new QGroupBox("Port Configuration"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); grid->setSpacing(4); @@ -7995,13 +8197,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:"); @@ -8099,16 +8307,16 @@ 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); + // 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); @@ -8175,14 +8383,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 +8399,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 +8422,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 +8445,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); } }; @@ -8270,25 +8469,27 @@ 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"); - 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(); s.setValue("SerialAutoOpen", on ? "True" : "False"); s.save(); }); - vbox->addWidget(autoOpen); + connectionBox->addWidget(autoOpen); } // ── 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 +8498,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 +8507,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 +8583,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 +8603,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 +8613,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 +8630,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 +8637,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 +8672,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 +8699,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 +8706,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 +8731,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 +8757,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 +8764,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 +8785,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 +8811,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 +8853,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 +8880,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 +8910,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 +8936,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 +8962,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,15 +8988,14 @@ QWidget* RadioSetupDialog::buildSerialTab() { auto* group = new QGroupBox("TMate 2 Display"); - group->setStyleSheet(kGroupStyle); auto* grid = new QGridLayout(group); 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); - note->setStyleSheet(kLabelStyle); + setTone(note, Tone::Hint); grid->addWidget(note, 0, 0, 1, 7); static const struct { @@ -8843,20 +9013,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)); - rowLbl->setStyleSheet(kLabelStyle); + 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])); - lbl->setStyleSheet(kLabelStyle); + 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()); - AetherSDR::ThemeManager::instance().applyStyleSheet( - spin, QString(kEditStyleTemplate).replace("QLineEdit", "QSpinBox")); + // 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; @@ -8874,7 +9044,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); + applyLabelStyle(lbl); grid->addWidget(lbl, row, 0, 1, 2); auto* spin = new QSpinBox; @@ -8882,9 +9052,8 @@ 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); + spin->setFixedWidth(120); + grid->addWidget(spin, row, 2, 1, 2, Qt::AlignLeft); connect(spin, QOverload::of(&QSpinBox::valueChanged), this, [this, key](int val) { @@ -8901,8 +9070,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); } @@ -9076,10 +9245,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 +9296,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 +9314,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 +9321,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(); @@ -9162,9 +9329,9 @@ 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."); - letterDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }"); + setTone(letterDesc, Tone::Hint); letterDesc->setWordWrap(true); letterLayout->addWidget(letterDesc); @@ -9195,15 +9362,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 +9375,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); @@ -9226,15 +9390,11 @@ QWidget* RadioSetupDialog::buildUiEnhancementsTab() auto* col = new QVBoxLayout; col->setSpacing(4); - auto* lbl = new QLabel(QString(kLetters[i])); - lbl->setAlignment(Qt::AlignCenter); - AetherSDR::ThemeManager::instance().applyStyleSheet(lbl, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); - + // The swatch carries its own letter; no caption above it. auto* btn = new QPushButton(QString(kLetters[i])); - btn->setStyleSheet(kBtnBase); + ThemeManager::instance().applyStyleSheet(btn, kBtnBase); colorBtns.append(btn); - col->addWidget(lbl); col->addWidget(btn); colorGrid->addLayout(col); } @@ -9244,15 +9404,11 @@ 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); vbox->addWidget(grp); - vbox->addStretch(); // ── Helpers (capture manager by pointer, buttons by value) ──────────────── SliceColorManager* pMgr = &SliceColorManager::instance(); @@ -9262,7 +9418,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)); @@ -9345,8 +9501,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"); - clickGrp->setStyleSheet(kGroupStyle); + auto* clickGrp = new QGroupBox("Single-Click Delay"); auto* clickLayout = new QVBoxLayout(clickGrp); clickLayout->setSpacing(8); @@ -9354,7 +9509,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 +9523,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 +9540,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); @@ -9416,15 +9566,11 @@ 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"); - wheelGrp->setStyleSheet(kGroupStyle); + auto* wheelGrp = new QGroupBox("Mouse Wheel"); 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 +9583,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); @@ -9450,6 +9596,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; } @@ -9469,7 +9617,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,13 +9628,13 @@ 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); 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); @@ -9496,26 +9643,16 @@ 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); - 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); + auto* forgetSel = new QPushButton("Forget Selected"); + auto* forgetAll = new QPushButton("Forget All"); btnRow->addWidget(forgetSel); btnRow->addStretch(); btnRow->addWidget(forgetAll); @@ -9586,7 +9723,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 +9734,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 +9757,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,11 +9766,13 @@ 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); - 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). @@ -9666,18 +9802,14 @@ 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); + // 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] { @@ -9698,23 +9830,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 +9862,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..60dedd5304 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: @@ -119,6 +120,14 @@ class RadioSetupDialog : public PersistentDialog { 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/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..080cd54fc9 100644 --- a/src/gui/RadioSetupDialog_Peripherals.cpp +++ b/src/gui/RadioSetupDialog_Peripherals.cpp @@ -34,6 +34,10 @@ #include "models/TunerModel.h" #include +#include +#include +#include +#include #include #include #include @@ -174,24 +178,83 @@ 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; }"; +// 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 color token; the +// rest of the line keeps the list's text color. +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, 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(); + 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()); + 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 color = ThemeManager::instance().color( + PeripheralConnectionSource::colorToken(static_cast(source.toInt()))); + painter->save(); + painter->setFont(opt.font); + painter->setPen(color); + painter->drawText(at, QStringLiteral("●")); + painter->restore(); + } +}; QString peripheralTr(const char* text) { @@ -257,25 +320,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 +350,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 +433,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 +452,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 +499,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 +511,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 +540,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 +548,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; } @@ -636,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) { @@ -648,7 +706,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 +827,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", @@ -851,10 +906,9 @@ 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)); - 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 +955,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 +974,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 +997,16 @@ 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)); + // 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)); - applyEditStyle(serialCustomEdit); populateSerialPortCombo(serialCombo, serialCustomEdit, PeripheralSettings::deviceString(group, "SerialPort")); serialCustomEdit->setVisible(serialCombo->currentData().toString() == "__custom__"); @@ -982,7 +1038,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 +1053,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 +1061,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 +1150,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 +1175,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 +1220,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 +1269,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 +1350,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 +1388,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 +1399,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 +1696,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 +1728,23 @@ 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->setItemDelegate(new PeripheralListDelegate(deviceList)); 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,17 +1752,16 @@ 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); + listActions->addWidget(addButton); + listActions->addWidget(removeButton); + listActions->addStretch(1); listLayout->addLayout(listActions); auto* removalNotice = new QLabel(listGroup); removalNotice->setObjectName(QStringLiteral("peripheralRemovalNotice")); 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 +1770,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. " @@ -2279,6 +2299,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/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/peripheral_auth_dialog_test.cpp b/tests/peripheral_auth_dialog_test.cpp index 099b565b57..9c1f4b9ada 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" @@ -1316,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"); @@ -1326,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); } @@ -1361,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()) { @@ -1372,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, @@ -1401,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; @@ -1809,6 +1817,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 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 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) == color.rgb()) { + ++n; + } + } + } + return n; +} + bool checkStatusPresentation() { AppSettings::instance().remove(QStringLiteral("Peripherals")); @@ -1835,6 +1860,43 @@ 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 orange, + // RADIO amber, DIRECT green. Each state is read against the other two. + { + using PeripheralConnectionSource::Source; + 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, "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 (!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 // by itself; only the explicit attention does. dialog.editPeripheralStatusForTest(QStringLiteral("tgxl"), [](PeripheralDeviceStatus& state) { @@ -1901,7 +1963,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 +2025,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..08df7246b5 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 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 d48d0e02c3..60c6e9e6bc 100644 --- a/tests/radio_setup_label_theme_token_test.cpp +++ b/tests/radio_setup_label_theme_token_test.cpp @@ -1,67 +1,40 @@ -// #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 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, 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 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. // -// 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 +#include #include using namespace AetherSDR; @@ -71,46 +44,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 +83,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 +92,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(darkCaption != lightCaption); + QVERIFY(darkField != lightField); + QVERIFY(darkInk != lightInk); QVERIFY(tm.setActiveTheme(QLatin1String(kDarkTheme))); } - // 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 +133,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 +141,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 color 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 +175,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 color 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 colors 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 +238,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); @@ -302,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_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/radio_setup_region_field_test.cpp b/tests/radio_setup_region_field_test.cpp index c2a051e66e..265f5f9807 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 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 // 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 color 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 color 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/radio_setup_window_size_test.cpp b/tests/radio_setup_window_size_test.cpp new file mode 100644 index 0000000000..963bc769d9 --- /dev/null +++ b/tests/radio_setup_window_size_test.cpp @@ -0,0 +1,107 @@ +// 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, 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. + +#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)); + } + + 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) +{ + 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 cfc1358dd1..47068512fe 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 @@ -3660,6 +3664,52 @@ 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) + +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 @@ -3670,6 +3720,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 +3746,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 +3773,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 +3801,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 +4966,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 @@ -8445,6 +8500,8 @@ 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_pair_grid_test radio_setup_max_power_field_test radio_setup_recording_mode_dim_test radio_setup_headphone_output_gate_test