diff --git a/AGENTS.md b/AGENTS.md
index 8e374bc64..ca0db65be 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -218,6 +218,11 @@ Key source directories: `src/core/` (protocol, audio, DSP), `src/models/`
TUs; new feature code goes in a sibling, NOT `MainWindow.cpp` — see
[Adding code to MainWindow](#adding-code-to-mainwindow)
- `PanadapterStream` — VITA-49 UDP parsing, routes FFT/waterfall/audio/meters
+- `CrossNeedleMeterGeometry` — PWR applet's cross-needle power/SWR face math.
+ **Before touching the response model, SWR-contour construction, or label
+ placement, read [`docs/cross-needle-meter-math.md`](docs/cross-needle-meter-math.md)** —
+ the authoritative model + decision record (it exists because these formulas
+ have churned when edited without a shared spec).
**Threading:** up to 12 threads — see `docs/architecture/pipelines.md` for the
full thread diagram, data flow, cross-thread signal map, and GPU rendering notes.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c150a258b..29c28d91e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -776,6 +776,9 @@ set(GUI_SOURCES
src/gui/RxApplet.cpp
src/gui/AdaptiveFilterControls.cpp
src/gui/FilterPassbandWidget.cpp
+ src/gui/AnalogMeterFaceTheme.cpp
+ src/gui/RadioSwrValidityFilter.cpp
+ src/gui/SMeterGeometry.cpp
src/gui/SMeterWidget.cpp
src/gui/CrossNeedleMeterGeometry.cpp
src/gui/CrossNeedleMeterWidget.cpp
@@ -2293,6 +2296,30 @@ target_include_directories(theme_manager_test PRIVATE src)
target_link_libraries(theme_manager_test PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Test)
add_test(NAME theme_manager_test COMMAND theme_manager_test)
+# AppSettings persistence safety. Each scenario is a separate process because
+# AppSettings is a process-wide singleton and load state must not leak between
+# cases.
+add_executable(app_settings_safety_test
+ tests/app_settings_safety_test.cpp
+ src/core/AppSettings.cpp
+)
+target_include_directories(app_settings_safety_test PRIVATE src)
+target_link_libraries(app_settings_safety_test PRIVATE Qt6::Core)
+foreach(APP_SETTINGS_SCENARIO
+ save-before-load
+ load-then-save
+ first-run
+ count-guard
+ backup-recovery
+ missing-live-temp
+ missing-live-backup
+ missing-live-corrupt
+ failed-load)
+ add_test(
+ NAME app_settings_safety_${APP_SETTINGS_SCENARIO}
+ COMMAND app_settings_safety_test ${APP_SETTINGS_SCENARIO})
+endforeach()
+
add_executable(panadapter_model_rx_antenna_test
tests/panadapter_model_rx_antenna_test.cpp
src/models/PanadapterModel.cpp
@@ -2827,6 +2854,7 @@ add_test(NAME radio_status_ownership_test COMMAND radio_status_ownership_test)
qt_add_resources(CROSS_NEEDLE_METER_TEST_RESOURCES resources/resources.qrc)
add_executable(cross_needle_meter_test
tests/cross_needle_meter_test.cpp
+ src/gui/AnalogMeterFaceTheme.cpp
src/gui/CrossNeedleMeterGeometry.cpp
src/gui/CrossNeedleMeterWidget.cpp
${CROSS_NEEDLE_METER_TEST_RESOURCES}
@@ -2840,6 +2868,29 @@ add_test(NAME cross_needle_meter_test COMMAND cross_needle_meter_test)
set_tests_properties(cross_needle_meter_test PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
+# Versioned standard S-meter face: responsive geometry, calibrated mappings,
+# resource validation/fallback, and offscreen rendering of every meter mode.
+add_executable(s_meter_geometry_test
+ tests/s_meter_geometry_test.cpp
+ src/gui/AnalogMeterFaceTheme.cpp
+ src/gui/RadioSwrValidityFilter.cpp
+ src/gui/SMeterGeometry.cpp
+ src/gui/SMeterWidget.cpp
+ src/core/ThemeManager.cpp
+ src/core/AppSettings.cpp
+ src/core/LogManager.cpp
+ src/core/AsyncLogWriter.cpp
+ ${CROSS_NEEDLE_METER_TEST_RESOURCES}
+)
+target_include_directories(s_meter_geometry_test PRIVATE src)
+target_link_libraries(s_meter_geometry_test PRIVATE
+ Qt6::Core Qt6::Gui Qt6::Widgets
+)
+set_target_properties(s_meter_geometry_test PROPERTIES AUTOMOC ON)
+add_test(NAME s_meter_geometry_test COMMAND s_meter_geometry_test)
+set_tests_properties(s_meter_geometry_test PROPERTIES
+ ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
+
# MCP server field-mapping / protocol regression test (#4177). Pure Python,
# no app or Qt needed — guards the schema ↔ bridge verb field mapping.
find_program(PYTHON3_EXECUTABLE NAMES python3 python)
diff --git a/docs/cross-needle-meter-math.md b/docs/cross-needle-meter-math.md
new file mode 100644
index 000000000..6bdda7dc8
--- /dev/null
+++ b/docs/cross-needle-meter-math.md
@@ -0,0 +1,236 @@
+# Cross-needle power/SWR meter — mathematical model & decision record
+
+**Read this before changing anything about the PWR applet's cross-needle
+meter.** This file is the authoritative source of truth for *how the meter
+computes and draws forward power, reflected power, the needle crossing, and the
+SWR contours* — and, just as importantly, *which modeling choices are settled
+and why*. It exists because the model was reshaped by successive edits that each
+looked locally reasonable but pulled the geometry in opposite directions. If you
+are about to "correct" the physics, your correction must update this document
+(and the tests) or it will be reverted by the next contributor.
+
+Authoritative reference physics: a classic amateur cross-needle meter (Daiwa
+CN-series / Diamond SX-series). The governing write-up presents **two** pictures
+— an idealized flat-rest model (its §10/§11) and the real-meter deviations (its
+§13). Those two pictures imply *opposite* geometries, which is the root cause of
+past churn. **This document picks one and records why.**
+
+---
+
+## 0. Where each kind of fact lives (do not duplicate)
+
+| Fact | Home | Notes |
+|---|---|---|
+| **Formulas / algorithms** | `src/gui/CrossNeedleMeterGeometry.cpp` `.h` | The only place math lives. |
+| **Parameters / calibration data** | `resources/meterfaces/cross-needle-v12.json` | Numbers only, no logic. Compiled into the app via `resources.qrc`. |
+| **Calibration fit + diagnostics** | `tools/fit_cross_needle_response.py` | Refits the response coefficients; `--check` guards them. |
+| **Executable spec (enforcement)** | `tests/cross_needle_meter_test.cpp` | The tests are the contract. If a change is "correct," it changes the tests deliberately, not incidentally. |
+| **Decisions & rationale** | **this file** | Why the model is shaped the way it is. |
+
+Rule: the JSON never contains logic; the code never hard-codes calibration that
+belongs in JSON; comments and READMEs are descriptive, **the tests are
+normative.** When the model changes, update all five rows in the same change.
+
+---
+
+## 1. Core electrical quantities
+
+A directional coupler yields forward/reflected envelope voltages `Vf`, `Vr`:
+
+ rho = Vr/Vf = sqrt(Pr/Pf) 0 <= rho < 1
+ SWR = (1 + rho) / (1 - rho) (`swrFromPowers`)
+ Pr = Pf * rho^2 (`reflectedPowerWatts`)
+ inverse: rho = (SWR - 1)/(SWR + 1)
+
+SWR depends only on the ratio `Vr/Vf`, not on absolute power — this is why one
+fixed family of printed contours reads SWR at any power. Unchanged across all
+revisions; do not touch.
+
+---
+
+## 2. Detector + movement response `angleForValue()`
+
+Needle deflection is a degree-5 Bernstein of **normalized power** — a real
+D'Arsonval movement driving a square-root watt scale:
+
+ p = (P - Pmin)/(Pmax - Pmin) // normalized power
+ r = degree-5 Bernstein(p; responseCoefficients[0..5]) // monotonic + concave
+ angle = lerp(responseStartRadians, responseEndRadians, r)
+
+`responseModel` = `concave_bernstein_v1`. The coefficients (non-decreasing, with
+non-positive second differences → concave, square-root-like compression) and the
+start/end angles live in each scale's `response` block in the JSON. Two entry
+points share this Bernstein: **`angleForValue`** clamps `p` to `[0, 1]` for live
+needles and printed ticks (a movement never reads past full scale), while
+**`angleForNormalizedPower`** does NOT clamp — SWR-contour construction passes
+`p` slightly over 1 to extend a movement a little past full scale (§4).
+
+**Angled rest (the key property).** `responseStartRadians` equals the scale's
+printed-zero angle (`reference_angles_radians[0]` == `startRadians`). At zero
+power `r = 0`, so each needle parks *on its printed 0 mark, pointing up into the
+dial* — exactly like a real cross-needle meter. It does NOT rest flat on the
+concealed pivot baseline. This is what keeps the low-SWR contours visible and
+shallow (see Decision **D1**). `printedAngleForIndex()` therefore returns the
+calibrated movement angle for every tick, including 0.
+
+---
+
+## 3. Needle rays and their crossing
+
+Pivots are concealed below the visible face (`forwardScale.center`,
+`reflectedScale.center`, both below the canvas). Each needle is a straight ray
+from its pivot at the deflection angle; the tip rides a circle of radius
+= needle length:
+
+ tip = center + (cos(angle), sin(angle)) * radius (`pointOnScale`)
+ crossing = line-intersection(fwd pivot→fwd tip, ref pivot→ref tip) (`needleIntersection`)
+
+The mechanism only ever does this: two rays, one crossing. SWR is *read* off the
+printed contour that passes through the crossing, never computed by a third
+movement. Verified against a stored proof case in the tests
+(`validation.intersection`).
+
+---
+
+## 4. Constant-SWR contours `swrGuidePath()`
+
+A contour is the locus of crossings at fixed SWR as power sweeps up from the
+hidden convergence:
+
+ ratio = ((SWR-1)/(SWR+1))^2 = rho^2 = Pr/Pf (infinity → ratio = 1)
+ sweep forward power up (extending past full scale via angleForNormalizedPower)
+ point = crossing of fwd needle at P and ref needle at P*ratio
+ // terminate on ONE common boundary: a fixed graph_clearance short of
+ // whichever power arc is nearer (design 19 — a consistent fan; see D1).
+
+Within the readable range a contour is an **exact** constant-SWR locus: the
+crossing at true SWR = S at any readable power lands on the S contour, so the
+printed grid reads correctly. The short segment past full scale (needed only by
+the low-SWR contours to reach the common boundary) is **drawn face art**, not a
+readable locus — the ratio-exactness tests therefore verify only the readable
+portion. Contours are **not** circular arcs.
+
+---
+
+## 5. SWR number placement `swrLabelCenters()`
+
+Derived, not authored. There are **no per-guide label coordinates.** Each number
+anchors on its own contour at a fraction `label_arc_fraction` of that contour's
+**visible** arc length (from where it clears the mask to its upper end), then is
+decluttered:
+
+1. Anchor at `label_arc_fraction` up the visible contour; if the box collides,
+ step along the contour by `label_declutter_step` (alternating ±, and past the
+ top along the upper tangent for a stub too short to host a box, e.g. 1.1),
+ and along the contour normal (alternating ±), until the box is clear.
+2. "Clear" = keeps `label_box_padding` from every already-placed label, every
+ *other* contour path, the power-scale number boxes, the lower mask, and the
+ face edge.
+3. Result is memoized in `swrLabelCenterCache`, keyed by the exact `QFont` used
+ by the widget. A widget/application font change therefore recomputes the
+ collision layout before rebuilding the static face.
+
+To move the whole number ring, change **one** value: `label_arc_fraction`. The
+tests enforce association (each number nearest its own contour or its upper
+continuation), spacing, non-overlap, mask clearance, and clearance from the
+reflected-scale numbers.
+
+---
+
+## 6. Decisions (settled — change only by editing this section + the tests)
+
+### D1 — Angled rest → single hidden convergence → shallow, visible low-SWR lines
+**Status: ADOPTED** (this supersedes the earlier flat-rest decision; see the
+changelog at the end). Implemented by `concave_bernstein_v1` with the response
+starting at the angled printed-zero rest (§2).
+
+The needles park on their printed zeros, pointing up into the dial. At zero
+power both needles are angled (not colinear on the baseline), so every
+constant-SWR contour begins at the **same** hidden crossing just below the lower
+mask (~`(750, 979)`) and fans upward. The low-SWR lines ride up **along the
+reflected needle's rest ray** — visible above the mask and rising at a shallow
+angle that steepens toward SWR = ∞ (measured: 1.1 ≈ 19°, 1.2 ≈ 32°, 1.5 ≈ 62°).
+
+**Why this model (motivation, not proof).** The real Daiwa **CN-801** face
+(its manual's 第2図, https://sq7fpd.boff.pl/CN801.pdf) shows the SWR 1.1–1.5
+lines visible and shallow and the fan converging near the bottom — which is what
+motivated the angled-rest choice over the idealized flat-rest picture (the
+reference write-up's §13 vs §10/§11). Be honest about what the manual actually
+establishes: it confirms SWR is read at the needle crossing and that 10 W / 0.4 W
+reads SWR 1.5 (our `validation` proof asserts exactly this). It does **not**
+document the detector response, the single hidden convergence, or the
+past-full-scale extension — those are our engineering choices, informed by the
+face's appearance, not proven by the manual. Treat them as settled-by-decision
+here, not as facts derived from the source.
+
+**Retired model (do NOT reintroduce without flipping this decision + the tests):**
+a `monotonic_voltage_bernstein_v1` response that rested both needles flat on the
+pivot baseline (forward −π, reflected 0) so contours left independent baseline
+origins vertically (§10). That is mathematically clean but it pins the low-SWR
+crossings onto the baseline: SWR 1.1/1.2 fell entirely below the mask and 1.3–2
+drew as steep stubs — visibly wrong against the CN-801. The tests now assert the
+angled-rest behaviour (`all contours share one convergence concealed below the
+lower mask`, `low-SWR contours (1.1, 1.2) reach the visible face`, `low-SWR lines
+emerge shallow and steepen toward higher SWR`).
+
+### D2 — SWR labels are derived, never authored per-guide
+**Status: ADOPTED.** See §5. An SWR guide in JSON stores only
+`{label, display_label, swr}`. The three global knobs
+(`label_arc_fraction`, `label_declutter_step`, `label_box_padding`) plus the
+declutter algorithm produce every position. This replaced 24 hand-tuned
+`label_y`/`label_offset` pixel values that had to be re-nudged after any
+geometry change.
+
+### D3 — JSON is data, code is math, tests are the contract
+**Status: ADOPTED.** See §0. A change to the physics is not "done" until the
+formula (code), any parameters (JSON), the fit tool, this document, and the
+tests all agree in the same change.
+
+---
+
+## 7. Changing the model safely — checklist
+
+1. State the intent here (edit the relevant Decision, or add a new one).
+2. Change the formula in `CrossNeedleMeterGeometry.cpp`.
+3. If calibration changed: re-run `tools/fit_cross_needle_response.py`, paste the
+ coefficients into the JSON, and confirm `--check` passes.
+4. Update `tests/cross_needle_meter_test.cpp` to assert the new behavior *on
+ purpose*, and `resources/meterfaces/README.md` for the parameter docs.
+5. Bump `format_version`/`design_version` in the JSON if the schema/geometry
+ meaning changed.
+6. Rebuild and run `cross_needle_meter_test` — it is the acceptance gate.
+
+---
+
+## 8. Changelog
+
+- **design_version 19 — consistent fan: all contours terminate on a common
+ boundary.** Every contour now sweeps until its crossing reaches one shared
+ envelope a fixed `graph_clearance` short of whichever power arc is nearer, so
+ the whole SWR family is an even fan parallel to the arcs (matches the CN-801).
+ Low-SWR crossings reach that boundary only a little past full forward scale,
+ so `angleForNormalizedPower` extends the movement angle modestly past unity
+ (≤ ~2.5× full-scale power) for contour drawing only — **live needles and the
+ power ticks still clamp at full scale.** Consequence: a contour is an exact
+ constant-SWR locus within the readable range and drawn face-art beyond it; the
+ ratio-exactness tests verify only the readable portion (see D3 note).
+ **`format_version` bumped 5 → 6** here: the `response` and `swr` schema changed
+ across designs 16→19 (removed `linear_end_voltage`/`blend_end_voltage`, renamed
+ `label_arc_radius` → `label_arc_fraction`, added `mask_gap`), so a stale
+ format-5 file is now rejected at the format gate rather than on content.
+- **design_version 18 — contours reach the arcs + leading-end mask gap.**
+ `graph_clearance` reduced (154 → 60) so the high-SWR contours extend close to
+ the power arcs instead of stopping far short. Low-SWR contours are unchanged
+ (they stop at full forward scale — the readable limit). Added `mask_gap` (a
+ render-only trim, applied in `CrossNeedleMeterWidget::drawSwrGuides`) so every
+ contour's lower end sits a small gap above the mask, matching the real face.
+- **design_version 17 — angled-rest / shallow low-SWR (D1 flipped).** Moved the
+ needle zero-rest from the flat pivot baseline back to the angled printed-zero
+ position (`concave_bernstein_v1`, power domain). Reason: the flat-rest model
+ (design 16) hid the SWR 1.1/1.2 contours below the mask and drew 1.3–2 as
+ steep stubs, contradicting the real Daiwa CN-801 face. Now the low-SWR lines
+ are visible and shallow. Also: SWR label anchor changed from an absolute
+ arc-length (`label_arc_radius`) to a fraction of each contour's visible length
+ (`label_arc_fraction`), so it adapts to the single-origin fan.
+- **design_version 16 — flat-rest / spread-origin (retired).** See D1's retired
+ model. Introduced the voltage-domain response and vertical baseline departure.
diff --git a/resources/meterfaces/README.md b/resources/meterfaces/README.md
index 24b840ac8..fdf2e0e36 100644
--- a/resources/meterfaces/README.md
+++ b/resources/meterfaces/README.md
@@ -1,10 +1,107 @@
-# Cross-needle meter geometry
+# Meter-face geometry
+
+## Standard S-meter
+
+`s-meter-v1.json` is the editable source of truth for the standard S-meter's
+responsive face construction. It preserves the original `SMeterWidget`
+formulas rather than imposing the cross-needle meter's fixed design canvas:
+the arc radius and horizontal center scale from widget width, while the arc
+center height, typography, pivot, and readouts retain their established
+width/height-relative or pixel offsets.
+
+The file owns the shallow-arc sweep, RX calibration, selectable TX scale
+ticks, tick/label placement, needle and shadow dimensions, pivot/glow sizing,
+peak overlays, and readout placement. The established **Aether default** face
+continues to use `SMeterWidget` and `ThemeManager` colors. The three physical
+faces use the separate shared catalog described below, so geometry edits cannot
+accidentally change theme behavior.
+
+The mechanical construction has one authority. A value first selects its
+calibrated point on the outer scale arc. The line from the concealed movement
+pivot through that point is the value's movement ray. The needle extends along
+that ray, and the inner TX tick is placed where the same ray intersects the
+inner scale arc. Peak markers use the same construction. Do not calculate a
+needle endpoint by extending a radius from the scale arc's center: the arc
+center and physical movement pivot are intentionally different points.
+
+Important rules:
+
+- Keep the RX calibration anchors mechanically consistent: S0 is -127 dBm,
+ S9 is -73 dBm at fraction 0.6, and S9+60 is -13 dBm at fraction 1.0.
+- `center_y_height_factor` participates in
+ `centerY = radius + height * factor`; it is not an absolute normalized Y.
+- Power tick policies are ordered from the largest threshold to the 0 W
+ default. The radio-selected maximum and warning point remain live widget
+ state; this resource controls only how that scale is printed.
+- Every version-1 field is required and type-checked. A missing, mistyped, or
+ mechanically invalid field rejects the complete resource; fields are never
+ silently mixed with compiled defaults.
+- Keep the JSON and `SMeterGeometry::fallback()` exactly aligned. The test
+ compares every field. The JSON is the shipping authority; the complete
+ fallback is selected only when the complete resource is rejected, so a
+ damaged resource cannot crash, distort, or blank the applet.
+- The pivot mask uses `pivot.radius_width_factor`, but its effective width is
+ capped by the preferred face aspect ratio from `sizing`. This preserves the
+ approved mask at normal sizes while preventing it and its glow from
+ ballooning when a detached window is made much wider without becoming taller.
+- `readout.top_extra_pixels` is the minimum gap above the larger left/right
+ value font. The smaller centered source label is top-aligned against those
+ values using the fonts' actual ascents, maximizing its clearance from the
+ printed arc at minimum, docked, or detached sizes.
+- `sizing.minimum_aspect_ratio` and `maximum_aspect_ratio` bound only the
+ face's render viewport. Normal widget sizes retain the original responsive
+ formulas exactly; pathological tall or wide detached windows center a
+ bounded face instead of stretching the concealed pivot outside its arcs or
+ scaling labels until they collide. The lower bound remains below the normal
+ docked aspect ratio, so the standard applet is pixel-for-pixel unchanged.
+
+After editing, run `s_meter_geometry_test` and use the automation bridge to
+grab `standardSMeter` at its normal, minimum, and enlarged sizes.
+
+## Shared physical analog themes
+
+`analog-meter-themes-v1.json` is the runtime source of truth for the physical
+**Classic warm**, **Dark-room uplight**, and **Graphite dark** materials shared
+by the standard S-meter and the PWR cross-needle meter. It owns only visual
+materials: the deterministic gradients, fixed-seed paper grain, ink and text
+palettes, needle colors, and the approved symmetric nine-point lower mask. It
+does not own either meter's calibration, arcs, ticks, or movement geometry.
+
+The gradient coordinates use the PWR meter's 1448 x 948 face rectangle. The
+shared painter maps that reference rectangle to the target widget, so both
+meters receive the same material recipe without storing or stretching a raster
+image. The grain texture is generated deterministically and expensive material
+layers are cached by the widgets.
+
+The lower-mask boundary is normalized to its target face. Keep its nine points
+strictly left-to-right and mirrored around x = 0.5. The standard S-meter uses
+this profile only for the three physical themes; **Aether default** deliberately
+retains its existing half-disc mask.
+
+Classic warm and Dark-room uplight use the dark layered needle palette;
+Graphite dark uses the restrained light metallic palette. These colors change
+only the material stack around the already-calibrated pivot-to-tip line. The
+standard meter's needle tip still comes exclusively from `s-meter-v1.json`, and
+the PWR meter's two needle rays still come exclusively from
+`cross-needle-v12.json`.
+
+For format compatibility, `cross-needle-v12.json` still carries its original
+V12 appearance values. They are no longer the runtime authority for shared
+background or palette colors; edit `analog-meter-themes-v1.json` and its full
+compiled fallback together. A future cleanup can remove those compatibility
+fields without mixing that migration into a visual theme change.
+
+## Cross-needle PWR meter
`cross-needle-v12.json` is the editable source of truth for the PWR applet's
cross-needle Forward / Reflected power and SWR face. It is intentionally kept
as data rather than buried as painter constants so the face can be refined
without reverse-engineering `CrossNeedleMeterWidget`.
+The filename is a stable resource URI retained for compatibility. The payload's
+authoritative `format_version` and `design_version` fields are currently 6 and
+19; inspect those fields rather than inferring either version from the filename.
+
The geometry is expressed in a 1500 x 1000 design coordinate system. The
widget applies one uniform scale and centers that design in its available
rectangle; never compensate for a different widget aspect ratio by changing
@@ -12,12 +109,11 @@ individual points.
Important construction rules:
-- `face_gradient` defines the old-instrument material entirely in code: a
- three-stop vertical base, one broad center backlight, and one symmetric edge
- vignette. All centers and radii use design coordinates. The painter also
- reuses this exact construction when clearing the upper scale underpass and
- the SWR label boxes, so those regions never become flat-color patches.
-- `uplight_gradient` defines the selectable **Dark-room uplight** treatment:
+- The runtime background materials and palettes come from
+ `analog-meter-themes-v1.json`. The older `face_gradient`, `uplight_gradient`,
+ and `dark_theme` blocks remain compatibility data in the stable geometry
+ resource; they are not the runtime material authority.
+- The shared `uplight_gradient` defines the selectable **Dark-room uplight** treatment:
one low-exposure vertical card gradient, a broad bottom-center halo, a
smaller warm hotspot just behind the lower mask, a tight paper-diffusion
bloom, and a symmetric vignette. The broad halo has an additional shoulder
@@ -31,30 +127,48 @@ Important construction rules:
being designed. `scale_separator_rgba` replaces the classic face's
opaque cream graph separator with translucent copper so the uplight remains
visible through the complete left and right scale bands.
-- `dark_theme` defines the selectable **Graphite dark** treatment. It combines
+- The shared `dark_gradient` and Graphite palette define the selectable
+ **Graphite dark** treatment. It combines
a matte charcoal card, a restrained neutral ambient lift, a low amber edge
glow, a symmetric vignette, and scale-stable fixed-seed paper/paint grain.
The same block owns its
aged-ivory scale/text colors, copper SWR ink, steel-blue inner rules, dark
mask palette, and metallic needle colors. These are material overrides
only; all arcs, ticks, guides, pivots, and calibration remain shared.
-- `needles` defines a code-drawn physical material stack without changing the
+- `needles` defines the PWR meter's code-drawn physical material dimensions
+ without changing the
calibrated movement rays. `line_*` is the exact pivot-to-tip body;
`soft_shadow_*` and `shadow_*` form a restrained two-depth cast shadow;
`edge_*` shades the side opposite the light; and `highlight_*` adds a narrow
- reflection on the upper-left edge. Edge offsets are measured perpendicular
+ reflection on the upper-left edge; its colors come from the shared palette.
+ Edge offsets are measured perpendicular
to each needle, while shadow offsets are ordinary design-space x/y values.
Keep both edge strokes narrower than `line_width` and the soft shadow wider
than the contact shadow so the effect survives at 260 x 173 without reading
as a bevel or glow.
- `forward_scale.center` is the concealed right-hand movement pivot and
`reflected_scale.center` is the concealed left-hand movement pivot.
-- Tick angles are calibrated and non-linear. Keep `values`, `angles`, and
- `labels` the same length and do not replace them with evenly-spaced angles.
- The renderer derives a shape-preserving monotone cubic movement curve from
- those ticks. It passes through every stored calibration angle while keeping
- the first derivative continuous, so live needle motion does not form a
- visible elbow at each calibration interval.
+- `reference_angles_radians` preserves the perspective-corrected photographic
+ tick observations; it is fitting evidence, not runtime calibration. Keep it,
+ `values`, and `labels` the same length. Each scale's `response` is the sole
+ movement authority used to derive major ticks, power-valued minor ticks,
+ live needles, inverse readings, and SWR construction.
+ `concave_bernstein_v1` is a normalized degree-5 Bernstein response in
+ normalized POWER (a real D'Arsonval movement with a sqrt watt scale). Its six
+ controls are non-decreasing with non-positive second differences (concave,
+ square-root-like compression). `response.start_radians` is the ANGLED rest =
+ the printed-zero angle (`reference_angles_radians[0]` == `start_radians`), so
+ at zero power the needle parks on its printed 0 mark pointing up into the dial
+ like a real cross-needle meter — it does NOT rest flat on the pivot baseline.
+ Positive-power tick fits deviate from the perspective-corrected observations
+ by at most ~26 design pixels on Forward and ~19 on Reflected (a few pixels at
+ the normal applet width), within the declared `maximum_reference_error_pixels`.
+ See `docs/cross-needle-meter-math.md` (Decision D1) for why angled rest, not
+ flat-baseline rest, is the settled model.
+- Verify response edits with `python3 tools/fit_cross_needle_response.py --check`.
+ The dependency-free utility reports positive-tick residuals plus, for every
+ guide, its curvature reversals and how many samples reach the visible face
+ (low-SWR contours must be visible, mirroring the real meter).
- `scale_overlap` controls the upper crossing. The reflected trajectory is one
continuous circle, but a short flat-ended mask is applied at its crossing
before Forward is overprinted. Adjust `reflected_gap_half_span_radians` to
@@ -69,34 +183,81 @@ Important construction rules:
- `range.label` accepts explicit `\n` line breaks. Keep each multiplier on its
own short line in the open top-right area instead of extending text back
toward the right `(W)` label or upper power graphs.
+- The Range legend's visibility is a client preference in the versioned
+ `CrossNeedleMeter` AppSettings object, not face geometry. The right-click
+ **Show Range** action defaults on and may hide only this printed legend;
+ range selection, multiplier calibration, and needle positions are unchanged.
- `typography` contains every face font size in design pixels. These values are
intentionally larger than the photographic proof so labels survive the
normal 260 x 173 applet size; positions remain controlled by the geometry.
-- Each SWR guide retains the approved V12 source construction directly:
- `visible_upper`, the registered `registered_datum` at y = 880,
- `hidden_lower`, the source-fitted `quadratic_a`, and an independent
- `label_center`. The visible segment is an exact quadratic x(y) with at most
- six design pixels of bow; a C1 Hermite segment continues it behind the mask.
- Three source guides (`infinity`, `8`, and `1.7`) are deliberately straight.
- Do not regenerate this fan from the movement calibration: that discarded
- the perspective-corrected physical tracing and produced large S-shaped
- curves absent from both the source meter and approved V12 proof.
-- The lower mask deliberately hides the movement centers and the convergence
- of the SWR constructions. Its boundary is symmetric about x = 750.
+- An SWR guide stores only its semantic `swr` value; its number's position is
+ derived, not authored (see `label_arc_fraction` below). Its curve is generated
+ deterministically from the two calibrated movement maps. For finite SWR,
+ reflected/forward power is
+ `((SWR - 1) / (SWR + 1))^2`; the infinity guide uses a ratio of 1. Every
+ sampled point is the intersection of the Forward needle at `F` and the
+ Reflected needle at `F * ratio`. Sampling advances uniformly in Forward
+ detector voltage, so screen-space segments do not bunch according to power.
+ This is why some guides have a slight, smooth curve: the two printed power
+ scales are non-linear.
+- All SWR guides share ONE hidden convergence. Because the needles rest angled
+ (on their printed zeros), the zero-power crossing is a single point just below
+ the lower mask (~`(750, 979)`) that every contour fans up from. The low-SWR
+ lines (1.1–1.5) rise from there along the reflected needle's rest ray —
+ visible above the mask and shallow, steepening toward SWR = ∞. This mirrors
+ the real Daiwa CN-801 face.
+- `curve_samples` controls the polyline resolution. It must be high enough that
+ straight segments between exact movement intersections preserve the target
+ ratio after rasterization. It only bounds path approximation error; it cannot
+ cure a noisy movement response and must not be used as a substitute for the
+ smooth response fit. At 256 samples the current maximum midpoint deviation
+ from the exact constant-ratio locus is below 0.019 design pixel.
+- `graph_clearance` is the visual inset from the nearer power-scale arc at which
+ EVERY contour terminates, so the whole SWR family is a consistent fan parallel
+ to the arcs (smaller = closer to the arcs). Low-SWR crossings reach that
+ boundary only a little past full forward scale, so contour drawing extends the
+ movement angle modestly past unity to get there (live needles and ticks still
+ clamp at full scale). A contour is thus an exact SWR locus within the readable
+ range and drawn face-art beyond it.
+- `mask_gap` trims the leading (lower) end of every contour this many design
+ pixels above the lower mask, leaving a small gap between the line and the mask
+ like the real meter face. It is a render trim only; the geometry path is
+ unchanged, so it does not affect SWR readings or label placement.
+- SWR numbers are placed by one derived rule, not per-guide coordinates. Each
+ number anchors at fraction `label_arc_fraction` of its contour's VISIBLE arc
+ length (from the mask crossing to the upper end). It then searches along the
+ contour — and past the top along the contour's smooth upper tangent when a
+ low-SWR stub is too short to host a box (e.g. 1.1) — and along the contour
+ normal. `label_declutter_step` is the search increment; `label_box_padding`
+ the required gap from other labels. Placement is part of resource validation:
+ a box that cannot clear the mask, another number, a power-scale number, or
+ another SWR guide makes the geometry invalid instead of silently accepting the
+ last attempted position.
+- The lower mask deliberately hides the two movement centers and the shared
+ convergence. Its boundary is symmetric about x = 750.
- Both power scales use the same range multiplier: x1 = 20 W, x10 = 200 W,
and x100 = 2 kW.
After editing, run `cross_needle_meter_test` and use the automation bridge to
-grab `crossNeedleMeter`. The validation block records the approved V12 proof:
+grab `crossNeedleMeter`. The validation block records a mechanical proof:
100 W forward and SWR 1.5 derive 4 W reflected and intersect the printed 1.5
-guide within two design pixels.
+guide within half a design pixel.
-The test suite verifies all 12 registered guide paths, their straight/curved
-classification, six-pixel bow limit, C1 hidden continuation, label anchors,
-and the approved 100 W / 4 W active intersection. This prevents a mechanically
-recomputed fan from replacing the perspective-corrected source geometry.
+The test suite verifies the ratio along all 12 guides and between their sampled
+points, that all contours share one convergence concealed below the mask, that
+the low-SWR contours (1.1, 1.2) reach the visible face and the low-SWR lines
+emerge shallow and steepen toward higher SWR, monotonic concave response
+coefficients, needles parked on their printed zeros, exact positive-tick/
+live-needle alignment, explicit reference-observation budgets, at most one gentle
+curvature inflection per guide, the 0.03-pixel path-approximation budget,
+calibrated endpoints, ordering, range-multiplier invariance, every label's
+own-contour or tangent-continuation association and full font-box clearances, and
+both the 10 W / 0.4 W x1 and 100 W / 4 W x10 SWR 1.5 intersections. It also
+renders every material theme and checks the physical needle layers over the
+corrected guide ink.
-The construction was derived from perspective-corrected measurements of a
-Daiwa-style physical cross-needle meter and checked against manufacturer
-documentation. It intentionally contains no product logo or copied face
-artwork.
+The face proportions and visual envelope were derived from
+perspective-corrected measurements of a Daiwa-style physical cross-needle
+meter. Mechanical SWR calibration comes from the stored movement scales and
+the power-ratio relationship above. The resource intentionally contains no
+product logo or copied face artwork.
diff --git a/resources/meterfaces/analog-meter-themes-v1.json b/resources/meterfaces/analog-meter-themes-v1.json
new file mode 100644
index 000000000..52e9ac9fb
--- /dev/null
+++ b/resources/meterfaces/analog-meter-themes-v1.json
@@ -0,0 +1,150 @@
+{
+ "format_version": 1,
+ "reference_face": [26.0, 26.0, 1448.0, 948.0],
+ "lower_mask": {
+ "boundary_normalized": [
+ [0.0179558011, 0.9261603376],
+ [0.1546961326, 0.9261603376],
+ [0.3273480663, 0.9219409283],
+ [0.4309392265, 0.9008438819],
+ [0.5000000000, 0.8924050633],
+ [0.5690607735, 0.9008438819],
+ [0.6726519337, 0.9219409283],
+ [0.8453038674, 0.9261603376],
+ [0.9820441989, 0.9261603376]
+ ],
+ "bottom_normalized": 1.0
+ },
+ "classic_gradient": {
+ "top_rgba": [232, 216, 183, 255],
+ "middle_rgba": [250, 242, 221, 255],
+ "bottom_rgba": [222, 205, 172, 255],
+ "middle_stop": 0.50,
+ "glow_center": [750.0, 400.0],
+ "glow_radius": 900.0,
+ "glow_inner_rgba": [255, 250, 235, 205],
+ "glow_outer_rgba": [255, 250, 235, 0],
+ "vignette_center": [750.0, 440.0],
+ "vignette_radius": 930.0,
+ "vignette_clear_stop": 0.62,
+ "vignette_edge_rgba": [106, 97, 76, 65],
+ "paper_grain_opacity": 0.0
+ },
+ "uplight_gradient": {
+ "top_rgba": [142, 110, 73, 255],
+ "middle_rgba": [174, 126, 70, 255],
+ "bottom_rgba": [169, 119, 65, 255],
+ "middle_stop": 0.60,
+ "halo_center": [750.0, 920.0],
+ "halo_radius": 930.0,
+ "halo_inner_rgba": [255, 181, 83, 180],
+ "halo_middle_rgba": [242, 160, 72, 170],
+ "halo_middle_stop": 0.52,
+ "halo_shoulder_rgba": [218, 143, 68, 40],
+ "halo_shoulder_stop": 0.72,
+ "halo_outer_rgba": [211, 137, 69, 0],
+ "hotspot_center": [750.0, 930.0],
+ "hotspot_radius": 470.0,
+ "hotspot_inner_rgba": [255, 245, 110, 220],
+ "hotspot_middle_rgba": [255, 195, 65, 145],
+ "hotspot_middle_stop": 0.48,
+ "hotspot_outer_rgba": [255, 146, 44, 0],
+ "bloom_center": [750.0, 940.0],
+ "bloom_radius": 330.0,
+ "bloom_inner_rgba": [255, 246, 95, 225],
+ "bloom_middle_rgba": [255, 215, 65, 90],
+ "bloom_middle_stop": 0.52,
+ "bloom_outer_rgba": [255, 220, 130, 0],
+ "vignette_center": [750.0, 650.0],
+ "vignette_radius": 890.0,
+ "vignette_clear_stop": 0.34,
+ "vignette_edge_rgba": [9, 10, 12, 120],
+ "paper_grain_opacity": 0.100
+ },
+ "dark_gradient": {
+ "top_rgba": [20, 22, 23, 255],
+ "middle_rgba": [27, 28, 28, 255],
+ "bottom_rgba": [25, 24, 23, 255],
+ "middle_stop": 0.58,
+ "ambient_center": [750.0, 410.0],
+ "ambient_radius": 900.0,
+ "ambient_inner_rgba": [68, 66, 61, 55],
+ "ambient_outer_rgba": [40, 40, 38, 0],
+ "glow_center": [750.0, 940.0],
+ "glow_radius": 720.0,
+ "glow_inner_rgba": [190, 92, 35, 100],
+ "glow_middle_rgba": [112, 59, 34, 38],
+ "glow_middle_stop": 0.58,
+ "glow_outer_rgba": [80, 43, 30, 0],
+ "vignette_center": [750.0, 500.0],
+ "vignette_radius": 940.0,
+ "vignette_clear_stop": 0.42,
+ "vignette_edge_rgba": [0, 0, 0, 120],
+ "paper_grain_opacity": 0.220
+ },
+ "palettes": {
+ "classic-warm": {
+ "ribbon_rgba": [224, 212, 187, 225],
+ "scale_outer_rgba": [30, 36, 38, 255],
+ "scale_separator_rgba": [246, 237, 216, 255],
+ "scale_calibration_rgba": [126, 62, 54, 235],
+ "scale_inner_rgba": [48, 65, 91, 255],
+ "major_tick_rgba": [35, 42, 46, 255],
+ "minor_tick_rgba": [55, 72, 91, 230],
+ "text_rgba": [35, 42, 46, 255],
+ "secondary_text_rgba": [54, 61, 66, 255],
+ "swr_guide_rgba": [161, 74, 58, 230],
+ "swr_label_rgba": [45, 45, 42, 255],
+ "needle_rgba": [18, 22, 25, 255],
+ "needle_edge_rgba": [2, 4, 5, 210],
+ "needle_highlight_rgba": [132, 132, 124, 175],
+ "needle_shadow_rgba": [0, 0, 0, 80],
+ "needle_soft_shadow_rgba": [0, 0, 0, 28],
+ "mask_fill_rgba": [34, 40, 47, 255],
+ "mask_edge_rgba": [87, 99, 110, 255],
+ "mask_text_rgba": [234, 238, 241, 255]
+ },
+ "dark-room-uplight": {
+ "ribbon_rgba": [224, 212, 187, 225],
+ "scale_outer_rgba": [30, 36, 38, 255],
+ "scale_separator_rgba": [117, 75, 48, 150],
+ "scale_calibration_rgba": [126, 62, 54, 235],
+ "scale_inner_rgba": [48, 65, 91, 255],
+ "major_tick_rgba": [35, 42, 46, 255],
+ "minor_tick_rgba": [55, 72, 91, 230],
+ "text_rgba": [35, 42, 46, 255],
+ "secondary_text_rgba": [54, 61, 66, 255],
+ "swr_guide_rgba": [161, 74, 58, 230],
+ "swr_label_rgba": [45, 45, 42, 255],
+ "needle_rgba": [18, 22, 25, 255],
+ "needle_edge_rgba": [2, 4, 5, 210],
+ "needle_highlight_rgba": [132, 132, 124, 175],
+ "needle_shadow_rgba": [0, 0, 0, 80],
+ "needle_soft_shadow_rgba": [0, 0, 0, 28],
+ "mask_fill_rgba": [34, 40, 47, 255],
+ "mask_edge_rgba": [87, 99, 110, 255],
+ "mask_text_rgba": [234, 238, 241, 255]
+ },
+ "graphite-dark": {
+ "ribbon_rgba": [44, 43, 40, 225],
+ "scale_outer_rgba": [202, 188, 159, 255],
+ "scale_separator_rgba": [126, 95, 73, 225],
+ "scale_calibration_rgba": [119, 71, 59, 235],
+ "scale_inner_rgba": [59, 84, 105, 255],
+ "major_tick_rgba": [205, 191, 162, 255],
+ "minor_tick_rgba": [151, 139, 117, 230],
+ "text_rgba": [205, 190, 158, 255],
+ "secondary_text_rgba": [179, 162, 132, 255],
+ "swr_guide_rgba": [139, 77, 58, 225],
+ "swr_label_rgba": [205, 190, 158, 255],
+ "needle_rgba": [202, 197, 183, 255],
+ "needle_edge_rgba": [82, 78, 69, 220],
+ "needle_highlight_rgba": [240, 234, 216, 255],
+ "needle_shadow_rgba": [0, 0, 0, 105],
+ "needle_soft_shadow_rgba": [0, 0, 0, 36],
+ "mask_fill_rgba": [20, 23, 26, 255],
+ "mask_edge_rgba": [64, 69, 74, 255],
+ "mask_text_rgba": [219, 207, 181, 255]
+ }
+ }
+}
diff --git a/resources/meterfaces/cross-needle-v12.json b/resources/meterfaces/cross-needle-v12.json
index 2fc44bf9c..e9fe34cd0 100644
--- a/resources/meterfaces/cross-needle-v12.json
+++ b/resources/meterfaces/cross-needle-v12.json
@@ -1,6 +1,6 @@
{
- "format_version": 1,
- "design_version": 12,
+ "format_version": 6,
+ "design_version": 19,
"canvas": { "width": 1500.0, "height": 1000.0 },
"frame": {
"outer_inset": 6.0,
@@ -136,7 +136,14 @@
"start_radians": -2.922529943067865,
"end_radians": -1.6309954106531042,
"values": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0],
- "angles_radians": [-2.922529943067865, -2.7575556577103635, -2.653074551777382, -2.5618442190472583, -2.4858302805855743, -2.407647882308845, -2.334921104168284, -2.2667679859484724, -2.202570894238984, -2.142006166065045, -2.091917422015382, -2.0183544582402044, -1.9426720825833272, -1.862655454785555, -1.7811567909659995, -1.7009954106531042],
+ "reference_angles_radians": [-2.922529943067865, -2.7575556577103635, -2.653074551777382, -2.5618442190472583, -2.4858302805855743, -2.407647882308845, -2.334921104168284, -2.2667679859484724, -2.202570894238984, -2.142006166065045, -2.091917422015382, -2.0183544582402044, -1.9426720825833272, -1.862655454785555, -1.7811567909659995, -1.7009954106531042],
+ "response": {
+ "model": "concave_bernstein_v1",
+ "start_radians": -2.922529943067865,
+ "end_radians": -1.7009954106531042,
+ "coefficients": [0.0, 0.4833365491938737, 0.6144073900045368, 0.7454782308151997, 0.8727391154075999, 1.0],
+ "maximum_reference_error_pixels": 28.0
+ },
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "12", "14", "16", "18", "20"],
"minor_subdivisions": 5,
"label_offset": 52.0
@@ -147,7 +154,14 @@
"start_radians": -0.21906271052192816,
"end_radians": -1.510597242936689,
"values": [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.4, 2.8, 3.2, 3.6, 4.0],
- "angles_radians": [-0.21906271052192816, -0.28884457131740043, -0.37052366107858065, -0.4545569503357463, -0.5365773955295918, -0.6171645408570188, -0.6969666117403229, -0.7729990905931439, -0.8502040419406404, -0.9283390589586046, -0.9964397983314542, -1.1232381953495887, -1.1989205710064659, -1.2789371988042382, -1.3604358626237936, -1.440597242936689],
+ "reference_angles_radians": [-0.21906271052192816, -0.28884457131740043, -0.37052366107858065, -0.4545569503357463, -0.5365773955295918, -0.6171645408570188, -0.6969666117403229, -0.7729990905931439, -0.8502040419406404, -0.9283390589586046, -0.9964397983314542, -1.1232381953495887, -1.1989205710064659, -1.2789371988042382, -1.3604358626237936, -1.440597242936689],
+ "response": {
+ "model": "concave_bernstein_v1",
+ "start_radians": -0.21906271052192816,
+ "end_radians": -1.440597242936689,
+ "coefficients": [0.0, 0.26723624915834737, 0.5344724983166947, 0.7871286394467959, 0.8935643197233979, 1.0],
+ "maximum_reference_error_pixels": 20.0
+ },
"labels": ["0", "0.2", "0.4", "0.6", "0.8", "1", "1.2", "", "1.6", "", "2", "", "", "", "3.6", "4"],
"minor_subdivisions": 2,
"label_offset": 52.0
@@ -169,91 +183,25 @@
"guide_rgba": [161, 74, 58, 230],
"guide_width": 3.2,
"label_rgba": [45, 45, 42, 255],
+ "graph_clearance": 60.0,
+ "mask_gap": 14.0,
+ "curve_samples": 256,
+ "label_arc_fraction": 0.88,
+ "label_declutter_step": 3.0,
+ "label_box_padding": 2.0,
"guides": [
- {
- "label": "infinity", "display_label": "infinity", "swr": "infinity",
- "visible_upper": [572.4055589579017, 456.7180848363287],
- "registered_datum": [706.0, 880.0],
- "hidden_lower": [775.4354660011804, 1100.0],
- "quadratic_a": 0.0, "label_center": [600.0, 640.0]
- },
- {
- "label": "8", "display_label": "", "swr": 8.0,
- "visible_upper": [643.7386837586928, 400.9725086642011],
- "registered_datum": [777.0, 880.0],
- "hidden_lower": [838.2021023915222, 1100.0],
- "quadratic_a": 0.0, "label_center": [0.0, 0.0]
- },
- {
- "label": "4", "display_label": "4", "swr": 4.0,
- "visible_upper": [729.1373769183177, 349.28646739305657],
- "registered_datum": [814.5, 880.0],
- "hidden_lower": [849.8859020434641, 1100.0],
- "quadratic_a": 0.0000424263985491, "label_center": [720.0, 610.0]
- },
- {
- "label": "3", "display_label": "3", "swr": 3.0,
- "visible_upper": [817.0220412910962, 375.4214323734527],
- "registered_datum": [859.0, 880.0],
- "hidden_lower": [877.3027015186148, 1100.0],
- "quadratic_a": -0.0000126751055122, "label_center": [775.0, 600.0]
- },
- {
- "label": "2.5", "display_label": "2.5", "swr": 2.5,
- "visible_upper": [880.5753026176498, 418.50208345200866],
- "registered_datum": [892.0, 880.0],
- "hidden_lower": [897.4462508583301, 1100.0],
- "quadratic_a": -0.00000883735220486, "label_center": [835.0, 600.0]
- },
- {
- "label": "2", "display_label": "2", "swr": 2.0,
- "visible_upper": [944.1031963228712, 471.62447595989926],
- "registered_datum": [920.0, 880.0],
- "hidden_lower": [907.0151297546643, 1100.0],
- "quadratic_a": 0.0000851310500331, "label_center": [940.0, 625.0]
- },
- {
- "label": "1.7", "display_label": "1.7", "swr": 1.7,
- "visible_upper": [1001.8471706723545, 531.0406361711822],
- "registered_datum": [940.0, 880.0],
- "hidden_lower": [901.0087048571288, 1100.0],
- "quadratic_a": 0.0, "label_center": [1025.0, 655.0]
- },
- {
- "label": "1.5", "display_label": "1.5", "swr": 1.5,
- "visible_upper": [1049.4649081096002, 591.025257178578],
- "registered_datum": [957.5, 880.0],
- "hidden_lower": [887.4859925937714, 1100.0],
- "quadratic_a": -0.000102863228292, "label_center": [1095.0, 690.0]
- },
- {
- "label": "1.4", "display_label": "1.4", "swr": 1.4,
- "visible_upper": [1086.976731505757, 648.2095714488747],
- "registered_datum": [982.5, 880.0],
- "hidden_lower": [883.3376615249372, 1100.0],
- "quadratic_a": -0.000291448908069, "label_center": [1150.0, 720.0]
- },
- {
- "label": "1.3", "display_label": "1.3", "swr": 1.3,
- "visible_upper": [1118.233544092191, 705.9785120853796],
- "registered_datum": [1000.0, 880.0],
- "hidden_lower": [850.5277709552518, 1100.0],
- "quadratic_a": -0.0007925113422276841, "label_center": [1200.0, 750.0]
- },
- {
- "label": "1.2", "display_label": "1.2", "swr": 1.2,
- "visible_upper": [1143.465448206219, 763.0567964294365],
- "registered_datum": [1032.0, 880.0],
- "hidden_lower": [822.3050553034373, 1100.0],
- "quadratic_a": -0.00116458142175, "label_center": [1250.0, 800.0]
- },
- {
- "label": "1.1", "display_label": "1.1", "swr": 1.1,
- "visible_upper": [1160.5087268449388, 810.3525905949436],
- "registered_datum": [1063.0, 880.0],
- "hidden_lower": [754.9925652779679, 1100.0],
- "quadratic_a": -0.0015727756537, "label_center": [1290.0, 835.0]
- }
+ { "label": "infinity", "display_label": "infinity", "swr": "infinity" },
+ { "label": "8", "display_label": "8", "swr": 8.0 },
+ { "label": "4", "display_label": "4", "swr": 4.0 },
+ { "label": "3", "display_label": "3", "swr": 3.0 },
+ { "label": "2.5", "display_label": "2.5", "swr": 2.5 },
+ { "label": "2", "display_label": "2", "swr": 2.0 },
+ { "label": "1.7", "display_label": "1.7", "swr": 1.7 },
+ { "label": "1.5", "display_label": "1.5", "swr": 1.5 },
+ { "label": "1.4", "display_label": "1.4", "swr": 1.4 },
+ { "label": "1.3", "display_label": "1.3", "swr": 1.3 },
+ { "label": "1.2", "display_label": "1.2", "swr": 1.2 },
+ { "label": "1.1", "display_label": "1.1", "swr": 1.1 }
]
},
"needles": {
@@ -286,8 +234,8 @@
"active_swr": 1.5,
"active_reflected_watts": 4.0,
"range_multiplier": 10.0,
- "intersection": [971.7280475161609, 835.2917160356153],
+ "intersection": [964.606018283696, 830.5562245313764],
"guide": "1.5",
- "maximum_guide_error": 2.0
+ "maximum_guide_error": 0.5
}
}
diff --git a/resources/meterfaces/s-meter-v1.json b/resources/meterfaces/s-meter-v1.json
new file mode 100644
index 000000000..82bad6e02
--- /dev/null
+++ b/resources/meterfaces/s-meter-v1.json
@@ -0,0 +1,125 @@
+{
+ "format_version": 1,
+ "design_version": 5,
+ "sizing": {
+ "preferred": [280, 140],
+ "minimum": [200, 100],
+ "minimum_aspect_ratio": 1.75,
+ "maximum_aspect_ratio": 4.0
+ },
+ "arc": {
+ "center_x_width_factor": 0.5,
+ "radius_width_factor": 0.85,
+ "center_y_height_factor": 0.35,
+ "start_degrees": 55.0,
+ "end_degrees": 125.0,
+ "inner_gap_pixels": 6.0,
+ "line_width_pixels": 3.0
+ },
+ "tick_style": {
+ "start_offset_pixels": 2.0,
+ "end_offset_pixels": 14.0,
+ "label_offset_pixels": 26.0,
+ "line_width_pixels": 1.5,
+ "font_minimum_pixels": 10,
+ "font_height_factor": 0.1,
+ "bold": true
+ },
+ "rx_scale": {
+ "minimum_dbm": -127.0,
+ "s9_dbm": -73.0,
+ "maximum_dbm": -13.0,
+ "db_per_s_unit": 6.0,
+ "s9_fraction": 0.6,
+ "ticks": [
+ {"value": -121.0, "label": "1"},
+ {"value": -109.0, "label": "3"},
+ {"value": -97.0, "label": "5"},
+ {"value": -85.0, "label": "7"},
+ {"value": -73.0, "label": "9"},
+ {"value": -53.0, "label": "+20"},
+ {"value": -33.0, "label": "+40"}
+ ]
+ },
+ "swr_scale": {
+ "minimum": 1.0,
+ "maximum": 3.0,
+ "has_warning": true,
+ "warning_start": 2.5,
+ "ticks": [
+ {"value": 1.0, "label": "1"},
+ {"value": 1.5, "label": "1.5"},
+ {"value": 2.0, "label": "2"},
+ {"value": 2.5, "label": "2.5"},
+ {"value": 3.0, "label": "3"}
+ ]
+ },
+ "level_scale": {
+ "minimum": -40.0,
+ "maximum": 5.0,
+ "has_warning": true,
+ "warning_start": 0.0,
+ "ticks": [
+ {"value": -40.0, "label": "-40"},
+ {"value": -30.0, "label": "-30"},
+ {"value": -20.0, "label": "-20"},
+ {"value": -10.0, "label": "-10"},
+ {"value": 0.0, "label": "0"}
+ ]
+ },
+ "compression_scale": {
+ "minimum": 0.0,
+ "maximum": 25.0,
+ "has_warning": false,
+ "warning_start": 0.0,
+ "ticks": [
+ {"value": 0.0, "label": "0"},
+ {"value": 5.0, "label": "-5"},
+ {"value": 10.0, "label": "-10"},
+ {"value": 15.0, "label": "-15"},
+ {"value": 20.0, "label": "-20"},
+ {"value": 25.0, "label": "-25"}
+ ]
+ },
+ "power_tick_policies": [
+ {"minimum_scale_watts": 2000.0, "tick_step_watts": 100, "label_step_watts": 500},
+ {"minimum_scale_watts": 600.0, "tick_step_watts": 50, "label_step_watts": 100},
+ {"minimum_scale_watts": 0.0, "tick_step_watts": 10, "label_step_watts": 40}
+ ],
+ "needle": {
+ "pivot_y_below_widget_pixels": 6.0,
+ "tip_extension_pixels": 14.0,
+ "line_width_pixels": 2.0,
+ "shadow_width_pixels": 3.0,
+ "shadow_offset": [1.0, 1.0]
+ },
+ "pivot": {
+ "minimum_radius_pixels": 13.5,
+ "radius_width_factor": 0.0975,
+ "glow_radius_factor": 3.4,
+ "glow_middle_factor": 0.45,
+ "glow_center_alpha": 80,
+ "glow_middle_alpha": 28,
+ "rim_width_pixels": 1.0
+ },
+ "peak_marker": {
+ "radius_inset_pixels": 2.0,
+ "length_pixels": 6.0,
+ "half_width_pixels": 3.0,
+ "minimum_lead_db": 1.0
+ },
+ "peak_hold": {
+ "inner_radius_offset_pixels": -4.0,
+ "outer_radius_offset_pixels": 10.0,
+ "line_width_pixels": 2.0,
+ "visible_above_minimum_db": 1.0
+ },
+ "readout": {
+ "source_font_minimum_pixels": 9,
+ "source_font_height_divisor": 14.0,
+ "value_font_minimum_pixels": 13,
+ "value_font_height_divisor": 8.0,
+ "top_extra_pixels": 4,
+ "side_margin_pixels": 6
+ }
+}
diff --git a/resources/resources.qrc b/resources/resources.qrc
index 921806c17..315878258 100644
--- a/resources/resources.qrc
+++ b/resources/resources.qrc
@@ -20,7 +20,9 @@
themes/default-light.json
+ meterfaces/analog-meter-themes-v1.json
meterfaces/cross-needle-v12.json
+ meterfaces/s-meter-v1.json
help/getting-started.md
diff --git a/src/core/AppSettings.cpp b/src/core/AppSettings.cpp
index 143666e6b..9d12d12c3 100644
--- a/src/core/AppSettings.cpp
+++ b/src/core/AppSettings.cpp
@@ -49,6 +49,70 @@ QString machineBinding()
constexpr char kIdentityConfigKey[] = "GuiClientIdentity";
+bool readSettingsFile(const QString& path, QMap& settings,
+ QMap& stationSettings,
+ QString& stationName, QString& error)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ error = file.errorString();
+ return false;
+ }
+
+ QMap parsedSettings;
+ QMap parsedStationSettings;
+ QString parsedStationName = QStringLiteral("AetherSDR");
+ QString currentStation;
+ bool sawRoot = false;
+ QXmlStreamReader xml(&file);
+
+ while (!xml.atEnd()) {
+ xml.readNext();
+
+ if (xml.isStartElement()) {
+ const QString tag = xml.name().toString();
+ if (!sawRoot) {
+ if (tag != QStringLiteral("Settings")) {
+ xml.raiseError(QStringLiteral("root element is not Settings"));
+ } else {
+ sawRoot = true;
+ }
+ continue;
+ }
+
+ if (tag == parsedStationName && currentStation.isEmpty()) {
+ currentStation = tag;
+ continue;
+ }
+
+ const QString text = xml.readElementText();
+ if (!currentStation.isEmpty()) {
+ parsedStationSettings.insert(tag, text);
+ } else {
+ parsedSettings.insert(tag, text);
+ if (tag == QStringLiteral("StationName")) {
+ parsedStationName = text;
+ }
+ }
+ } else if (xml.isEndElement()
+ && xml.name().toString() == currentStation) {
+ currentStation.clear();
+ }
+ }
+
+ if (xml.hasError() || !sawRoot) {
+ error = xml.hasError() ? xml.errorString()
+ : QStringLiteral("missing Settings root element");
+ return false;
+ }
+
+ settings = parsedSettings;
+ stationSettings = parsedStationSettings;
+ stationName = parsedStationName;
+ error.clear();
+ return true;
+}
+
} // namespace
AppSettings::AppSettings()
@@ -97,100 +161,100 @@ void AppSettings::migrateSettingsPath()
void AppSettings::load()
{
- QFile file(m_filePath);
- if (!file.exists()) {
- // First launch or migration needed
- migrateFromQSettings();
- return;
- }
- if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
- qWarning() << "AppSettings: cannot open" << m_filePath;
- return;
- }
-
+ m_loadState = LoadState::Failed;
+ m_preserveRecoveryBackup = false;
+ m_loadedCount = 0;
m_settings.clear();
m_stationSettings.clear();
-
- QXmlStreamReader xml(&file);
- QString currentStation; // non-empty when inside a station element
-
- while (!xml.atEnd()) {
- xml.readNext();
-
- if (xml.isStartElement()) {
- const QString tag = xml.name().toString();
-
- if (tag == "Settings") continue; // root element
-
- // Check if this is the station element
- if (tag == m_stationName && currentStation.isEmpty()) {
- currentStation = tag;
- continue;
+ m_stationName = QStringLiteral("AetherSDR");
+
+ const QString tmpPath = m_filePath + QStringLiteral(".tmp");
+ const QString bakPath = m_filePath + QStringLiteral(".bak");
+
+ if (!QFile::exists(m_filePath)) {
+ QString recoveryError;
+
+ // A validated temp file is the newest intended state after a crash in
+ // the save window between live->backup and temp->live. Promote it only
+ // when the live path is absent, so a valid committed live file always
+ // remains authoritative.
+ if (QFile::exists(tmpPath)
+ && readSettingsFile(tmpPath, m_settings, m_stationSettings,
+ m_stationName, recoveryError)) {
+ if (QFile::rename(tmpPath, m_filePath)) {
+ QFile::setPermissions(m_filePath,
+ QFileDevice::ReadOwner | QFileDevice::WriteOwner);
+ m_preserveRecoveryBackup = QFile::exists(bakPath);
+ m_loadState = LoadState::ReadyToSave;
+ m_loadedCount = m_settings.size();
+ qDebug() << "AppSettings: promoted interrupted commit with"
+ << m_settings.size() << "settings";
+ return;
}
+ qWarning() << "AppSettings: valid temporary settings could not be promoted";
+ m_settings.clear();
+ m_stationSettings.clear();
+ m_stationName = QStringLiteral("AetherSDR");
+ } else if (QFile::exists(tmpPath)) {
+ qWarning() << "AppSettings: temporary recovery file is corrupt:"
+ << recoveryError;
+ }
- // Read the text content
- const QString text = xml.readElementText();
+ if (QFile::exists(bakPath)
+ && readSettingsFile(bakPath, m_settings, m_stationSettings,
+ m_stationName, recoveryError)) {
+ m_preserveRecoveryBackup = true;
+ m_loadState = LoadState::ReadyToSave;
+ m_loadedCount = m_settings.size();
+ qDebug() << "AppSettings: recovered missing live profile from backup with"
+ << m_settings.size() << "settings";
+ return;
+ }
- if (!currentStation.isEmpty()) {
- m_stationSettings.insert(tag, text);
- } else {
- m_settings.insert(tag, text);
- // Track station name
- if (tag == "StationName")
- m_stationName = text;
+ // Recovery artifacts prove this is not a true first launch. If none is
+ // usable, stay failed so startup code cannot replace them with defaults.
+ if (QFile::exists(tmpPath) || QFile::exists(bakPath)) {
+ if (QFile::exists(bakPath)) {
+ qWarning() << "AppSettings: backup recovery file is unavailable or corrupt:"
+ << recoveryError;
}
- } else if (xml.isEndElement()) {
- if (xml.name().toString() == m_stationName && !currentStation.isEmpty())
- currentStation.clear();
+ m_settings.clear();
+ m_stationSettings.clear();
+ m_stationName = QStringLiteral("AetherSDR");
+ return;
}
+
+ // No live or recovery artifacts: this is a genuine first launch or a
+ // legacy QSettings migration.
+ m_loadState = LoadState::ReadyToSave;
+ migrateFromQSettings();
+ m_loadedCount = m_settings.size();
+ return;
}
- if (xml.hasError()) {
- qWarning() << "AppSettings: XML parse error:" << xml.errorString();
+ QString error;
+ if (!readSettingsFile(m_filePath, m_settings, m_stationSettings,
+ m_stationName, error)) {
+ qWarning() << "AppSettings: cannot load" << m_filePath << ':' << error;
- // Try to recover from backup if the main file is corrupt
- const QString bakPath = m_filePath + ".bak";
- if (QFile::exists(bakPath) && m_settings.size() < 10) {
- qWarning() << "AppSettings: main file corrupt, recovering from backup";
- file.close();
+ if (!QFile::exists(bakPath)
+ || !readSettingsFile(bakPath, m_settings, m_stationSettings,
+ m_stationName, error)) {
+ if (QFile::exists(bakPath)) {
+ qWarning() << "AppSettings: backup also unavailable or corrupt:" << error;
+ }
m_settings.clear();
m_stationSettings.clear();
-
- QFile bakFile(bakPath);
- if (bakFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
- QXmlStreamReader bakXml(&bakFile);
- QString bakStation;
- while (!bakXml.atEnd()) {
- bakXml.readNext();
- if (bakXml.isStartElement()) {
- const QString tag = bakXml.name().toString();
- if (tag == "Settings") continue;
- if (tag == m_stationName && bakStation.isEmpty()) {
- bakStation = tag;
- continue;
- }
- const QString text = bakXml.readElementText();
- if (!bakStation.isEmpty())
- m_stationSettings.insert(tag, text);
- else {
- m_settings.insert(tag, text);
- if (tag == "StationName") m_stationName = text;
- }
- } else if (bakXml.isEndElement()) {
- if (bakXml.name().toString() == m_stationName && !bakStation.isEmpty())
- bakStation.clear();
- }
- }
- bakFile.close();
- if (!bakXml.hasError())
- qDebug() << "AppSettings: recovered" << m_settings.size() << "settings from backup";
- else
- qWarning() << "AppSettings: backup also corrupt:" << bakXml.errorString();
- }
+ m_stationName = QStringLiteral("AetherSDR");
+ return;
}
+
+ m_preserveRecoveryBackup = true;
+ qDebug() << "AppSettings: recovered" << m_settings.size()
+ << "settings from backup";
}
- file.close();
+ m_loadState = LoadState::ReadyToSave;
m_loadedCount = m_settings.size();
qDebug() << "AppSettings: loaded" << m_settings.size() << "settings +"
<< m_stationSettings.size() << "station settings from" << m_filePath;
@@ -207,6 +271,11 @@ void AppSettings::save()
}
}
+ if (m_loadState != LoadState::ReadyToSave) {
+ qWarning() << "AppSettings: refusing to save before a successful load";
+ return;
+ }
+
// Guard: refuse to save if we'd lose more than half the settings.
// This catches cases where the app crashes early or settings were
// cleared from memory before save() runs.
@@ -218,8 +287,8 @@ void AppSettings::save()
// Atomic save: write to temp file, then rename over the original.
// This prevents data loss if the app crashes or is killed mid-write.
- const QString tmpPath = m_filePath + ".tmp";
-
+ const QString tmpPath = m_filePath + QStringLiteral(".tmp");
+ const QString bakPath = m_filePath + QStringLiteral(".bak");
QFile file(tmpPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qWarning() << "AppSettings: cannot write" << tmpPath;
@@ -266,41 +335,79 @@ void AppSettings::save()
// If parsing fails, the file is corrupt; don't overwrite the original.
{
QFile check(tmpPath);
- if (check.open(QIODevice::ReadOnly | QIODevice::Text)) {
- QXmlStreamReader validator(&check);
- while (!validator.atEnd()) validator.readNext();
- check.close();
- if (validator.hasError()) {
- qWarning() << "AppSettings: temp file failed validation:"
- << validator.errorString() << "— NOT saving";
- QFile::remove(tmpPath);
- return;
- }
+ if (!check.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ qWarning() << "AppSettings: cannot reopen temp file for validation — NOT saving";
+ QFile::remove(tmpPath);
+ return;
+ }
+ QXmlStreamReader validator(&check);
+ while (!validator.atEnd()) {
+ validator.readNext();
+ }
+ check.close();
+ if (validator.hasError()) {
+ qWarning() << "AppSettings: temp file failed validation:"
+ << validator.errorString() << "— NOT saving";
+ QFile::remove(tmpPath);
+ return;
}
}
// Atomic rename: on Linux/macOS this is a single inode swap.
- // On Windows, QFile::rename fails if the target exists, so remove first.
+ // On Windows, QFile::rename fails if the target exists, so rotate first.
+ QString displacedCorruptPath;
+ bool rotatedLiveToBackup = false;
if (QFile::exists(m_filePath)) {
- // Keep a backup in case something goes wrong
- const QString bakPath = m_filePath + ".bak";
- QFile::remove(bakPath);
- QFile::rename(m_filePath, bakPath);
- // Tighten the backup the same way as the live file — see below.
- QFile::setPermissions(bakPath,
- QFileDevice::ReadOwner | QFileDevice::WriteOwner);
+ if (m_preserveRecoveryBackup && QFile::exists(bakPath)) {
+ // Keep the known-good recovery source through the first save after
+ // recovery. Move the existing live file aside only until the
+ // replacement has been installed.
+ displacedCorruptPath = m_filePath + QStringLiteral(".corrupt");
+ QFile::remove(displacedCorruptPath);
+ if (!QFile::rename(m_filePath, displacedCorruptPath)) {
+ qWarning() << "AppSettings: cannot move corrupt live file aside";
+ QFile::remove(tmpPath);
+ return;
+ }
+ } else {
+ QFile::remove(bakPath);
+ if (!QFile::rename(m_filePath, bakPath)) {
+ qWarning() << "AppSettings: cannot rotate live settings to backup";
+ QFile::remove(tmpPath);
+ return;
+ }
+ rotatedLiveToBackup = true;
+ QFile::setPermissions(bakPath,
+ QFileDevice::ReadOwner | QFileDevice::WriteOwner);
+ }
}
if (!QFile::rename(tmpPath, m_filePath)) {
qWarning() << "AppSettings: atomic rename failed from" << tmpPath << "to" << m_filePath;
+ if (!displacedCorruptPath.isEmpty()) {
+ QFile::rename(displacedCorruptPath, m_filePath);
+ } else if (rotatedLiveToBackup && !QFile::exists(m_filePath)) {
+ if (!QFile::copy(bakPath, m_filePath)) {
+ qWarning() << "AppSettings: could not restore live settings from backup";
+ } else {
+ QFile::setPermissions(m_filePath,
+ QFileDevice::ReadOwner | QFileDevice::WriteOwner);
+ }
+ }
return;
}
+ if (!displacedCorruptPath.isEmpty()) {
+ QFile::remove(displacedCorruptPath);
+ }
+
// Restrict permissions to owner read/write (mode 0600). Default umask
// leaves these XML files world-readable, exposing MQTT broker creds,
// SmartLink email, radio nicknames, etc. Matches AsyncLogWriter
// precedent. See GHSA-mmqp-cm4w-cvpp (L5).
QFile::setPermissions(m_filePath,
QFileDevice::ReadOwner | QFileDevice::WriteOwner);
+ m_loadedCount = m_settings.size();
+ m_preserveRecoveryBackup = false;
}
void AppSettings::reset()
@@ -310,6 +417,8 @@ void AppSettings::reset()
m_stationSettings.clear();
m_stationName = "AetherSDR";
m_loadedCount = 0;
+ m_loadState = LoadState::NotAttempted;
+ m_preserveRecoveryBackup = false;
m_persistentGuiClientId.clear();
m_effectiveGuiClientId.clear();
m_effectiveStationName.clear();
@@ -549,8 +658,17 @@ void AppSettings::recordPersistentGuiClientIdReply(const QString& clientId)
void AppSettings::migrateFromQSettings()
{
- QSettings old("AetherSDR", "AetherSDR");
- const QStringList keys = old.allKeys();
+ std::unique_ptr old;
+ if (QStandardPaths::isTestModeEnabled()) {
+ const QString isolatedLegacyPath =
+ QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation)
+ + QStringLiteral("/AetherSDR/legacy-qsettings.ini");
+ old = std::make_unique(isolatedLegacyPath, QSettings::IniFormat);
+ } else {
+ old = std::make_unique(QStringLiteral("AetherSDR"),
+ QStringLiteral("AetherSDR"));
+ }
+ const QStringList keys = old->allKeys();
if (keys.isEmpty()) {
// No old settings — first launch. Set defaults.
@@ -576,37 +694,37 @@ void AppSettings::migrateFromQSettings()
// Map old QSettings keys to new XML keys
// MainWindow
- if (old.contains("lastRadioSerial"))
- setValue("LastConnectedRadioSerial", old.value("lastRadioSerial").toString());
- if (old.contains("geometry"))
- setValue("MainWindowGeometry", old.value("geometry").toByteArray().toBase64());
- if (old.contains("windowState"))
- setValue("MainWindowState", old.value("windowState").toByteArray().toBase64());
- if (old.contains("splitterState"))
- setValue("SplitterState", old.value("splitterState").toByteArray().toBase64());
+ if (old->contains("lastRadioSerial"))
+ setValue("LastConnectedRadioSerial", old->value("lastRadioSerial").toString());
+ if (old->contains("geometry"))
+ setValue("MainWindowGeometry", old->value("geometry").toByteArray().toBase64());
+ if (old->contains("windowState"))
+ setValue("MainWindowState", old->value("windowState").toByteArray().toBase64());
+ if (old->contains("splitterState"))
+ setValue("SplitterState", old->value("splitterState").toByteArray().toBase64());
// Spectrum
- if (old.contains("spectrum/splitRatio"))
- setValue("SpectrumSplitRatio", old.value("spectrum/splitRatio").toString());
+ if (old->contains("spectrum/splitRatio"))
+ setValue("SpectrumSplitRatio", old->value("spectrum/splitRatio").toString());
// Spots
- if (old.contains("spots/enabled"))
- setValue("IsSpotsEnabled", old.value("spots/enabled").toBool() ? "True" : "False");
- if (old.contains("spots/levels"))
- setValue("SpotsMaxLevel", old.value("spots/levels").toString());
- if (old.contains("spots/position"))
- setValue("SpotsStartingHeightPercentage", old.value("spots/position").toString());
- if (old.contains("spots/fontSize"))
- setValue("SpotFontSize", old.value("spots/fontSize").toString());
- if (old.contains("spots/overrideColors"))
+ if (old->contains("spots/enabled"))
+ setValue("IsSpotsEnabled", old->value("spots/enabled").toBool() ? "True" : "False");
+ if (old->contains("spots/levels"))
+ setValue("SpotsMaxLevel", old->value("spots/levels").toString());
+ if (old->contains("spots/position"))
+ setValue("SpotsStartingHeightPercentage", old->value("spots/position").toString());
+ if (old->contains("spots/fontSize"))
+ setValue("SpotFontSize", old->value("spots/fontSize").toString());
+ if (old->contains("spots/overrideColors"))
setValue("IsSpotsOverrideColorsEnabled",
- old.value("spots/overrideColors").toBool() ? "True" : "False");
- if (old.contains("spots/overrideBg"))
+ old->value("spots/overrideColors").toBool() ? "True" : "False");
+ if (old->contains("spots/overrideBg"))
setValue("IsSpotsOverrideBackgroundColorsEnabled",
- old.value("spots/overrideBg").toBool() ? "True" : "False");
- if (old.contains("spots/overrideBgAuto"))
+ old->value("spots/overrideBg").toBool() ? "True" : "False");
+ if (old->contains("spots/overrideBgAuto"))
setValue("IsSpotsOverrideToAutoBackgroundColorEnabled",
- old.value("spots/overrideBgAuto").toBool() ? "True" : "False");
+ old->value("spots/overrideBgAuto").toBool() ? "True" : "False");
// Set defaults for new keys
setValue("ApplicationVersion", QCoreApplication::applicationVersion());
diff --git a/src/core/AppSettings.h b/src/core/AppSettings.h
index 55e202d8c..1d4612fe0 100644
--- a/src/core/AppSettings.h
+++ b/src/core/AppSettings.h
@@ -71,6 +71,12 @@ class AppSettings {
void migrateFromQSettings();
private:
+ enum class LoadState {
+ NotAttempted,
+ ReadyToSave,
+ Failed,
+ };
+
AppSettings();
~AppSettings();
AppSettings(const AppSettings&) = delete;
@@ -85,6 +91,8 @@ class AppSettings {
QMap m_stationSettings; // per-station key=value
QString m_stationName{"AetherSDR"};
int m_loadedCount{0}; // settings count at load time (guard against truncated saves)
+ LoadState m_loadState{LoadState::NotAttempted};
+ bool m_preserveRecoveryBackup{false};
std::unique_ptr m_guiClientLock;
QString m_persistentGuiClientId;
QString m_effectiveGuiClientId;
diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp
index 867756671..6ba1ce2a1 100644
--- a/src/core/AutomationServer.cpp
+++ b/src/core/AutomationServer.cpp
@@ -440,11 +440,27 @@ QJsonObject describeWidget(const QWidget* w)
}
}
- // The PWR applet's custom-painted cross-needle meter publishes its live
- // mechanics as dynamic properties. Surface them generically so bridge
- // validation can prove the two calibrated movements and SWR intersection
- // without coupling the core automation server to a gui/ header.
+ // The custom-painted analog meters publish their live mechanics as dynamic
+ // properties. Surface them generically so bridge validation can prove the
+ // standard meter's native SWR filtering and the PWR applet's two calibrated
+ // movements without coupling the core automation server to gui/ headers.
{
+ const QVariant txSwrSource = w->property("txSwrSource");
+ if (txSwrSource.isValid()) {
+ o[QStringLiteral("txSwrSource")] = txSwrSource.toString();
+ o[QStringLiteral("txSwr")] = w->property("txSwr").toDouble();
+ o[QStringLiteral("txSwrRaw")] = w->property("txSwrRaw").toDouble();
+ o[QStringLiteral("txSwrForwardWatts")] =
+ w->property("txSwrForwardWatts").toDouble();
+ o[QStringLiteral("txSwrPowerEnvelopeWatts")] =
+ w->property("txSwrPowerEnvelopeWatts").toDouble();
+ o[QStringLiteral("txSwrMinimumForwardWatts")] =
+ w->property("txSwrMinimumForwardWatts").toDouble();
+ o[QStringLiteral("txSwrHeld")] = w->property("txSwrHeld").toBool();
+ o[QStringLiteral("txMode")] = w->property("txMode").toString();
+ o[QStringLiteral("transmitting")] =
+ w->property("transmitting").toBool();
+ }
const QVariant meterStyle = w->property("meterStyle");
if (meterStyle.isValid()) {
o[QStringLiteral("meterStyle")] = meterStyle.toString();
@@ -465,6 +481,8 @@ QJsonObject describeWidget(const QWidget* w)
o[QStringLiteral("swr")] = w->property("swr").toDouble();
o[QStringLiteral("rangeMultiplier")] =
w->property("rangeMultiplier").toDouble();
+ o[QStringLiteral("rangeLegendVisible")] =
+ w->property("rangeLegendVisible").toBool();
o[QStringLiteral("transmitting")] =
w->property("transmitting").toBool();
o[QStringLiteral("effectiveActive")] =
diff --git a/src/gui/AnalogMeterFaceTheme.cpp b/src/gui/AnalogMeterFaceTheme.cpp
new file mode 100644
index 000000000..3d26b9732
--- /dev/null
+++ b/src/gui/AnalogMeterFaceTheme.cpp
@@ -0,0 +1,692 @@
+#include "AnalogMeterFaceTheme.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace AetherSDR {
+
+namespace {
+
+constexpr int kFormatVersion = 1;
+
+bool readFiniteDouble(const QJsonObject& object, const QString& key,
+ double& output, QString& error)
+{
+ const QJsonValue value = object.value(key);
+ if (!value.isDouble() || !std::isfinite(value.toDouble())) {
+ error = QStringLiteral("%1 must be a finite number").arg(key);
+ return false;
+ }
+ output = value.toDouble();
+ return true;
+}
+
+bool readPoint(const QJsonObject& object, const QString& key,
+ QPointF& output, QString& error)
+{
+ const QJsonValue value = object.value(key);
+ if (!value.isArray()) {
+ error = QStringLiteral("%1 must be a two-number array").arg(key);
+ return false;
+ }
+ const QJsonArray array = value.toArray();
+ if (array.size() != 2 || !array[0].isDouble() || !array[1].isDouble()
+ || !std::isfinite(array[0].toDouble()) || !std::isfinite(array[1].toDouble())) {
+ error = QStringLiteral("%1 must be a two-number array").arg(key);
+ return false;
+ }
+ output = QPointF(array[0].toDouble(), array[1].toDouble());
+ return true;
+}
+
+bool readRect(const QJsonObject& object, const QString& key,
+ QRectF& output, QString& error)
+{
+ const QJsonValue value = object.value(key);
+ if (!value.isArray()) {
+ error = QStringLiteral("%1 must be a four-number array").arg(key);
+ return false;
+ }
+ const QJsonArray array = value.toArray();
+ if (array.size() != 4) {
+ error = QStringLiteral("%1 must be a four-number array").arg(key);
+ return false;
+ }
+ for (const QJsonValue& entry : array) {
+ if (!entry.isDouble() || !std::isfinite(entry.toDouble())) {
+ error = QStringLiteral("%1 must be a four-number array").arg(key);
+ return false;
+ }
+ }
+ output = QRectF(array[0].toDouble(), array[1].toDouble(),
+ array[2].toDouble(), array[3].toDouble());
+ return true;
+}
+
+bool readColor(const QJsonObject& object, const QString& key,
+ QColor& output, QString& error)
+{
+ const QJsonValue value = object.value(key);
+ if (!value.isArray()) {
+ error = QStringLiteral("%1 must be an RGBA array").arg(key);
+ return false;
+ }
+ const QJsonArray array = value.toArray();
+ if (array.size() != 4) {
+ error = QStringLiteral("%1 must be an RGBA array").arg(key);
+ return false;
+ }
+ int channels[4]{};
+ for (int index = 0; index < 4; ++index) {
+ if (!array[index].isDouble()) {
+ error = QStringLiteral("%1 must be an RGBA array").arg(key);
+ return false;
+ }
+ channels[index] = array[index].toInt(-1);
+ if (channels[index] < 0 || channels[index] > 255) {
+ error = QStringLiteral("%1 contains an invalid channel").arg(key);
+ return false;
+ }
+ }
+ output = QColor(channels[0], channels[1], channels[2], channels[3]);
+ return true;
+}
+
+bool readPalette(const QJsonObject& object,
+ AnalogMeterFaceThemeCatalog::Palette& palette,
+ QString& error)
+{
+ return readColor(object, QStringLiteral("ribbon_rgba"), palette.ribbon, error)
+ && readColor(object, QStringLiteral("scale_outer_rgba"), palette.scaleOuter, error)
+ && readColor(object, QStringLiteral("scale_separator_rgba"), palette.scaleSeparator, error)
+ && readColor(object, QStringLiteral("scale_calibration_rgba"), palette.scaleCalibration, error)
+ && readColor(object, QStringLiteral("scale_inner_rgba"), palette.scaleInner, error)
+ && readColor(object, QStringLiteral("major_tick_rgba"), palette.majorTick, error)
+ && readColor(object, QStringLiteral("minor_tick_rgba"), palette.minorTick, error)
+ && readColor(object, QStringLiteral("text_rgba"), palette.text, error)
+ && readColor(object, QStringLiteral("secondary_text_rgba"), palette.secondaryText, error)
+ && readColor(object, QStringLiteral("swr_guide_rgba"), palette.swrGuide, error)
+ && readColor(object, QStringLiteral("swr_label_rgba"), palette.swrLabel, error)
+ && readColor(object, QStringLiteral("needle_rgba"), palette.needle, error)
+ && readColor(object, QStringLiteral("needle_edge_rgba"), palette.needleEdge, error)
+ && readColor(object, QStringLiteral("needle_highlight_rgba"), palette.needleHighlight, error)
+ && readColor(object, QStringLiteral("needle_shadow_rgba"), palette.needleShadow, error)
+ && readColor(object, QStringLiteral("needle_soft_shadow_rgba"), palette.needleSoftShadow, error)
+ && readColor(object, QStringLiteral("mask_fill_rgba"), palette.maskFill, error)
+ && readColor(object, QStringLiteral("mask_edge_rgba"), palette.maskEdge, error)
+ && readColor(object, QStringLiteral("mask_text_rgba"), palette.maskText, error);
+}
+
+const QImage& paperGrainTexture()
+{
+ static const QImage texture = []() {
+ QImage image(180, 120, QImage::Format_RGB32);
+ quint32 state = 0x6d2b79f5U;
+ const auto nextNoise = [&state]() {
+ state ^= state << 13U;
+ state ^= state >> 17U;
+ state ^= state << 5U;
+ return state;
+ };
+ for (int y = 0; y < image.height(); ++y) {
+ const int rowBias = static_cast((nextNoise() >> 28U) & 0x0fU) - 8;
+ int horizontalFibre = 0;
+ QRgb* line = reinterpret_cast(image.scanLine(y));
+ for (int x = 0; x < image.width(); ++x) {
+ const quint32 sample = nextNoise();
+ const int fine = static_cast((sample >> 26U) & 0x3fU) - 32;
+ horizontalFibre = (7 * horizontalFibre + fine) / 8;
+ const int fleck = (sample & 0xffU) < 6U
+ ? (((sample >> 8U) & 1U) == 0U ? -34 : 34)
+ : 0;
+ const int level = std::clamp(
+ 128 + rowBias + fine + 2 * horizontalFibre + fleck, 58, 198);
+ line[x] = qRgb(level, level, level);
+ }
+ }
+ return image;
+ }();
+ return texture;
+}
+
+void drawPaperGrain(QPainter& painter, const QRectF& face, double opacity)
+{
+ if (opacity <= 0.0) {
+ return;
+ }
+ painter.save();
+ painter.setOpacity(opacity);
+ painter.setCompositionMode(QPainter::CompositionMode_Overlay);
+ painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
+ painter.drawImage(face, paperGrainTexture());
+ painter.restore();
+}
+
+bool validStop(double stop)
+{
+ return std::isfinite(stop) && stop > 0.0 && stop < 1.0;
+}
+
+} // namespace
+
+QString analogMeterFaceThemeId(AnalogMeterFaceTheme theme)
+{
+ switch (theme) {
+ case AnalogMeterFaceTheme::AetherDefault:
+ return QStringLiteral("aether-default");
+ case AnalogMeterFaceTheme::ClassicWarm:
+ return QStringLiteral("classic-warm");
+ case AnalogMeterFaceTheme::DarkRoomUplight:
+ return QStringLiteral("dark-room-uplight");
+ case AnalogMeterFaceTheme::GraphiteDark:
+ return QStringLiteral("graphite-dark");
+ }
+ return QStringLiteral("aether-default");
+}
+
+AnalogMeterFaceTheme analogMeterFaceThemeFromId(
+ const QString& id, AnalogMeterFaceTheme fallback)
+{
+ if (id == QStringLiteral("aether-default")) {
+ return AnalogMeterFaceTheme::AetherDefault;
+ }
+ if (id == QStringLiteral("classic-warm")) {
+ return AnalogMeterFaceTheme::ClassicWarm;
+ }
+ if (id == QStringLiteral("dark-room-uplight")) {
+ return AnalogMeterFaceTheme::DarkRoomUplight;
+ }
+ if (id == QStringLiteral("graphite-dark")) {
+ return AnalogMeterFaceTheme::GraphiteDark;
+ }
+ return fallback;
+}
+
+AnalogMeterFaceThemeCatalog AnalogMeterFaceThemeCatalog::loadResource(QString* error)
+{
+ QFile resource(QStringLiteral(":/meterfaces/analog-meter-themes-v1.json"));
+ if (!resource.open(QIODevice::ReadOnly)) {
+ if (error) {
+ *error = QStringLiteral("cannot open shared analog meter theme resource");
+ }
+ return {};
+ }
+ return load(resource, error);
+}
+
+AnalogMeterFaceThemeCatalog AnalogMeterFaceThemeCatalog::load(
+ QIODevice& device, QString* error)
+{
+ if (error) {
+ error->clear();
+ }
+ QJsonParseError parseError;
+ const QJsonDocument document = QJsonDocument::fromJson(device.readAll(), &parseError);
+ if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
+ if (error) {
+ *error = parseError.error != QJsonParseError::NoError
+ ? parseError.errorString()
+ : QStringLiteral("shared analog meter theme root is not an object");
+ }
+ return {};
+ }
+
+ const QJsonObject root = document.object();
+ AnalogMeterFaceThemeCatalog catalog = fallback();
+ QString readError;
+ if (!root.value(QStringLiteral("format_version")).isDouble()
+ || root.value(QStringLiteral("format_version")).toInt() != kFormatVersion) {
+ readError = QStringLiteral("unsupported shared analog meter theme version");
+ }
+ catalog.formatVersion = root.value(QStringLiteral("format_version")).toInt();
+ if (readError.isEmpty()
+ && !readRect(root, QStringLiteral("reference_face"), catalog.referenceFace, readError)) {
+ }
+
+ const QJsonObject mask = root.value(QStringLiteral("lower_mask")).toObject();
+ if (readError.isEmpty() && mask.isEmpty()) {
+ readError = QStringLiteral("lower_mask must be an object");
+ }
+ if (readError.isEmpty()) {
+ const QJsonValue boundaryValue = mask.value(QStringLiteral("boundary_normalized"));
+ if (!boundaryValue.isArray()) {
+ readError = QStringLiteral("lower mask boundary must be an array");
+ } else {
+ QVector boundary;
+ for (const QJsonValue& pointValue : boundaryValue.toArray()) {
+ if (!pointValue.isArray()) {
+ readError = QStringLiteral("lower mask point must be a two-number array");
+ break;
+ }
+ const QJsonArray point = pointValue.toArray();
+ if (point.size() != 2 || !point[0].isDouble() || !point[1].isDouble()
+ || !std::isfinite(point[0].toDouble())
+ || !std::isfinite(point[1].toDouble())) {
+ readError = QStringLiteral("lower mask point must be a two-number array");
+ break;
+ }
+ boundary.push_back(QPointF(point[0].toDouble(), point[1].toDouble()));
+ }
+ if (readError.isEmpty()) {
+ catalog.normalizedMaskBoundary = boundary;
+ }
+ }
+ }
+ if (readError.isEmpty()) {
+ readFiniteDouble(mask, QStringLiteral("bottom_normalized"),
+ catalog.normalizedMaskBottom, readError);
+ }
+
+ const QJsonObject classic = root.value(QStringLiteral("classic_gradient")).toObject();
+ if (readError.isEmpty() && classic.isEmpty()) {
+ readError = QStringLiteral("classic_gradient must be an object");
+ }
+ if (readError.isEmpty()) {
+ FaceGradient& gradient = catalog.classicGradient;
+ readColor(classic, QStringLiteral("top_rgba"), gradient.top, readError)
+ && readColor(classic, QStringLiteral("middle_rgba"), gradient.middle, readError)
+ && readColor(classic, QStringLiteral("bottom_rgba"), gradient.bottom, readError)
+ && readFiniteDouble(classic, QStringLiteral("middle_stop"), gradient.middleStop, readError)
+ && readPoint(classic, QStringLiteral("glow_center"), gradient.glowCenter, readError)
+ && readFiniteDouble(classic, QStringLiteral("glow_radius"), gradient.glowRadius, readError)
+ && readColor(classic, QStringLiteral("glow_inner_rgba"), gradient.glowInner, readError)
+ && readColor(classic, QStringLiteral("glow_outer_rgba"), gradient.glowOuter, readError)
+ && readPoint(classic, QStringLiteral("vignette_center"), gradient.vignetteCenter, readError)
+ && readFiniteDouble(classic, QStringLiteral("vignette_radius"), gradient.vignetteRadius, readError)
+ && readFiniteDouble(classic, QStringLiteral("vignette_clear_stop"), gradient.vignetteClearStop, readError)
+ && readColor(classic, QStringLiteral("vignette_edge_rgba"), gradient.vignetteEdge, readError)
+ && readFiniteDouble(classic, QStringLiteral("paper_grain_opacity"), gradient.paperGrainOpacity, readError);
+ }
+
+ const QJsonObject uplight = root.value(QStringLiteral("uplight_gradient")).toObject();
+ if (readError.isEmpty() && uplight.isEmpty()) {
+ readError = QStringLiteral("uplight_gradient must be an object");
+ }
+ if (readError.isEmpty()) {
+ UplightGradient& gradient = catalog.uplightGradient;
+ readColor(uplight, QStringLiteral("top_rgba"), gradient.top, readError)
+ && readColor(uplight, QStringLiteral("middle_rgba"), gradient.middle, readError)
+ && readColor(uplight, QStringLiteral("bottom_rgba"), gradient.bottom, readError)
+ && readFiniteDouble(uplight, QStringLiteral("middle_stop"), gradient.middleStop, readError)
+ && readPoint(uplight, QStringLiteral("halo_center"), gradient.haloCenter, readError)
+ && readFiniteDouble(uplight, QStringLiteral("halo_radius"), gradient.haloRadius, readError)
+ && readColor(uplight, QStringLiteral("halo_inner_rgba"), gradient.haloInner, readError)
+ && readColor(uplight, QStringLiteral("halo_middle_rgba"), gradient.haloMiddle, readError)
+ && readFiniteDouble(uplight, QStringLiteral("halo_middle_stop"), gradient.haloMiddleStop, readError)
+ && readColor(uplight, QStringLiteral("halo_shoulder_rgba"), gradient.haloShoulder, readError)
+ && readFiniteDouble(uplight, QStringLiteral("halo_shoulder_stop"), gradient.haloShoulderStop, readError)
+ && readColor(uplight, QStringLiteral("halo_outer_rgba"), gradient.haloOuter, readError)
+ && readPoint(uplight, QStringLiteral("hotspot_center"), gradient.hotspotCenter, readError)
+ && readFiniteDouble(uplight, QStringLiteral("hotspot_radius"), gradient.hotspotRadius, readError)
+ && readColor(uplight, QStringLiteral("hotspot_inner_rgba"), gradient.hotspotInner, readError)
+ && readColor(uplight, QStringLiteral("hotspot_middle_rgba"), gradient.hotspotMiddle, readError)
+ && readFiniteDouble(uplight, QStringLiteral("hotspot_middle_stop"), gradient.hotspotMiddleStop, readError)
+ && readColor(uplight, QStringLiteral("hotspot_outer_rgba"), gradient.hotspotOuter, readError)
+ && readPoint(uplight, QStringLiteral("bloom_center"), gradient.bloomCenter, readError)
+ && readFiniteDouble(uplight, QStringLiteral("bloom_radius"), gradient.bloomRadius, readError)
+ && readColor(uplight, QStringLiteral("bloom_inner_rgba"), gradient.bloomInner, readError)
+ && readColor(uplight, QStringLiteral("bloom_middle_rgba"), gradient.bloomMiddle, readError)
+ && readFiniteDouble(uplight, QStringLiteral("bloom_middle_stop"), gradient.bloomMiddleStop, readError)
+ && readColor(uplight, QStringLiteral("bloom_outer_rgba"), gradient.bloomOuter, readError)
+ && readPoint(uplight, QStringLiteral("vignette_center"), gradient.vignetteCenter, readError)
+ && readFiniteDouble(uplight, QStringLiteral("vignette_radius"), gradient.vignetteRadius, readError)
+ && readFiniteDouble(uplight, QStringLiteral("vignette_clear_stop"), gradient.vignetteClearStop, readError)
+ && readColor(uplight, QStringLiteral("vignette_edge_rgba"), gradient.vignetteEdge, readError)
+ && readFiniteDouble(uplight, QStringLiteral("paper_grain_opacity"), gradient.paperGrainOpacity, readError);
+ }
+
+ const QJsonObject dark = root.value(QStringLiteral("dark_gradient")).toObject();
+ if (readError.isEmpty() && dark.isEmpty()) {
+ readError = QStringLiteral("dark_gradient must be an object");
+ }
+ if (readError.isEmpty()) {
+ DarkGradient& gradient = catalog.darkGradient;
+ readColor(dark, QStringLiteral("top_rgba"), gradient.top, readError)
+ && readColor(dark, QStringLiteral("middle_rgba"), gradient.middle, readError)
+ && readColor(dark, QStringLiteral("bottom_rgba"), gradient.bottom, readError)
+ && readFiniteDouble(dark, QStringLiteral("middle_stop"), gradient.middleStop, readError)
+ && readPoint(dark, QStringLiteral("ambient_center"), gradient.ambientCenter, readError)
+ && readFiniteDouble(dark, QStringLiteral("ambient_radius"), gradient.ambientRadius, readError)
+ && readColor(dark, QStringLiteral("ambient_inner_rgba"), gradient.ambientInner, readError)
+ && readColor(dark, QStringLiteral("ambient_outer_rgba"), gradient.ambientOuter, readError)
+ && readPoint(dark, QStringLiteral("glow_center"), gradient.glowCenter, readError)
+ && readFiniteDouble(dark, QStringLiteral("glow_radius"), gradient.glowRadius, readError)
+ && readColor(dark, QStringLiteral("glow_inner_rgba"), gradient.glowInner, readError)
+ && readColor(dark, QStringLiteral("glow_middle_rgba"), gradient.glowMiddle, readError)
+ && readFiniteDouble(dark, QStringLiteral("glow_middle_stop"), gradient.glowMiddleStop, readError)
+ && readColor(dark, QStringLiteral("glow_outer_rgba"), gradient.glowOuter, readError)
+ && readPoint(dark, QStringLiteral("vignette_center"), gradient.vignetteCenter, readError)
+ && readFiniteDouble(dark, QStringLiteral("vignette_radius"), gradient.vignetteRadius, readError)
+ && readFiniteDouble(dark, QStringLiteral("vignette_clear_stop"), gradient.vignetteClearStop, readError)
+ && readColor(dark, QStringLiteral("vignette_edge_rgba"), gradient.vignetteEdge, readError)
+ && readFiniteDouble(dark, QStringLiteral("paper_grain_opacity"), gradient.paperGrainOpacity, readError);
+ }
+
+ const QJsonObject palettes = root.value(QStringLiteral("palettes")).toObject();
+ if (readError.isEmpty() && palettes.isEmpty()) {
+ readError = QStringLiteral("palettes must be an object");
+ }
+ if (readError.isEmpty()) {
+ const QJsonObject classicPalette = palettes.value(QStringLiteral("classic-warm")).toObject();
+ const QJsonObject uplightPalette = palettes.value(QStringLiteral("dark-room-uplight")).toObject();
+ const QJsonObject darkPalette = palettes.value(QStringLiteral("graphite-dark")).toObject();
+ if (classicPalette.isEmpty() || uplightPalette.isEmpty() || darkPalette.isEmpty()) {
+ readError = QStringLiteral("all physical meter palettes are required");
+ } else {
+ readPalette(classicPalette, catalog.classicPalette, readError)
+ && readPalette(uplightPalette, catalog.uplightPalette, readError)
+ && readPalette(darkPalette, catalog.darkPalette, readError);
+ }
+ }
+
+ QString validationError;
+ if (readError.isEmpty() && !catalog.isValid(&validationError)) {
+ readError = validationError;
+ }
+ if (!readError.isEmpty()) {
+ if (error) {
+ *error = readError;
+ }
+ return {};
+ }
+ return catalog;
+}
+
+AnalogMeterFaceThemeCatalog AnalogMeterFaceThemeCatalog::fallback()
+{
+ AnalogMeterFaceThemeCatalog catalog;
+ catalog.formatVersion = kFormatVersion;
+ catalog.referenceFace = QRectF(26.0, 26.0, 1448.0, 948.0);
+ catalog.normalizedMaskBoundary = {
+ {0.0179558011, 0.9261603376}, {0.1546961326, 0.9261603376},
+ {0.3273480663, 0.9219409283}, {0.4309392265, 0.9008438819},
+ {0.5, 0.8924050633}, {0.5690607735, 0.9008438819},
+ {0.6726519337, 0.9219409283}, {0.8453038674, 0.9261603376},
+ {0.9820441989, 0.9261603376}};
+ catalog.normalizedMaskBottom = 1.0;
+
+ catalog.classicGradient = {
+ {232, 216, 183}, {250, 242, 221}, {222, 205, 172}, 0.50,
+ {750.0, 400.0}, 900.0, {255, 250, 235, 205}, {255, 250, 235, 0},
+ {750.0, 440.0}, 930.0, 0.62, {106, 97, 76, 65}, 0.0};
+ catalog.uplightGradient = {
+ {142, 110, 73}, {174, 126, 70}, {169, 119, 65}, 0.60,
+ {750.0, 920.0}, 930.0, {255, 181, 83, 180}, {242, 160, 72, 170},
+ 0.52, {218, 143, 68, 40}, 0.72, {211, 137, 69, 0},
+ {750.0, 930.0}, 470.0, {255, 245, 110, 220}, {255, 195, 65, 145},
+ 0.48, {255, 146, 44, 0}, {750.0, 940.0}, 330.0,
+ {255, 246, 95, 225}, {255, 215, 65, 90}, 0.52, {255, 220, 130, 0},
+ {750.0, 650.0}, 890.0, 0.34, {9, 10, 12, 120}, 0.100};
+ catalog.darkGradient = {
+ {20, 22, 23}, {27, 28, 28}, {25, 24, 23}, 0.58,
+ {750.0, 410.0}, 900.0, {68, 66, 61, 55}, {40, 40, 38, 0},
+ {750.0, 940.0}, 720.0, {190, 92, 35, 100}, {112, 59, 34, 38},
+ 0.58, {80, 43, 30, 0}, {750.0, 500.0}, 940.0, 0.42,
+ {0, 0, 0, 120}, 0.220};
+
+ const Palette physicalPalette{
+ {224, 212, 187, 225}, {30, 36, 38}, {246, 237, 216},
+ {126, 62, 54, 235}, {48, 65, 91}, {35, 42, 46},
+ {55, 72, 91, 230}, {35, 42, 46}, {54, 61, 66},
+ {161, 74, 58, 230}, {45, 45, 42}, {18, 22, 25},
+ {2, 4, 5, 210}, {132, 132, 124, 175}, {0, 0, 0, 80},
+ {0, 0, 0, 28}, {34, 40, 47}, {87, 99, 110}, {234, 238, 241}};
+ catalog.classicPalette = physicalPalette;
+ catalog.uplightPalette = physicalPalette;
+ catalog.uplightPalette.scaleSeparator = QColor(117, 75, 48, 150);
+ catalog.darkPalette = {
+ {44, 43, 40, 225}, {202, 188, 159}, {126, 95, 73, 225},
+ {119, 71, 59, 235}, {59, 84, 105}, {205, 191, 162},
+ {151, 139, 117, 230}, {205, 190, 158}, {179, 162, 132},
+ {139, 77, 58, 225}, {205, 190, 158}, {202, 197, 183},
+ {82, 78, 69, 220}, {240, 234, 216}, {0, 0, 0, 105},
+ {0, 0, 0, 36}, {20, 23, 26}, {64, 69, 74}, {219, 207, 181}};
+ return catalog;
+}
+
+bool AnalogMeterFaceThemeCatalog::isValid(QString* error) const
+{
+ const auto fail = [error](const QString& message) {
+ if (error) {
+ *error = message;
+ }
+ return false;
+ };
+ if (error) {
+ error->clear();
+ }
+ if (formatVersion != kFormatVersion) {
+ return fail(QStringLiteral("unsupported shared analog meter theme version"));
+ }
+ if (!(referenceFace.width() > 0.0) || !(referenceFace.height() > 0.0)) {
+ return fail(QStringLiteral("shared analog meter reference face is invalid"));
+ }
+ if (normalizedMaskBoundary.size() != 9
+ || !(normalizedMaskBottom > 0.0 && normalizedMaskBottom <= 1.0)) {
+ return fail(QStringLiteral("shared analog meter lower mask is invalid"));
+ }
+ double previousX = -1.0;
+ for (const QPointF& point : normalizedMaskBoundary) {
+ if (!std::isfinite(point.x()) || !std::isfinite(point.y())
+ || point.x() < 0.0 || point.x() > 1.0
+ || point.y() < 0.0 || point.y() > normalizedMaskBottom
+ || point.x() <= previousX) {
+ return fail(QStringLiteral("shared analog meter lower mask is invalid"));
+ }
+ previousX = point.x();
+ }
+ for (int index = 0; index < normalizedMaskBoundary.size() / 2; ++index) {
+ const QPointF left = normalizedMaskBoundary[index];
+ const QPointF right = normalizedMaskBoundary[normalizedMaskBoundary.size() - 1 - index];
+ if (std::abs((left.x() + right.x()) - 1.0) > 1e-6
+ || std::abs(left.y() - right.y()) > 1e-6) {
+ return fail(QStringLiteral("shared analog meter lower mask is not symmetric"));
+ }
+ }
+ const QPointF center = normalizedMaskBoundary.at(normalizedMaskBoundary.size() / 2);
+ if (std::abs(center.x() - 0.5) > 1e-6) {
+ return fail(QStringLiteral("shared analog meter lower mask is not symmetric"));
+ }
+ if (!validStop(classicGradient.middleStop)
+ || !validStop(classicGradient.vignetteClearStop)
+ || !(classicGradient.glowRadius > 0.0)
+ || !(classicGradient.vignetteRadius > 0.0)
+ || classicGradient.paperGrainOpacity < 0.0
+ || classicGradient.paperGrainOpacity > 0.30) {
+ return fail(QStringLiteral("classic analog meter material is invalid"));
+ }
+ if (!validStop(uplightGradient.middleStop)
+ || !validStop(uplightGradient.haloMiddleStop)
+ || !validStop(uplightGradient.haloShoulderStop)
+ || !(uplightGradient.haloMiddleStop < uplightGradient.haloShoulderStop)
+ || !validStop(uplightGradient.hotspotMiddleStop)
+ || !validStop(uplightGradient.bloomMiddleStop)
+ || !validStop(uplightGradient.vignetteClearStop)
+ || !(uplightGradient.haloRadius > 0.0)
+ || !(uplightGradient.hotspotRadius > 0.0)
+ || !(uplightGradient.bloomRadius > 0.0)
+ || !(uplightGradient.vignetteRadius > 0.0)
+ || uplightGradient.paperGrainOpacity < 0.0
+ || uplightGradient.paperGrainOpacity > 0.30) {
+ return fail(QStringLiteral("uplight analog meter material is invalid"));
+ }
+ if (!validStop(darkGradient.middleStop)
+ || !validStop(darkGradient.glowMiddleStop)
+ || !validStop(darkGradient.vignetteClearStop)
+ || !(darkGradient.ambientRadius > 0.0)
+ || !(darkGradient.glowRadius > 0.0)
+ || !(darkGradient.vignetteRadius > 0.0)
+ || darkGradient.paperGrainOpacity < 0.0
+ || darkGradient.paperGrainOpacity > 0.30) {
+ return fail(QStringLiteral("graphite analog meter material is invalid"));
+ }
+ return true;
+}
+
+const AnalogMeterFaceThemeCatalog::Palette& AnalogMeterFaceThemeCatalog::palette(
+ AnalogMeterFaceTheme theme) const
+{
+ switch (theme) {
+ case AnalogMeterFaceTheme::DarkRoomUplight:
+ return uplightPalette;
+ case AnalogMeterFaceTheme::GraphiteDark:
+ return darkPalette;
+ case AnalogMeterFaceTheme::AetherDefault:
+ case AnalogMeterFaceTheme::ClassicWarm:
+ return classicPalette;
+ }
+ return classicPalette;
+}
+
+void AnalogMeterFaceThemeCatalog::drawBackground(
+ QPainter& painter, const QRectF& face, AnalogMeterFaceTheme theme) const
+{
+ if (theme == AnalogMeterFaceTheme::AetherDefault) {
+ return;
+ }
+
+ painter.save();
+ painter.setClipRect(face, Qt::IntersectClip);
+ painter.translate(face.left(), face.top());
+ painter.scale(face.width() / referenceFace.width(),
+ face.height() / referenceFace.height());
+ painter.translate(-referenceFace.left(), -referenceFace.top());
+ const QRectF materialFace = referenceFace;
+
+ if (theme == AnalogMeterFaceTheme::GraphiteDark) {
+ const DarkGradient& dark = darkGradient;
+ QLinearGradient card(materialFace.topLeft(), materialFace.bottomLeft());
+ card.setColorAt(0.0, dark.top);
+ card.setColorAt(dark.middleStop, dark.middle);
+ card.setColorAt(1.0, dark.bottom);
+ painter.fillRect(materialFace, card);
+
+ QRadialGradient ambient(dark.ambientCenter, dark.ambientRadius);
+ ambient.setColorAt(0.0, dark.ambientInner);
+ ambient.setColorAt(1.0, dark.ambientOuter);
+ painter.fillRect(materialFace, ambient);
+
+ painter.save();
+ painter.setCompositionMode(QPainter::CompositionMode_Screen);
+ QRadialGradient glow(dark.glowCenter, dark.glowRadius);
+ glow.setColorAt(0.0, dark.glowInner);
+ glow.setColorAt(dark.glowMiddleStop, dark.glowMiddle);
+ glow.setColorAt(1.0, dark.glowOuter);
+ painter.fillRect(materialFace, glow);
+ painter.restore();
+
+ QColor clearEdge = dark.vignetteEdge;
+ clearEdge.setAlpha(0);
+ QRadialGradient vignette(dark.vignetteCenter, dark.vignetteRadius);
+ vignette.setColorAt(0.0, clearEdge);
+ vignette.setColorAt(dark.vignetteClearStop, clearEdge);
+ vignette.setColorAt(1.0, dark.vignetteEdge);
+ painter.fillRect(materialFace, vignette);
+ drawPaperGrain(painter, materialFace, dark.paperGrainOpacity);
+ painter.restore();
+ return;
+ }
+
+ if (theme == AnalogMeterFaceTheme::DarkRoomUplight) {
+ const UplightGradient& light = uplightGradient;
+ QLinearGradient ambient(materialFace.topLeft(), materialFace.bottomLeft());
+ ambient.setColorAt(0.0, light.top);
+ ambient.setColorAt(light.middleStop, light.middle);
+ ambient.setColorAt(1.0, light.bottom);
+ painter.fillRect(materialFace, ambient);
+
+ QRadialGradient halo(light.haloCenter, light.haloRadius);
+ halo.setColorAt(0.0, light.haloInner);
+ halo.setColorAt(light.haloMiddleStop, light.haloMiddle);
+ halo.setColorAt(light.haloShoulderStop, light.haloShoulder);
+ halo.setColorAt(1.0, light.haloOuter);
+ painter.fillRect(materialFace, halo);
+
+ painter.save();
+ painter.setCompositionMode(QPainter::CompositionMode_Screen);
+ QRadialGradient hotspot(light.hotspotCenter, light.hotspotRadius);
+ hotspot.setColorAt(0.0, light.hotspotInner);
+ hotspot.setColorAt(light.hotspotMiddleStop, light.hotspotMiddle);
+ hotspot.setColorAt(1.0, light.hotspotOuter);
+ painter.fillRect(materialFace, hotspot);
+ QRadialGradient bloom(light.bloomCenter, light.bloomRadius);
+ bloom.setColorAt(0.0, light.bloomInner);
+ bloom.setColorAt(light.bloomMiddleStop, light.bloomMiddle);
+ bloom.setColorAt(1.0, light.bloomOuter);
+ painter.fillRect(materialFace, bloom);
+ painter.restore();
+
+ QColor clearEdge = light.vignetteEdge;
+ clearEdge.setAlpha(0);
+ QRadialGradient vignette(light.vignetteCenter, light.vignetteRadius);
+ vignette.setColorAt(0.0, clearEdge);
+ vignette.setColorAt(light.vignetteClearStop, clearEdge);
+ vignette.setColorAt(1.0, light.vignetteEdge);
+ painter.fillRect(materialFace, vignette);
+ drawPaperGrain(painter, materialFace, light.paperGrainOpacity);
+ painter.restore();
+ return;
+ }
+
+ const FaceGradient& material = classicGradient;
+ QLinearGradient base(materialFace.topLeft(), materialFace.bottomLeft());
+ base.setColorAt(0.0, material.top);
+ base.setColorAt(material.middleStop, material.middle);
+ base.setColorAt(1.0, material.bottom);
+ painter.fillRect(materialFace, base);
+ QRadialGradient glow(material.glowCenter, material.glowRadius);
+ glow.setColorAt(0.0, material.glowInner);
+ glow.setColorAt(1.0, material.glowOuter);
+ painter.fillRect(materialFace, glow);
+ QColor clearEdge = material.vignetteEdge;
+ clearEdge.setAlpha(0);
+ QRadialGradient vignette(material.vignetteCenter, material.vignetteRadius);
+ vignette.setColorAt(0.0, clearEdge);
+ vignette.setColorAt(material.vignetteClearStop, clearEdge);
+ vignette.setColorAt(1.0, material.vignetteEdge);
+ painter.fillRect(materialFace, vignette);
+ drawPaperGrain(painter, materialFace, material.paperGrainOpacity);
+ painter.restore();
+}
+
+QVector AnalogMeterFaceThemeCatalog::lowerMaskBoundary(
+ const QRectF& face) const
+{
+ QVector boundary;
+ boundary.reserve(normalizedMaskBoundary.size());
+ for (const QPointF& normalized : normalizedMaskBoundary) {
+ boundary.push_back(QPointF(face.left() + normalized.x() * face.width(),
+ face.top() + normalized.y() * face.height()));
+ }
+ return boundary;
+}
+
+QPainterPath AnalogMeterFaceThemeCatalog::lowerMaskPath(const QRectF& face) const
+{
+ const QVector boundary = lowerMaskBoundary(face);
+ QPainterPath path;
+ if (boundary.isEmpty()) {
+ return path;
+ }
+ const double bottom = face.top() + normalizedMaskBottom * face.height();
+ path.moveTo(boundary.first().x(), bottom);
+ for (const QPointF& point : boundary) {
+ path.lineTo(point);
+ }
+ path.lineTo(boundary.last().x(), bottom);
+ path.closeSubpath();
+ return path;
+}
+
+} // namespace AetherSDR
diff --git a/src/gui/AnalogMeterFaceTheme.h b/src/gui/AnalogMeterFaceTheme.h
new file mode 100644
index 000000000..67f65df0f
--- /dev/null
+++ b/src/gui/AnalogMeterFaceTheme.h
@@ -0,0 +1,159 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+class QIODevice;
+class QPainter;
+class QPainterPath;
+
+namespace AetherSDR {
+
+// Shared physical face materials used by both analog meter widgets. The
+// standard S-meter adds AetherDefault; the independent PWR meter uses the
+// three physical treatments only.
+enum class AnalogMeterFaceTheme {
+ AetherDefault,
+ ClassicWarm,
+ DarkRoomUplight,
+ GraphiteDark,
+};
+
+QString analogMeterFaceThemeId(AnalogMeterFaceTheme theme);
+AnalogMeterFaceTheme analogMeterFaceThemeFromId(
+ const QString& id, AnalogMeterFaceTheme fallback);
+
+class AnalogMeterFaceThemeCatalog {
+public:
+ struct FaceGradient {
+ QColor top;
+ QColor middle;
+ QColor bottom;
+ double middleStop{0.5};
+ QPointF glowCenter;
+ double glowRadius{1.0};
+ QColor glowInner;
+ QColor glowOuter;
+ QPointF vignetteCenter;
+ double vignetteRadius{1.0};
+ double vignetteClearStop{0.0};
+ QColor vignetteEdge;
+ double paperGrainOpacity{0.0};
+
+ bool operator==(const FaceGradient&) const = default;
+ };
+
+ struct UplightGradient {
+ QColor top;
+ QColor middle;
+ QColor bottom;
+ double middleStop{0.5};
+ QPointF haloCenter;
+ double haloRadius{1.0};
+ QColor haloInner;
+ QColor haloMiddle;
+ double haloMiddleStop{0.0};
+ QColor haloShoulder;
+ double haloShoulderStop{0.0};
+ QColor haloOuter;
+ QPointF hotspotCenter;
+ double hotspotRadius{1.0};
+ QColor hotspotInner;
+ QColor hotspotMiddle;
+ double hotspotMiddleStop{0.0};
+ QColor hotspotOuter;
+ QPointF bloomCenter;
+ double bloomRadius{1.0};
+ QColor bloomInner;
+ QColor bloomMiddle;
+ double bloomMiddleStop{0.0};
+ QColor bloomOuter;
+ QPointF vignetteCenter;
+ double vignetteRadius{1.0};
+ double vignetteClearStop{0.0};
+ QColor vignetteEdge;
+ double paperGrainOpacity{0.0};
+
+ bool operator==(const UplightGradient&) const = default;
+ };
+
+ struct DarkGradient {
+ QColor top;
+ QColor middle;
+ QColor bottom;
+ double middleStop{0.5};
+ QPointF ambientCenter;
+ double ambientRadius{1.0};
+ QColor ambientInner;
+ QColor ambientOuter;
+ QPointF glowCenter;
+ double glowRadius{1.0};
+ QColor glowInner;
+ QColor glowMiddle;
+ double glowMiddleStop{0.0};
+ QColor glowOuter;
+ QPointF vignetteCenter;
+ double vignetteRadius{1.0};
+ double vignetteClearStop{0.0};
+ QColor vignetteEdge;
+ double paperGrainOpacity{0.0};
+
+ bool operator==(const DarkGradient&) const = default;
+ };
+
+ struct Palette {
+ QColor ribbon;
+ QColor scaleOuter;
+ QColor scaleSeparator;
+ QColor scaleCalibration;
+ QColor scaleInner;
+ QColor majorTick;
+ QColor minorTick;
+ QColor text;
+ QColor secondaryText;
+ QColor swrGuide;
+ QColor swrLabel;
+ QColor needle;
+ QColor needleEdge;
+ QColor needleHighlight;
+ QColor needleShadow;
+ QColor needleSoftShadow;
+ QColor maskFill;
+ QColor maskEdge;
+ QColor maskText;
+
+ bool operator==(const Palette&) const = default;
+ };
+
+ static AnalogMeterFaceThemeCatalog loadResource(QString* error = nullptr);
+ static AnalogMeterFaceThemeCatalog load(QIODevice& device, QString* error = nullptr);
+ static AnalogMeterFaceThemeCatalog fallback();
+
+ bool isValid(QString* error = nullptr) const;
+ const Palette& palette(AnalogMeterFaceTheme theme) const;
+ void drawBackground(QPainter& painter, const QRectF& face,
+ AnalogMeterFaceTheme theme) const;
+ QPainterPath lowerMaskPath(const QRectF& face) const;
+ QVector lowerMaskBoundary(const QRectF& face) const;
+
+ // Keep the resource and compiled fallback mechanically comparable. A
+ // defaulted C++20 equality operator automatically includes every field
+ // added to the catalog or one of its nested material records.
+ bool operator==(const AnalogMeterFaceThemeCatalog&) const = default;
+
+ int formatVersion{0};
+ QRectF referenceFace;
+ QVector normalizedMaskBoundary;
+ double normalizedMaskBottom{1.0};
+ FaceGradient classicGradient;
+ UplightGradient uplightGradient;
+ DarkGradient darkGradient;
+ Palette classicPalette;
+ Palette uplightPalette;
+ Palette darkPalette;
+};
+
+} // namespace AetherSDR
diff --git a/src/gui/AppletPanel.cpp b/src/gui/AppletPanel.cpp
index 8e6eab070..b79cb2947 100644
--- a/src/gui/AppletPanel.cpp
+++ b/src/gui/AppletPanel.cpp
@@ -95,16 +95,19 @@ MeterSettings::Snapshot loadVuMeterSettings()
MeterSettings::LegacyCrossNeedle legacyCrossNeedle;
const MeterSettings::Snapshot settings =
MeterSettings::decode(raw, &error, &legacyCrossNeedle);
+ const int storedVersion = QJsonDocument::fromJson(raw).object()
+ .value(QStringLiteral("version")).toInt();
if (!error.isEmpty()) {
qCWarning(lcGui).noquote()
<< "AppletPanel: ignoring invalid VuMeter settings:"
<< error;
- } else if (legacyCrossNeedle.present) {
- // Version 1 temporarily combined both meters. Move its theme into
- // the independent PWR feature object and rewrite VuMeter as the
- // standard-only version 2 object. Queue every value before one
- // save so the AppSettings document changes atomically.
- if (!appSettings.contains(PowerMeterSettings::kSettingsKey)) {
+ } else if (storedVersion < MeterSettings::kVersion) {
+ // Version 1 temporarily combined both meters. Move that PWR theme
+ // first, then rewrite either legacy version as the version-3
+ // standard-only object with the established Aether face. Queue
+ // every value before one save so AppSettings changes atomically.
+ if (legacyCrossNeedle.present
+ && !appSettings.contains(PowerMeterSettings::kSettingsKey)) {
const PowerMeterSettings::Snapshot powerSettings =
PowerMeterSettings::migrateLegacyTheme(
legacyCrossNeedle.faceTheme);
@@ -363,7 +366,10 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent)
m_vuRxSelect = meterSettings.rxSelect;
m_vuPeakHoldEnabled = meterSettings.peakHoldEnabled;
m_vuPeakDecayRate = meterSettings.peakDecayRate;
+ m_vuFaceTheme = MeterSettings::normalizeFaceTheme(meterSettings.faceTheme);
+ m_sMeter->setFaceTheme(analogMeterFaceThemeFromId(
+ m_vuFaceTheme, AnalogMeterFaceTheme::AetherDefault));
m_sMeter->setTxMode(MeterSettings::txMeterItems()[m_vuTxSelect]);
m_sMeter->setRxMode(MeterSettings::rxMeterItems()[m_vuRxSelect]);
m_sMeter->setPeakHoldEnabled(m_vuPeakHoldEnabled);
@@ -398,6 +404,10 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent)
// tray button toggles its visibility.
m_sMeterContainer = m_containerMgr->createContainer("VU", "S-Meter");
m_sMeterContainer->setContent(sMeterContent);
+ connect(m_sMeterContainer, &ContainerWidget::dockModeChanged,
+ m_sMeter, [this](ContainerWidget::DockMode mode) {
+ m_sMeter->setFloating(mode == ContainerWidget::DockMode::Floating);
+ });
const bool sMeterOn = AppSettings::instance()
.value("Applet_VU", "True").toString() == "True";
m_sMeterContainer->setContainerVisible(sMeterOn);
@@ -985,6 +995,12 @@ void AppletPanel::setStandardMeterTxValues(float forwardWatts, float swr)
m_sMeter->setTxMeters(forwardWatts, swr);
}
+void AppletPanel::setStandardRadioMeterTxValues(
+ float forwardWatts, float forwardWattsInstant, float swr)
+{
+ m_sMeter->setRadioTxMeters(forwardWatts, forwardWattsInstant, swr);
+}
+
void AppletPanel::setCrossNeedleDirectionalValues(
float forwardWatts, float reflectedWatts, float swr,
bool reflectedPowerMeasured)
@@ -1015,6 +1031,7 @@ void AppletPanel::persistVuMeterSettings() const
settings.rxSelect = m_vuRxSelect;
settings.peakHoldEnabled = m_vuPeakHoldEnabled;
settings.peakDecayRate = m_vuPeakDecayRate;
+ settings.faceTheme = m_vuFaceTheme;
AppSettings& appSettings = AppSettings::instance();
appSettings.setValue(MeterSettings::kSettingsKey,
@@ -1043,6 +1060,34 @@ void AppletPanel::showStandardMeterContextMenu(QWidget* source,
menu.addAction(action);
};
+ addHeader(QStringLiteral("Face theme"));
+ QActionGroup* faceThemeGroup = new QActionGroup(&menu);
+ struct FaceThemeAction {
+ const char* label;
+ const char* id;
+ const char* objectName;
+ };
+ static constexpr FaceThemeAction faceThemes[] = {
+ {"Aether default", "aether-default", "standardSMeterAetherFaceThemeAction"},
+ {"Classic warm", "classic-warm", "standardSMeterClassicFaceThemeAction"},
+ {"Dark-room uplight", "dark-room-uplight", "standardSMeterUplightFaceThemeAction"},
+ {"Graphite dark", "graphite-dark", "standardSMeterDarkFaceThemeAction"},
+ };
+ for (const FaceThemeAction& faceTheme : faceThemes) {
+ const QString id = QString::fromLatin1(faceTheme.id);
+ QAction* action = menu.addAction(QString::fromLatin1(faceTheme.label));
+ action->setObjectName(QString::fromLatin1(faceTheme.objectName));
+ action->setCheckable(true);
+ action->setChecked(m_vuFaceTheme == id);
+ faceThemeGroup->addAction(action);
+ connect(action, &QAction::triggered, this, [this, id]() {
+ m_vuFaceTheme = MeterSettings::normalizeFaceTheme(id);
+ m_sMeter->setFaceTheme(analogMeterFaceThemeFromId(
+ m_vuFaceTheme, AnalogMeterFaceTheme::AetherDefault));
+ persistVuMeterSettings();
+ });
+ }
+
addHeader(QStringLiteral("TX Select"));
QActionGroup* txGroup = new QActionGroup(&menu);
for (int i = 0; i < MeterSettings::txMeterItems().size(); ++i) {
diff --git a/src/gui/AppletPanel.h b/src/gui/AppletPanel.h
index 8941cd17e..65bf2720b 100644
--- a/src/gui/AppletPanel.h
+++ b/src/gui/AppletPanel.h
@@ -82,6 +82,9 @@ class AppletPanel : public QWidget {
CrossNeedleMeterWidget* crossNeedleMeterWidget() const;
void setMeterTxValues(float forwardWatts, float swr);
void setStandardMeterTxValues(float forwardWatts, float swr);
+ void setStandardRadioMeterTxValues(float forwardWatts,
+ float forwardWattsInstant,
+ float swr);
void setCrossNeedleDirectionalValues(float forwardWatts,
float reflectedWatts,
float swr,
@@ -273,6 +276,7 @@ class AppletPanel : public QWidget {
int m_vuRxSelect{0};
bool m_vuPeakHoldEnabled{false};
QString m_vuPeakDecayRate{QStringLiteral("Medium")};
+ QString m_vuFaceTheme{QStringLiteral("aether-default")};
RxApplet* m_rxApplet{nullptr};
TunerApplet* m_tunerApplet{nullptr};
AmpApplet* m_ampApplet{nullptr};
diff --git a/src/gui/CrossNeedleMeterApplet.cpp b/src/gui/CrossNeedleMeterApplet.cpp
index 0c85ed7f0..247da6dc2 100644
--- a/src/gui/CrossNeedleMeterApplet.cpp
+++ b/src/gui/CrossNeedleMeterApplet.cpp
@@ -8,6 +8,7 @@
#include
#include
+#include
#include
#include
#include
@@ -108,21 +109,32 @@ void CrossNeedleMeterApplet::loadSettings()
.value(MeterSettings::kSettingsKey, QString())
.toString();
MeterSettings::Snapshot settings;
+ bool rewriteSettings = false;
if (!raw.isEmpty()) {
QString error;
settings = MeterSettings::decode(raw.toUtf8(), &error);
if (!error.isEmpty()) {
qCWarning(lcGui).noquote()
<< "CrossNeedleMeterApplet: ignoring invalid settings:" << error;
+ } else {
+ const int storedVersion =
+ QJsonDocument::fromJson(raw.toUtf8()).object()
+ .value(QStringLiteral("version")).toInt();
+ rewriteSettings = storedVersion < MeterSettings::kVersion;
}
}
setFaceTheme(settings.faceTheme, false);
+ setRangeLegendVisible(settings.showRange, false);
+ if (rewriteSettings) {
+ persistSettings();
+ }
}
void CrossNeedleMeterApplet::persistSettings() const
{
MeterSettings::Snapshot settings;
settings.faceTheme = m_faceTheme;
+ settings.showRange = m_rangeLegendVisible;
AppSettings& appSettings = AppSettings::instance();
appSettings.setValue(MeterSettings::kSettingsKey,
MeterSettings::encode(settings));
@@ -146,6 +158,16 @@ void CrossNeedleMeterApplet::setFaceTheme(const QString& theme, bool persist)
}
}
+void CrossNeedleMeterApplet::setRangeLegendVisible(bool visible, bool persist)
+{
+ m_rangeLegendVisible = visible;
+ m_meter->setRangeLegendVisible(visible);
+ setProperty("crossNeedleRangeLegendVisible", visible);
+ if (persist) {
+ persistSettings();
+ }
+}
+
void CrossNeedleMeterApplet::showContextMenu(const QPoint& position)
{
QMenu menu(m_meter);
@@ -193,6 +215,16 @@ void CrossNeedleMeterApplet::showContextMenu(const QPoint& position)
setFaceTheme(MeterSettings::kDarkTheme, true);
});
+ menu.addSeparator();
+ addHeader(tr("Display"));
+ QAction* showRangeAction = menu.addAction(tr("Show Range"));
+ showRangeAction->setObjectName(QStringLiteral("crossNeedleShowRangeAction"));
+ showRangeAction->setCheckable(true);
+ showRangeAction->setChecked(m_rangeLegendVisible);
+ connect(showRangeAction, &QAction::toggled, this, [this](bool checked) {
+ setRangeLegendVisible(checked, true);
+ });
+
if (qEnvironmentVariableIsSet("AETHER_AUTOMATION")) {
menu.addSeparator();
addHeader(QStringLiteral("Automation proof"));
diff --git a/src/gui/CrossNeedleMeterApplet.h b/src/gui/CrossNeedleMeterApplet.h
index e57d8188e..1dab2543c 100644
--- a/src/gui/CrossNeedleMeterApplet.h
+++ b/src/gui/CrossNeedleMeterApplet.h
@@ -30,10 +30,12 @@ class CrossNeedleMeterApplet : public QWidget {
void loadSettings();
void persistSettings() const;
void setFaceTheme(const QString& theme, bool persist);
+ void setRangeLegendVisible(bool visible, bool persist);
void showContextMenu(const QPoint& position);
CrossNeedleMeterWidget* m_meter{nullptr};
QString m_faceTheme;
+ bool m_rangeLegendVisible{true};
bool m_floating{false};
};
diff --git a/src/gui/CrossNeedleMeterGeometry.cpp b/src/gui/CrossNeedleMeterGeometry.cpp
index 8ee1e6004..0aabe1f35 100644
--- a/src/gui/CrossNeedleMeterGeometry.cpp
+++ b/src/gui/CrossNeedleMeterGeometry.cpp
@@ -1,14 +1,19 @@
#include "CrossNeedleMeterGeometry.h"
#include
+#include
+#include
+#include
#include
#include
#include
#include
#include
+#include
#include
#include
+#include
namespace AetherSDR {
@@ -43,61 +48,6 @@ QVector readDoubles(const QJsonValue &value) {
return result;
}
-QVector monotoneTangents(const QVector &values,
- const QVector &outputs) {
- const int count = values.size();
- if (count < 2 || outputs.size() != count) {
- return {};
- }
-
- QVector intervals(count - 1);
- QVector slopes(count - 1);
- for (int i = 0; i + 1 < count; ++i) {
- intervals[i] = values[i + 1] - values[i];
- if (!(intervals[i] > 0.0)) {
- return {};
- }
- slopes[i] = (outputs[i + 1] - outputs[i]) / intervals[i];
- }
-
- QVector tangents(count);
- if (count == 2) {
- tangents[0] = slopes[0];
- tangents[1] = slopes[0];
- return tangents;
- }
-
- for (int i = 1; i + 1 < count; ++i) {
- if (slopes[i - 1] * slopes[i] <= 0.0) {
- tangents[i] = 0.0;
- continue;
- }
- const double firstWeight = 2.0 * intervals[i] + intervals[i - 1];
- const double secondWeight = intervals[i] + 2.0 * intervals[i - 1];
- tangents[i] = (firstWeight + secondWeight) /
- (firstWeight / slopes[i - 1] + secondWeight / slopes[i]);
- }
-
- const auto endpointTangent = [](double firstInterval, double secondInterval,
- double firstSlope, double secondSlope) {
- double tangent = ((2.0 * firstInterval + secondInterval) * firstSlope -
- firstInterval * secondSlope) /
- (firstInterval + secondInterval);
- if (tangent * firstSlope <= 0.0) {
- return 0.0;
- }
- if (firstSlope * secondSlope < 0.0 &&
- std::abs(tangent) > 3.0 * std::abs(firstSlope)) {
- tangent = 3.0 * firstSlope;
- }
- return tangent;
- };
- tangents[0] = endpointTangent(intervals[0], intervals[1], slopes[0], slopes[1]);
- tangents[count - 1] = endpointTangent(intervals[count - 2], intervals[count - 3],
- slopes[count - 2], slopes[count - 3]);
- return tangents;
-}
-
QStringList readStrings(const QJsonValue &value) {
QStringList result;
const QJsonArray a = value.toArray();
@@ -118,8 +68,19 @@ CrossNeedleMeterGeometry::Scale readScale(const QJsonObject &o) {
scale.startRadians = o.value(QStringLiteral("start_radians")).toDouble();
scale.endRadians = o.value(QStringLiteral("end_radians")).toDouble();
scale.values = readDoubles(o.value(QStringLiteral("values")));
- scale.anglesRadians = readDoubles(o.value(QStringLiteral("angles_radians")));
- scale.angleTangents = monotoneTangents(scale.values, scale.anglesRadians);
+ scale.referenceAnglesRadians =
+ readDoubles(o.value(QStringLiteral("reference_angles_radians")));
+ const QJsonObject response = o.value(QStringLiteral("response")).toObject();
+ scale.responseModel = response.value(QStringLiteral("model")).toString();
+ scale.responseStartRadians =
+ response.value(QStringLiteral("start_radians")).toDouble();
+ scale.responseEndRadians =
+ response.value(QStringLiteral("end_radians")).toDouble();
+ scale.responseCoefficients =
+ readDoubles(response.value(QStringLiteral("coefficients")));
+ scale.maximumReferenceErrorPixels =
+ response.value(QStringLiteral("maximum_reference_error_pixels"))
+ .toDouble(scale.maximumReferenceErrorPixels);
scale.labels = readStrings(o.value(QStringLiteral("labels")));
scale.minorSubdivisions = o.value(QStringLiteral("minor_subdivisions")).toInt(1);
scale.labelOffset = o.value(QStringLiteral("label_offset")).toDouble(34.0);
@@ -423,6 +384,15 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri
geometry.forwardScale = readScale(root.value(QStringLiteral("forward_scale")).toObject());
geometry.reflectedScale = readScale(root.value(QStringLiteral("reflected_scale")).toObject());
+ const auto deriveTickAngles = [](Scale &scale) {
+ scale.anglesRadians.clear();
+ scale.anglesRadians.reserve(scale.values.size());
+ for (const double value : scale.values) {
+ scale.anglesRadians.append(CrossNeedleMeterGeometry::angleForValue(scale, value));
+ }
+ };
+ deriveTickAngles(geometry.forwardScale);
+ deriveTickAngles(geometry.reflectedScale);
const QJsonObject titles = root.value(QStringLiteral("titles")).toObject();
const QJsonObject forwardTitle = titles.value(QStringLiteral("forward")).toObject();
@@ -463,6 +433,20 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri
swr.value(QStringLiteral("guide_width")).toDouble(geometry.swrStyle.guideWidth);
geometry.swrStyle.label =
readColor(swr.value(QStringLiteral("label_rgba")), geometry.swrStyle.label);
+ geometry.swrStyle.graphClearance =
+ swr.value(QStringLiteral("graph_clearance"))
+ .toDouble(geometry.swrStyle.graphClearance);
+ geometry.swrStyle.maskGap =
+ swr.value(QStringLiteral("mask_gap")).toDouble(geometry.swrStyle.maskGap);
+ geometry.swrStyle.curveSamples =
+ swr.value(QStringLiteral("curve_samples")).toInt(geometry.swrStyle.curveSamples);
+ geometry.swrStyle.labelArcFraction =
+ swr.value(QStringLiteral("label_arc_fraction")).toDouble(geometry.swrStyle.labelArcFraction);
+ geometry.swrStyle.labelDeclutterStep =
+ swr.value(QStringLiteral("label_declutter_step"))
+ .toDouble(geometry.swrStyle.labelDeclutterStep);
+ geometry.swrStyle.labelBoxPadding =
+ swr.value(QStringLiteral("label_box_padding")).toDouble(geometry.swrStyle.labelBoxPadding);
const QJsonArray guides = swr.value(QStringLiteral("guides")).toArray();
geometry.swrGuides.reserve(guides.size());
for (const QJsonValue &value : guides) {
@@ -475,11 +459,6 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri
swrValue.toString() == QStringLiteral("infinity")
? std::numeric_limits::infinity()
: swrValue.toDouble();
- guide.visibleUpper = readPoint(o.value(QStringLiteral("visible_upper")));
- guide.registeredDatum = readPoint(o.value(QStringLiteral("registered_datum")));
- guide.hiddenLower = readPoint(o.value(QStringLiteral("hidden_lower")));
- guide.quadraticA = o.value(QStringLiteral("quadratic_a")).toDouble();
- guide.labelCenter = readPoint(o.value(QStringLiteral("label_center")));
geometry.swrGuides.append(guide);
}
@@ -555,8 +534,8 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri
CrossNeedleMeterGeometry CrossNeedleMeterGeometry::fallback() {
CrossNeedleMeterGeometry geometry;
- geometry.formatVersion = 1;
- geometry.designVersion = 12;
+ geometry.formatVersion = 6;
+ geometry.designVersion = 19;
geometry.rangeLabel = QStringLiteral("RANGE 20 W x1 200 W x10 2 kW x100");
geometry.forwardTitle = {QStringLiteral("FORWARD"), QPointF(180.0, 545.0), -58.0};
geometry.reflectedTitle = {QStringLiteral("REFLECTED"), QPointF(1320.0, 545.0), 58.0};
@@ -565,9 +544,18 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::fallback() {
geometry.forwardScale.startRadians = -2.922529943067865;
geometry.forwardScale.endRadians = -1.6309954106531042;
geometry.forwardScale.values = {0.0, 20.0};
- geometry.forwardScale.anglesRadians = {-2.922529943067865, -1.7009954106531042};
- geometry.forwardScale.angleTangents =
- monotoneTangents(geometry.forwardScale.values, geometry.forwardScale.anglesRadians);
+ geometry.forwardScale.referenceAnglesRadians = {-2.922529943067865,
+ -1.7009954106531042};
+ geometry.forwardScale.responseModel = QStringLiteral("concave_bernstein_v1");
+ geometry.forwardScale.responseStartRadians = -2.922529943067865;
+ geometry.forwardScale.responseEndRadians = -1.7009954106531042;
+ geometry.forwardScale.responseCoefficients = {
+ 0.0, 0.4833365491938737, 0.6144073900045368,
+ 0.7454782308151997, 0.8727391154075999, 1.0};
+ geometry.forwardScale.maximumReferenceErrorPixels = 1.0;
+ geometry.forwardScale.anglesRadians = {
+ angleForValue(geometry.forwardScale, 0.0),
+ angleForValue(geometry.forwardScale, 20.0)};
geometry.forwardScale.labels = {QStringLiteral("0"), QStringLiteral("20")};
geometry.forwardScale.minorSubdivisions = 5;
geometry.reflectedScale.center = QPointF(401.0, 1057.0);
@@ -575,16 +563,23 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::fallback() {
geometry.reflectedScale.startRadians = -0.21906271052192816;
geometry.reflectedScale.endRadians = -1.510597242936689;
geometry.reflectedScale.values = {0.0, 4.0};
- geometry.reflectedScale.anglesRadians = {-0.21906271052192816, -1.440597242936689};
- geometry.reflectedScale.angleTangents =
- monotoneTangents(geometry.reflectedScale.values, geometry.reflectedScale.anglesRadians);
+ geometry.reflectedScale.referenceAnglesRadians = {-0.21906271052192816,
+ -1.440597242936689};
+ geometry.reflectedScale.responseModel = QStringLiteral("concave_bernstein_v1");
+ geometry.reflectedScale.responseStartRadians = -0.21906271052192816;
+ geometry.reflectedScale.responseEndRadians = -1.440597242936689;
+ geometry.reflectedScale.responseCoefficients = {
+ 0.0, 0.26723624915834737, 0.5344724983166947,
+ 0.7871286394467959, 0.8935643197233979, 1.0};
+ geometry.reflectedScale.maximumReferenceErrorPixels = 1.0;
+ geometry.reflectedScale.anglesRadians = {
+ angleForValue(geometry.reflectedScale, 0.0),
+ angleForValue(geometry.reflectedScale, 4.0)};
geometry.reflectedScale.labels = {QStringLiteral("0"), QStringLiteral("4")};
geometry.reflectedScale.minorSubdivisions = 2;
- geometry.swrGuides = {
- {QStringLiteral("1.5"), QStringLiteral("1.5"), 1.5,
- QPointF(1049.4649081096002, 591.025257178578), QPointF(957.5, 880.0),
- QPointF(887.4859925937714, 1100.0), -0.000102863228292,
- QPointF(1095.0, 690.0)}};
+ geometry.swrStyle.graphClearance = 60.0;
+ geometry.swrStyle.curveSamples = 128;
+ geometry.swrGuides = {{QStringLiteral("1.5"), QStringLiteral("1.5"), 1.5}};
geometry.mask.boundary = {{52.0, 904.0}, {250.0, 904.0}, {500.0, 900.0},
{650.0, 880.0}, {750.0, 872.0}, {850.0, 880.0},
{1000.0, 900.0}, {1250.0, 904.0}, {1448.0, 904.0}};
@@ -599,7 +594,7 @@ bool CrossNeedleMeterGeometry::isValid(QString *error) const {
return false;
};
- if (formatVersion != 1) {
+ if (formatVersion != 6) {
return fail(QStringLiteral("unsupported cross-needle geometry format version"));
}
if (designVersion <= 0) {
@@ -676,20 +671,88 @@ bool CrossNeedleMeterGeometry::isValid(QString *error) const {
return fail(QStringLiteral("cross-needle needle material is invalid"));
}
const auto validScale = [](const Scale &scale) {
- return finitePoint(scale.center) && scale.radius > 0.0 && std::isfinite(scale.radius) &&
- scale.values.size() >= 2 && scale.values.size() == scale.anglesRadians.size() &&
- scale.values.size() == scale.angleTangents.size() &&
- scale.values.size() == scale.labels.size() &&
- std::is_sorted(scale.values.cbegin(), scale.values.cend()) &&
- std::all_of(scale.anglesRadians.cbegin(), scale.anglesRadians.cend(),
- [](double angle) { return std::isfinite(angle); }) &&
- std::all_of(scale.angleTangents.cbegin(), scale.angleTangents.cend(),
- [](double tangent) { return std::isfinite(tangent); }) &&
- scale.minorSubdivisions >= 1;
+ if (!finitePoint(scale.center) || !(scale.radius > 0.0) ||
+ !std::isfinite(scale.radius) || scale.values.size() < 2 ||
+ scale.values.size() != scale.anglesRadians.size() ||
+ scale.values.size() != scale.referenceAnglesRadians.size() ||
+ scale.responseModel != QStringLiteral("concave_bernstein_v1") ||
+ scale.responseCoefficients.size() != 6 ||
+ scale.values.size() != scale.labels.size() || scale.minorSubdivisions < 1) {
+ return false;
+ }
+ const bool anglesIncrease =
+ scale.anglesRadians.last() > scale.anglesRadians.first();
+ for (int index = 1; index < scale.values.size(); ++index) {
+ if (!(scale.values[index] > scale.values[index - 1]) ||
+ !std::isfinite(scale.anglesRadians[index]) ||
+ !std::isfinite(scale.referenceAnglesRadians[index]) ||
+ ((scale.anglesRadians[index] > scale.anglesRadians[index - 1]) !=
+ anglesIncrease) ||
+ ((scale.referenceAnglesRadians[index] >
+ scale.referenceAnglesRadians[index - 1]) != anglesIncrease)) {
+ return false;
+ }
+ }
+ if (!std::isfinite(scale.anglesRadians.first()) ||
+ !std::isfinite(scale.referenceAnglesRadians.first()) ||
+ !std::isfinite(scale.responseStartRadians) ||
+ !std::isfinite(scale.responseEndRadians) ||
+ scale.responseEndRadians == scale.responseStartRadians ||
+ (scale.responseEndRadians > scale.responseStartRadians) != anglesIncrease ||
+ !(scale.maximumReferenceErrorPixels > 0.0) ||
+ !std::isfinite(scale.maximumReferenceErrorPixels) ||
+ std::abs(scale.responseCoefficients.first()) > 1e-12 ||
+ std::abs(scale.responseCoefficients.last() - 1.0) > 1e-12) {
+ return false;
+ }
+ for (int index = 0; index < scale.responseCoefficients.size(); ++index) {
+ const double coefficient = scale.responseCoefficients[index];
+ if (!std::isfinite(coefficient) || coefficient < 0.0 || coefficient > 1.0 ||
+ (index > 0 &&
+ coefficient + 1e-12 < scale.responseCoefficients[index - 1])) {
+ return false;
+ }
+ }
+ // Concave control polygon (non-positive second differences): sensitivity
+ // decreases with power, so calibration noise cannot knot the contours.
+ for (int index = 0; index + 2 < scale.responseCoefficients.size(); ++index) {
+ const double secondDifference = scale.responseCoefficients[index + 2] -
+ 2.0 * scale.responseCoefficients[index + 1] +
+ scale.responseCoefficients[index];
+ if (secondDifference > 1e-12) {
+ return false;
+ }
+ }
+ for (int index = 0; index < scale.values.size(); ++index) {
+ if (std::abs(CrossNeedleMeterGeometry::angleForValue(
+ scale, scale.values[index]) -
+ scale.anglesRadians[index]) > 1e-12) {
+ return false;
+ }
+ }
+ return true;
};
if (!validScale(forwardScale) || !validScale(reflectedScale)) {
return fail(QStringLiteral("cross-needle power scale arrays are invalid"));
}
+ const auto scaleFitIsValid = [](const Scale &scale) {
+ // The needle parks on the printed zero (angled rest), so every tick
+ // including 0 is constrained by the photographed calibration.
+ for (int index = 0; index < scale.values.size(); ++index) {
+ const double fittedAngle =
+ CrossNeedleMeterGeometry::angleForValue(scale, scale.values[index]);
+ const double errorPixels =
+ std::abs(fittedAngle - scale.referenceAnglesRadians[index]) * scale.radius;
+ if (!std::isfinite(errorPixels) ||
+ errorPixels > scale.maximumReferenceErrorPixels) {
+ return false;
+ }
+ }
+ return true;
+ };
+ if (!scaleFitIsValid(forwardScale) || !scaleFitIsValid(reflectedScale)) {
+ return fail(QStringLiteral("cross-needle movement fit exceeds tick tolerance"));
+ }
if (!std::isfinite(scaleOverlap.reflectedGapCenterRadians) ||
!(scaleOverlap.reflectedGapHalfSpanRadians > 0.0 &&
scaleOverlap.reflectedGapHalfSpanRadians < 0.05) ||
@@ -697,70 +760,152 @@ bool CrossNeedleMeterGeometry::isValid(QString *error) const {
!(scaleOverlap.reflectedGapWidth > 0.0)) {
return fail(QStringLiteral("cross-needle scale overlap is invalid"));
}
- if (swrGuides.isEmpty()) {
- return fail(QStringLiteral("cross-needle SWR guide set is empty"));
+ if (swrGuides.isEmpty() || !(swrStyle.graphClearance > 0.0) ||
+ swrStyle.graphClearance >= std::min(forwardScale.radius, reflectedScale.radius) ||
+ swrStyle.curveSamples < 32 || swrStyle.curveSamples > 1024 ||
+ !(swrStyle.labelArcFraction > 0.0) || !(swrStyle.labelArcFraction <= 1.0) ||
+ !(swrStyle.labelDeclutterStep > 0.0) || !std::isfinite(swrStyle.labelDeclutterStep) ||
+ !(swrStyle.labelBoxPadding >= 0.0) || !std::isfinite(swrStyle.labelBoxPadding) ||
+ !(swrStyle.maskGap >= 0.0) || !std::isfinite(swrStyle.maskGap)) {
+ return fail(QStringLiteral("cross-needle SWR construction settings are invalid"));
+ }
+ bool validMaskBoundary = mask.boundary.size() >= 3;
+ double previousMaskX = -std::numeric_limits::infinity();
+ double maximumMaskY = -std::numeric_limits::infinity();
+ for (const QPointF &point : mask.boundary) {
+ validMaskBoundary = validMaskBoundary && finitePoint(point) &&
+ point.x() >= 0.0 && point.x() <= canvasWidth &&
+ point.y() >= 0.0 && point.y() <= canvasHeight &&
+ point.x() > previousMaskX;
+ previousMaskX = point.x();
+ maximumMaskY = std::max(maximumMaskY, point.y());
+ }
+ for (qsizetype index = 0; index < mask.boundary.size(); ++index) {
+ const QPointF &left = mask.boundary[index];
+ const QPointF &right = mask.boundary[mask.boundary.size() - 1 - index];
+ validMaskBoundary = validMaskBoundary &&
+ std::abs(left.x() + right.x() - canvasWidth) <= 0.01 &&
+ std::abs(left.y() - right.y()) <= 0.01;
}
+ if (!validMaskBoundary || !std::isfinite(mask.bottomY) ||
+ mask.bottomY < maximumMaskY || mask.bottomY > canvasHeight ||
+ mask.label.trimmed().isEmpty() || !finitePoint(mask.labelCenter) ||
+ mask.labelCenter.x() < 0.0 || mask.labelCenter.x() > canvasWidth ||
+ mask.labelCenter.y() < 0.0 || mask.labelCenter.y() > canvasHeight) {
+ return fail(QStringLiteral("cross-needle lower mask is invalid"));
+ }
+ swrLabelCenterCache.clear();
+ swrLabelCenterCacheFontKey.clear();
+ swrLabelPlacementError.clear();
+ QFont swrLabelFont;
+ if (QGuiApplication::instance()) {
+ swrLabelFont = QGuiApplication::font();
+ }
+ swrLabelFont.setPixelSize(typography.swrNumberPixels);
+ swrLabelFont.setBold(true);
for (const SwrGuide &guide : swrGuides) {
const bool validSwr = guide.swr > 1.0;
- const bool validConstruction =
- finitePoint(guide.visibleUpper) && finitePoint(guide.registeredDatum) &&
- finitePoint(guide.hiddenLower) && std::isfinite(guide.quadraticA) &&
- guide.visibleUpper.y() > 0.0 && guide.visibleUpper.y() < canvasHeight &&
- guide.visibleUpper.y() < guide.registeredDatum.y() &&
- guide.registeredDatum.y() < guide.hiddenLower.y();
const bool validLabel =
- guide.displayLabel.isEmpty() ||
- (finitePoint(guide.labelCenter) && guide.labelCenter.x() > 0.0 &&
- guide.labelCenter.x() < canvasWidth && guide.labelCenter.y() > 0.0 &&
- guide.labelCenter.y() < canvasHeight);
- const double visibleYSpan = guide.registeredDatum.y() - guide.visibleUpper.y();
- const double maximumVisibleBulge =
- std::abs(guide.quadraticA) * visibleYSpan * visibleYSpan / 4.0;
- if (guide.label.isEmpty() || !validSwr || !validConstruction || !validLabel ||
- maximumVisibleBulge > 6.01) {
+ guide.displayLabel.isEmpty() || !guide.displayLabel.trimmed().isEmpty();
+ if (guide.label.isEmpty() || !validSwr || !validLabel) {
return fail(QStringLiteral("cross-needle SWR guide '%1' is invalid").arg(guide.label));
}
const QPainterPath path = swrGuidePath(guide);
- if (path.elementCount() != 7 || path.elementAt(0).type != QPainterPath::MoveToElement ||
- path.elementAt(1).type != QPainterPath::CurveToElement ||
- path.elementAt(4).type != QPainterPath::CurveToElement) {
- return fail(QStringLiteral("cross-needle SWR guide '%1' path is invalid")
- .arg(guide.label));
+ if (path.elementCount() != swrStyle.curveSamples + 1 ||
+ path.elementAt(0).type != QPainterPath::MoveToElement ||
+ !finitePoint(path.currentPosition())) {
+ return fail(QStringLiteral("cross-needle SWR guide '%1' path is invalid "
+ "(%2 of %3 samples)")
+ .arg(guide.label)
+ .arg(path.elementCount())
+ .arg(swrStyle.curveSamples + 1));
+ }
+ if (!guide.displayLabel.isEmpty()) {
+ const QPointF labelCenter = swrGuideLabelCenter(guide, swrLabelFont);
+ if (!swrLabelPlacementError.isEmpty()) {
+ return fail(swrLabelPlacementError);
+ }
+ if (!finitePoint(labelCenter) || labelCenter.x() <= 0.0 ||
+ labelCenter.x() >= canvasWidth || labelCenter.y() <= 0.0 ||
+ labelCenter.y() >= canvasHeight) {
+ return fail(QStringLiteral("cross-needle SWR guide '%1' label is outside the face")
+ .arg(guide.label));
+ }
}
}
- if (mask.boundary.size() < 3) {
- return fail(QStringLiteral("cross-needle lower mask is invalid"));
+ if (!swrLabelPlacementError.isEmpty()) {
+ return fail(swrLabelPlacementError);
}
return true;
}
-double CrossNeedleMeterGeometry::interpolate(const Scale &scale, double value) {
+double CrossNeedleMeterGeometry::angleForValue(const Scale &scale, double value) {
+ if (scale.values.size() < 2 || scale.responseCoefficients.size() != 6) {
+ return 0.0;
+ }
+ const double span = scale.values.last() - scale.values.first();
+ const double normalizedPower =
+ span > 0.0 ? std::clamp((value - scale.values.first()) / span, 0.0, 1.0) : 0.0;
+ return angleForNormalizedPower(scale, normalizedPower);
+}
+
+double CrossNeedleMeterGeometry::angleForNormalizedPower(const Scale &scale,
+ double normalizedPower) {
+ if (scale.responseCoefficients.size() != 6) {
+ return scale.responseStartRadians;
+ }
+ // Degree-5 Bernstein in normalized POWER (concave, square-root-like),
+ // evaluated by de Casteljau. normalizedPower 0 maps to the angled
+ // printed-zero rest. Callers pass values in [0, 1] for live/tick use;
+ // SWR-contour construction passes values > 1 to extrapolate a movement a
+ // little past full scale so every contour can reach the common termination
+ // envelope (see swrGuidePath / docs/cross-needle-meter-math.md D1).
+ std::array work{};
+ std::copy(scale.responseCoefficients.cbegin(), scale.responseCoefficients.cend(),
+ work.begin());
+ for (int remaining = static_cast(scale.responseCoefficients.size()) - 1;
+ remaining > 0; --remaining) {
+ for (int index = 0; index < remaining; ++index) {
+ work[index] = std::lerp(work[index], work[index + 1], normalizedPower);
+ }
+ }
+ return std::lerp(scale.responseStartRadians, scale.responseEndRadians, work[0]);
+}
+
+double CrossNeedleMeterGeometry::printedAngleForIndex(const Scale &scale, int index) {
+ if (index < 0 || index >= scale.anglesRadians.size()) {
+ return 0.0;
+ }
+ // The needle parks on the printed zero, so every tick (including 0) uses
+ // the calibrated movement angle directly.
+ return scale.anglesRadians[index];
+}
+
+double CrossNeedleMeterGeometry::inverseInterpolate(const Scale &scale,
+ double angleRadians) {
if (scale.values.size() < 2 || scale.values.size() != scale.anglesRadians.size() ||
- scale.values.size() != scale.angleTangents.size()) {
+ scale.responseCoefficients.size() != 6) {
return 0.0;
}
- const double clamped = std::clamp(value, scale.values.first(), scale.values.last());
- const auto upper = std::lower_bound(scale.values.cbegin(), scale.values.cend(), clamped);
- if (upper == scale.values.cbegin()) {
- return scale.anglesRadians.first();
- }
- if (upper == scale.values.cend()) {
- return scale.anglesRadians.last();
- }
- const int upperIndex = static_cast(std::distance(scale.values.cbegin(), upper));
- const int lowerIndex = upperIndex - 1;
- const double span = scale.values[upperIndex] - scale.values[lowerIndex];
- const double fraction = (clamped - scale.values[lowerIndex]) / span;
- const double fractionSquared = fraction * fraction;
- const double fractionCubed = fractionSquared * fraction;
- const double lowerBasis = 2.0 * fractionCubed - 3.0 * fractionSquared + 1.0;
- const double lowerTangentBasis = fractionCubed - 2.0 * fractionSquared + fraction;
- const double upperBasis = -2.0 * fractionCubed + 3.0 * fractionSquared;
- const double upperTangentBasis = fractionCubed - fractionSquared;
- return lowerBasis * scale.anglesRadians[lowerIndex] +
- lowerTangentBasis * span * scale.angleTangents[lowerIndex] +
- upperBasis * scale.anglesRadians[upperIndex] +
- upperTangentBasis * span * scale.angleTangents[upperIndex];
+
+ const bool increasing = scale.anglesRadians.last() > scale.anglesRadians.first();
+ const double minimumAngle =
+ std::min(scale.anglesRadians.first(), scale.anglesRadians.last());
+ const double maximumAngle =
+ std::max(scale.anglesRadians.first(), scale.anglesRadians.last());
+ const double target = std::clamp(angleRadians, minimumAngle, maximumAngle);
+ double lower = scale.values.first();
+ double upper = scale.values.last();
+ for (int iteration = 0; iteration < 64; ++iteration) {
+ const double middle = (lower + upper) * 0.5;
+ const double angle = angleForValue(scale, middle);
+ if ((angle < target) == increasing) {
+ lower = middle;
+ } else {
+ upper = middle;
+ }
+ }
+ return (lower + upper) * 0.5;
}
QPointF CrossNeedleMeterGeometry::pointOnScale(const Scale &scale, double angleRadians) {
@@ -769,12 +914,12 @@ QPointF CrossNeedleMeterGeometry::pointOnScale(const Scale &scale, double angleR
double CrossNeedleMeterGeometry::forwardAngle(double forwardWatts, double multiplier) const {
const double safeMultiplier = multiplier > 0.0 ? multiplier : 1.0;
- return interpolate(forwardScale, std::max(0.0, forwardWatts) / safeMultiplier);
+ return angleForValue(forwardScale, std::max(0.0, forwardWatts) / safeMultiplier);
}
double CrossNeedleMeterGeometry::reflectedAngle(double reflectedWatts, double multiplier) const {
const double safeMultiplier = multiplier > 0.0 ? multiplier : 1.0;
- return interpolate(reflectedScale, std::max(0.0, reflectedWatts) / safeMultiplier);
+ return angleForValue(reflectedScale, std::max(0.0, reflectedWatts) / safeMultiplier);
}
QPointF CrossNeedleMeterGeometry::forwardTip(double forwardWatts, double multiplier) const {
@@ -816,44 +961,342 @@ QPointF CrossNeedleMeterGeometry::needleIntersection(double forwardWatts, double
QPainterPath CrossNeedleMeterGeometry::swrGuidePath(const SwrGuide &guide) const {
QPainterPath path;
- const double visibleYSpan = guide.registeredDatum.y() - guide.visibleUpper.y();
- const double hiddenYSpan = guide.hiddenLower.y() - guide.registeredDatum.y();
- if (!(visibleYSpan > 0.0 && hiddenYSpan > 0.0)) {
+ if (!(guide.swr > 1.0) || swrStyle.curveSamples < 2) {
return path;
}
- // Port of the approved V12 construction. The visible source-traced
- // quadratic is represented exactly as a cubic Hermite segment. Its end
- // tangent then feeds the concealed Hermite continuation, so there is no
- // splice at the mask boundary and no mechanically invented S-curve.
- const double visibleChordSlope =
- (guide.registeredDatum.x() - guide.visibleUpper.x()) / visibleYSpan;
- const double upperSlope = visibleChordSlope - guide.quadraticA * visibleYSpan;
- const double datumSlope = visibleChordSlope + guide.quadraticA * visibleYSpan;
- const double lowerSlope =
- (guide.hiddenLower.x() - guide.registeredDatum.x()) / hiddenYSpan;
-
- path.moveTo(guide.visibleUpper);
- path.cubicTo(
- guide.visibleUpper + QPointF(upperSlope * visibleYSpan / 3.0,
- visibleYSpan / 3.0),
- guide.registeredDatum - QPointF(datumSlope * visibleYSpan / 3.0,
- visibleYSpan / 3.0),
- guide.registeredDatum);
- path.cubicTo(
- guide.registeredDatum + QPointF(datumSlope * hiddenYSpan / 3.0,
- hiddenYSpan / 3.0),
- guide.hiddenLower - QPointF(lowerSlope * hiddenYSpan / 3.0,
- hiddenYSpan / 3.0),
- guide.hiddenLower);
+ // A cross-needle SWR guide is the locus of intersections produced by a
+ // constant reflected/forward power ratio. Sample in detector-voltage
+ // space, not power space: the movement input is proportional to sqrt(P),
+ // and uniform voltage steps avoid over-tessellating one part of a curve
+ // while starving another.
+ const double ratio = std::isinf(guide.swr)
+ ? 1.0
+ : std::pow((guide.swr - 1.0) / (guide.swr + 1.0), 2.0);
+ const double forwardMaximum = forwardScale.values.last();
+ const double reflectedMaximum = reflectedScale.values.last();
+
+ // Every contour terminates on ONE common boundary: a fixed clearance short
+ // of whichever power arc is nearer. Low-SWR crossings reach that boundary
+ // only a little past full forward scale, so we extend the movement angles
+ // slightly past unity (angleForNormalizedPower, unclamped) to get there.
+ // This makes the whole SWR family a consistent fan parallel to the arcs
+ // (see docs/cross-needle-meter-math.md D1). Live needles are unaffected.
+ const auto point = [this, ratio, forwardMaximum, reflectedMaximum](double voltage) {
+ const double forwardNormalized = voltage * voltage;
+ const double reflectedNormalized =
+ forwardNormalized * forwardMaximum * ratio / reflectedMaximum;
+ return lineIntersection(
+ forwardScale.center,
+ pointOnScale(forwardScale,
+ angleForNormalizedPower(forwardScale, forwardNormalized)),
+ reflectedScale.center,
+ pointOnScale(reflectedScale,
+ angleForNormalizedPower(reflectedScale, reflectedNormalized)));
+ };
+ const auto reachesGraphEnvelope = [this](const QPointF &p) {
+ const double forwardDistance =
+ std::hypot(p.x() - forwardScale.center.x(), p.y() - forwardScale.center.y());
+ const double reflectedDistance =
+ std::hypot(p.x() - reflectedScale.center.x(), p.y() - reflectedScale.center.y());
+ return forwardDistance >= forwardScale.radius - swrStyle.graphClearance ||
+ reflectedDistance >= reflectedScale.radius - swrStyle.graphClearance;
+ };
+
+ // Cap on how far past full scale a movement may be extrapolated to reach
+ // the boundary (~2.5x full-scale power). Step outward from the hidden
+ // convergence to the FIRST envelope crossing, then bisect for precision.
+ constexpr double kMaxVoltage = 1.6;
+ double endingVoltage = kMaxVoltage;
+ double previous = 0.0;
+ constexpr int kProbe = 256;
+ for (int i = 1; i <= kProbe; ++i) {
+ const double v = kMaxVoltage * static_cast(i) / kProbe;
+ if (reachesGraphEnvelope(point(v))) {
+ double lower = previous;
+ double upper = v;
+ for (int iteration = 0; iteration < 48; ++iteration) {
+ const double middle = (lower + upper) * 0.5;
+ if (reachesGraphEnvelope(point(middle))) {
+ upper = middle;
+ } else {
+ lower = middle;
+ }
+ }
+ endingVoltage = (lower + upper) * 0.5;
+ break;
+ }
+ previous = v;
+ }
+
+ // The needles rest on their printed zeros (angled rest), so voltage 0 is a
+ // well-defined crossing: the single hidden convergence just below the mask
+ // that every contour fans from. Start the path there and sweep to the
+ // common boundary.
+ for (int sample = 0; sample <= swrStyle.curveSamples; ++sample) {
+ const double fraction =
+ static_cast(sample) / static_cast(swrStyle.curveSamples);
+ const QPointF p = point(endingVoltage * fraction);
+ if (!std::isfinite(p.x()) || !std::isfinite(p.y())) {
+ return {};
+ }
+ if (sample == 0) {
+ path.moveTo(p);
+ } else {
+ path.lineTo(p);
+ }
+ }
return path;
}
-QPointF CrossNeedleMeterGeometry::swrGuideLabelCenter(const SwrGuide &guide) const {
+double CrossNeedleMeterGeometry::maskBoundaryY(double x) const {
+ if (mask.boundary.isEmpty()) {
+ return mask.bottomY;
+ }
+ if (x <= mask.boundary.first().x()) {
+ return mask.boundary.first().y();
+ }
+ for (int index = 1; index < mask.boundary.size(); ++index) {
+ const QPointF &first = mask.boundary[index - 1];
+ const QPointF &second = mask.boundary[index];
+ if (x <= second.x()) {
+ if (second.x() == first.x()) {
+ return std::min(first.y(), second.y());
+ }
+ const double fraction = (x - first.x()) / (second.x() - first.x());
+ return first.y() + (second.y() - first.y()) * fraction;
+ }
+ }
+ return mask.boundary.last().y();
+}
+
+QRectF CrossNeedleMeterGeometry::swrLabelBox(
+ const QPointF ¢er, const SwrGuide &guide, const QFont &labelFont) const
+{
+ const QString display = guide.displayLabel == QStringLiteral("infinity")
+ ? QString::fromUtf8("\xe2\x88\x9e")
+ : guide.displayLabel;
+ QRectF box;
+ if (QGuiApplication::instance()) {
+ // Match the rendered number exactly (same font, size, weight, and the
+ // small background inset the painter and tests use) so a declutter
+ // decision made here is the one the device sees.
+ box = QFontMetricsF(labelFont)
+ .boundingRect(display)
+ .adjusted(-3.0, -2.0, 3.0, 2.0);
+ } else {
+ // Headless fallback: a proportional estimate a touch larger than the
+ // glyphs, so decisions stay conservative without a font available.
+ const double pixels = static_cast(typography.swrNumberPixels);
+ double width = 0.0;
+ for (const QChar character : display) {
+ width += character == QLatin1Char('.') ? 0.36 * pixels
+ : display == QString::fromUtf8("\xe2\x88\x9e") ? 1.05 * pixels
+ : 0.62 * pixels;
+ }
+ box = QRectF(0.0, 0.0, width + 6.0, 1.30 * pixels);
+ }
+ box.moveCenter(center);
+ return box;
+}
+
+QVector CrossNeedleMeterGeometry::swrLabelCenters(const QFont &labelFont) const {
+ const QString fontKey = labelFont.key();
+ if (swrLabelCenterCache.size() == swrGuides.size() &&
+ swrLabelCenterCacheFontKey == fontKey) {
+ return swrLabelCenterCache;
+ }
+ swrLabelPlacementError.clear();
+ // Every number rides its own contour: the anchor is a fraction
+ // (labelArcFraction) of that contour's VISIBLE arc length, then decluttered.
+ // No per-guide coordinates.
+ QVector centers(swrGuides.size(),
+ QPointF(std::numeric_limits::quiet_NaN(),
+ std::numeric_limits::quiet_NaN()));
+ QVector paths(swrGuides.size());
+ for (int index = 0; index < swrGuides.size(); ++index) {
+ paths[index] = swrGuidePath(swrGuides[index]);
+ }
+
+ const auto pointAtDistance = [](const QPainterPath &path, double target, QPointF *tangent) {
+ if (path.isEmpty()) {
+ return QPointF(std::numeric_limits::quiet_NaN(),
+ std::numeric_limits::quiet_NaN());
+ }
+ double traversed = 0.0;
+ QPointF first = path.elementAt(0);
+ for (int element = 1; element < path.elementCount(); ++element) {
+ const QPointF second = path.elementAt(element);
+ const double segment = std::hypot(second.x() - first.x(), second.y() - first.y());
+ if (traversed + segment >= target && segment > 1e-12) {
+ if (tangent) {
+ *tangent = (second - first) / segment;
+ }
+ return first + (second - first) * ((target - traversed) / segment);
+ }
+ traversed += segment;
+ first = second;
+ }
+ QPointF finalTangent(0.0, -1.0);
+ if (path.elementCount() >= 2) {
+ const QPointF before = path.elementAt(path.elementCount() - 2);
+ const QPointF end = path.currentPosition();
+ const double segment = std::hypot(end.x() - before.x(), end.y() - before.y());
+ finalTangent = segment > 1e-12 ? (end - before) / segment : QPointF(0.0, -1.0);
+ }
+ if (tangent) {
+ *tangent = finalTangent;
+ }
+ return path.currentPosition() + finalTangent * std::max(0.0, target - traversed);
+ };
+
+ QVector powerNumberBoxes;
+ if (QGuiApplication::instance()) {
+ QFont scaleFont = labelFont;
+ scaleFont.setPixelSize(typography.scaleNumberPixels);
+ scaleFont.setWeight(QFont::Medium);
+ const QFontMetricsF metrics(scaleFont);
+ const auto appendScaleBoxes = [&](const Scale &scale) {
+ for (int index = 0; index < scale.labels.size(); ++index) {
+ if (scale.labels[index].isEmpty()) {
+ continue;
+ }
+ const double angle = printedAngleForIndex(scale, index);
+ const QPointF radial(std::cos(angle), std::sin(angle));
+ const QPointF center = pointOnScale(scale, angle) + radial * scale.labelOffset;
+ QRectF box = metrics.boundingRect(scale.labels[index]);
+ box.moveCenter(center);
+ powerNumberBoxes.append(box);
+ }
+ };
+ appendScaleBoxes(forwardScale);
+ appendScaleBoxes(reflectedScale);
+ }
+
+ const double gap = swrStyle.labelBoxPadding;
+ QVector placed;
+ placed.reserve(swrGuides.size());
+ for (int index = 0; index < swrGuides.size(); ++index) {
+ const SwrGuide &guide = swrGuides[index];
+ const auto boxClears = [&](const QPointF &candidate) {
+ const QRectF box = swrLabelBox(candidate, guide, labelFont);
+ if (!(box.bottom() + 3.0 < maskBoundaryY(box.left()) &&
+ box.bottom() + 3.0 < maskBoundaryY(box.center().x()) &&
+ box.bottom() + 3.0 < maskBoundaryY(box.right()) &&
+ box.left() > frame.faceInset && box.right() < canvasWidth - frame.faceInset &&
+ box.top() > frame.faceInset && box.bottom() < mask.bottomY)) {
+ return false;
+ }
+ const QRectF spaced = box.adjusted(-gap, -gap, gap, gap);
+ for (const QRectF &other : placed) {
+ if (spaced.intersects(other)) {
+ return false;
+ }
+ }
+ for (const QRectF &powerBox : powerNumberBoxes) {
+ if (spaced.intersects(powerBox)) {
+ return false;
+ }
+ }
+ for (int other = 0; other < paths.size(); ++other) {
+ if (other != index && paths[other].intersects(spaced)) {
+ return false;
+ }
+ }
+ return true;
+ };
+ // Anchor within the VISIBLE portion of the contour (above the mask):
+ // walk from the mask crossing a fraction of the visible arc length, so
+ // short low-SWR contours and long high-SWR contours label in the same
+ // relative near-the-outer-end region rather than in the hidden crowd.
+ double totalLength = 0.0;
+ double visibleStart = -1.0;
+ for (int element = 1; element < paths[index].elementCount(); ++element) {
+ const QPointF a = paths[index].elementAt(element - 1);
+ const QPointF b = paths[index].elementAt(element);
+ if (visibleStart < 0.0 && b.y() < maskBoundaryY(b.x())) {
+ visibleStart = totalLength;
+ }
+ totalLength += std::hypot(b.x() - a.x(), b.y() - a.y());
+ }
+ if (visibleStart < 0.0) {
+ visibleStart = 0.0;
+ }
+ const double baseDistance =
+ visibleStart + swrStyle.labelArcFraction * (totalLength - visibleStart);
+ bool found = false;
+ for (int attempt = 0; attempt <= 256; ++attempt) {
+ const int magnitude = (attempt + 1) / 2;
+ const int direction = attempt == 0 ? 0 : (attempt % 2 == 1 ? 1 : -1);
+ // No upper clamp: distances beyond the contour length extrapolate
+ // along the upper tangent, letting a crowded low-SWR label (e.g.
+ // 1.1, whose visible stub is too short to host a box) ride up its
+ // own continuation into open space while staying nearest itself.
+ const double distance = std::max(
+ baseDistance + direction * magnitude * swrStyle.labelDeclutterStep, 0.0);
+ QPointF tangent;
+ const QPointF anchor = pointAtDistance(paths[index], distance, &tangent);
+ const QPointF normal(-tangent.y(), tangent.x());
+ for (int normalAttempt = 0; normalAttempt <= 128; ++normalAttempt) {
+ const int normalMagnitude = (normalAttempt + 1) / 2;
+ const int normalDirection =
+ normalAttempt == 0 ? 0 : (normalAttempt % 2 == 1 ? 1 : -1);
+ const QPointF candidate =
+ anchor + normal * (normalDirection * normalMagnitude *
+ swrStyle.labelDeclutterStep);
+ if (boxClears(candidate)) {
+ centers[index] = candidate;
+ placed.append(swrLabelBox(candidate, guide, labelFont));
+ found = true;
+ break;
+ }
+ }
+ if (found) {
+ break;
+ }
+ }
+ if (!found) {
+ swrLabelPlacementError =
+ QStringLiteral("cross-needle SWR guide '%1' has no collision-free label position")
+ .arg(guide.label);
+ break;
+ }
+ }
+ swrLabelCenterCache = centers;
+ swrLabelCenterCacheFontKey = fontKey;
+ return centers;
+}
+
+QPointF CrossNeedleMeterGeometry::swrGuideLabelCenter(
+ const SwrGuide &guide, const QFont &labelFont) const
+{
if (guide.displayLabel.isEmpty()) {
return {};
}
- return guide.labelCenter;
+ const QVector centers = swrLabelCenters(labelFont);
+ for (int index = 0; index < swrGuides.size(); ++index) {
+ if (swrGuides[index].label == guide.label) {
+ return centers[index];
+ }
+ }
+ return {};
+}
+
+QPointF CrossNeedleMeterGeometry::swrGuideUpperEndpoint(const SwrGuide &guide) const {
+ return swrGuidePath(guide).currentPosition();
+}
+
+QPointF CrossNeedleMeterGeometry::powerReadingsAtIntersection(
+ const QPointF &point, double rangeMultiplier) const {
+ const double safeMultiplier = rangeMultiplier > 0.0 ? rangeMultiplier : 1.0;
+ const double forwardAngleRadians =
+ std::atan2(point.y() - forwardScale.center.y(),
+ point.x() - forwardScale.center.x());
+ const double reflectedAngleRadians =
+ std::atan2(point.y() - reflectedScale.center.y(),
+ point.x() - reflectedScale.center.x());
+ return QPointF(inverseInterpolate(forwardScale, forwardAngleRadians) * safeMultiplier,
+ inverseInterpolate(reflectedScale, reflectedAngleRadians) * safeMultiplier);
}
double CrossNeedleMeterGeometry::distanceToGuide(const QPointF &point,
diff --git a/src/gui/CrossNeedleMeterGeometry.h b/src/gui/CrossNeedleMeterGeometry.h
index 3abe88511..8f361575f 100644
--- a/src/gui/CrossNeedleMeterGeometry.h
+++ b/src/gui/CrossNeedleMeterGeometry.h
@@ -8,6 +8,7 @@
#include
class QIODevice;
+class QFont;
namespace AetherSDR {
@@ -17,6 +18,11 @@ namespace AetherSDR {
// This class validates that resource and owns the mechanical mappings used by
// both the painter and tests: non-linear scale interpolation, concealed-pivot
// needle rays, constant-SWR curves, and their intersection.
+//
+// BEFORE changing the response model, contour construction, or label placement,
+// read docs/cross-needle-meter-math.md. It is the authoritative model + decision
+// record: which geometry choices are settled and why. Reshaping the physics
+// without updating that doc (and the tests) is how this component churns.
class CrossNeedleMeterGeometry {
public:
struct Frame {
@@ -170,10 +176,29 @@ class CrossNeedleMeterGeometry {
double startRadians{0.0};
double endRadians{0.0};
QVector values;
+ // Runtime calibration angles are derived from the response below.
+ // The photographed reference angles remain separate evidence: they
+ // guide the fit but never compete with the response used by ticks,
+ // needles, and SWR construction.
QVector anglesRadians;
- // Derived monotone-cubic tangents. They preserve every calibrated
- // tick while avoiding visible elbows between adjacent intervals.
- QVector angleTangents;
+ QVector referenceAnglesRadians;
+ // A normalized degree-5 Bernstein response maps normalized power
+ // [0, 1] to needle deflection [0, 1]. Its non-decreasing, concave
+ // control polygon gives natural square-root-like compression (a real
+ // D'Arsonval movement + sqrt watt scale) while forbidding repeated
+ // acceleration, so calibration noise cannot knot the SWR contours.
+ // The response starts at the ANGLED printed-zero rest position
+ // (responseStartRadians == the "0" tick angle == startRadians): at
+ // zero power each needle parks on its printed 0 mark, pointing up into
+ // the dial exactly like a real cross-needle meter, so the low-SWR
+ // contours stay visible and rise at a shallow angle. One response
+ // drives printed ticks, live needles, inverse readings, and every SWR
+ // contour. See docs/cross-needle-meter-math.md, Decision D1.
+ QString responseModel{QStringLiteral("concave_bernstein_v1")};
+ double responseStartRadians{0.0};
+ double responseEndRadians{0.0};
+ QVector responseCoefficients;
+ double maximumReferenceErrorPixels{30.0};
QStringList labels;
int minorSubdivisions{1};
double labelOffset{34.0};
@@ -182,7 +207,7 @@ class CrossNeedleMeterGeometry {
struct Title {
QString text;
QPointF center;
- // Authored with the same sign convention as the V12 proof generator.
+ // Authored with the same image-space sign convention as the proof renderer.
double rotationDegrees{0.0};
};
@@ -199,23 +224,39 @@ class CrossNeedleMeterGeometry {
struct SwrGuide {
QString label;
QString displayLabel;
- // V12 source-registered construction: a restrained quadratic from
- // the traced upper endpoint to the y=880 datum, then a C1 Hermite
- // continuation behind the lower mask. These fields intentionally
- // preserve the physical face instead of recomputing a different fan
- // from the movement calibration.
+ // Semantic constant-SWR construction. The path is generated from the
+ // two calibrated movement maps; the number's position is then derived
+ // (see swrLabelCenters) rather than authored per guide.
double swr{1.0};
- QPointF visibleUpper;
- QPointF registeredDatum;
- QPointF hiddenLower;
- double quadraticA{0.0};
- QPointF labelCenter;
};
struct SwrStyle {
QColor guide{161, 74, 58, 230};
double guideWidth{3.2};
QColor label{45, 45, 42};
+ // Common termination envelope: EVERY contour ends this far short of
+ // whichever power arc is nearer, so the SWR family is a consistent fan
+ // (design 19). Low-SWR contours extend a little past full scale to
+ // reach it; see swrGuidePath and docs/cross-needle-meter-math.md D1.
+ double graphClearance{60.0};
+ // Trim each contour's lower (leading) end this many design pixels above
+ // the mask boundary, leaving a visible gap between the line and the
+ // mask like the real meter face. Purely a render trim; the geometry
+ // path is unchanged.
+ double maskGap{14.0};
+ int curveSamples{128};
+ // Derived SWR-number placement. Every label rides its own contour,
+ // anchored at labelArcFraction of that contour's visible arc length.
+ // Crowded labels move along their own path by a deterministic declutter
+ // step until their boxes clear neighbours and the mask.
+ // These are the ONLY placement knobs — no per-guide hand-tuning.
+ // labelArcFraction is the fraction of each contour's own arc length
+ // (measured from the hidden convergence) at which its number anchors,
+ // so short low-SWR contours and long high-SWR contours are labeled in
+ // the same relative near-the-outer-end region.
+ double labelArcFraction{0.88};
+ double labelDeclutterStep{3.0};
+ double labelBoxPadding{2.0};
};
struct NeedleStyle {
@@ -289,25 +330,46 @@ class CrossNeedleMeterGeometry {
double forwardAngle(double forwardWatts, double rangeMultiplier) const;
double reflectedAngle(double reflectedWatts, double rangeMultiplier) const;
+ static double angleForValue(const Scale &scale, double value);
+ // Bernstein movement angle for an already-normalized power. Not clamped to
+ // [0, 1]: SWR-contour construction passes slightly-over-unity values to
+ // extend a movement a little past full scale. Live needles/ticks always
+ // pass clamped values via angleForValue.
+ static double angleForNormalizedPower(const Scale &scale, double normalizedPower);
QPointF forwardTip(double forwardWatts, double rangeMultiplier) const;
QPointF reflectedTip(double reflectedWatts, double rangeMultiplier) const;
QPointF needleIntersection(double forwardWatts, double reflectedWatts,
double rangeMultiplier) const;
QPainterPath swrGuidePath(const SwrGuide &guide) const;
- QPointF swrGuideLabelCenter(const SwrGuide &guide) const;
+ QPointF swrGuideLabelCenter(const SwrGuide &guide, const QFont &labelFont) const;
+ QPointF swrGuideUpperEndpoint(const SwrGuide &guide) const;
+ QPointF powerReadingsAtIntersection(const QPointF &point,
+ double rangeMultiplier = 1.0) const;
double distanceToGuide(const QPointF &point, const SwrGuide &guide) const;
QString nearestGuideLabel(const QPointF &point, double *distance = nullptr) const;
static double reflectedPowerWatts(double forwardWatts, double swr);
static double swrFromPowers(double forwardWatts, double reflectedWatts);
static double rangeMultiplierFor(int maxWatts, bool amplifierActive);
+ static double printedAngleForIndex(const Scale &scale, int index);
private:
- static double interpolate(const Scale &scale, double value);
+ static double inverseInterpolate(const Scale &scale, double angleRadians);
static QPointF pointOnScale(const Scale &scale, double angleRadians);
static QPointF lineIntersection(const QPointF &firstOrigin, const QPointF &firstTip,
const QPointF &secondOrigin, const QPointF &secondTip);
+ // Deterministic derived placement of all SWR numbers, in guide order.
+ // Result is memoized by the exact rendered font: the geometry is immutable
+ // after load, while a widget or application font change must recompute the
+ // collision layout before the static face is rebuilt.
+ QVector swrLabelCenters(const QFont &labelFont) const;
+ mutable QVector swrLabelCenterCache;
+ mutable QString swrLabelCenterCacheFontKey;
+ mutable QString swrLabelPlacementError;
+ QRectF swrLabelBox(const QPointF ¢er, const SwrGuide &guide,
+ const QFont &labelFont) const;
+ double maskBoundaryY(double x) const;
};
} // namespace AetherSDR
diff --git a/src/gui/CrossNeedleMeterSettings.cpp b/src/gui/CrossNeedleMeterSettings.cpp
index 53ffe7599..3ff554229 100644
--- a/src/gui/CrossNeedleMeterSettings.cpp
+++ b/src/gui/CrossNeedleMeterSettings.cpp
@@ -18,6 +18,7 @@ QString encode(const Snapshot& settings)
QJsonObject root;
root.insert(QStringLiteral("version"), kVersion);
root.insert(QStringLiteral("faceTheme"), normalizeTheme(settings.faceTheme));
+ root.insert(QStringLiteral("showRange"), settings.showRange);
return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact));
}
@@ -40,7 +41,8 @@ Snapshot decode(const QByteArray& encoded, QString* error)
}
const QJsonObject root = document.object();
- if (root.value(QStringLiteral("version")).toInt() != kVersion) {
+ const int version = root.value(QStringLiteral("version")).toInt();
+ if (version != 1 && version != kVersion) {
if (error) {
*error = QStringLiteral("unsupported CrossNeedleMeter settings version");
}
@@ -49,6 +51,10 @@ Snapshot decode(const QByteArray& encoded, QString* error)
settings.faceTheme = normalizeTheme(
root.value(QStringLiteral("faceTheme")).toString());
+ if (version >= 2) {
+ settings.showRange =
+ root.value(QStringLiteral("showRange")).toBool(true);
+ }
return settings;
}
diff --git a/src/gui/CrossNeedleMeterSettings.h b/src/gui/CrossNeedleMeterSettings.h
index 9965a5beb..f4530dbe3 100644
--- a/src/gui/CrossNeedleMeterSettings.h
+++ b/src/gui/CrossNeedleMeterSettings.h
@@ -5,7 +5,7 @@
namespace AetherSDR::CrossNeedleMeterSettingsCodec {
-inline constexpr int kVersion = 1;
+inline constexpr int kVersion = 2;
inline const QString kSettingsKey = QStringLiteral("CrossNeedleMeter");
inline const QString kClassicTheme = QStringLiteral("classic-warm");
inline const QString kUplightTheme = QStringLiteral("dark-room-uplight");
@@ -13,6 +13,7 @@ inline const QString kDarkTheme = QStringLiteral("graphite-dark");
struct Snapshot {
QString faceTheme{kUplightTheme};
+ bool showRange{true};
};
QString normalizeTheme(const QString& theme);
diff --git a/src/gui/CrossNeedleMeterWidget.cpp b/src/gui/CrossNeedleMeterWidget.cpp
index d1570c9ce..51a2c5bb4 100644
--- a/src/gui/CrossNeedleMeterWidget.cpp
+++ b/src/gui/CrossNeedleMeterWidget.cpp
@@ -6,7 +6,6 @@
#include
#include
#include
-#include
#include
#include
#include
@@ -76,56 +75,6 @@ QPainterPath sampledArc(const QPointF ¢er, double radius, double startRadian
return path;
}
-const QImage &paperGrainTexture() {
- // Keep the texture near the meter's nominal on-screen size. The previous
- // 750 x 500 field averaged several noise samples into every applet pixel,
- // making its grain disappear when the design was reduced to 260 x 173.
- // Two correlated noise bands and sparse flecks read as paper/paint fibres
- // without introducing a repeating raster asset.
- static const QImage texture = []() {
- // Roughly 1.4 logical applet pixels per texel at the nominal size:
- // broad enough to remain visible on a Retina capture, fine enough not
- // to look like discrete digital blocks.
- QImage image(180, 120, QImage::Format_RGB32);
- quint32 state = 0x6d2b79f5U;
- const auto nextNoise = [&state]() {
- state ^= state << 13U;
- state ^= state >> 17U;
- state ^= state << 5U;
- return state;
- };
- for (int y = 0; y < image.height(); ++y) {
- const int rowBias = static_cast((nextNoise() >> 28U) & 0x0fU) - 8;
- int horizontalFibre = 0;
- QRgb *line = reinterpret_cast(image.scanLine(y));
- for (int x = 0; x < image.width(); ++x) {
- const quint32 sample = nextNoise();
- const int fine = static_cast((sample >> 26U) & 0x3fU) - 32;
- horizontalFibre = (7 * horizontalFibre + fine) / 8;
- const int fleck = (sample & 0xffU) < 6U
- ? (((sample >> 8U) & 1U) == 0U ? -34 : 34)
- : 0;
- const int value = std::clamp(
- 128 + rowBias + fine + 2 * horizontalFibre + fleck, 58, 198);
- line[x] = qRgb(value, value, value);
- }
- }
- return image;
- }();
- return texture;
-}
-
-void drawPaperGrain(QPainter &painter, const QRectF &face, double opacity) {
- painter.save();
- painter.setOpacity(opacity);
- // Overlay keeps a mid-grey texture luminance-neutral while giving its
- // darker and lighter fibres enough contrast to survive at 260 px wide.
- painter.setCompositionMode(QPainter::CompositionMode_Overlay);
- painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
- painter.drawImage(face, paperGrainTexture());
- painter.restore();
-}
-
} // namespace
CrossNeedleMeterWidget::CrossNeedleMeterWidget(QWidget *parent)
@@ -145,6 +94,14 @@ CrossNeedleMeterWidget::CrossNeedleMeterWidget(QWidget *parent)
m_geometry = CrossNeedleMeterGeometry::fallback();
}
+ QString themeError;
+ m_faceThemes = AnalogMeterFaceThemeCatalog::loadResource(&themeError);
+ if (!m_faceThemes.isValid()) {
+ qCWarning(lcCrossNeedleMeter).noquote()
+ << "CrossNeedleMeterWidget: using fallback face themes:" << themeError;
+ m_faceThemes = AnalogMeterFaceThemeCatalog::fallback();
+ }
+
setObjectName(QStringLiteral("crossNeedleMeter"));
setAccessibleName(tr("Cross-needle power and SWR meter"));
setAccessibleDescription(tr("Transmit meter with separate forward and reflected power needles; "
@@ -190,6 +147,18 @@ QString CrossNeedleMeterWidget::faceThemeId() const {
return QStringLiteral("classic-warm");
}
+AnalogMeterFaceTheme CrossNeedleMeterWidget::analogFaceTheme() const {
+ switch (m_faceTheme) {
+ case FaceTheme::ClassicWarm:
+ return AnalogMeterFaceTheme::ClassicWarm;
+ case FaceTheme::DarkRoomUplight:
+ return AnalogMeterFaceTheme::DarkRoomUplight;
+ case FaceTheme::GraphiteDark:
+ return AnalogMeterFaceTheme::GraphiteDark;
+ }
+ return AnalogMeterFaceTheme::ClassicWarm;
+}
+
void CrossNeedleMeterWidget::setFaceTheme(FaceTheme theme) {
if (m_faceTheme == theme) {
return;
@@ -200,6 +169,16 @@ void CrossNeedleMeterWidget::setFaceTheme(FaceTheme theme) {
update();
}
+void CrossNeedleMeterWidget::setRangeLegendVisible(bool visible) {
+ if (m_rangeLegendVisible == visible) {
+ return;
+ }
+ m_rangeLegendVisible = visible;
+ m_cacheValid = false;
+ publishAutomationProperties();
+ update();
+}
+
QString CrossNeedleMeterWidget::accessibleValueText() const {
if (!m_transmitting && !m_automationFixture) {
return tr("Receive, forward and reflected needles parked at zero");
@@ -388,19 +367,8 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter,
const CrossNeedleMeterGeometry::Scale &scale,
const CrossNeedleMeterGeometry::Title &title) const {
const CrossNeedleMeterGeometry::ScaleStyle &style = m_geometry.scaleStyle;
- const CrossNeedleMeterGeometry::DarkTheme &dark = m_geometry.darkTheme;
- const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark;
- const QColor outer = graphiteDark ? dark.scaleOuter : style.outer;
- const QColor separator = graphiteDark
- ? dark.scaleSeparator
- : (m_faceTheme == FaceTheme::DarkRoomUplight
- ? m_geometry.uplightGradient.scaleSeparator
- : style.separator);
- const QColor calibration = graphiteDark ? dark.scaleCalibration : style.calibration;
- const QColor inner = graphiteDark ? dark.scaleInner : style.inner;
- const QColor majorTick = graphiteDark ? dark.majorTick : style.majorTick;
- const QColor minorTick = graphiteDark ? dark.minorTick : style.minorTick;
- const QColor text = graphiteDark ? dark.text : style.text;
+ const AnalogMeterFaceThemeCatalog::Palette &colors =
+ m_faceThemes.palette(analogFaceTheme());
// drawFace() leaves the face-coloured brush active. An open QPainterPath
// is implicitly closed for filling, so retaining that brush would paint a
@@ -413,31 +381,36 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter,
// slate inner rule without relying on raster artwork or changing an arc.
if (m_faceTheme == FaceTheme::ClassicWarm) {
painter.setPen(
- QPen(style.ribbon, style.ribbonWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin));
+ QPen(colors.ribbon, style.ribbonWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin));
painter.drawPath(sampledArc(scale.center, scale.radius - style.ribbonInset,
scale.startRadians, scale.endRadians));
}
- painter.setPen(QPen(outer, style.outerWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin));
+ painter.setPen(QPen(colors.scaleOuter, style.outerWidth, Qt::SolidLine, Qt::FlatCap,
+ Qt::RoundJoin));
painter.drawPath(sampledArc(scale.center, scale.radius, scale.startRadians, scale.endRadians));
painter.setPen(
- QPen(separator, style.separatorWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin));
+ QPen(colors.scaleSeparator, style.separatorWidth, Qt::SolidLine, Qt::FlatCap,
+ Qt::RoundJoin));
painter.drawPath(sampledArc(scale.center, scale.radius - style.separatorInset,
scale.startRadians, scale.endRadians));
painter.setPen(
- QPen(calibration, style.calibrationWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin));
+ QPen(colors.scaleCalibration, style.calibrationWidth, Qt::SolidLine, Qt::FlatCap,
+ Qt::RoundJoin));
painter.drawPath(sampledArc(scale.center, scale.radius - style.calibrationInset,
scale.startRadians, scale.endRadians));
- painter.setPen(QPen(inner, style.innerWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin));
+ painter.setPen(QPen(colors.scaleInner, style.innerWidth, Qt::SolidLine, Qt::FlatCap,
+ Qt::RoundJoin));
painter.drawPath(sampledArc(scale.center, scale.radius - style.innerInset, scale.startRadians,
scale.endRadians));
- const QPen minorPen(minorTick, style.minorTickWidth, Qt::SolidLine, Qt::SquareCap);
+ const QPen minorPen(colors.minorTick, style.minorTickWidth, Qt::SolidLine, Qt::SquareCap);
for (int i = 0; i + 1 < scale.anglesRadians.size(); ++i) {
- const double first = scale.anglesRadians[i];
- const double second = scale.anglesRadians[i + 1];
+ const double firstValue = scale.values[i];
+ const double secondValue = scale.values[i + 1];
for (int subdivision = 1; subdivision < scale.minorSubdivisions; ++subdivision) {
const double fraction = static_cast(subdivision) / scale.minorSubdivisions;
- const double angle = first + (second - first) * fraction;
+ const double value = std::lerp(firstValue, secondValue, fraction);
+ const double angle = CrossNeedleMeterGeometry::angleForValue(scale, value);
const QPointF radial(std::cos(angle), std::sin(angle));
const QPointF point = scale.center + radial * scale.radius;
painter.setPen(minorPen);
@@ -449,15 +422,15 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter,
numberFont.setPixelSize(m_geometry.typography.scaleNumberPixels);
numberFont.setWeight(QFont::Medium);
painter.setFont(numberFont);
- painter.setPen(QPen(majorTick, style.majorTickWidth, Qt::SolidLine, Qt::SquareCap));
+ painter.setPen(QPen(colors.majorTick, style.majorTickWidth, Qt::SolidLine, Qt::SquareCap));
for (int i = 0; i < scale.anglesRadians.size(); ++i) {
- const double angle = scale.anglesRadians[i];
+ const double angle = CrossNeedleMeterGeometry::printedAngleForIndex(scale, i);
const QPointF radial(std::cos(angle), std::sin(angle));
const QPointF point = scale.center + radial * scale.radius;
painter.drawLine(point - radial * 20.0, point + radial * 14.0);
if (!scale.labels[i].isEmpty()) {
painter.save();
- painter.setPen(text);
+ painter.setPen(colors.text);
drawCenteredText(painter, point + radial * scale.labelOffset, scale.labels[i]);
painter.restore();
}
@@ -467,26 +440,43 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter,
titleFont.setPixelSize(m_geometry.typography.sideTitlePixels);
titleFont.setBold(true);
painter.setFont(titleFont);
- painter.setPen(text);
+ painter.setPen(colors.text);
drawRotatedText(painter, title.center, title.text, title.rotationDegrees);
}
void CrossNeedleMeterWidget::drawSwrGuides(QPainter &painter) const {
- const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark;
- const QColor guideColor =
- graphiteDark ? m_geometry.darkTheme.swrGuide : m_geometry.swrStyle.guide;
- const QColor labelColor =
- graphiteDark ? m_geometry.darkTheme.swrLabel : m_geometry.swrStyle.label;
- painter.setPen(QPen(guideColor, m_geometry.swrStyle.guideWidth, Qt::SolidLine, Qt::RoundCap,
+ const AnalogMeterFaceThemeCatalog::Palette &colors =
+ m_faceThemes.palette(analogFaceTheme());
+ painter.setPen(QPen(colors.swrGuide, m_geometry.swrStyle.guideWidth, Qt::SolidLine, Qt::RoundCap,
Qt::RoundJoin));
+ // Trim the leading (lower) end of every contour to sit a small gap above
+ // the mask, like the real meter face: clip guide drawing to the region
+ // above the mask boundary raised by maskGap. Labels below are unaffected.
+ painter.save();
+ const QVector &maskEdge = m_geometry.mask.boundary;
+ const double maskGap = m_geometry.swrStyle.maskGap;
+ if (maskGap > 0.0 && maskEdge.size() >= 2) {
+ QPainterPath above;
+ above.moveTo(0.0, 0.0);
+ above.lineTo(m_geometry.canvasWidth, 0.0);
+ above.lineTo(m_geometry.canvasWidth, maskEdge.last().y() - maskGap);
+ for (int i = maskEdge.size() - 1; i >= 0; --i) {
+ above.lineTo(maskEdge[i].x(), maskEdge[i].y() - maskGap);
+ }
+ above.lineTo(0.0, maskEdge.first().y() - maskGap);
+ above.closeSubpath();
+ painter.setClipPath(above, Qt::IntersectClip);
+ }
for (const CrossNeedleMeterGeometry::SwrGuide &guide : m_geometry.swrGuides) {
painter.drawPath(m_geometry.swrGuidePath(guide));
}
+ painter.restore();
QFont labelFont = font();
labelFont.setPixelSize(m_geometry.typography.swrNumberPixels);
labelFont.setBold(true);
painter.setFont(labelFont);
+ const QFontMetricsF metrics(labelFont);
for (const CrossNeedleMeterGeometry::SwrGuide &guide : m_geometry.swrGuides) {
if (guide.displayLabel.isEmpty()) {
continue;
@@ -494,10 +484,11 @@ void CrossNeedleMeterWidget::drawSwrGuides(QPainter &painter) const {
const QString display = guide.displayLabel == QStringLiteral("infinity")
? QString::fromUtf8("\xe2\x88\x9e")
: guide.displayLabel;
- const QFontMetricsF metrics(labelFont);
const QRectF textRect = metrics.boundingRect(display).adjusted(-3.0, -2.0, 3.0, 2.0);
+ const QPointF labelCenter =
+ m_geometry.swrGuideLabelCenter(guide, labelFont);
QRectF background = textRect;
- background.moveCenter(m_geometry.swrGuideLabelCenter(guide));
+ background.moveCenter(labelCenter);
const CrossNeedleMeterGeometry::Frame &frame = m_geometry.frame;
const QRectF face(frame.faceInset, frame.faceInset,
m_geometry.canvasWidth - 2.0 * frame.faceInset,
@@ -506,115 +497,13 @@ void CrossNeedleMeterWidget::drawSwrGuides(QPainter &painter) const {
painter.setClipRect(background);
drawFaceBackground(painter, face);
painter.restore();
- painter.setPen(labelColor);
- drawCenteredText(painter, m_geometry.swrGuideLabelCenter(guide), display);
+ painter.setPen(colors.swrLabel);
+ drawCenteredText(painter, labelCenter, display);
}
}
void CrossNeedleMeterWidget::drawFaceBackground(QPainter &painter, const QRectF &face) const {
- if (m_faceTheme == FaceTheme::GraphiteDark) {
- const CrossNeedleMeterGeometry::DarkTheme &dark = m_geometry.darkTheme;
-
- QLinearGradient card(face.topLeft(), face.bottomLeft());
- card.setColorAt(0.0, dark.top);
- card.setColorAt(dark.middleStop, dark.middle);
- card.setColorAt(1.0, dark.bottom);
- painter.fillRect(face, card);
-
- QRadialGradient ambient(dark.ambientCenter, dark.ambientRadius);
- ambient.setColorAt(0.0, dark.ambientInner);
- ambient.setColorAt(1.0, dark.ambientOuter);
- painter.fillRect(face, ambient);
-
- painter.save();
- painter.setCompositionMode(QPainter::CompositionMode_Screen);
- QRadialGradient glow(dark.glowCenter, dark.glowRadius);
- glow.setColorAt(0.0, dark.glowInner);
- glow.setColorAt(dark.glowMiddleStop, dark.glowMiddle);
- glow.setColorAt(1.0, dark.glowOuter);
- painter.fillRect(face, glow);
- painter.restore();
-
- QColor clearEdge = dark.vignetteEdge;
- clearEdge.setAlpha(0);
- QRadialGradient vignette(dark.vignetteCenter, dark.vignetteRadius);
- vignette.setColorAt(0.0, clearEdge);
- vignette.setColorAt(dark.vignetteClearStop, clearEdge);
- vignette.setColorAt(1.0, dark.vignetteEdge);
- painter.fillRect(face, vignette);
-
- drawPaperGrain(painter, face, dark.paperGrainOpacity);
- return;
- }
-
- if (m_faceTheme == FaceTheme::DarkRoomUplight) {
- const CrossNeedleMeterGeometry::UplightGradient &light = m_geometry.uplightGradient;
-
- // The theme is deliberately composed from ordinary QPainter layers:
- // low ambient exposure, a broad lamp halo, a concentrated bulb glow,
- // a small paper-diffusion bloom, then a symmetric edge vignette. The
- // two inner layers use Screen blending to behave like emitted light
- // passing through the card rather than opaque orange paint.
- QLinearGradient ambient(face.topLeft(), face.bottomLeft());
- ambient.setColorAt(0.0, light.top);
- ambient.setColorAt(light.middleStop, light.middle);
- ambient.setColorAt(1.0, light.bottom);
- painter.fillRect(face, ambient);
-
- QRadialGradient halo(light.haloCenter, light.haloRadius);
- halo.setColorAt(0.0, light.haloInner);
- halo.setColorAt(light.haloMiddleStop, light.haloMiddle);
- halo.setColorAt(light.haloShoulderStop, light.haloShoulder);
- halo.setColorAt(1.0, light.haloOuter);
- painter.fillRect(face, halo);
-
- painter.save();
- painter.setCompositionMode(QPainter::CompositionMode_Screen);
- QRadialGradient hotspot(light.hotspotCenter, light.hotspotRadius);
- hotspot.setColorAt(0.0, light.hotspotInner);
- hotspot.setColorAt(light.hotspotMiddleStop, light.hotspotMiddle);
- hotspot.setColorAt(1.0, light.hotspotOuter);
- painter.fillRect(face, hotspot);
-
- QRadialGradient bloom(light.bloomCenter, light.bloomRadius);
- bloom.setColorAt(0.0, light.bloomInner);
- bloom.setColorAt(light.bloomMiddleStop, light.bloomMiddle);
- bloom.setColorAt(1.0, light.bloomOuter);
- painter.fillRect(face, bloom);
- painter.restore();
-
- QColor clearEdge = light.vignetteEdge;
- clearEdge.setAlpha(0);
- QRadialGradient vignette(light.vignetteCenter, light.vignetteRadius);
- vignette.setColorAt(0.0, clearEdge);
- vignette.setColorAt(light.vignetteClearStop, clearEdge);
- vignette.setColorAt(1.0, light.vignetteEdge);
- painter.fillRect(face, vignette);
-
- drawPaperGrain(painter, face, light.paperGrainOpacity);
- return;
- }
-
- const CrossNeedleMeterGeometry::FaceGradient &material = m_geometry.faceGradient;
-
- QLinearGradient base(face.topLeft(), face.bottomLeft());
- base.setColorAt(0.0, material.top);
- base.setColorAt(material.middleStop, material.middle);
- base.setColorAt(1.0, material.bottom);
- painter.fillRect(face, base);
-
- QRadialGradient glow(material.glowCenter, material.glowRadius);
- glow.setColorAt(0.0, material.glowInner);
- glow.setColorAt(1.0, material.glowOuter);
- painter.fillRect(face, glow);
-
- QColor clearEdge = material.vignetteEdge;
- clearEdge.setAlpha(0);
- QRadialGradient vignette(material.vignetteCenter, material.vignetteRadius);
- vignette.setColorAt(0.0, clearEdge);
- vignette.setColorAt(material.vignetteClearStop, clearEdge);
- vignette.setColorAt(1.0, material.vignetteEdge);
- painter.fillRect(face, vignette);
+ m_faceThemes.drawBackground(painter, face, analogFaceTheme());
}
void CrossNeedleMeterWidget::drawFace(QPainter &painter) const {
@@ -664,20 +553,23 @@ void CrossNeedleMeterWidget::drawFace(QPainter &painter) const {
drawScale(painter, m_geometry.reflectedScale, m_geometry.reflectedTitle);
}
- QFont rangeFont = font();
- rangeFont.setPixelSize(m_geometry.typography.rangePixels);
- rangeFont.setBold(true);
- painter.setFont(rangeFont);
- painter.setPen(m_faceTheme == FaceTheme::GraphiteDark ? m_geometry.darkTheme.rangeText
- : QColor(54, 61, 66));
- drawCenteredMultilineText(painter, m_geometry.rangeLabelCenter, m_geometry.rangeLabel);
+ const AnalogMeterFaceThemeCatalog::Palette &colors =
+ m_faceThemes.palette(analogFaceTheme());
+ if (m_rangeLegendVisible) {
+ QFont rangeFont = font();
+ rangeFont.setPixelSize(m_geometry.typography.rangePixels);
+ rangeFont.setBold(true);
+ painter.setFont(rangeFont);
+ painter.setPen(colors.secondaryText);
+ drawCenteredMultilineText(painter, m_geometry.rangeLabelCenter,
+ m_geometry.rangeLabel);
+ }
QFont unitFont = font();
unitFont.setPixelSize(m_geometry.typography.unitPixels);
unitFont.setBold(true);
painter.setFont(unitFont);
- painter.setPen(m_faceTheme == FaceTheme::GraphiteDark ? m_geometry.darkTheme.text
- : m_geometry.scaleStyle.text);
+ painter.setPen(colors.text);
drawCenteredText(painter, m_geometry.forwardUnitCenter, QStringLiteral("(W)"));
drawCenteredText(painter, m_geometry.reflectedUnitCenter, QStringLiteral("(W)"));
@@ -692,28 +584,25 @@ void CrossNeedleMeterWidget::drawFace(QPainter &painter) const {
}
void CrossNeedleMeterWidget::drawLowerMask(QPainter &painter) const {
- const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark;
- const QColor fill = graphiteDark ? m_geometry.darkTheme.maskFill : m_geometry.mask.fill;
- const QColor edge = graphiteDark ? m_geometry.darkTheme.maskEdge : m_geometry.mask.edge;
- const QColor text = graphiteDark ? m_geometry.darkTheme.maskText : m_geometry.mask.text;
- QPolygonF polygon;
- polygon.append(QPointF(m_geometry.mask.boundary.first().x(), m_geometry.mask.bottomY));
- for (const QPointF &point : m_geometry.mask.boundary) {
- polygon.append(point);
- }
- polygon.append(QPointF(m_geometry.mask.boundary.last().x(), m_geometry.mask.bottomY));
+ const AnalogMeterFaceThemeCatalog::Palette &colors =
+ m_faceThemes.palette(analogFaceTheme());
+ const CrossNeedleMeterGeometry::Frame &frame = m_geometry.frame;
+ const QRectF face(frame.faceInset, frame.faceInset,
+ m_geometry.canvasWidth - 2.0 * frame.faceInset,
+ m_geometry.canvasHeight - 2.0 * frame.faceInset);
+ const QVector boundary = m_faceThemes.lowerMaskBoundary(face);
painter.setPen(Qt::NoPen);
- painter.setBrush(fill);
- painter.drawPolygon(polygon);
+ painter.setBrush(colors.maskFill);
+ painter.drawPath(m_faceThemes.lowerMaskPath(face));
- painter.setPen(QPen(edge, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
- painter.drawPolyline(QPolygonF(m_geometry.mask.boundary));
+ painter.setPen(QPen(colors.maskEdge, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
+ painter.drawPolyline(QPolygonF(boundary));
QFont labelFont = font();
labelFont.setPixelSize(m_geometry.typography.maskLabelPixels);
labelFont.setBold(true);
painter.setFont(labelFont);
- painter.setPen(text);
+ painter.setPen(colors.maskText);
drawCenteredText(painter, m_geometry.mask.labelCenter, m_geometry.mask.label);
}
@@ -750,21 +639,21 @@ void CrossNeedleMeterWidget::drawNeedles(QPainter &painter) const {
painter.save();
painter.setClipPath(clip);
- const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark;
+ const AnalogMeterFaceThemeCatalog::Palette &colors =
+ m_faceThemes.palette(analogFaceTheme());
const CrossNeedleMeterGeometry::NeedleStyle &style = m_geometry.needleStyle;
- const QColor shadow = graphiteDark ? m_geometry.darkTheme.needleShadow : style.shadow;
- const QColor line = graphiteDark ? m_geometry.darkTheme.needle : style.line;
- const QColor edge = graphiteDark ? m_geometry.darkTheme.needleEdge : style.edge;
- const QColor highlight =
- graphiteDark ? m_geometry.darkTheme.needleHighlight : style.highlight;
- const auto drawMovement = [&painter, &style, shadow, line, edge, highlight](
+ const QColor shadow = colors.needleShadow;
+ const QColor line = colors.needle;
+ const QColor edge = colors.needleEdge;
+ const QColor highlight = colors.needleHighlight;
+ const auto drawMovement = [&painter, &style, &colors, shadow, line, edge, highlight](
const QPointF &pivot, const QPointF &tip) {
// Build physical depth from code-drawn material layers. Only the soft
// shadows and sub-pixel edge reflections are displaced; the body is
// always painted on the calibrated pivot-to-tip ray. The cast shadows
// remain continuous over the printed scale, as they would on a
// physical meter face beneath an elevated needle.
- painter.setPen(QPen(style.softShadow, style.softShadowWidth, Qt::SolidLine,
+ painter.setPen(QPen(colors.needleSoftShadow, style.softShadowWidth, Qt::SolidLine,
Qt::RoundCap));
painter.drawLine(pivot + style.softShadowOffset, tip + style.softShadowOffset);
painter.setPen(QPen(shadow, style.shadowWidth, Qt::SolidLine, Qt::RoundCap));
@@ -874,6 +763,7 @@ void CrossNeedleMeterWidget::publishAutomationProperties() {
m_reflectedPowerMeasured ? QStringLiteral("measured") : QStringLiteral("derived"));
setProperty("swr", m_swr);
setProperty("rangeMultiplier", m_rangeMultiplier);
+ setProperty("rangeLegendVisible", m_rangeLegendVisible);
setProperty("transmitting", m_transmitting);
setProperty("effectiveActive", m_transmitting || m_automationFixture);
setProperty("automationFixture", m_automationFixture);
diff --git a/src/gui/CrossNeedleMeterWidget.h b/src/gui/CrossNeedleMeterWidget.h
index f6e0d1d73..5c00ae627 100644
--- a/src/gui/CrossNeedleMeterWidget.h
+++ b/src/gui/CrossNeedleMeterWidget.h
@@ -1,5 +1,6 @@
#pragma once
+#include "AnalogMeterFaceTheme.h"
#include "CrossNeedleMeterGeometry.h"
#include "MeterSmoother.h"
@@ -43,6 +44,7 @@ class CrossNeedleMeterWidget : public QWidget {
double rangeMultiplier() const { return m_rangeMultiplier; }
bool isTransmitting() const { return m_transmitting; }
bool reflectedPowerMeasured() const { return m_reflectedPowerMeasured; }
+ bool rangeLegendVisible() const { return m_rangeLegendVisible; }
FaceTheme faceTheme() const { return m_faceTheme; }
QString faceThemeId() const;
const CrossNeedleMeterGeometry &geometry() const { return m_geometry; }
@@ -54,6 +56,7 @@ class CrossNeedleMeterWidget : public QWidget {
void setTransmitting(bool transmitting);
void setPowerScale(int maxWatts, bool amplifierActive);
void setFaceTheme(FaceTheme theme);
+ void setRangeLegendVisible(bool visible);
// Test fixture used only by the in-process automation bridge. AppletPanel
// exposes it only when AETHER_AUTOMATION is set; production UI cannot call
@@ -85,6 +88,7 @@ class CrossNeedleMeterWidget : public QWidget {
void drawCenteredText(QPainter &painter, const QPointF ¢er, const QString &text) const;
void drawCenteredMultilineText(QPainter &painter, const QPointF ¢er,
const QString &text) const;
+ AnalogMeterFaceTheme analogFaceTheme() const;
void drawRotatedText(QPainter &painter, const QPointF ¢er, const QString &text,
double degrees) const;
void updateTargets(bool snap);
@@ -92,6 +96,7 @@ class CrossNeedleMeterWidget : public QWidget {
void publishAutomationProperties();
CrossNeedleMeterGeometry m_geometry;
+ AnalogMeterFaceThemeCatalog m_faceThemes;
MeterSmoother m_forwardSmoother;
MeterSmoother m_reflectedSmoother;
QTimer m_animationTimer;
@@ -109,6 +114,7 @@ class CrossNeedleMeterWidget : public QWidget {
double m_rangeMultiplier{10.0};
bool m_transmitting{false};
bool m_reflectedPowerMeasured{false};
+ bool m_rangeLegendVisible{true};
bool m_automationFixture{false};
FaceTheme m_faceTheme{FaceTheme::DarkRoomUplight};
};
diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp
index a7ce2f138..920869ac8 100644
--- a/src/gui/MainWindow_Wiring.cpp
+++ b/src/gui/MainWindow_Wiring.cpp
@@ -4270,7 +4270,8 @@ void MainWindow::wireMeters()
this, [this](float fwd, float swr) {
if (m_radioModel.amplifier().present() && m_radioModel.amplifier().operate())
return;
- m_appletPanel->setStandardMeterTxValues(fwd, swr);
+ m_appletPanel->setStandardRadioMeterTxValues(
+ fwd, m_radioModel.meterModel().fwdPowerInstant(), swr);
#ifdef HAVE_HIDAPI
m_tmate2TxWatts = fwd;
if (m_radioModel.transmitModel().isTransmitting()) {
diff --git a/src/gui/RadioSwrValidityFilter.cpp b/src/gui/RadioSwrValidityFilter.cpp
new file mode 100644
index 000000000..841d081a4
--- /dev/null
+++ b/src/gui/RadioSwrValidityFilter.cpp
@@ -0,0 +1,155 @@
+#include "RadioSwrValidityFilter.h"
+
+#include
+#include
+
+namespace AetherSDR {
+
+RadioSwrValidityFilter::Result RadioSwrValidityFilter::update(
+ float forwardPowerInstant, float rawSwr,
+ qint64 timestampMs, float maximumDisplayedSwr)
+{
+ const qint64 boundedTimestampMs = std::max(timestampMs, 0);
+ const qint64 effectiveTimestampMs =
+ m_lastTimestampMs >= 0
+ ? std::max(boundedTimestampMs, m_lastTimestampMs)
+ : boundedTimestampMs;
+ if (m_lastTimestampMs >= 0) {
+ const double elapsedMs =
+ static_cast(effectiveTimestampMs - m_lastTimestampMs);
+ const double release =
+ std::exp2(-elapsedMs / kEnvelopeHalfLifeMs);
+ m_forwardEnvelopeWatts =
+ static_cast(m_forwardEnvelopeWatts * release);
+ }
+ m_lastTimestampMs = effectiveTimestampMs;
+
+ const bool finiteForward = std::isfinite(forwardPowerInstant);
+ if (finiteForward && forwardPowerInstant > 0.0f) {
+ m_forwardEnvelopeWatts =
+ std::max(m_forwardEnvelopeWatts, forwardPowerInstant);
+ }
+
+ const float minimumForwardPower = std::max(
+ kMinimumForwardPowerWatts,
+ m_forwardEnvelopeWatts * kEnvelopeThresholdFraction);
+ const bool hasAbsolutePower =
+ finiteForward && forwardPowerInstant >= kMinimumForwardPowerWatts;
+ const bool hasMeaningfulPower =
+ hasAbsolutePower && forwardPowerInstant >= minimumForwardPower;
+ bool held = false;
+
+ if (!std::isfinite(rawSwr)) {
+ held = m_hasReading;
+ m_lowSwrCandidateSamples = 0;
+ clearTimedConfirmations();
+ } else if (!hasAbsolutePower) {
+ // No measurable carrier means there is no trustworthy SWR sample.
+ // In particular, do not let repeated no-carrier packets eventually
+ // satisfy either of the time-based confirmation windows.
+ held = m_hasReading;
+ m_lowSwrCandidateSamples = 0;
+ clearTimedConfirmations();
+ } else if (rawSwr < 1.0f) {
+ // SWR below 1 is physically impossible and may be the radio's
+ // unavailable/over-range sentinel. A single corrupt sample must not
+ // flash a frightening full-scale warning, but persistence under any
+ // measurable carrier remains actionable even during power foldback.
+ if (confirmationElapsed(m_belowUnityStartedAtMs,
+ effectiveTimestampMs)) {
+ m_displayedSwr = maximumDisplayedSwr;
+ m_hasReading = true;
+ clearTimedConfirmations();
+ } else {
+ held = m_hasReading;
+ m_lowPowerRecoveryStartedAtMs = -1;
+ }
+ m_lowSwrCandidateSamples = 0;
+ } else if (!hasMeaningfulPower) {
+ if (m_hasReading && rawSwr > m_displayedSwr) {
+ // Preserve worsening foldback warnings only while measurable RF is
+ // still present. Safety-significant rises remain immediate even
+ // while forward power is below the relative envelope threshold.
+ m_displayedSwr = rawSwr;
+ m_lowSwrCandidateSamples = 0;
+ clearTimedConfirmations();
+ } else if (m_hasReading && rawSwr < m_displayedSwr) {
+ // A real re-match or ALC recovery can improve SWR while power is
+ // temporarily below the envelope threshold. Require one continuous,
+ // bounded wall-clock confirmation for every physically valid
+ // decrease, including near-unity readings, instead of holding the
+ // stale warning until the 750 ms envelope has completely released.
+ if (confirmationElapsed(m_lowPowerRecoveryStartedAtMs,
+ effectiveTimestampMs)) {
+ m_displayedSwr = rawSwr;
+ m_hasReading = true;
+ clearTimedConfirmations();
+ } else {
+ held = true;
+ // rawSwr >= 1.0 here, so this sample breaks any run of
+ // below-unity sentinels: reset that window (but keep the
+ // recovery window advancing) so an interrupted sub-1.0 streak
+ // cannot later peg the meter to full scale on a stale start.
+ m_belowUnityStartedAtMs = -1;
+ }
+ m_lowSwrCandidateSamples = 0;
+ } else {
+ held = m_hasReading;
+ m_lowSwrCandidateSamples = 0;
+ clearTimedConfirmations();
+ }
+ } else if (rawSwr <= kLowSwrConfirmationMaximum
+ && m_hasReading
+ && m_displayedSwr > kLowSwrConfirmationMaximum) {
+ clearTimedConfirmations();
+ ++m_lowSwrCandidateSamples;
+ if (m_lowSwrCandidateSamples >= kLowSwrConfirmationSamples) {
+ m_displayedSwr = rawSwr;
+ m_hasReading = true;
+ m_lowSwrCandidateSamples = 0;
+ } else {
+ held = true;
+ }
+ } else {
+ m_displayedSwr = rawSwr;
+ m_hasReading = true;
+ m_lowSwrCandidateSamples = 0;
+ clearTimedConfirmations();
+ }
+
+ return {
+ m_displayedSwr,
+ m_forwardEnvelopeWatts,
+ minimumForwardPower,
+ held,
+ m_hasReading,
+ };
+}
+
+bool RadioSwrValidityFilter::confirmationElapsed(
+ qint64& startedAtMs, qint64 timestampMs)
+{
+ if (startedAtMs < 0) {
+ startedAtMs = timestampMs;
+ return false;
+ }
+ return timestampMs - startedAtMs >= kTimedConfirmationMs;
+}
+
+void RadioSwrValidityFilter::clearTimedConfirmations()
+{
+ m_lowPowerRecoveryStartedAtMs = -1;
+ m_belowUnityStartedAtMs = -1;
+}
+
+void RadioSwrValidityFilter::reset()
+{
+ m_displayedSwr = 1.0f;
+ m_forwardEnvelopeWatts = 0.0f;
+ m_lastTimestampMs = -1;
+ clearTimedConfirmations();
+ m_hasReading = false;
+ m_lowSwrCandidateSamples = 0;
+}
+
+} // namespace AetherSDR
diff --git a/src/gui/RadioSwrValidityFilter.h b/src/gui/RadioSwrValidityFilter.h
new file mode 100644
index 000000000..94b55b26c
--- /dev/null
+++ b/src/gui/RadioSwrValidityFilter.h
@@ -0,0 +1,51 @@
+#pragma once
+
+#include
+
+namespace AetherSDR {
+
+// Validates the radio-native SWR stream against its instantaneous forward
+// power. The forward-power envelope attacks immediately and releases with
+// elapsed time, so brief modulation gaps cannot force SWR toward 1 while a
+// sustained lower-power operating point eventually becomes authoritative.
+//
+// Note on AGENTS.md "use MeterSmoother": the forward-power envelope here is a
+// trust GATE (is there enough measurable carrier to believe this SWR sample?),
+// not display smoothing — it never smooths the number shown on the meter. The
+// displayed SWR is passed through verbatim once the gate accepts it, so this is
+// deliberately outside the MeterSmoother rule, which governs the value drawn.
+class RadioSwrValidityFilter {
+public:
+ struct Result {
+ float displayedSwr{1.0f};
+ float forwardEnvelopeWatts{0.0f};
+ float minimumForwardWatts{0.05f};
+ bool held{false};
+ bool hasReading{false};
+ };
+
+ Result update(float forwardPowerInstant, float rawSwr,
+ qint64 timestampMs, float maximumDisplayedSwr);
+ void reset();
+
+private:
+ static constexpr float kMinimumForwardPowerWatts = 0.05f;
+ static constexpr float kEnvelopeThresholdFraction = 0.20f;
+ static constexpr float kLowSwrConfirmationMaximum = 1.2f;
+ static constexpr int kLowSwrConfirmationSamples = 3;
+ static constexpr qint64 kTimedConfirmationMs = 250;
+ static constexpr double kEnvelopeHalfLifeMs = 750.0;
+
+ static bool confirmationElapsed(qint64& startedAtMs, qint64 timestampMs);
+ void clearTimedConfirmations();
+
+ float m_displayedSwr{1.0f};
+ float m_forwardEnvelopeWatts{0.0f};
+ qint64 m_lastTimestampMs{-1};
+ qint64 m_lowPowerRecoveryStartedAtMs{-1};
+ qint64 m_belowUnityStartedAtMs{-1};
+ bool m_hasReading{false};
+ int m_lowSwrCandidateSamples{0};
+};
+
+} // namespace AetherSDR
diff --git a/src/gui/SMeterGeometry.cpp b/src/gui/SMeterGeometry.cpp
new file mode 100644
index 000000000..b372ba1d4
--- /dev/null
+++ b/src/gui/SMeterGeometry.cpp
@@ -0,0 +1,778 @@
+#include "SMeterGeometry.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+namespace AetherSDR {
+
+namespace {
+
+bool finite(double value)
+{
+ return std::isfinite(value);
+}
+
+bool positive(double value)
+{
+ return finite(value) && value > 0.0;
+}
+
+bool nonNegative(double value)
+{
+ return finite(value) && value >= 0.0;
+}
+
+bool nearlyEqual(double first, double second, double tolerance = 1.0e-9)
+{
+ return finite(first) && finite(second) && std::abs(first - second) <= tolerance;
+}
+
+bool parseFailure(QString& error, const QString& path, const QString& requirement)
+{
+ error = QStringLiteral("s-meter-v1.json: %1 must be %2").arg(path, requirement);
+ return false;
+}
+
+QString childPath(const QString& parent, const QString& key)
+{
+ return parent.isEmpty() ? key : parent + QLatin1Char('.') + key;
+}
+
+bool readObject(const QJsonObject& object, const QString& key, const QString& parentPath,
+ QJsonObject& output, QString& error)
+{
+ const QString path = childPath(parentPath, key);
+ const QJsonValue value = object.value(key);
+ if (!value.isObject()) {
+ return parseFailure(error, path, QStringLiteral("an object"));
+ }
+ output = value.toObject();
+ return true;
+}
+
+bool readNumberValue(const QJsonValue& value, const QString& path, double& output,
+ QString& error)
+{
+ if (!value.isDouble() || !finite(value.toDouble())) {
+ return parseFailure(error, path, QStringLiteral("a finite number"));
+ }
+ output = value.toDouble();
+ return true;
+}
+
+bool readNumber(const QJsonObject& object, const QString& key, const QString& parentPath,
+ double& output, QString& error)
+{
+ return readNumberValue(object.value(key), childPath(parentPath, key), output, error);
+}
+
+bool readIntValue(const QJsonValue& value, const QString& path, int& output, QString& error)
+{
+ double number = 0.0;
+ if (!readNumberValue(value, path, number, error)) {
+ return false;
+ }
+ if (std::floor(number) != number || number < std::numeric_limits::min()
+ || number > std::numeric_limits::max()) {
+ return parseFailure(error, path, QStringLiteral("an integer"));
+ }
+ output = static_cast(number);
+ return true;
+}
+
+bool readInt(const QJsonObject& object, const QString& key, const QString& parentPath,
+ int& output, QString& error)
+{
+ return readIntValue(object.value(key), childPath(parentPath, key), output, error);
+}
+
+bool readBool(const QJsonObject& object, const QString& key, const QString& parentPath,
+ bool& output, QString& error)
+{
+ const QString path = childPath(parentPath, key);
+ const QJsonValue value = object.value(key);
+ if (!value.isBool()) {
+ return parseFailure(error, path, QStringLiteral("a boolean"));
+ }
+ output = value.toBool();
+ return true;
+}
+
+bool readString(const QJsonObject& object, const QString& key, const QString& parentPath,
+ QString& output, QString& error)
+{
+ const QString path = childPath(parentPath, key);
+ const QJsonValue value = object.value(key);
+ if (!value.isString()) {
+ return parseFailure(error, path, QStringLiteral("a string"));
+ }
+ output = value.toString();
+ return true;
+}
+
+bool readSize(const QJsonObject& object, const QString& key, const QString& parentPath,
+ QSize& output, QString& error)
+{
+ const QString path = childPath(parentPath, key);
+ const QJsonValue value = object.value(key);
+ if (!value.isArray()) {
+ return parseFailure(error, path, QStringLiteral("a two-integer array"));
+ }
+ const QJsonArray values = value.toArray();
+ if (values.size() != 2) {
+ return parseFailure(error, path, QStringLiteral("a two-integer array"));
+ }
+ int width = 0;
+ int height = 0;
+ if (!readIntValue(values.at(0), path + QStringLiteral("[0]"), width, error)
+ || !readIntValue(values.at(1), path + QStringLiteral("[1]"), height, error)) {
+ return false;
+ }
+ output = QSize(width, height);
+ return true;
+}
+
+bool readPoint(const QJsonObject& object, const QString& key, const QString& parentPath,
+ QPointF& output, QString& error)
+{
+ const QString path = childPath(parentPath, key);
+ const QJsonValue value = object.value(key);
+ if (!value.isArray()) {
+ return parseFailure(error, path, QStringLiteral("a two-number array"));
+ }
+ const QJsonArray values = value.toArray();
+ if (values.size() != 2) {
+ return parseFailure(error, path, QStringLiteral("a two-number array"));
+ }
+ double x = 0.0;
+ double y = 0.0;
+ if (!readNumberValue(values.at(0), path + QStringLiteral("[0]"), x, error)
+ || !readNumberValue(values.at(1), path + QStringLiteral("[1]"), y, error)) {
+ return false;
+ }
+ output = QPointF(x, y);
+ return true;
+}
+
+bool readTicks(const QJsonObject& object, const QString& key, const QString& parentPath,
+ QVector& output, QString& error)
+{
+ const QString path = childPath(parentPath, key);
+ const QJsonValue value = object.value(key);
+ if (!value.isArray()) {
+ return parseFailure(error, path, QStringLiteral("an array"));
+ }
+
+ const QJsonArray values = value.toArray();
+ output.clear();
+ output.reserve(values.size());
+ for (qsizetype index = 0; index < values.size(); ++index) {
+ const QString entryPath = path + QStringLiteral("[%1]").arg(index);
+ if (!values.at(index).isObject()) {
+ return parseFailure(error, entryPath, QStringLiteral("an object"));
+ }
+ const QJsonObject tickObject = values.at(index).toObject();
+ SMeterGeometry::Tick tick;
+ if (!readNumber(tickObject, QStringLiteral("value"), entryPath, tick.value, error)
+ || !readString(tickObject, QStringLiteral("label"), entryPath, tick.label, error)) {
+ return false;
+ }
+ output.push_back(tick);
+ }
+ return true;
+}
+
+bool readStaticScale(const QJsonObject& root, const QString& key,
+ SMeterGeometry::StaticScale& scale, QString& error)
+{
+ QJsonObject object;
+ if (!readObject(root, key, QString(), object, error)) {
+ return false;
+ }
+ return readNumber(object, QStringLiteral("minimum"), key, scale.minimum, error)
+ && readNumber(object, QStringLiteral("maximum"), key, scale.maximum, error)
+ && readBool(object, QStringLiteral("has_warning"), key, scale.hasWarning, error)
+ && readNumber(object, QStringLiteral("warning_start"), key, scale.warningStart, error)
+ && readTicks(object, QStringLiteral("ticks"), key, scale.ticks, error);
+}
+
+SMeterGeometry rejectedGeometry(QString* error, const QString& message)
+{
+ if (error) {
+ *error = message;
+ }
+ return {};
+}
+
+bool validTicks(const QVector& ticks, double minimum, double maximum)
+{
+ if (ticks.isEmpty()) {
+ return false;
+ }
+ double previous = -std::numeric_limits::infinity();
+ for (const SMeterGeometry::Tick& tick : ticks) {
+ if (!finite(tick.value) || tick.value < minimum || tick.value > maximum
+ || tick.value <= previous || tick.label.isEmpty()) {
+ return false;
+ }
+ previous = tick.value;
+ }
+ return true;
+}
+
+} // namespace
+
+SMeterGeometry SMeterGeometry::loadResource(QString* error)
+{
+ QFile file(QStringLiteral(":/meterfaces/s-meter-v1.json"));
+ if (!file.open(QIODevice::ReadOnly)) {
+ if (error) {
+ *error = QStringLiteral("cannot open :/meterfaces/s-meter-v1.json");
+ }
+ return {};
+ }
+ return load(file, error);
+}
+
+SMeterGeometry SMeterGeometry::load(QIODevice& device, QString* error)
+{
+ if (error) {
+ error->clear();
+ }
+
+ QJsonParseError parseError;
+ const QJsonDocument document = QJsonDocument::fromJson(device.readAll(), &parseError);
+ if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
+ return rejectedGeometry(
+ error, QStringLiteral("invalid JSON: %1").arg(parseError.errorString()));
+ }
+
+ const QJsonObject root = document.object();
+ SMeterGeometry geometry;
+ QString fieldError;
+ QJsonObject sizing;
+ QJsonObject arc;
+ QJsonObject tickStyle;
+ QJsonObject rxScale;
+ QJsonObject needle;
+ QJsonObject pivot;
+ QJsonObject peakMarker;
+ QJsonObject peakHold;
+ QJsonObject readout;
+
+ const bool fixedFieldsValid =
+ readInt(root, QStringLiteral("format_version"), QString(), geometry.formatVersion,
+ fieldError)
+ && readInt(root, QStringLiteral("design_version"), QString(), geometry.designVersion,
+ fieldError)
+ && readObject(root, QStringLiteral("sizing"), QString(), sizing, fieldError)
+ && readSize(sizing, QStringLiteral("preferred"), QStringLiteral("sizing"),
+ geometry.sizing.preferred, fieldError)
+ && readSize(sizing, QStringLiteral("minimum"), QStringLiteral("sizing"),
+ geometry.sizing.minimum, fieldError)
+ && readNumber(sizing, QStringLiteral("minimum_aspect_ratio"),
+ QStringLiteral("sizing"), geometry.sizing.minimumAspectRatio,
+ fieldError)
+ && readNumber(sizing, QStringLiteral("maximum_aspect_ratio"),
+ QStringLiteral("sizing"), geometry.sizing.maximumAspectRatio,
+ fieldError)
+ && readObject(root, QStringLiteral("arc"), QString(), arc, fieldError)
+ && readNumber(arc, QStringLiteral("center_x_width_factor"), QStringLiteral("arc"),
+ geometry.arc.centerXWidthFactor, fieldError)
+ && readNumber(arc, QStringLiteral("radius_width_factor"), QStringLiteral("arc"),
+ geometry.arc.radiusWidthFactor, fieldError)
+ && readNumber(arc, QStringLiteral("center_y_height_factor"), QStringLiteral("arc"),
+ geometry.arc.centerYHeightFactor, fieldError)
+ && readNumber(arc, QStringLiteral("start_degrees"), QStringLiteral("arc"),
+ geometry.arc.startDegrees, fieldError)
+ && readNumber(arc, QStringLiteral("end_degrees"), QStringLiteral("arc"),
+ geometry.arc.endDegrees, fieldError)
+ && readNumber(arc, QStringLiteral("inner_gap_pixels"), QStringLiteral("arc"),
+ geometry.arc.innerGapPixels, fieldError)
+ && readNumber(arc, QStringLiteral("line_width_pixels"), QStringLiteral("arc"),
+ geometry.arc.lineWidthPixels, fieldError)
+ && readObject(root, QStringLiteral("tick_style"), QString(), tickStyle, fieldError)
+ && readNumber(tickStyle, QStringLiteral("start_offset_pixels"),
+ QStringLiteral("tick_style"), geometry.tickStyle.startOffsetPixels,
+ fieldError)
+ && readNumber(tickStyle, QStringLiteral("end_offset_pixels"),
+ QStringLiteral("tick_style"), geometry.tickStyle.endOffsetPixels,
+ fieldError)
+ && readNumber(tickStyle, QStringLiteral("label_offset_pixels"),
+ QStringLiteral("tick_style"), geometry.tickStyle.labelOffsetPixels,
+ fieldError)
+ && readNumber(tickStyle, QStringLiteral("line_width_pixels"),
+ QStringLiteral("tick_style"), geometry.tickStyle.lineWidthPixels,
+ fieldError)
+ && readInt(tickStyle, QStringLiteral("font_minimum_pixels"),
+ QStringLiteral("tick_style"), geometry.tickStyle.fontMinimumPixels,
+ fieldError)
+ && readNumber(tickStyle, QStringLiteral("font_height_factor"),
+ QStringLiteral("tick_style"), geometry.tickStyle.fontHeightFactor,
+ fieldError)
+ && readBool(tickStyle, QStringLiteral("bold"), QStringLiteral("tick_style"),
+ geometry.tickStyle.bold, fieldError)
+ && readObject(root, QStringLiteral("rx_scale"), QString(), rxScale, fieldError)
+ && readNumber(rxScale, QStringLiteral("minimum_dbm"), QStringLiteral("rx_scale"),
+ geometry.rxScale.minimumDbm, fieldError)
+ && readNumber(rxScale, QStringLiteral("s9_dbm"), QStringLiteral("rx_scale"),
+ geometry.rxScale.s9Dbm, fieldError)
+ && readNumber(rxScale, QStringLiteral("maximum_dbm"), QStringLiteral("rx_scale"),
+ geometry.rxScale.maximumDbm, fieldError)
+ && readNumber(rxScale, QStringLiteral("db_per_s_unit"), QStringLiteral("rx_scale"),
+ geometry.rxScale.dbPerSUnit, fieldError)
+ && readNumber(rxScale, QStringLiteral("s9_fraction"), QStringLiteral("rx_scale"),
+ geometry.rxScale.s9Fraction, fieldError)
+ && readTicks(rxScale, QStringLiteral("ticks"), QStringLiteral("rx_scale"),
+ geometry.rxScale.ticks, fieldError)
+ && readStaticScale(root, QStringLiteral("swr_scale"), geometry.swrScale, fieldError)
+ && readStaticScale(root, QStringLiteral("level_scale"), geometry.levelScale, fieldError)
+ && readStaticScale(root, QStringLiteral("compression_scale"),
+ geometry.compressionScale, fieldError)
+ && readObject(root, QStringLiteral("needle"), QString(), needle, fieldError)
+ && readNumber(needle, QStringLiteral("pivot_y_below_widget_pixels"),
+ QStringLiteral("needle"), geometry.needle.pivotYBelowWidgetPixels,
+ fieldError)
+ && readNumber(needle, QStringLiteral("tip_extension_pixels"),
+ QStringLiteral("needle"), geometry.needle.tipExtensionPixels,
+ fieldError)
+ && readNumber(needle, QStringLiteral("line_width_pixels"), QStringLiteral("needle"),
+ geometry.needle.lineWidthPixels, fieldError)
+ && readNumber(needle, QStringLiteral("shadow_width_pixels"),
+ QStringLiteral("needle"), geometry.needle.shadowWidthPixels, fieldError)
+ && readPoint(needle, QStringLiteral("shadow_offset"), QStringLiteral("needle"),
+ geometry.needle.shadowOffset, fieldError)
+ && readObject(root, QStringLiteral("pivot"), QString(), pivot, fieldError)
+ && readNumber(pivot, QStringLiteral("minimum_radius_pixels"), QStringLiteral("pivot"),
+ geometry.pivot.minimumRadiusPixels, fieldError)
+ && readNumber(pivot, QStringLiteral("radius_width_factor"), QStringLiteral("pivot"),
+ geometry.pivot.radiusWidthFactor, fieldError)
+ && readNumber(pivot, QStringLiteral("glow_radius_factor"), QStringLiteral("pivot"),
+ geometry.pivot.glowRadiusFactor, fieldError)
+ && readNumber(pivot, QStringLiteral("glow_middle_factor"), QStringLiteral("pivot"),
+ geometry.pivot.glowMiddleFactor, fieldError)
+ && readInt(pivot, QStringLiteral("glow_center_alpha"), QStringLiteral("pivot"),
+ geometry.pivot.glowCenterAlpha, fieldError)
+ && readInt(pivot, QStringLiteral("glow_middle_alpha"), QStringLiteral("pivot"),
+ geometry.pivot.glowMiddleAlpha, fieldError)
+ && readNumber(pivot, QStringLiteral("rim_width_pixels"), QStringLiteral("pivot"),
+ geometry.pivot.rimWidthPixels, fieldError)
+ && readObject(root, QStringLiteral("peak_marker"), QString(), peakMarker, fieldError)
+ && readNumber(peakMarker, QStringLiteral("radius_inset_pixels"),
+ QStringLiteral("peak_marker"), geometry.peakMarker.radiusInsetPixels,
+ fieldError)
+ && readNumber(peakMarker, QStringLiteral("length_pixels"),
+ QStringLiteral("peak_marker"), geometry.peakMarker.lengthPixels,
+ fieldError)
+ && readNumber(peakMarker, QStringLiteral("half_width_pixels"),
+ QStringLiteral("peak_marker"), geometry.peakMarker.halfWidthPixels,
+ fieldError)
+ && readNumber(peakMarker, QStringLiteral("minimum_lead_db"),
+ QStringLiteral("peak_marker"), geometry.peakMarker.minimumLeadDb,
+ fieldError)
+ && readObject(root, QStringLiteral("peak_hold"), QString(), peakHold, fieldError)
+ && readNumber(peakHold, QStringLiteral("inner_radius_offset_pixels"),
+ QStringLiteral("peak_hold"), geometry.peakHold.innerRadiusOffsetPixels,
+ fieldError)
+ && readNumber(peakHold, QStringLiteral("outer_radius_offset_pixels"),
+ QStringLiteral("peak_hold"), geometry.peakHold.outerRadiusOffsetPixels,
+ fieldError)
+ && readNumber(peakHold, QStringLiteral("line_width_pixels"),
+ QStringLiteral("peak_hold"), geometry.peakHold.lineWidthPixels,
+ fieldError)
+ && readNumber(peakHold, QStringLiteral("visible_above_minimum_db"),
+ QStringLiteral("peak_hold"), geometry.peakHold.visibleAboveMinimumDb,
+ fieldError)
+ && readObject(root, QStringLiteral("readout"), QString(), readout, fieldError)
+ && readInt(readout, QStringLiteral("source_font_minimum_pixels"),
+ QStringLiteral("readout"), geometry.readout.sourceFontMinimumPixels,
+ fieldError)
+ && readNumber(readout, QStringLiteral("source_font_height_divisor"),
+ QStringLiteral("readout"), geometry.readout.sourceFontHeightDivisor,
+ fieldError)
+ && readInt(readout, QStringLiteral("value_font_minimum_pixels"),
+ QStringLiteral("readout"), geometry.readout.valueFontMinimumPixels,
+ fieldError)
+ && readNumber(readout, QStringLiteral("value_font_height_divisor"),
+ QStringLiteral("readout"), geometry.readout.valueFontHeightDivisor,
+ fieldError)
+ && readInt(readout, QStringLiteral("top_extra_pixels"), QStringLiteral("readout"),
+ geometry.readout.topExtraPixels, fieldError)
+ && readInt(readout, QStringLiteral("side_margin_pixels"), QStringLiteral("readout"),
+ geometry.readout.sideMarginPixels, fieldError);
+ if (!fixedFieldsValid) {
+ return rejectedGeometry(error, fieldError);
+ }
+
+ const QJsonValue policiesValue = root.value(QStringLiteral("power_tick_policies"));
+ if (!policiesValue.isArray()) {
+ return rejectedGeometry(error,
+ QStringLiteral("s-meter-v1.json: power_tick_policies must be an array"));
+ }
+ const QJsonArray policies = policiesValue.toArray();
+ geometry.powerTickPolicies.clear();
+ geometry.powerTickPolicies.reserve(policies.size());
+ for (qsizetype index = 0; index < policies.size(); ++index) {
+ const QString path = QStringLiteral("power_tick_policies[%1]").arg(index);
+ if (!policies.at(index).isObject()) {
+ return rejectedGeometry(
+ error, QStringLiteral("s-meter-v1.json: %1 must be an object").arg(path));
+ }
+ const QJsonObject policyObject = policies.at(index).toObject();
+ PowerTickPolicy policy;
+ if (!readNumber(policyObject, QStringLiteral("minimum_scale_watts"), path,
+ policy.minimumScaleWatts, fieldError)
+ || !readInt(policyObject, QStringLiteral("tick_step_watts"), path,
+ policy.tickStepWatts, fieldError)
+ || !readInt(policyObject, QStringLiteral("label_step_watts"), path,
+ policy.labelStepWatts, fieldError)) {
+ return rejectedGeometry(error, fieldError);
+ }
+ geometry.powerTickPolicies.push_back(policy);
+ }
+
+ QString validationError;
+ if (!geometry.isValid(&validationError)) {
+ return rejectedGeometry(error, validationError);
+ }
+ return geometry;
+}
+
+SMeterGeometry SMeterGeometry::fallback()
+{
+ SMeterGeometry geometry;
+ geometry.formatVersion = 1;
+ geometry.designVersion = 5;
+ geometry.rxScale.ticks = {{-121.0, QStringLiteral("1")},
+ {-109.0, QStringLiteral("3")},
+ {-97.0, QStringLiteral("5")},
+ {-85.0, QStringLiteral("7")},
+ {-73.0, QStringLiteral("9")},
+ {-53.0, QStringLiteral("+20")},
+ {-33.0, QStringLiteral("+40")}};
+ geometry.swrScale =
+ {1.0,
+ 3.0,
+ 2.5,
+ true,
+ {{1.0, QStringLiteral("1")},
+ {1.5, QStringLiteral("1.5")},
+ {2.0, QStringLiteral("2")},
+ {2.5, QStringLiteral("2.5")},
+ {3.0, QStringLiteral("3")}}};
+ geometry.levelScale =
+ {-40.0,
+ 5.0,
+ 0.0,
+ true,
+ {{-40.0, QStringLiteral("-40")},
+ {-30.0, QStringLiteral("-30")},
+ {-20.0, QStringLiteral("-20")},
+ {-10.0, QStringLiteral("-10")},
+ {0.0, QStringLiteral("0")}}};
+ geometry.compressionScale =
+ {0.0,
+ 25.0,
+ 0.0,
+ false,
+ {{0.0, QStringLiteral("0")},
+ {5.0, QStringLiteral("-5")},
+ {10.0, QStringLiteral("-10")},
+ {15.0, QStringLiteral("-15")},
+ {20.0, QStringLiteral("-20")},
+ {25.0, QStringLiteral("-25")}}};
+ geometry.powerTickPolicies = {{2000.0, 100, 500}, {600.0, 50, 100}, {0.0, 10, 40}};
+ return geometry;
+}
+
+bool SMeterGeometry::isValid(QString* error) const
+{
+ if (error) {
+ error->clear();
+ }
+ const auto fail = [error](const QString& message) {
+ if (error) {
+ *error = message;
+ }
+ return false;
+ };
+
+ if (formatVersion != 1 || designVersion <= 0) {
+ return fail(QStringLiteral("unsupported standard S-meter geometry version"));
+ }
+ const double preferredAspectRatio =
+ static_cast(sizing.preferred.width())
+ / static_cast(std::max(sizing.preferred.height(), 1));
+ if (sizing.preferred.width() <= 0 || sizing.preferred.height() <= 0
+ || sizing.minimum.width() <= 0 || sizing.minimum.height() <= 0
+ || sizing.preferred.width() < sizing.minimum.width()
+ || sizing.preferred.height() < sizing.minimum.height()
+ || !positive(sizing.minimumAspectRatio)
+ || !positive(sizing.maximumAspectRatio)
+ || sizing.minimumAspectRatio >= sizing.maximumAspectRatio
+ || preferredAspectRatio < sizing.minimumAspectRatio
+ || preferredAspectRatio > sizing.maximumAspectRatio) {
+ return fail(QStringLiteral("standard S-meter sizing is invalid"));
+ }
+ if (!(arc.centerXWidthFactor > 0.0 && arc.centerXWidthFactor < 1.0)
+ || !positive(arc.radiusWidthFactor) || !positive(arc.centerYHeightFactor)
+ || !(arc.startDegrees > 0.0 && arc.startDegrees < arc.endDegrees
+ && arc.endDegrees < 180.0)
+ || !positive(arc.innerGapPixels) || !positive(arc.lineWidthPixels)) {
+ return fail(QStringLiteral("standard S-meter arc is invalid"));
+ }
+ if (!nonNegative(tickStyle.startOffsetPixels) || !positive(tickStyle.endOffsetPixels)
+ || tickStyle.endOffsetPixels <= tickStyle.startOffsetPixels
+ || !positive(tickStyle.labelOffsetPixels)
+ || tickStyle.labelOffsetPixels <= tickStyle.endOffsetPixels
+ || !positive(tickStyle.lineWidthPixels) || tickStyle.fontMinimumPixels <= 0
+ || !positive(tickStyle.fontHeightFactor)) {
+ return fail(QStringLiteral("standard S-meter tick style is invalid"));
+ }
+ if (!finite(rxScale.minimumDbm) || !finite(rxScale.s9Dbm)
+ || !finite(rxScale.maximumDbm) || rxScale.minimumDbm >= rxScale.s9Dbm
+ || rxScale.s9Dbm >= rxScale.maximumDbm || !positive(rxScale.dbPerSUnit)
+ || !(rxScale.s9Fraction > 0.0 && rxScale.s9Fraction < 1.0)
+ || !nearlyEqual(rxScale.s9Dbm - rxScale.minimumDbm, 9.0 * rxScale.dbPerSUnit)
+ || !nearlyEqual(rxScale.maximumDbm - rxScale.s9Dbm, 60.0)
+ || !validTicks(rxScale.ticks, rxScale.minimumDbm, rxScale.maximumDbm)) {
+ return fail(QStringLiteral("standard S-meter RX scale is invalid"));
+ }
+ const auto validStaticScale = [](const StaticScale& scale) {
+ return finite(scale.minimum) && finite(scale.maximum) && scale.minimum < scale.maximum
+ && (!scale.hasWarning
+ || (finite(scale.warningStart) && scale.warningStart >= scale.minimum
+ && scale.warningStart <= scale.maximum))
+ && validTicks(scale.ticks, scale.minimum, scale.maximum);
+ };
+ if (!validStaticScale(swrScale) || !validStaticScale(levelScale)
+ || !validStaticScale(compressionScale)) {
+ return fail(QStringLiteral("standard S-meter TX scale is invalid"));
+ }
+ if (powerTickPolicies.isEmpty()) {
+ return fail(QStringLiteral("standard S-meter power tick policies are empty"));
+ }
+ double previousMinimum = std::numeric_limits::infinity();
+ for (const PowerTickPolicy& policy : powerTickPolicies) {
+ if (!nonNegative(policy.minimumScaleWatts) || policy.minimumScaleWatts >= previousMinimum
+ || policy.tickStepWatts <= 0 || policy.labelStepWatts <= 0
+ || policy.labelStepWatts % policy.tickStepWatts != 0) {
+ return fail(QStringLiteral("standard S-meter power tick policy is invalid"));
+ }
+ previousMinimum = policy.minimumScaleWatts;
+ }
+ if (powerTickPolicies.constLast().minimumScaleWatts != 0.0) {
+ return fail(QStringLiteral("standard S-meter power tick policies lack a default"));
+ }
+ if (!nonNegative(needle.pivotYBelowWidgetPixels)
+ || !nonNegative(needle.tipExtensionPixels) || !positive(needle.lineWidthPixels)
+ || !positive(needle.shadowWidthPixels) || !finite(needle.shadowOffset.x())
+ || !finite(needle.shadowOffset.y()) || !positive(pivot.minimumRadiusPixels)
+ || !positive(pivot.radiusWidthFactor) || !(pivot.glowRadiusFactor > 1.0)
+ || !(pivot.glowMiddleFactor > 0.0 && pivot.glowMiddleFactor < 1.0)
+ || pivot.glowCenterAlpha < 0 || pivot.glowCenterAlpha > 255
+ || pivot.glowMiddleAlpha < 0 || pivot.glowMiddleAlpha > 255
+ || !positive(pivot.rimWidthPixels)) {
+ return fail(QStringLiteral("standard S-meter needle or pivot is invalid"));
+ }
+ if (!nonNegative(peakMarker.radiusInsetPixels) || !positive(peakMarker.lengthPixels)
+ || !positive(peakMarker.halfWidthPixels) || !nonNegative(peakMarker.minimumLeadDb)
+ || !finite(peakHold.innerRadiusOffsetPixels)
+ || !finite(peakHold.outerRadiusOffsetPixels)
+ || peakHold.innerRadiusOffsetPixels >= peakHold.outerRadiusOffsetPixels
+ || !positive(peakHold.lineWidthPixels) || !nonNegative(peakHold.visibleAboveMinimumDb)) {
+ return fail(QStringLiteral("standard S-meter peak geometry is invalid"));
+ }
+ if (readout.sourceFontMinimumPixels <= 0 || !positive(readout.sourceFontHeightDivisor)
+ || readout.valueFontMinimumPixels <= 0 || !positive(readout.valueFontHeightDivisor)
+ || readout.topExtraPixels < 0 || readout.sideMarginPixels < 0) {
+ return fail(QStringLiteral("standard S-meter readout geometry is invalid"));
+ }
+
+ const QVector validationSizes = {
+ QSizeF(sizing.minimum),
+ QSizeF(sizing.preferred),
+ QSizeF(sizing.preferred.width() * 2.0, sizing.preferred.height()),
+ QSizeF(sizing.preferred.width(), sizing.preferred.height() * 1.5),
+ QSizeF(sizing.minimum.width(), sizing.minimum.height() * 20.0),
+ QSizeF(sizing.minimum.width() * 10.0, sizing.minimum.height())};
+ for (const QSizeF& size : validationSizes) {
+ const Layout layout = layoutFor(size);
+ const double markerRadius = layout.radius - peakMarker.radiusInsetPixels;
+ const double peakHoldInnerRadius = layout.radius + peakHold.innerRadiusOffsetPixels;
+ if (!positive(layout.radius) || !positive(layout.innerRadius)
+ || !positive(markerRadius) || !positive(peakHoldInnerRadius)
+ || !finite(layout.centerX) || !finite(layout.centerY)
+ || !finite(layout.needlePivotY) || !positive(layout.pivotRadius)
+ || !layout.viewport.isValid()
+ || layout.viewport.left() < 0.0 || layout.viewport.top() < 0.0
+ || layout.viewport.right() > size.width()
+ || layout.viewport.bottom() > size.height()
+ || layout.centerX <= layout.viewport.left()
+ || layout.centerX >= layout.viewport.right()
+ || layout.pivotRadius >= layout.viewport.width() * 0.5
+ || layout.pivotRadius >= layout.viewport.height()) {
+ return fail(QStringLiteral("standard S-meter derived layout is invalid"));
+ }
+
+ const QPointF center(layout.centerX, layout.centerY);
+ const QPointF pivotPoint(layout.centerX, layout.needlePivotY);
+ const double pivotDistance = std::hypot(
+ pivotPoint.x() - center.x(), pivotPoint.y() - center.y());
+ const double smallestMovementRadius =
+ std::min({layout.innerRadius, markerRadius, peakHoldInnerRadius});
+ if (!(pivotDistance < smallestMovementRadius)) {
+ return fail(QStringLiteral("standard S-meter pivot must remain inside movement arcs"));
+ }
+
+ for (const double fraction : {0.0, 0.5, 1.0}) {
+ const MovementRay ray = movementRayFor(size, fraction);
+ const double directionLength = std::hypot(ray.direction.x(), ray.direction.y());
+ const std::optional innerPoint =
+ movementRayCircleIntersection(size, fraction, layout.innerRadius);
+ if (!nearlyEqual(directionLength, 1.0, 1.0e-8) || !innerPoint
+ || ray.scalePoint.x() < layout.viewport.left()
+ || ray.scalePoint.x() > layout.viewport.right()
+ || ray.scalePoint.y() < layout.viewport.top()
+ || ray.scalePoint.y() > layout.viewport.bottom()) {
+ return fail(QStringLiteral("standard S-meter movement ray is invalid"));
+ }
+ }
+ }
+ return true;
+}
+
+SMeterGeometry::Layout SMeterGeometry::layoutFor(const QSizeF& size) const
+{
+ Layout layout;
+ const double width = std::max(size.width(), 1.0);
+ const double height = std::max(size.height(), 1.0);
+ const double aspectRatio = width / height;
+ layout.viewport = QRectF(0.0, 0.0, width, height);
+ if (aspectRatio < sizing.minimumAspectRatio) {
+ const double viewportHeight = width / sizing.minimumAspectRatio;
+ layout.viewport.setTop((height - viewportHeight) * 0.5);
+ layout.viewport.setHeight(viewportHeight);
+ } else if (aspectRatio > sizing.maximumAspectRatio) {
+ const double viewportWidth = height * sizing.maximumAspectRatio;
+ layout.viewport.setLeft((width - viewportWidth) * 0.5);
+ layout.viewport.setWidth(viewportWidth);
+ }
+
+ const double faceWidth = layout.viewport.width();
+ const double faceHeight = layout.viewport.height();
+ layout.centerX = layout.viewport.left() + faceWidth * arc.centerXWidthFactor;
+ layout.radius = faceWidth * arc.radiusWidthFactor;
+ layout.centerY = layout.viewport.top()
+ + layout.radius + faceHeight * arc.centerYHeightFactor;
+ layout.needlePivotY = layout.viewport.bottom() + needle.pivotYBelowWidgetPixels;
+ layout.innerRadius = layout.radius - arc.innerGapPixels;
+ const double preferredAspect = static_cast(sizing.preferred.width())
+ / static_cast(sizing.preferred.height());
+ const double pivotScaleWidth = std::min(faceWidth, faceHeight * preferredAspect);
+ layout.pivotRadius = std::max(
+ pivot.minimumRadiusPixels, pivotScaleWidth * pivot.radiusWidthFactor);
+ layout.tickFontPixels = std::max(
+ tickStyle.fontMinimumPixels,
+ static_cast(std::floor(faceHeight * tickStyle.fontHeightFactor)));
+ layout.sourceFontPixels =
+ std::max(readout.sourceFontMinimumPixels,
+ static_cast(std::floor(faceHeight / readout.sourceFontHeightDivisor)));
+ layout.valueFontPixels =
+ std::max(readout.valueFontMinimumPixels,
+ static_cast(std::floor(faceHeight / readout.valueFontHeightDivisor)));
+ return layout;
+}
+
+double SMeterGeometry::fractionToRadians(double fraction) const
+{
+ const double start = qDegreesToRadians(arc.startDegrees);
+ const double end = qDegreesToRadians(arc.endDegrees);
+ return end - std::clamp(fraction, 0.0, 1.0) * (end - start);
+}
+
+double SMeterGeometry::rxFraction(double dbm) const
+{
+ const double clamped = std::clamp(dbm, rxScale.minimumDbm, rxScale.maximumDbm);
+ if (clamped <= rxScale.s9Dbm) {
+ return rxScale.s9Fraction * (clamped - rxScale.minimumDbm)
+ / (rxScale.s9Dbm - rxScale.minimumDbm);
+ }
+ return rxScale.s9Fraction
+ + (1.0 - rxScale.s9Fraction) * (clamped - rxScale.s9Dbm)
+ / (rxScale.maximumDbm - rxScale.s9Dbm);
+}
+
+double SMeterGeometry::scaleFraction(const StaticScale& scale, double value) const
+{
+ return std::clamp((value - scale.minimum) / (scale.maximum - scale.minimum), 0.0, 1.0);
+}
+
+SMeterGeometry::MovementRay SMeterGeometry::movementRayFor(
+ const QSizeF& size, double fraction) const
+{
+ const Layout layout = layoutFor(size);
+ const double angle = fractionToRadians(fraction);
+ const QPointF pivot(layout.centerX, layout.needlePivotY);
+ const QPointF scalePoint(layout.centerX + layout.radius * std::cos(angle),
+ layout.centerY - layout.radius * std::sin(angle));
+ const QPointF delta = scalePoint - pivot;
+ const double length = std::hypot(delta.x(), delta.y());
+ const QPointF direction = length > 0.0 ? delta / length : QPointF();
+ return {pivot, scalePoint, direction};
+}
+
+std::optional SMeterGeometry::movementRayCircleIntersection(
+ const QSizeF& size, double fraction, double radius) const
+{
+ if (!positive(radius)) {
+ return std::nullopt;
+ }
+
+ const Layout layout = layoutFor(size);
+ const MovementRay ray = movementRayFor(size, fraction);
+ const double directionLength = std::hypot(ray.direction.x(), ray.direction.y());
+ if (directionLength <= 0.0) {
+ return std::nullopt;
+ }
+
+ const QPointF center(layout.centerX, layout.centerY);
+ const QPointF relativePivot = ray.pivot - center;
+ const double projection = QPointF::dotProduct(relativePivot, ray.direction);
+ const double constant = QPointF::dotProduct(relativePivot, relativePivot) - radius * radius;
+ const double discriminant = projection * projection - constant;
+ if (discriminant < 0.0) {
+ return std::nullopt;
+ }
+
+ const double distance = -projection + std::sqrt(discriminant);
+ if (distance < 0.0 || !finite(distance)) {
+ return std::nullopt;
+ }
+ return ray.pivot + distance * ray.direction;
+}
+
+QPointF SMeterGeometry::needleTip(const QSizeF& size, double fraction) const
+{
+ const MovementRay ray = movementRayFor(size, fraction);
+ return ray.scalePoint + needle.tipExtensionPixels * ray.direction;
+}
+
+const SMeterGeometry::PowerTickPolicy& SMeterGeometry::powerTickPolicy(double maximumWatts) const
+{
+ for (const PowerTickPolicy& policy : powerTickPolicies) {
+ if (maximumWatts >= policy.minimumScaleWatts) {
+ return policy;
+ }
+ }
+ return powerTickPolicies.constLast();
+}
+
+} // namespace AetherSDR
diff --git a/src/gui/SMeterGeometry.h b/src/gui/SMeterGeometry.h
new file mode 100644
index 000000000..bea19ac68
--- /dev/null
+++ b/src/gui/SMeterGeometry.h
@@ -0,0 +1,177 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+class QIODevice;
+
+namespace AetherSDR {
+
+// Versioned, editable construction for the standard S-meter face.
+//
+// The shipping values live in resources/meterfaces/s-meter-v1.json. Unlike
+// the fixed-aspect cross-needle face, this meter intentionally keeps its
+// existing responsive width/height formulas so applet and detached-window
+// resizing remain pixel-for-pixel compatible with the original painter.
+class SMeterGeometry {
+public:
+ struct Tick {
+ double value{0.0};
+ QString label;
+ };
+
+ struct Sizing {
+ QSize preferred{280, 140};
+ QSize minimum{200, 100};
+ // Outside this supported width/height range, the meter face is
+ // centered in a bounded viewport instead of stretching its movement
+ // geometry until the pivot leaves the printed arcs.
+ double minimumAspectRatio{1.75};
+ double maximumAspectRatio{4.0};
+ };
+
+ struct Arc {
+ double centerXWidthFactor{0.5};
+ double radiusWidthFactor{0.85};
+ // arcCenterY = radius + height * centerYHeightFactor
+ double centerYHeightFactor{0.35};
+ double startDegrees{55.0};
+ double endDegrees{125.0};
+ double innerGapPixels{6.0};
+ double lineWidthPixels{3.0};
+ };
+
+ struct TickStyle {
+ double startOffsetPixels{2.0};
+ double endOffsetPixels{14.0};
+ double labelOffsetPixels{26.0};
+ double lineWidthPixels{1.5};
+ int fontMinimumPixels{10};
+ double fontHeightFactor{0.1};
+ bool bold{true};
+ };
+
+ struct RxScale {
+ double minimumDbm{-127.0};
+ double s9Dbm{-73.0};
+ double maximumDbm{-13.0};
+ double dbPerSUnit{6.0};
+ double s9Fraction{0.6};
+ QVector ticks;
+ };
+
+ struct StaticScale {
+ double minimum{0.0};
+ double maximum{1.0};
+ double warningStart{0.0};
+ bool hasWarning{false};
+ QVector ticks;
+ };
+
+ struct PowerTickPolicy {
+ double minimumScaleWatts{0.0};
+ int tickStepWatts{10};
+ int labelStepWatts{40};
+ };
+
+ struct Needle {
+ double pivotYBelowWidgetPixels{6.0};
+ double tipExtensionPixels{14.0};
+ double lineWidthPixels{2.0};
+ double shadowWidthPixels{3.0};
+ QPointF shadowOffset{1.0, 1.0};
+ };
+
+ struct Pivot {
+ double minimumRadiusPixels{13.5};
+ // The width-derived radius is capped by the preferred face aspect
+ // ratio so a wide, short floating window cannot inflate the mask.
+ double radiusWidthFactor{0.0975};
+ double glowRadiusFactor{3.4};
+ double glowMiddleFactor{0.45};
+ int glowCenterAlpha{80};
+ int glowMiddleAlpha{28};
+ double rimWidthPixels{1.0};
+ };
+
+ struct PeakMarker {
+ double radiusInsetPixels{2.0};
+ double lengthPixels{6.0};
+ double halfWidthPixels{3.0};
+ double minimumLeadDb{1.0};
+ };
+
+ struct PeakHold {
+ double innerRadiusOffsetPixels{-4.0};
+ double outerRadiusOffsetPixels{10.0};
+ double lineWidthPixels{2.0};
+ double visibleAboveMinimumDb{1.0};
+ };
+
+ struct Readout {
+ int sourceFontMinimumPixels{9};
+ double sourceFontHeightDivisor{14.0};
+ int valueFontMinimumPixels{13};
+ double valueFontHeightDivisor{8.0};
+ int topExtraPixels{4};
+ int sideMarginPixels{6};
+ };
+
+ struct Layout {
+ QRectF viewport;
+ double centerX{0.0};
+ double radius{0.0};
+ double centerY{0.0};
+ double needlePivotY{0.0};
+ double innerRadius{0.0};
+ double pivotRadius{0.0};
+ int tickFontPixels{0};
+ int sourceFontPixels{0};
+ int valueFontPixels{0};
+ };
+
+ struct MovementRay {
+ QPointF pivot;
+ QPointF scalePoint;
+ QPointF direction;
+ };
+
+ static SMeterGeometry loadResource(QString* error = nullptr);
+ static SMeterGeometry load(QIODevice& device, QString* error = nullptr);
+ static SMeterGeometry fallback();
+
+ bool isValid(QString* error = nullptr) const;
+ Layout layoutFor(const QSizeF& size) const;
+ double fractionToRadians(double fraction) const;
+ double rxFraction(double dbm) const;
+ double scaleFraction(const StaticScale& scale, double value) const;
+ MovementRay movementRayFor(const QSizeF& size, double fraction) const;
+ std::optional movementRayCircleIntersection(
+ const QSizeF& size, double fraction, double radius) const;
+ QPointF needleTip(const QSizeF& size, double fraction) const;
+ const PowerTickPolicy& powerTickPolicy(double maximumWatts) const;
+
+ int formatVersion{0};
+ int designVersion{0};
+ Sizing sizing;
+ Arc arc;
+ TickStyle tickStyle;
+ RxScale rxScale;
+ StaticScale swrScale;
+ StaticScale levelScale;
+ StaticScale compressionScale;
+ QVector powerTickPolicies;
+ Needle needle;
+ Pivot pivot;
+ PeakMarker peakMarker;
+ PeakHold peakHold;
+ Readout readout;
+};
+
+} // namespace AetherSDR
diff --git a/src/gui/SMeterWidget.cpp b/src/gui/SMeterWidget.cpp
index 201c2d691..5393d156d 100644
--- a/src/gui/SMeterWidget.cpp
+++ b/src/gui/SMeterWidget.cpp
@@ -1,35 +1,102 @@
#include "SMeterWidget.h"
+#include "SMeterWidgetAccessible.h"
+#include "core/LogManager.h"
#include "core/ThemeManager.h"
#include
#include
#include
+#include
#include
#include
#include
#include
-#include
+#include
+#include
namespace AetherSDR {
+SMeterWidgetAccessible::SMeterWidgetAccessible(QWidget* widget)
+ : QAccessibleWidget(widget, QAccessible::Indicator)
+{
+}
+
+QString SMeterWidgetAccessible::text(QAccessible::Text textType) const
+{
+ const SMeterWidget* meter = qobject_cast(widget());
+ if (meter && textType == QAccessible::Value) {
+ return meter->accessibleValueText();
+ }
+ return QAccessibleWidget::text(textType);
+}
+
+QAccessibleInterface* sMeterAccessibleFactory(const QString& key, QObject* object)
+{
+ if (key == QLatin1String("AetherSDR::SMeterWidget")) {
+ return new SMeterWidgetAccessible(qobject_cast(object));
+ }
+ return nullptr;
+}
+
// --- Construction ------------------------------------------------------------
SMeterWidget::SMeterWidget(QWidget* parent)
- : QWidget(parent)
+ : QWidget(parent), m_geometry(SMeterGeometry::fallback())
{
+ static bool s_accessibilityFactoryInstalled = false;
+ if (!s_accessibilityFactoryInstalled) {
+ s_accessibilityFactoryInstalled = true;
+ QAccessible::installFactory(sMeterAccessibleFactory);
+ }
+
+ QString geometryError;
+ const SMeterGeometry loadedGeometry = SMeterGeometry::loadResource(&geometryError);
+ if (loadedGeometry.isValid()) {
+ m_geometry = loadedGeometry;
+ } else {
+ qCWarning(lcMeters).noquote()
+ << "SMeterWidget: using fallback geometry:" << geometryError;
+ }
+
+ QString themeError;
+ m_faceThemes = AnalogMeterFaceThemeCatalog::loadResource(&themeError);
+ if (!m_faceThemes.isValid()) {
+ qCWarning(lcMeters).noquote()
+ << "SMeterWidget: using fallback face themes:" << themeError;
+ m_faceThemes = AnalogMeterFaceThemeCatalog::fallback();
+ }
+
+ const float minimumDbm = static_cast(m_geometry.rxScale.minimumDbm);
+ m_levelDbm = minimumDbm;
+ m_peakDbm = minimumDbm;
+ m_peakHoldDbm = minimumDbm;
+ m_peakHoldDecayStartDbm = minimumDbm;
+
setMinimumSize(minimumSizeHint());
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
setFocusPolicy(Qt::TabFocus);
+ setProperty("faceTheme", faceThemeId());
+
+ connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, [this]() {
+ if (m_faceTheme == AnalogMeterFaceTheme::AetherDefault) {
+ m_backgroundCacheValid = false;
+ update();
+ }
+ });
m_needleFraction = dbmToFraction(m_levelDbm);
m_targetNeedleFraction = m_needleFraction;
- m_peakHoldDecayStartDbm = m_peakHoldDbm;
-
+ m_radioSwrClock.start();
m_needleAnimation.setTimerType(Qt::PreciseTimer);
m_needleAnimation.setInterval(kNeedleAnimationIntervalMs);
connect(&m_needleAnimation, &QTimer::timeout, this, &SMeterWidget::animateNeedle);
+ m_accessibilityTimer.setSingleShot(true);
+ m_accessibilityTimer.setInterval(kAccessibilityAnnouncementIntervalMs);
+ connect(&m_accessibilityTimer, &QTimer::timeout,
+ this, &SMeterWidget::publishAccessibleValue);
+
// Peak hold decay: drops 0.5 dB every 50 ms after a new peak
m_peakDecay.setInterval(50);
connect(&m_peakDecay, &QTimer::timeout, this, [this]() {
@@ -40,6 +107,7 @@ SMeterWidget::SMeterWidget(QWidget* parent)
}
updateNeedleTarget();
update();
+ scheduleAccessibleValue();
});
// Hard reset peak hold every 10 seconds
@@ -49,12 +117,61 @@ SMeterWidget::SMeterWidget(QWidget* parent)
m_peakDbm = m_levelDbm;
updateNeedleTarget();
update();
+ scheduleAccessibleValue();
});
}
// --- Public interface --------------------------------------------------------
-void SMeterWidget::setLevel(float dbm)
+void SMeterWidget::setFloating(bool floating)
+{
+ setSizePolicy(floating ? QSizePolicy::Expanding : QSizePolicy::Preferred,
+ floating ? QSizePolicy::Expanding : QSizePolicy::Fixed);
+ updateGeometry();
+}
+
+QString SMeterWidget::faceThemeId() const
+{
+ return analogMeterFaceThemeId(m_faceTheme);
+}
+
+QString SMeterWidget::accessibleValueText() const
+{
+ if (m_transmitting) {
+ switch (m_txMode) {
+ case TxMode::Power:
+ return tr("Transmit power, %1 watts").arg(m_txPower, 0, 'f', 0);
+ case TxMode::SWR:
+ return tr("Transmit SWR, %1").arg(m_txSwr, 0, 'f', 1);
+ case TxMode::Level:
+ return tr("Transmit level, %1 dB").arg(m_micLevel, 0, 'f', 0);
+ case TxMode::Compression:
+ return tr("Transmit compression, %1 dB").arg(-m_compLevel, 0, 'f', 0);
+ }
+ }
+
+ if (usesUnavailableRxMeter()) {
+ return unavailableRxMeterLabel();
+ }
+ const float displayDbm =
+ m_rxMode == RxMode::SMeterPeak ? m_peakDbm : m_levelDbm;
+ return tr("%1, %2 dBm")
+ .arg(sUnitsTextFor(displayDbm))
+ .arg(static_cast(displayDbm));
+}
+
+void SMeterWidget::setFaceTheme(AnalogMeterFaceTheme theme)
+{
+ if (m_faceTheme == theme) {
+ return;
+ }
+ m_faceTheme = theme;
+ m_backgroundCacheValid = false;
+ setProperty("faceTheme", faceThemeId());
+ update();
+}
+
+void SMeterWidget::setLevel(float dbm) // a11y-check: skip -- settled update is timer-throttled
{
m_receiveMeterReadingActive = false;
m_levelDbm = dbm;
@@ -80,23 +197,7 @@ void SMeterWidget::setLevel(float dbm)
if (!m_transmitting) {
update();
- if (hasFocus() && QAccessible::isActive()) {
- // Announce the same value that paintEvent displays — in Peak mode
- // the needle and text readout track m_peakDbm, not the raw level.
- const float displayDbm = (m_rxMode == RxMode::SMeterPeak) ? m_peakDbm : m_levelDbm;
- QString sText;
- if (displayDbm <= S0_DBM)
- sText = QStringLiteral("S0");
- else if (displayDbm <= S9_DBM)
- sText = QStringLiteral("S%1").arg(qBound(0, qRound((displayDbm - S0_DBM) / DB_PER_S), 9));
- else
- sText = QStringLiteral("S9+%1").arg(qRound(displayDbm - S9_DBM));
- const QString accessText = sText + QStringLiteral(", ")
- + QString::number(static_cast(displayDbm))
- + QStringLiteral(" dBm");
- QAccessibleValueChangeEvent event(this, accessText);
- QAccessible::updateAccessibility(&event);
- }
+ scheduleAccessibleValue();
}
}
@@ -124,26 +225,18 @@ void SMeterWidget::setReceiveMeterReading(
m_peakHoldTimerRunning = true;
}
} else {
- m_levelDbm = S0_DBM;
- m_peakDbm = S0_DBM;
- m_peakHoldDbm = S0_DBM;
- m_peakHoldDecayStartDbm = S0_DBM;
+ const float minimumDbm = static_cast(m_geometry.rxScale.minimumDbm);
+ m_levelDbm = minimumDbm;
+ m_peakDbm = minimumDbm;
+ m_peakHoldDbm = minimumDbm;
+ m_peakHoldDecayStartDbm = minimumDbm;
m_peakHoldTimerRunning = false;
}
updateNeedleTarget();
if (!m_transmitting) {
update();
- if (hasFocus() && QAccessible::isActive()) {
- QString accessText = unavailableRxMeterLabel();
- if (hasDisplayDbm) {
- accessText = reading.label;
- accessText += QStringLiteral(", %1 dBm")
- .arg(static_cast(reading.dbm));
- }
- QAccessibleValueChangeEvent event(this, accessText);
- QAccessible::updateAccessibility(&event);
- }
+ scheduleAccessibleValue();
}
}
@@ -151,7 +244,26 @@ void SMeterWidget::setTxMeters(float fwdPower, float swr)
{
m_txPower = fwdPower;
m_txSwr = swr;
+ m_radioSwrFilter.reset();
+ setProperty("txSwr", m_txSwr);
+ setProperty("txSwrSource", QStringLiteral("external"));
+ setProperty("txSwrHeld", false);
+ setProperty("txSwrPowerEnvelopeWatts", 0.0);
+ setProperty("txSwrMinimumForwardWatts", 0.05);
+
+ finishTxMeterUpdate();
+}
+
+void SMeterWidget::setRadioTxMeters(float fwdPower, float fwdPowerInstant, float swr)
+{
+ m_txPower = fwdPower;
+ updateRadioSwr(fwdPowerInstant, swr, m_radioSwrClock.elapsed());
+
+ finishTxMeterUpdate();
+}
+void SMeterWidget::finishTxMeterUpdate()
+{
updateNeedleTarget();
// Repaint whenever TX power data arrives — either because moxChanged set
@@ -160,6 +272,27 @@ void SMeterWidget::setTxMeters(float fwdPower, float swr)
if (m_transmitting || m_txPower > 0.5f) {
update();
}
+ if (m_transmitting) {
+ scheduleAccessibleValue();
+ }
+}
+
+void SMeterWidget::updateRadioSwr(
+ float forwardPowerInstant, float swr, qint64 timestampMs)
+{
+ const RadioSwrValidityFilter::Result result =
+ m_radioSwrFilter.update(
+ forwardPowerInstant, swr, timestampMs,
+ static_cast(m_geometry.swrScale.maximum));
+ m_txSwr = result.displayedSwr;
+
+ setProperty("txSwr", m_txSwr);
+ setProperty("txSwrRaw", swr);
+ setProperty("txSwrForwardWatts", forwardPowerInstant);
+ setProperty("txSwrPowerEnvelopeWatts", result.forwardEnvelopeWatts);
+ setProperty("txSwrMinimumForwardWatts", result.minimumForwardWatts);
+ setProperty("txSwrSource", QStringLiteral("radio"));
+ setProperty("txSwrHeld", result.held);
}
void SMeterWidget::setMicMeters(float micLevel, float compLevel, float micPeak, float compPeak)
@@ -174,20 +307,28 @@ void SMeterWidget::setMicMeters(float micLevel, float compLevel, float micPeak,
if (m_transmitting && (m_txMode == TxMode::Level || m_txMode == TxMode::Compression)) {
update();
+ scheduleAccessibleValue();
}
}
void SMeterWidget::setTransmitting(bool tx)
{
m_transmitting = tx;
+ setProperty("transmitting", tx);
if (!tx) {
// Clear TX values immediately on un-key so the RX reading becomes the
// animation target as soon as transmit ends.
m_txPower = 0.0f;
m_txSwr = 1.0f;
+ m_radioSwrFilter.reset();
+ setProperty("txSwr", m_txSwr);
+ setProperty("txSwrHeld", false);
+ setProperty("txSwrPowerEnvelopeWatts", 0.0);
+ setProperty("txSwrMinimumForwardWatts", 0.05);
}
updateNeedleTarget();
update();
+ scheduleAccessibleValue();
}
void SMeterWidget::setTxMode(const QString& mode)
@@ -196,8 +337,10 @@ void SMeterWidget::setTxMode(const QString& mode)
else if (mode == "SWR") m_txMode = TxMode::SWR;
else if (mode == "Level") m_txMode = TxMode::Level;
else if (mode == "Compression") m_txMode = TxMode::Compression;
+ setProperty("txMode", mode);
updateNeedleTarget();
update();
+ scheduleAccessibleValue();
}
void SMeterWidget::setRxMode(const QString& mode)
@@ -211,6 +354,7 @@ void SMeterWidget::setRxMode(const QString& mode)
}
updateNeedleTarget();
update();
+ scheduleAccessibleValue();
}
void SMeterWidget::updateNeedleTarget()
@@ -307,6 +451,32 @@ QString SMeterWidget::unavailableRxMeterLabel() const
return QStringLiteral("Meter unavailable");
}
+void SMeterWidget::scheduleAccessibleValue()
+{
+ // Meter streams can update tens of times per second. Publish at most one
+ // current value per interval instead of turning a focused screen reader
+ // into a sample-by-sample audio stream. The timeout always reads current
+ // widget state, so a burst collapses to its latest RX/TX value or mode.
+ if (hasFocus() && QAccessible::isActive() && !m_accessibilityTimer.isActive()) {
+ m_accessibilityTimer.start();
+ }
+}
+
+void SMeterWidget::publishAccessibleValue()
+{
+ if (!hasFocus() || !QAccessible::isActive()) {
+ return;
+ }
+
+ const QString value = accessibleValueText();
+ if (value == m_lastAccessibleValue) {
+ return;
+ }
+ m_lastAccessibleValue = value;
+ QAccessibleValueChangeEvent event(this, value);
+ QAccessible::updateAccessibility(&event);
+}
+
void SMeterWidget::updatePeakHoldValue()
{
if (!m_peakHoldEnabled || !m_peakHoldTimerRunning) {
@@ -328,29 +498,27 @@ void SMeterWidget::updatePeakHoldValue()
QString SMeterWidget::sUnitsText() const
{
- if (m_levelDbm <= S0_DBM) return "S0";
- if (m_levelDbm <= S9_DBM) {
- const int s = qRound((m_levelDbm - S0_DBM) / DB_PER_S);
- return QString("S%1").arg(qBound(0, s, 9));
+ return sUnitsTextFor(m_levelDbm);
+}
+
+QString SMeterWidget::sUnitsTextFor(float dbm) const
+{
+ const SMeterGeometry::RxScale& scale = m_geometry.rxScale;
+ if (dbm <= scale.minimumDbm) {
+ return QStringLiteral("S0");
+ }
+ if (dbm <= scale.s9Dbm) {
+ const int s = qRound((dbm - scale.minimumDbm) / scale.dbPerSUnit);
+ return QStringLiteral("S%1").arg(qBound(0, s, 9));
}
- const int over = qRound(m_levelDbm - S9_DBM);
- return QString("S9+%1").arg(over);
+ return QStringLiteral("S9+%1").arg(qRound(dbm - scale.s9Dbm));
}
// --- Mapping -----------------------------------------------------------------
float SMeterWidget::dbmToFraction(float dbm) const
{
- // S0 to S9 occupies the left 60% of the arc
- // S9 to S9+60 occupies the right 40%
- const float clamped = qBound(S0_DBM, dbm, MAX_DBM);
-
- if (clamped <= S9_DBM) {
- // Linear within S0..S9 -> 0.0..0.6
- return 0.6f * (clamped - S0_DBM) / (S9_DBM - S0_DBM);
- }
- // Linear within S9..S9+60 -> 0.6..1.0
- return 0.6f + 0.4f * (clamped - S9_DBM) / (MAX_DBM - S9_DBM);
+ return static_cast(m_geometry.rxFraction(dbm));
}
float SMeterWidget::txValueToFraction(float value) const
@@ -359,14 +527,11 @@ float SMeterWidget::txValueToFraction(float value) const
case TxMode::Power:
return qBound(0.0f, value / m_powerScaleMax, 1.0f);
case TxMode::SWR:
- // 1.0-3.0
- return qBound(0.0f, (value - 1.0f) / 2.0f, 1.0f);
+ return static_cast(m_geometry.scaleFraction(m_geometry.swrScale, value));
case TxMode::Level:
- // -40 to +5
- return qBound(0.0f, (value + 40.0f) / 45.0f, 1.0f);
+ return static_cast(m_geometry.scaleFraction(m_geometry.levelScale, value));
case TxMode::Compression:
- // Compression: 0 = none, 25 = heavy compression
- return qBound(0.0f, value / 25.0f, 1.0f);
+ return static_cast(m_geometry.scaleFraction(m_geometry.compressionScale, value));
}
return 0.0f;
}
@@ -384,6 +549,103 @@ float SMeterWidget::currentTxValue() const
// --- Paint -------------------------------------------------------------------
+void SMeterWidget::rebuildBackgroundLayer()
+{
+ const qreal dpr = devicePixelRatioF();
+ const QSize pixelSize(qMax(1, qRound(width() * dpr)),
+ qMax(1, qRound(height() * dpr)));
+ m_backgroundLayer = QPixmap(pixelSize);
+ m_backgroundLayer.setDevicePixelRatio(dpr);
+ m_backgroundLayer.fill(Qt::transparent);
+
+ QPainter painter(&m_backgroundLayer);
+ painter.setRenderHint(QPainter::Antialiasing);
+ if (m_faceTheme == AnalogMeterFaceTheme::AetherDefault) {
+ painter.fillRect(QRectF(QPointF(0.0, 0.0), QSizeF(size())),
+ QColor(0x0f, 0x0f, 0x1a));
+ } else {
+ m_faceThemes.drawBackground(
+ painter, QRectF(QPointF(0.0, 0.0), QSizeF(size())), m_faceTheme);
+ }
+
+ m_backgroundCacheSize = size();
+ m_backgroundCacheDpr = dpr;
+ m_backgroundCacheTheme = m_faceTheme;
+ m_backgroundCacheValid = true;
+}
+
+void SMeterWidget::drawPhysicalNeedle(
+ QPainter& painter, const QPointF& pivot, const QPointF& tip,
+ const QSizeF& faceSize,
+ const AnalogMeterFaceThemeCatalog::Palette& palette) const
+{
+ const QPointF movement = tip - pivot;
+ const double length = std::hypot(movement.x(), movement.y());
+ if (!(length > 0.0)) {
+ return;
+ }
+
+ const QPointF direction = movement / length;
+ QPointF normal(-direction.y(), direction.x());
+ if (QPointF::dotProduct(normal, QPointF(-1.0, -1.0)) < 0.0) {
+ normal = -normal;
+ }
+ const double materialScale = std::max(
+ 0.65, std::min(faceSize.width() / 280.0, faceSize.height() / 140.0));
+ const double bodyWidth = m_geometry.needle.lineWidthPixels * materialScale;
+ const double taperLength = 5.0 * materialScale;
+ const QPointF taperBase = tip - direction * taperLength;
+ const QPointF contactOffset = m_geometry.needle.shadowOffset * materialScale;
+ const QPointF softOffset = QPointF(2.2, 2.8) * materialScale;
+
+ painter.setPen(QPen(palette.needleSoftShadow, bodyWidth * 3.0,
+ Qt::SolidLine, Qt::RoundCap));
+ painter.drawLine(pivot + softOffset, tip + softOffset);
+ painter.setPen(QPen(palette.needleShadow, bodyWidth * 1.8,
+ Qt::SolidLine, Qt::RoundCap));
+ painter.drawLine(pivot + contactOffset, tip + contactOffset);
+
+ painter.setPen(QPen(palette.needle, bodyWidth,
+ Qt::SolidLine, Qt::FlatCap));
+ painter.drawLine(pivot, taperBase);
+ QPainterPath taperedTip;
+ taperedTip.moveTo(tip);
+ taperedTip.lineTo(taperBase + normal * bodyWidth * 0.5);
+ taperedTip.lineTo(taperBase - normal * bodyWidth * 0.5);
+ taperedTip.closeSubpath();
+ painter.setPen(Qt::NoPen);
+ painter.setBrush(palette.needle);
+ painter.drawPath(taperedTip);
+
+ painter.setBrush(Qt::NoBrush);
+ painter.setPen(QPen(palette.needleEdge, qMax(0.55, bodyWidth * 0.34),
+ Qt::SolidLine, Qt::FlatCap));
+ painter.drawLine(pivot - normal * bodyWidth * 0.34,
+ taperBase - normal * bodyWidth * 0.34);
+ painter.setPen(QPen(palette.needleHighlight, qMax(0.45, bodyWidth * 0.27),
+ Qt::SolidLine, Qt::FlatCap));
+ painter.drawLine(pivot + normal * bodyWidth * 0.27,
+ taperBase + normal * bodyWidth * 0.27);
+}
+
+void SMeterWidget::drawPhysicalMask(
+ QPainter& painter, const QRectF& face,
+ const AnalogMeterFaceThemeCatalog::Palette& palette) const
+{
+ painter.setPen(Qt::NoPen);
+ painter.setBrush(palette.maskFill);
+ painter.drawPath(m_faceThemes.lowerMaskPath(face));
+
+ const QVector boundary = m_faceThemes.lowerMaskBoundary(face);
+ const double scale = std::max(
+ 0.75, std::min(face.width() / 280.0, face.height() / 140.0));
+ painter.setBrush(Qt::NoBrush);
+ painter.setPen(QPen(palette.maskEdge,
+ m_geometry.pivot.rimWidthPixels * scale,
+ Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
+ painter.drawPolyline(QPolygonF(boundary));
+}
+
void SMeterWidget::paintEvent(QPaintEvent*)
{
QPainter p(this);
@@ -392,26 +654,50 @@ void SMeterWidget::paintEvent(QPaintEvent*)
const int w = width();
const int h = height();
- // Background
- p.fillRect(rect(), QColor(0x0f, 0x0f, 0x1a));
+ const qreal dpr = devicePixelRatioF();
+ if (!m_backgroundCacheValid || m_backgroundCacheSize != size()
+ || !qFuzzyCompare(m_backgroundCacheDpr, dpr)
+ || m_backgroundCacheTheme != m_faceTheme) {
+ rebuildBackgroundLayer();
+ }
+ p.drawPixmap(0, 0, m_backgroundLayer);
+
+ const bool physicalTheme = m_faceTheme != AnalogMeterFaceTheme::AetherDefault;
+ const AnalogMeterFaceThemeCatalog::Palette* physicalPalette =
+ physicalTheme ? &m_faceThemes.palette(m_faceTheme) : nullptr;
+ const QColor rxNormal = physicalTheme ? physicalPalette->text
+ : QColor(0xc8, 0xd8, 0xe8);
+ const QColor warning = physicalTheme ? physicalPalette->scaleCalibration
+ : QColor(0xff, 0x44, 0x44);
+ const QColor txNormal = physicalTheme ? physicalPalette->scaleInner
+ : QColor(0x00, 0x80, 0xd0);
+ const QColor tickNormal = physicalTheme ? physicalPalette->majorTick
+ : QColor(0xc8, 0xd8, 0xe8);
// -- Arc geometry ---------------------------------------------------------
- // Large radius with center far below widget -> shallow ~70 deg arc segment
- const float cx = w * 0.5f;
- const float radius = w * 0.85f;
- const float cy = h + radius - h * 0.65f; // arc center well below widget
- const float needleCy = h + 6.0f; // needle origin just below widget bottom
-
- // Convert arc degrees to radians
- const float arcStartRad = qDegreesToRadians(ARC_START_DEG);
- const float arcEndRad = qDegreesToRadians(ARC_END_DEG);
- const float arcSpanRad = arcEndRad - arcStartRad;
+ // Large radius with center far below widget -> shallow ~70 deg arc segment.
+ const SMeterGeometry::Layout layout = m_geometry.layoutFor(QSizeF(w, h));
+ const QRectF face = layout.viewport;
+ const double faceLeft = face.left();
+ const double faceRight = face.right();
+ const double faceTop = face.top();
+ const double faceBottom = face.bottom();
+ const double faceCenterX = face.center().x();
+ p.setClipRect(face);
+ const float cx = static_cast(layout.centerX);
+ const float radius = static_cast(layout.radius);
+ const float cy = static_cast(layout.centerY);
+ const float needleCy = static_cast(layout.needlePivotY);
+
+ // QPainter arc endpoints remain in degrees; moving elements use radians.
+ const float arcStartDeg = static_cast(m_geometry.arc.startDegrees);
+ const float arcEndDeg = static_cast(m_geometry.arc.endDegrees);
const bool unavailableRxScale = usesUnavailableRxMeter();
const bool calibratedRxScale = !unavailableRxScale;
- // fraction 0.0 -> left end (ARC_END_DEG), fraction 1.0 -> right end (ARC_START_DEG)
+ // fraction 0.0 -> left end, fraction 1.0 -> right end
auto fractionToAngle = [&](float frac) -> float {
- return arcEndRad - frac * arcSpanRad; // radians
+ return static_cast(m_geometry.fractionToRadians(frac));
};
// -- Draw colored outer arc (RX scale) ------------------------------------
@@ -419,25 +705,26 @@ void SMeterWidget::paintEvent(QPaintEvent*)
const QRectF outerArc(cx - radius, cy - radius, radius * 2, radius * 2);
// RX face always uses the existing Flex-style S scale. Uncalibrated
// Kiwi fallback readings do not move the needle or show fake dBm.
- const float s9Deg = qRadiansToDegrees(fractionToAngle(0.6f));
+ const float s9Deg = qRadiansToDegrees(
+ fractionToAngle(static_cast(m_geometry.rxScale.s9Fraction)));
- QPen whitePen(QColor(0xc8, 0xd8, 0xe8), 3);
+ QPen whitePen(rxNormal, m_geometry.arc.lineWidthPixels);
p.setPen(whitePen);
p.drawArc(outerArc,
static_cast(s9Deg * 16),
- static_cast((ARC_END_DEG - s9Deg) * 16));
+ static_cast((arcEndDeg - s9Deg) * 16));
- QPen redPen(QColor(0xff, 0x44, 0x44), 3);
+ QPen redPen(warning, m_geometry.arc.lineWidthPixels);
p.setPen(redPen);
p.drawArc(outerArc,
- static_cast(ARC_START_DEG * 16),
- static_cast((s9Deg - ARC_START_DEG) * 16));
+ static_cast(arcStartDeg * 16),
+ static_cast((s9Deg - arcStartDeg) * 16));
}
// -- Draw colored inner arc (TX scale) -- 6px gap -------------------------
- const float arcGap = 6.0f;
- const QColor blueColor(0x00, 0x80, 0xd0);
- const QColor redColor(0xff, 0x44, 0x44);
+ const float arcGap = static_cast(m_geometry.arc.innerGapPixels);
+ const QColor blueColor = txNormal;
+ const QColor redColor = warning;
{
const float innerR = radius - arcGap;
const QRectF innerArc(cx - innerR, cy - innerR, innerR * 2, innerR * 2);
@@ -446,63 +733,64 @@ void SMeterWidget::paintEvent(QPaintEvent*)
float redFrac = -1.0f; // -1 = no red zone
switch (m_txMode) {
case TxMode::Power: redFrac = m_powerRedStart / m_powerScaleMax; break;
- case TxMode::SWR: redFrac = (2.5f - 1.0f) / 2.0f; break; // 0.75
- case TxMode::Level: redFrac = (0.0f + 40.0f) / 45.0f; break; // ~0.89
+ case TxMode::SWR:
+ redFrac = static_cast(m_geometry.scaleFraction(
+ m_geometry.swrScale, m_geometry.swrScale.warningStart));
+ break;
+ case TxMode::Level:
+ redFrac = static_cast(m_geometry.scaleFraction(
+ m_geometry.levelScale, m_geometry.levelScale.warningStart));
+ break;
case TxMode::Compression: redFrac = -1.0f; break; // all blue
}
if (redFrac < 0.0f) {
// Entire arc is blue
- p.setPen(QPen(blueColor, 3));
+ p.setPen(QPen(blueColor, m_geometry.arc.lineWidthPixels));
p.drawArc(innerArc,
- static_cast(ARC_START_DEG * 16),
- static_cast((ARC_END_DEG - ARC_START_DEG) * 16));
+ static_cast(arcStartDeg * 16),
+ static_cast((arcEndDeg - arcStartDeg) * 16));
} else {
const float splitDeg = qRadiansToDegrees(fractionToAngle(redFrac));
- p.setPen(QPen(blueColor, 3));
+ p.setPen(QPen(blueColor, m_geometry.arc.lineWidthPixels));
p.drawArc(innerArc,
static_cast(splitDeg * 16),
- static_cast((ARC_END_DEG - splitDeg) * 16));
- p.setPen(QPen(redColor, 3));
+ static_cast((arcEndDeg - splitDeg) * 16));
+ p.setPen(QPen(redColor, m_geometry.arc.lineWidthPixels));
p.drawArc(innerArc,
- static_cast(ARC_START_DEG * 16),
- static_cast((splitDeg - ARC_START_DEG) * 16));
+ static_cast(arcStartDeg * 16),
+ static_cast((splitDeg - arcStartDeg) * 16));
}
}
// -- Tick drawing helpers -------------------------------------------------
QFont tickFont = font();
- tickFont.setPixelSize(qMax(10, h / 10));
- tickFont.setBold(true);
+ tickFont.setPixelSize(layout.tickFontPixels);
+ tickFont.setBold(m_geometry.tickStyle.bold);
p.setFont(tickFont);
const QFontMetrics tfm(tickFont);
- // Direction from needle origin through arc point, normalized
- auto needleDir = [&](float angle) -> std::pair {
- const float arcX = cx + radius * std::cos(angle);
- const float arcY = cy - radius * std::sin(angle);
- const float dx = arcX - cx;
- const float dy = arcY - needleCy;
- const float len = std::sqrt(dx * dx + dy * dy);
- return {dx / len, dy / len};
- };
-
// Outside tick (RX): extends outward from the arc, label above
auto drawOutsideTick = [&](float frac, const QString& label, const QColor& color,
bool showLabel) {
- const float angle = fractionToAngle(frac);
- const float arcX = cx + radius * std::cos(angle);
- const float arcY = cy - radius * std::sin(angle);
- auto [ux, uy] = needleDir(angle);
-
- const QPointF inner(arcX + 2 * ux, arcY + 2 * uy);
- const QPointF outer(arcX + 14 * ux, arcY + 14 * uy);
-
- p.setPen(QPen(color, 1.5));
+ const SMeterGeometry::MovementRay ray =
+ m_geometry.movementRayFor(QSizeF(w, h), frac);
+ const double arcX = ray.scalePoint.x();
+ const double arcY = ray.scalePoint.y();
+ const double ux = ray.direction.x();
+ const double uy = ray.direction.y();
+
+ const QPointF inner(arcX + m_geometry.tickStyle.startOffsetPixels * ux,
+ arcY + m_geometry.tickStyle.startOffsetPixels * uy);
+ const QPointF outer(arcX + m_geometry.tickStyle.endOffsetPixels * ux,
+ arcY + m_geometry.tickStyle.endOffsetPixels * uy);
+
+ p.setPen(QPen(color, m_geometry.tickStyle.lineWidthPixels));
p.drawLine(inner, outer);
if (showLabel) {
- const QPointF labelPt(arcX + 26 * ux, arcY + 26 * uy);
+ const QPointF labelPt(arcX + m_geometry.tickStyle.labelOffsetPixels * ux,
+ arcY + m_geometry.tickStyle.labelOffsetPixels * uy);
const int tw = tfm.horizontalAdvance(label);
p.setPen(color);
p.drawText(QPointF(labelPt.x() - tw / 2.0,
@@ -515,20 +803,29 @@ void SMeterWidget::paintEvent(QPaintEvent*)
auto drawInsideTick = [&](float frac, const QString& label,
const QColor& tickColor, const QColor& labelColor,
bool showLabel) {
- const float angle = fractionToAngle(frac);
- // Start from the inner colored arc, not the outer arc
- const float iArcX = cx + innerArcR * std::cos(angle);
- const float iArcY = cy - innerArcR * std::sin(angle);
- auto [ux, uy] = needleDir(angle);
+ const SMeterGeometry::MovementRay ray =
+ m_geometry.movementRayFor(QSizeF(w, h), frac);
+ const std::optional innerArcPoint =
+ m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, innerArcR);
+ if (!innerArcPoint) {
+ return;
+ }
+ const double iArcX = innerArcPoint->x();
+ const double iArcY = innerArcPoint->y();
+ const double ux = ray.direction.x();
+ const double uy = ray.direction.y();
- const QPointF outer(iArcX - 2 * ux, iArcY - 2 * uy);
- const QPointF inner(iArcX - 14 * ux, iArcY - 14 * uy);
+ const QPointF outer(iArcX - m_geometry.tickStyle.startOffsetPixels * ux,
+ iArcY - m_geometry.tickStyle.startOffsetPixels * uy);
+ const QPointF inner(iArcX - m_geometry.tickStyle.endOffsetPixels * ux,
+ iArcY - m_geometry.tickStyle.endOffsetPixels * uy);
- p.setPen(QPen(tickColor, 1.5));
+ p.setPen(QPen(tickColor, m_geometry.tickStyle.lineWidthPixels));
p.drawLine(inner, outer);
if (showLabel) {
- const QPointF labelPt(iArcX - 26 * ux, iArcY - 26 * uy);
+ const QPointF labelPt(iArcX - m_geometry.tickStyle.labelOffsetPixels * ux,
+ iArcY - m_geometry.tickStyle.labelOffsetPixels * uy);
const int tw = tfm.horizontalAdvance(label);
p.setPen(labelColor);
p.drawText(QPointF(labelPt.x() - tw / 2.0,
@@ -536,16 +833,13 @@ void SMeterWidget::paintEvent(QPaintEvent*)
}
};
- const QColor whiteColor(0xc8, 0xd8, 0xe8);
+ const QColor whiteColor = tickNormal;
+ const QColor labelColor = physicalTheme ? physicalPalette->text : whiteColor;
// -- Outside ticks (RX) ---------------------------------------------------
- for (int s = 1; s <= 9; s += 2) {
- const float dbm = S0_DBM + s * DB_PER_S;
- drawOutsideTick(dbmToFraction(dbm), QString::number(s), whiteColor, true);
- }
- for (int over : {20, 40}) {
- const float dbm = S9_DBM + over;
- drawOutsideTick(dbmToFraction(dbm), QString("+%1").arg(over), redColor, true);
+ for (const SMeterGeometry::Tick& tick : m_geometry.rxScale.ticks) {
+ const QColor& color = (tick.value > m_geometry.rxScale.s9Dbm) ? redColor : whiteColor;
+ drawOutsideTick(dbmToFraction(static_cast(tick.value)), tick.label, color, true);
}
// -- Inside ticks (TX): scale depends on TX mode --------------------------
@@ -554,18 +848,14 @@ void SMeterWidget::paintEvent(QPaintEvent*)
// Dynamic scale based on m_powerScaleMax
int maxW = static_cast(m_powerScaleMax);
int redW = static_cast(m_powerRedStart);
- int tickStep, labelStep;
- if (maxW >= 2000) { // PGXL: ticks every 100W, labels every 500W
- tickStep = 100; labelStep = 500;
- } else if (maxW >= 600) { // Aurora: ticks every 50W, labels every 100W
- tickStep = 50; labelStep = 100;
- } else { // Barefoot: ticks every 10W, labels every 40W
- tickStep = 10; labelStep = 40;
- }
+ const SMeterGeometry::PowerTickPolicy& policy =
+ m_geometry.powerTickPolicy(m_powerScaleMax);
+ const int tickStep = policy.tickStepWatts;
+ const int labelStep = policy.labelStepWatts;
for (int w = 0; w <= maxW; w += tickStep) {
const float frac = static_cast(w) / m_powerScaleMax;
const QColor& tc = (w >= redW) ? redColor : blueColor;
- const QColor& lc = (w >= redW) ? redColor : whiteColor;
+ const QColor& lc = (w >= redW) ? redColor : labelColor;
bool isLabeled = (w % labelStep == 0) || w == maxW || w == redW;
QString label = (w >= 1000) ? QString("%1k").arg(w / 1000.0f, 0, 'f', (w % 1000) ? 1 : 0)
: QString::number(w);
@@ -574,51 +864,51 @@ void SMeterWidget::paintEvent(QPaintEvent*)
break;
}
case TxMode::SWR: {
- // 1.0-3.0, ticks at 1, 1.5, 2, 2.5, 3. Red starting at 2.5.
- for (float s : {1.0f, 1.5f, 2.0f, 2.5f, 3.0f}) {
- const float frac = (s - 1.0f) / 2.0f;
- const bool red = (s >= 2.5f);
+ for (const SMeterGeometry::Tick& tick : m_geometry.swrScale.ticks) {
+ const float frac = static_cast(
+ m_geometry.scaleFraction(m_geometry.swrScale, tick.value));
+ const bool red = m_geometry.swrScale.hasWarning
+ && tick.value >= m_geometry.swrScale.warningStart;
const QColor& tc = red ? redColor : blueColor;
- const QColor& lc = red ? redColor : whiteColor;
- QString label = (s == static_cast(s))
- ? QString::number(static_cast(s))
- : QString::number(s, 'f', 1);
- drawInsideTick(frac, label, tc, lc, true);
+ const QColor& lc = red ? redColor : labelColor;
+ drawInsideTick(frac, tick.label, tc, lc, true);
}
break;
}
case TxMode::Level: {
- // -40 to +5, ticks at -40, -30, -20, -10, 0. Red at 0.
- for (int db : {-40, -30, -20, -10, 0}) {
- const float frac = (db + 40.0f) / 45.0f;
- const bool red = (db >= 0);
+ for (const SMeterGeometry::Tick& tick : m_geometry.levelScale.ticks) {
+ const float frac = static_cast(
+ m_geometry.scaleFraction(m_geometry.levelScale, tick.value));
+ const bool red = m_geometry.levelScale.hasWarning
+ && tick.value >= m_geometry.levelScale.warningStart;
const QColor& tc = red ? redColor : blueColor;
- const QColor& lc = red ? redColor : whiteColor;
- drawInsideTick(frac, QString::number(db), tc, lc, true);
+ const QColor& lc = red ? redColor : labelColor;
+ drawInsideTick(frac, tick.label, tc, lc, true);
}
break;
}
case TxMode::Compression: {
- // Visible face is 0 to -25 dB, while the stored value is 0..25.
- for (int db : {0, -5, -10, -15, -20, -25}) {
- const float frac = -db / 25.0f;
- drawInsideTick(frac, QString::number(db), blueColor, whiteColor, true);
+ for (const SMeterGeometry::Tick& tick : m_geometry.compressionScale.ticks) {
+ const float frac = static_cast(
+ m_geometry.scaleFraction(m_geometry.compressionScale, tick.value));
+ drawInsideTick(frac, tick.label, blueColor, labelColor, true);
}
break;
}
}
// Pivot cover radius — shared by the backlight glow and the cover itself.
- const float pivotCoverR = qMax(13.5f, w * 0.0975f);
+ const float pivotCoverR = static_cast(layout.pivotRadius);
// -- Pivot backlight glow -------------------------------------------------
// A warm radial glow behind the pivot, as if a lamp sits behind the mask.
// Drawn before the needle/cover; the cover masks the bright centre, leaving
// a soft halo spilling out from behind the moulding.
- {
- const float glowR = pivotCoverR * 3.4f;
+ if (!physicalTheme) {
+ const float glowR = pivotCoverR * static_cast(m_geometry.pivot.glowRadiusFactor);
const float edge = pivotCoverR / glowR;
- const float mid = edge + (1.0f - edge) * 0.45f;
+ const float mid = edge + (1.0f - edge)
+ * static_cast(m_geometry.pivot.glowMiddleFactor);
QColor glowColor =
ThemeManager::instance().color(QStringLiteral("color.meter.pivot.glow"));
auto glowAlpha = [&glowColor](int a) {
@@ -626,14 +916,16 @@ void SMeterWidget::paintEvent(QPaintEvent*)
c.setAlpha(a);
return c;
};
- QRadialGradient glow(QPointF(cx, h), glowR, QPointF(cx, h));
- glow.setColorAt(0.0f, glowAlpha(80));
- glow.setColorAt(edge, glowAlpha(80));
- glow.setColorAt(mid, glowAlpha(28));
+ QRadialGradient glow(QPointF(cx, faceBottom), glowR,
+ QPointF(cx, faceBottom));
+ glow.setColorAt(0.0f, glowAlpha(m_geometry.pivot.glowCenterAlpha));
+ glow.setColorAt(edge, glowAlpha(m_geometry.pivot.glowCenterAlpha));
+ glow.setColorAt(mid, glowAlpha(m_geometry.pivot.glowMiddleAlpha));
glow.setColorAt(1.0f, glowAlpha(0));
p.setPen(Qt::NoPen);
p.setBrush(glow);
- p.drawChord(QRectF(cx - glowR, h - glowR, glowR * 2.0f, glowR * 2.0f),
+ p.drawChord(QRectF(cx - glowR, faceBottom - glowR,
+ glowR * 2.0f, glowR * 2.0f),
0, 180 * 16);
}
@@ -642,29 +934,30 @@ void SMeterWidget::paintEvent(QPaintEvent*)
// arc center, so the pivot is barely out of frame.
// When transmitting, needle tracks the selected TX meter instead of RX.
{
- const float angle = fractionToAngle(m_needleFraction);
-
- // Needle extends to the end of the outer (RX) ticks: radius + 14
- const float tipR = radius + 14;
- const float tipX = cx + tipR * std::cos(angle);
- const float tipY = cy - tipR * std::sin(angle);
-
- // Needle shadow
- p.setPen(QPen(QColor(0, 0, 0, 80), 3));
- p.drawLine(QPointF(cx + 1, needleCy + 1), QPointF(tipX + 1, tipY + 1));
-
- // Needle
- p.setPen(QPen(QColor(0xff, 0xff, 0xff), 2));
- p.drawLine(QPointF(cx, needleCy), QPointF(tipX, tipY));
+ const QPointF tip = m_geometry.needleTip(QSizeF(w, h), m_needleFraction);
+ const QPointF pivot(cx, needleCy);
+ if (physicalTheme) {
+ drawPhysicalNeedle(p, pivot, tip, face.size(), *physicalPalette);
+ } else {
+ // Preserve the established Aether-default needle exactly.
+ p.setPen(QPen(QColor(0, 0, 0, 80), m_geometry.needle.shadowWidthPixels));
+ p.drawLine(pivot + m_geometry.needle.shadowOffset,
+ tip + m_geometry.needle.shadowOffset);
+ p.setPen(QPen(QColor(0xff, 0xff, 0xff), m_geometry.needle.lineWidthPixels));
+ p.drawLine(pivot, tip);
+ }
}
// -- Needle pivot cover ---------------------------------------------------
// A filled half-disc at the bottom-centre hides where the needle pivots,
// like the moulded bump on a classic analog VU meter. Drawn after the
// needle so it masks the base; the needle appears to emerge from under it.
- {
+ if (physicalTheme) {
+ drawPhysicalMask(p, face, *physicalPalette);
+ } else {
const float coverR = pivotCoverR;
- const QRectF coverRect(cx - coverR, h - coverR, coverR * 2.0f, coverR * 2.0f);
+ const QRectF coverRect(cx - coverR, faceBottom - coverR,
+ coverR * 2.0f, coverR * 2.0f);
p.setPen(Qt::NoPen);
p.setBrush(ThemeManager::instance().color(
QStringLiteral("color.meter.pivot.fill"))); // moulding
@@ -672,82 +965,102 @@ void SMeterWidget::paintEvent(QPaintEvent*)
// Subtle glossy rim along the curved top edge.
p.setBrush(Qt::NoBrush);
p.setPen(QPen(ThemeManager::instance().color(
- QStringLiteral("color.meter.pivot.rim")), 1));
+ QStringLiteral("color.meter.pivot.rim")), m_geometry.pivot.rimWidthPixels));
p.drawArc(coverRect, 0, 180 * 16);
}
// Draw peak marker (small triangle) — only in RX S-Meter Peak mode
if (!m_transmitting && calibratedRxScale && m_rxMode == RxMode::SMeterPeak
- && m_peakDbm > m_levelDbm + 1.0f) {
+ && m_peakDbm > m_levelDbm + m_geometry.peakMarker.minimumLeadDb) {
const float frac = dbmToFraction(m_peakDbm);
- const float angle = fractionToAngle(frac);
- const float markerR = radius - 2;
-
- const float cosA = std::cos(angle);
- const float sinA = std::sin(angle);
-
- const QPointF tip(cx + markerR * cosA, cy - markerR * sinA);
-
- p.setPen(Qt::NoPen);
- p.setBrush(QColor(0xff, 0xaa, 0x00));
- const float perpCos = -sinA;
- const float perpSin = cosA;
- const float sz = 3.0f;
- QPainterPath tri;
- tri.moveTo(tip);
- tri.lineTo(tip.x() - 6 * cosA + sz * perpCos,
- tip.y() + 6 * sinA + sz * perpSin);
- tri.lineTo(tip.x() - 6 * cosA - sz * perpCos,
- tip.y() + 6 * sinA - sz * perpSin);
- tri.closeSubpath();
- p.drawPath(tri);
+ const float markerR = radius - static_cast(m_geometry.peakMarker.radiusInsetPixels);
+ const SMeterGeometry::MovementRay ray =
+ m_geometry.movementRayFor(QSizeF(w, h), frac);
+ const std::optional markerPoint =
+ m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, markerR);
+ if (markerPoint) {
+ p.setPen(Qt::NoPen);
+ p.setBrush(physicalTheme ? physicalPalette->scaleCalibration
+ : QColor(0xff, 0xaa, 0x00));
+ const QPointF perpendicular(-ray.direction.y(), ray.direction.x());
+ const float halfWidth = static_cast(m_geometry.peakMarker.halfWidthPixels);
+ const float markerLength = static_cast(m_geometry.peakMarker.lengthPixels);
+ QPainterPath tri;
+ tri.moveTo(*markerPoint);
+ tri.lineTo(*markerPoint - markerLength * ray.direction + halfWidth * perpendicular);
+ tri.lineTo(*markerPoint - markerLength * ray.direction - halfWidth * perpendicular);
+ tri.closeSubpath();
+ p.drawPath(tri);
+ }
}
// -- Draw peak hold line (configurable overlay, independent of RX mode) ---
if (m_peakHoldEnabled && !m_transmitting && calibratedRxScale
- && m_peakHoldDbm > S0_DBM + 1.0f) {
+ && m_peakHoldDbm > m_geometry.rxScale.minimumDbm
+ + m_geometry.peakHold.visibleAboveMinimumDb) {
float frac = dbmToFraction(m_peakHoldDbm);
if (m_peakHoldDbm <= m_levelDbm + 0.01f) {
frac = m_needleFraction;
} else {
frac = qMax(frac, m_needleFraction);
}
- const float angle = fractionToAngle(frac);
-
- const float cosA = std::cos(angle);
- const float sinA = std::sin(angle);
- const QPointF inner(cx + (radius - 4) * cosA,
- cy - (radius - 4) * sinA);
- const QPointF outer(cx + (radius + 10) * cosA,
- cy - (radius + 10) * sinA);
-
- p.setPen(QPen(QColor(0xff, 0x44, 0x44, 0xcc), 2));
- p.drawLine(inner, outer);
+ const float peakInnerRadius =
+ radius + static_cast(m_geometry.peakHold.innerRadiusOffsetPixels);
+ const float peakOuterRadius =
+ radius + static_cast(m_geometry.peakHold.outerRadiusOffsetPixels);
+ const std::optional inner =
+ m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, peakInnerRadius);
+ const std::optional outer =
+ m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, peakOuterRadius);
+ if (inner && outer) {
+ QColor peakColor = physicalTheme ? physicalPalette->scaleCalibration
+ : QColor(0xff, 0x44, 0x44, 0xcc);
+ if (physicalTheme) {
+ peakColor.setAlpha(0xcc);
+ }
+ p.setPen(QPen(peakColor,
+ m_geometry.peakHold.lineWidthPixels));
+ p.drawLine(*inner, *outer);
+ }
}
- // -- Text readout -- all top-aligned on the same baseline -----------------
+ // -- Text readout -- aligned top edges with graph clearance ----------------
QFont srcFont = font();
- srcFont.setPixelSize(qMax(9, h / 14));
+ srcFont.setPixelSize(layout.sourceFontPixels);
const QFontMetrics sfm(srcFont);
- const int topY = sfm.height() + 2;
QFont valFont = font();
- valFont.setPixelSize(qMax(13, h / 8));
+ valFont.setPixelSize(layout.valueFontPixels);
valFont.setBold(true);
const QFontMetrics vfm(valFont);
+ // Keep the larger side values clear of the top edge, then align the
+ // smaller centered label by its font-box top. Center alignment still left
+ // the source label too close to the calibrated arc numbers below it.
+ const int valueBaseline = qRound(faceTop) + std::max(sfm.ascent(), vfm.ascent())
+ + m_geometry.readout.topExtraPixels;
+ const int sourceBaseline =
+ valueBaseline - vfm.ascent() + sfm.ascent();
+ const QColor sourceColor = physicalTheme ? physicalPalette->secondaryText
+ : QColor(0x80, 0x90, 0xa0);
+ const QColor accentColor = physicalTheme ? physicalPalette->text
+ : QColor(0x00, 0xb4, 0xd8);
+ const QColor valueColor = physicalTheme ? physicalPalette->text
+ : QColor(0xc8, 0xd8, 0xe8);
if (m_transmitting) {
// TX mode: show TX source label (center), mode name (left), value (right)
static const char* txLabels[] = {"Power", "SWR", "Level", "Compression"};
const QString srcLabel = txLabels[static_cast(m_txMode)];
p.setFont(srcFont);
- p.setPen(QColor(0x80, 0x90, 0xa0));
- p.drawText((w - sfm.horizontalAdvance(srcLabel)) / 2, topY, srcLabel);
+ p.setPen(sourceColor);
+ p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(srcLabel) / 2.0),
+ sourceBaseline, srcLabel);
p.setFont(valFont);
// Left: mode name in cyan
- p.setPen(QColor(0x00, 0xb4, 0xd8));
- p.drawText(6, topY, "TX");
+ p.setPen(accentColor);
+ p.drawText(qRound(faceLeft) + m_geometry.readout.sideMarginPixels,
+ valueBaseline, "TX");
// Right: formatted value
QString valText;
@@ -757,49 +1070,52 @@ void SMeterWidget::paintEvent(QPaintEvent*)
case TxMode::Level: valText = QString("%1 dB").arg(m_micLevel, 0, 'f', 0); break;
case TxMode::Compression: valText = QString("%1 dB").arg(-m_compLevel, 0, 'f', 0); break;
}
- p.setPen(QColor(0xc8, 0xd8, 0xe8));
- p.drawText(w - vfm.horizontalAdvance(valText) - 6, topY, valText);
+ p.setPen(valueColor);
+ p.drawText(qRound(faceRight) - vfm.horizontalAdvance(valText)
+ - m_geometry.readout.sideMarginPixels,
+ valueBaseline, valText);
} else {
// RX mode: show source label (center), S-units (left), dBm (right)
if (unavailableRxScale) {
const QString sourceLabel = unavailableRxMeterLabel();
p.setFont(srcFont);
- p.setPen(QColor(0x80, 0x90, 0xa0));
- p.drawText((w - sfm.horizontalAdvance(sourceLabel)) / 2,
- topY, sourceLabel);
+ p.setPen(sourceColor);
+ p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(sourceLabel) / 2.0),
+ sourceBaseline, sourceLabel);
p.setFont(valFont);
- p.setPen(QColor(0x00, 0xb4, 0xd8));
- p.drawText(6, topY, QStringLiteral("---"));
+ p.setPen(accentColor);
+ p.drawText(qRound(faceLeft) + m_geometry.readout.sideMarginPixels,
+ valueBaseline,
+ QStringLiteral("---"));
const QString rightText = QStringLiteral("---");
- p.setPen(QColor(0xc8, 0xd8, 0xe8));
- p.drawText(w - vfm.horizontalAdvance(rightText) - 6,
- topY, rightText);
+ p.setPen(valueColor);
+ p.drawText(qRound(faceRight) - vfm.horizontalAdvance(rightText)
+ - m_geometry.readout.sideMarginPixels,
+ valueBaseline, rightText);
return;
}
p.setFont(srcFont);
- p.setPen(QColor(0x80, 0x90, 0xa0));
- p.drawText((w - sfm.horizontalAdvance(m_source)) / 2, topY, m_source);
+ p.setPen(sourceColor);
+ p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(m_source) / 2.0),
+ sourceBaseline, m_source);
const float displayDbm = (m_rxMode == RxMode::SMeterPeak) ? m_peakDbm : m_levelDbm;
p.setFont(valFont);
- p.setPen(QColor(0x00, 0xb4, 0xd8));
+ p.setPen(accentColor);
// Show S-units based on the displayed value
- QString sText;
- if (displayDbm <= S0_DBM) sText = "S0";
- else if (displayDbm <= S9_DBM) {
- sText = QString("S%1").arg(qBound(0, qRound((displayDbm - S0_DBM) / DB_PER_S), 9));
- } else {
- sText = QString("S9+%1").arg(qRound(displayDbm - S9_DBM));
- }
- p.drawText(6, topY, sText);
+ const QString sText = sUnitsTextFor(displayDbm);
+ p.drawText(qRound(faceLeft) + m_geometry.readout.sideMarginPixels,
+ valueBaseline, sText);
const QString dbmText = QString("%1 dBm").arg(displayDbm, 0, 'f', 0);
- p.setPen(QColor(0xc8, 0xd8, 0xe8));
- p.drawText(w - vfm.horizontalAdvance(dbmText) - 6, topY, dbmText);
+ p.setPen(valueColor);
+ p.drawText(qRound(faceRight) - vfm.horizontalAdvance(dbmText)
+ - m_geometry.readout.sideMarginPixels,
+ valueBaseline, dbmText);
}
}
diff --git a/src/gui/SMeterWidget.h b/src/gui/SMeterWidget.h
index a4d04398f..2eddc49ed 100644
--- a/src/gui/SMeterWidget.h
+++ b/src/gui/SMeterWidget.h
@@ -3,8 +3,14 @@
#include
#include
#include
+#include
+#include "AnalogMeterFaceTheme.h"
#include "core/KiwiSdrProtocol.h"
+#include "RadioSwrValidityFilter.h"
+#include "SMeterGeometry.h"
+
+class QPainter;
namespace AetherSDR {
@@ -14,7 +20,7 @@ namespace AetherSDR {
// S0 = -127 dBm, S1 = -121 dBm, ... S9 = -73 dBm (6 dB per S-unit)
// S9+10 = -63 dBm, S9+20 = -53 dBm, S9+40 = -33 dBm, S9+60 = -13 dBm
//
-// The needle sweeps from S0 (left) to S9+60 (right) across a 180° arc.
+// The needle sweeps from S0 (left) to S9+60 (right) across a shallow 70° arc.
// Below S9 the scale markings are white; above S9 they are red.
class SMeterWidget : public QWidget {
Q_OBJECT
@@ -22,8 +28,8 @@ class SMeterWidget : public QWidget {
public:
explicit SMeterWidget(QWidget* parent = nullptr);
- QSize sizeHint() const override { return {280, 140}; }
- QSize minimumSizeHint() const override { return {200, 100}; }
+ QSize sizeHint() const override { return m_geometry.sizing.preferred; }
+ QSize minimumSizeHint() const override { return m_geometry.sizing.minimum; }
// Current reading in dBm.
float levelDbm() const { return m_levelDbm; }
@@ -31,6 +37,16 @@ class SMeterWidget : public QWidget {
// Reading as S-units string (e.g. "S7", "S9+20").
QString sUnitsText() const;
+ const SMeterGeometry& geometry() const { return m_geometry; }
+ AnalogMeterFaceTheme faceTheme() const { return m_faceTheme; }
+ QString faceThemeId() const;
+ QString accessibleValueText() const;
+
+ // Let a detached meter consume its resizable window while preserving the
+ // compact fixed-height sidebar layout when docked.
+ void setFloating(bool floating);
+ void setFaceTheme(AnalogMeterFaceTheme theme);
+
enum class TxMode { Power, SWR, Level, Compression };
enum class RxMode { SMeter, SMeterPeak };
enum class DecayRate { Fast, Medium, Slow };
@@ -43,6 +59,9 @@ public slots:
// Update TX meter values.
void setTxMeters(float fwdPower, float swr);
+ // The radio-native SWR value remains the displayed source. Instantaneous
+ // forward power is used only to reject samples taken without measurable RF.
+ void setRadioTxMeters(float fwdPower, float fwdPowerInstant, float swr);
// Update mic/compression meter values.
void setMicMeters(float micLevel, float compLevel, float micPeak, float compPeak);
@@ -73,6 +92,18 @@ public slots:
void updatePeakHoldValue();
bool usesUnavailableRxMeter() const;
QString unavailableRxMeterLabel() const;
+ QString sUnitsTextFor(float dbm) const;
+ void scheduleAccessibleValue();
+ void publishAccessibleValue();
+ void rebuildBackgroundLayer();
+ void drawPhysicalNeedle(QPainter& painter, const QPointF& pivot,
+ const QPointF& tip,
+ const QSizeF& faceSize,
+ const AnalogMeterFaceThemeCatalog::Palette& palette) const;
+ void drawPhysicalMask(QPainter& painter, const QRectF& face,
+ const AnalogMeterFaceThemeCatalog::Palette& palette) const;
+ void finishTxMeterUpdate();
+ void updateRadioSwr(float forwardPowerInstant, float swr, qint64 timestampMs);
// Map dBm to fraction (0.0 = left, 1.0 = right) for RX S-meter scale
float dbmToFraction(float dbm) const;
@@ -84,8 +115,18 @@ public slots:
float currentTxValue() const;
// RX state
- float m_levelDbm{-127.0f}; // current RX reading
- float m_peakDbm{-127.0f}; // RX peak hold
+ SMeterGeometry m_geometry;
+ AnalogMeterFaceThemeCatalog m_faceThemes;
+ AnalogMeterFaceTheme m_faceTheme{AnalogMeterFaceTheme::AetherDefault};
+ QPixmap m_backgroundLayer;
+ QSize m_backgroundCacheSize;
+ qreal m_backgroundCacheDpr{0.0};
+ AnalogMeterFaceTheme m_backgroundCacheTheme{AnalogMeterFaceTheme::AetherDefault};
+ bool m_backgroundCacheValid{false};
+ QTimer m_accessibilityTimer;
+ QString m_lastAccessibleValue;
+ float m_levelDbm{0.0f}; // current RX reading; initialized from geometry
+ float m_peakDbm{0.0f}; // RX peak hold; initialized from geometry
QString m_source{"S-Meter Peak"};
KiwiSdrProtocol::MeterReading m_receiveMeterReading;
bool m_receiveMeterReadingActive{false};
@@ -93,6 +134,8 @@ public slots:
// TX meter values (updated continuously, used when transmitting)
float m_txPower{0.0f};
float m_txSwr{1.0f};
+ RadioSwrValidityFilter m_radioSwrFilter;
+ QElapsedTimer m_radioSwrClock;
float m_micLevel{-50.0f}; // MIC meter — drives Level mode needle
float m_micPeak{-50.0f}; // MICPEAK meter — reserved for future peak tick
float m_compLevel{0.0f};
@@ -112,20 +155,15 @@ public slots:
// Peak hold line state
bool m_peakHoldEnabled{false};
- float m_peakHoldDbm{-127.0f};
- float m_peakHoldDecayStartDbm{-127.0f};
+ float m_peakHoldDbm{0.0f};
+ float m_peakHoldDecayStartDbm{0.0f};
int m_peakHoldTimeMs{1000};
float m_peakDecayDbPerSec{10.0f}; // Medium default
QElapsedTimer m_peakHoldTimer;
bool m_peakHoldTimerRunning{false};
- // S-unit reference: S0 = -127 dBm, each S-unit = 6 dB
- static constexpr float S0_DBM = -127.0f;
- static constexpr float S9_DBM = -73.0f;
- static constexpr float MAX_DBM = -13.0f; // S9+60
- static constexpr float DB_PER_S = 6.0f;
-
static constexpr int kNeedleAnimationIntervalMs = 8;
+ static constexpr int kAccessibilityAnnouncementIntervalMs = 100;
static constexpr float kNeedleAttackTimeSeconds = 0.045f;
static constexpr float kNeedleReleaseTimeSeconds = 0.180f;
static constexpr float kNeedleSnapEpsilon = 0.001f;
@@ -134,10 +172,6 @@ public slots:
float m_powerScaleMax{120.0f};
float m_powerRedStart{100.0f};
- // Arc geometry: shallow arc spanning ~70° (like SmartSDR)
- static constexpr float ARC_START_DEG = 55.0f; // right end (degrees from +X axis)
- static constexpr float ARC_END_DEG = 125.0f; // left end
-
};
} // namespace AetherSDR
diff --git a/src/gui/SMeterWidgetAccessible.h b/src/gui/SMeterWidgetAccessible.h
new file mode 100644
index 000000000..4b39949fd
--- /dev/null
+++ b/src/gui/SMeterWidgetAccessible.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include
+
+namespace AetherSDR {
+
+// Accessibility adapter for the custom-painted S-meter. Keeping the adapter
+// separate makes the non-visual value contract explicit and discoverable by
+// the GUI accessibility check.
+class SMeterWidgetAccessible : public QAccessibleWidget {
+public:
+ explicit SMeterWidgetAccessible(QWidget* widget);
+ QString text(QAccessible::Text textType) const override;
+};
+
+QAccessibleInterface* sMeterAccessibleFactory(const QString& key, QObject* object);
+
+} // namespace AetherSDR
diff --git a/src/gui/VuMeterSettings.cpp b/src/gui/VuMeterSettings.cpp
index 0371b7de3..9310795bc 100644
--- a/src/gui/VuMeterSettings.cpp
+++ b/src/gui/VuMeterSettings.cpp
@@ -27,6 +27,18 @@ const QStringList& decayItems()
return items;
}
+const QStringList& faceThemeItems()
+{
+ static const QStringList items{
+ kAetherTheme, kClassicTheme, kUplightTheme, kDarkTheme};
+ return items;
+}
+
+QString normalizeFaceTheme(const QString& theme)
+{
+ return faceThemeItems().contains(theme) ? theme : kAetherTheme;
+}
+
QString encode(const Snapshot& settings)
{
QJsonObject standard;
@@ -34,6 +46,7 @@ QString encode(const Snapshot& settings)
standard.insert(QStringLiteral("rxSelect"), settings.rxSelect);
standard.insert(QStringLiteral("peakHoldEnabled"), settings.peakHoldEnabled);
standard.insert(QStringLiteral("peakDecayRate"), settings.peakDecayRate);
+ standard.insert(QStringLiteral("faceTheme"), normalizeFaceTheme(settings.faceTheme));
QJsonObject root;
root.insert(QStringLiteral("version"), kVersion);
@@ -65,7 +78,7 @@ Snapshot decode(const QByteArray& encoded, QString* error,
const QJsonObject root = document.object();
const int version = root.value(QStringLiteral("version")).toInt();
- if (version != 1 && version != kVersion) {
+ if (version != 1 && version != 2 && version != kVersion) {
if (error) {
*error = QStringLiteral("unsupported VuMeter settings version");
}
@@ -94,6 +107,10 @@ Snapshot decode(const QByteArray& encoded, QString* error,
if (decayItems().contains(decay)) {
settings.peakDecayRate = decay;
}
+ if (version >= 3) {
+ settings.faceTheme = normalizeFaceTheme(
+ standard.value(QStringLiteral("faceTheme")).toString());
+ }
return settings;
}
diff --git a/src/gui/VuMeterSettings.h b/src/gui/VuMeterSettings.h
index da7495a1b..217abe221 100644
--- a/src/gui/VuMeterSettings.h
+++ b/src/gui/VuMeterSettings.h
@@ -6,19 +6,24 @@
namespace AetherSDR::VuMeterSettingsCodec {
-inline constexpr int kVersion = 2;
+inline constexpr int kVersion = 3;
inline const QString kSettingsKey = QStringLiteral("VuMeter");
+inline const QString kAetherTheme = QStringLiteral("aether-default");
+inline const QString kClassicTheme = QStringLiteral("classic-warm");
+inline const QString kUplightTheme = QStringLiteral("dark-room-uplight");
+inline const QString kDarkTheme = QStringLiteral("graphite-dark");
struct Snapshot {
int txSelect{0};
int rxSelect{0};
bool peakHoldEnabled{false};
QString peakDecayRate{QStringLiteral("Medium")};
+ QString faceTheme{kAetherTheme};
};
// Version 1 temporarily combined the standard and cross-needle meters. Keep
// just enough decode metadata to migrate its face theme into the independent
-// PWR applet before rewriting VuMeter as the version-2 standard-only object.
+// PWR applet before rewriting VuMeter as the current standard-only object.
struct LegacyCrossNeedle {
bool present{false};
bool selected{false};
@@ -28,6 +33,8 @@ struct LegacyCrossNeedle {
const QStringList& txMeterItems();
const QStringList& rxMeterItems();
const QStringList& decayItems();
+const QStringList& faceThemeItems();
+QString normalizeFaceTheme(const QString& theme);
QString encode(const Snapshot& settings);
Snapshot decode(const QByteArray& encoded, QString* error = nullptr,
diff --git a/tests/TestSettingsProfile.h b/tests/TestSettingsProfile.h
new file mode 100644
index 000000000..c59e4d8af
--- /dev/null
+++ b/tests/TestSettingsProfile.h
@@ -0,0 +1,48 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Must be constructed before QApplication/QCoreApplication and before the
+// first AppSettings or ThemeManager access. Qt does not honor the same config
+// environment variable on every supported platform, so redirect all relevant
+// homes and enable QStandardPaths test mode as a second layer.
+class TestSettingsProfile
+{
+public:
+ explicit TestSettingsProfile(const QString& testName)
+ : m_root(QDir::tempPath() + QStringLiteral("/") + testName
+ + QStringLiteral("-XXXXXX"))
+ {
+ if (!m_root.isValid()) {
+ return;
+ }
+
+ const QByteArray root = m_root.path().toUtf8();
+ qputenv("HOME", root);
+ qputenv("CFFIXED_USER_HOME", root);
+ qputenv("XDG_CONFIG_HOME", root);
+ qputenv("LOCALAPPDATA", root);
+ qputenv("APPDATA", root);
+ QStandardPaths::setTestModeEnabled(true);
+
+ // AppSettings first-run migration still probes the legacy QSettings
+ // store. On macOS the native CFPreferences backend can ignore HOME,
+ // so force that probe into a private INI directory as well.
+ const QString legacyRoot = m_root.path() + QStringLiteral("/legacy-settings");
+ QDir().mkpath(legacyRoot);
+ QSettings::setDefaultFormat(QSettings::IniFormat);
+ QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, legacyRoot);
+ QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, legacyRoot);
+ }
+
+ bool isValid() const { return m_root.isValid(); }
+ QString path() const { return m_root.path(); }
+
+private:
+ QTemporaryDir m_root;
+};
diff --git a/tests/amp_applet_test.cpp b/tests/amp_applet_test.cpp
index 046f4283d..414d5bac9 100644
--- a/tests/amp_applet_test.cpp
+++ b/tests/amp_applet_test.cpp
@@ -1,3 +1,4 @@
+#include "TestSettingsProfile.h"
#include "core/AppSettings.h"
#include "gui/AmpApplet.h"
@@ -39,6 +40,7 @@ void resetSettings()
QFile::remove(path);
QFile::remove(path + QStringLiteral(".bak"));
QFile::remove(path + QStringLiteral(".tmp"));
+ settings.load();
}
void testDefaultPlaceholder()
@@ -136,28 +138,17 @@ void testPreferenceReload()
int main(int argc, char** argv)
{
- QTemporaryDir fakeHome(QDir::tempPath() + "/aether-amp-applet-test-XXXXXX");
- if (!fakeHome.isValid()) {
+ TestSettingsProfile settingsProfile(QStringLiteral("aether-amp-applet-test"));
+ if (!settingsProfile.isValid()) {
std::printf("[FAIL] create temporary home\n");
return 1;
}
-
- const QByteArray fakeHomePath = fakeHome.path().toUtf8();
- qputenv("HOME", fakeHomePath);
- qputenv("CFFIXED_USER_HOME", fakeHomePath);
- qputenv("LOCALAPPDATA", fakeHomePath);
- qputenv("XDG_CONFIG_HOME", fakeHomePath);
if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) {
qputenv("QT_QPA_PLATFORM", "offscreen");
}
- QStandardPaths::setTestModeEnabled(true);
QApplication app(argc, argv);
- const QString configRoot =
- QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
- QDir(configRoot + QStringLiteral("/AetherSDR")).removeRecursively();
-
std::printf("AmpApplet temperature unit test harness\n\n");
testDefaultPlaceholder();
diff --git a/tests/antenna_alias_test.cpp b/tests/antenna_alias_test.cpp
index 14111b75b..8c3bc0767 100644
--- a/tests/antenna_alias_test.cpp
+++ b/tests/antenna_alias_test.cpp
@@ -1,3 +1,4 @@
+#include "TestSettingsProfile.h"
#include "core/AppSettings.h"
#include "models/AntennaAliasStore.h"
#include "models/SliceModel.h"
@@ -24,22 +25,15 @@ bool expect(bool condition, const char* label)
int main(int argc, char** argv)
{
- QTemporaryDir fakeHome(QDir::tempPath() + "/aether-antenna-alias-test-XXXXXX");
- if (!fakeHome.isValid()) {
+ TestSettingsProfile settingsProfile(QStringLiteral("aether-antenna-alias-test"));
+ if (!settingsProfile.isValid()) {
std::cerr << "[FAIL] create temporary home\n";
return 1;
}
- qputenv("HOME", fakeHome.path().toUtf8());
- qputenv("CFFIXED_USER_HOME", fakeHome.path().toUtf8());
- QStandardPaths::setTestModeEnabled(true);
QCoreApplication app(argc, argv);
- const QString configRoot =
- QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
- QDir(configRoot + "/AetherSDR").removeRecursively();
-
auto& settings = AppSettings::instance();
- settings.reset();
+ settings.load();
const QString radioKey = QStringLiteral("serial-123");
QMap aliases;
@@ -107,6 +101,5 @@ int main(int argc, char** argv)
ok &= expect(commands == QStringList({QStringLiteral("slice set 3 txant=XVTR")}),
"slice TX antenna command keeps canonical token");
- QDir(configRoot + "/AetherSDR").removeRecursively();
return ok ? 0 : 1;
}
diff --git a/tests/app_settings_safety_test.cpp b/tests/app_settings_safety_test.cpp
new file mode 100644
index 000000000..ae8e07c6e
--- /dev/null
+++ b/tests/app_settings_safety_test.cpp
@@ -0,0 +1,364 @@
+#include "TestSettingsProfile.h"
+#include "core/AppSettings.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+using namespace AetherSDR;
+
+namespace {
+
+int g_failures = 0;
+
+void expect(bool condition, const char* description)
+{
+ std::printf("%s %s\n", condition ? "[ OK ]" : "[FAIL]", description);
+ if (!condition) {
+ ++g_failures;
+ }
+}
+
+QString settingsPath()
+{
+ return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation)
+ + QStringLiteral("/AetherSDR/AetherSDR.settings");
+}
+
+QByteArray settingsDocument(int count, const QString& valuePrefix)
+{
+ QByteArray data;
+ QBuffer buffer(&data);
+ buffer.open(QIODevice::WriteOnly);
+ QXmlStreamWriter xml(&buffer);
+ xml.setAutoFormatting(true);
+ xml.writeStartDocument();
+ xml.writeStartElement(QStringLiteral("Settings"));
+ for (int index = 0; index < count; ++index) {
+ xml.writeTextElement(
+ QStringLiteral("Key%1").arg(index, 3, 10, QLatin1Char('0')),
+ QStringLiteral("%1-%2").arg(valuePrefix).arg(index));
+ }
+ xml.writeEndElement();
+ xml.writeEndDocument();
+ buffer.close();
+ return data;
+}
+
+bool writeFile(const QString& path, const QByteArray& data)
+{
+ QDir().mkpath(QFileInfo(path).absolutePath());
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ return false;
+ }
+ return file.write(data) == data.size();
+}
+
+QByteArray readFile(const QString& path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly)) {
+ return {};
+ }
+ return file.readAll();
+}
+
+QMap parseSettings(const QByteArray& data, bool* valid)
+{
+ QMap values;
+ QXmlStreamReader xml(data);
+ while (!xml.atEnd()) {
+ xml.readNext();
+ if (xml.isStartElement() && xml.name() != QStringLiteral("Settings")) {
+ const QString key = xml.name().toString();
+ values.insert(key, xml.readElementText());
+ }
+ }
+ *valid = !xml.hasError();
+ return values;
+}
+
+void testSaveBeforeLoad()
+{
+ const QString path = settingsPath();
+ const QByteArray live = settingsDocument(50, QStringLiteral("live"));
+ const QByteArray backup = settingsDocument(40, QStringLiteral("backup"));
+ expect(writeFile(path, live), "production-like live fixture is written");
+ expect(writeFile(path + QStringLiteral(".bak"), backup),
+ "production-like backup fixture is written");
+
+ AppSettings& settings = AppSettings::instance();
+ settings.setValue(QStringLiteral("OnlyNewKey"), QStringLiteral("new-value"));
+ settings.save();
+
+ expect(readFile(path) == live,
+ "save-before-load leaves the existing live file byte-for-byte unchanged");
+ expect(readFile(path + QStringLiteral(".bak")) == backup,
+ "save-before-load does not rotate or replace the backup");
+ expect(!QFile::exists(path + QStringLiteral(".tmp")),
+ "save-before-load creates no temporary settings file");
+}
+
+void testNormalLoadThenSave()
+{
+ const QString path = settingsPath();
+ const QByteArray original = settingsDocument(50, QStringLiteral("original"));
+ expect(writeFile(path, original), "normal-load fixture is written");
+
+ AppSettings& settings = AppSettings::instance();
+ settings.load();
+ expect(settings.value(QStringLiteral("Key049")).toString()
+ == QStringLiteral("original-49"),
+ "normal load reads the complete existing profile");
+ settings.setValue(QStringLiteral("AddedKey"), QStringLiteral("preserved"));
+ settings.save();
+
+ bool valid = false;
+ const QMap saved = parseSettings(readFile(path), &valid);
+ expect(valid, "normal load-then-save produces valid XML");
+ expect(saved.size() == 51 && saved.value(QStringLiteral("Key000"))
+ == QStringLiteral("original-0")
+ && saved.value(QStringLiteral("Key049"))
+ == QStringLiteral("original-49")
+ && saved.value(QStringLiteral("AddedKey"))
+ == QStringLiteral("preserved"),
+ "normal load-then-save preserves every old key and the new key");
+ expect(readFile(path + QStringLiteral(".bak")) == original,
+ "normal save retains the prior live profile as its backup");
+}
+
+void testFirstRunInitialization()
+{
+ const QString path = settingsPath();
+ AppSettings& settings = AppSettings::instance();
+ settings.load();
+
+ bool valid = false;
+ const QMap saved = parseSettings(readFile(path), &valid);
+ expect(valid && QFile::exists(path),
+ "first-run load creates a valid settings document");
+ expect(saved.size() == 13
+ && saved.value(QStringLiteral("AutoConnect")) == QStringLiteral("True")
+ && saved.value(QStringLiteral("AutoConnectToLastRadio"))
+ == QStringLiteral("True")
+ && saved.contains(QStringLiteral("GUIClientID")),
+ "first-run initialization writes only the intentional default profile");
+}
+
+void testSettingCountGuard()
+{
+ const QString path = settingsPath();
+ const QByteArray original = settingsDocument(50, QStringLiteral("guarded"));
+ expect(writeFile(path, original), "setting-count guard fixture is written");
+
+ AppSettings& settings = AppSettings::instance();
+ settings.load();
+ for (int index = 0; index < 40; ++index) {
+ settings.remove(
+ QStringLiteral("Key%1").arg(index, 3, 10, QLatin1Char('0')));
+ }
+ settings.save();
+
+ expect(readFile(path) == original,
+ "the existing setting-count guard still rejects a truncated save");
+ expect(!QFile::exists(path + QStringLiteral(".bak")),
+ "a setting-count rejection does not rotate the live profile");
+}
+
+void testBackupRecovery()
+{
+ const QString path = settingsPath();
+ const QByteArray corruptLive("partial");
+ const QByteArray goodBackup = settingsDocument(50, QStringLiteral("backup"));
+ expect(writeFile(path, corruptLive), "corrupt live fixture is written");
+ expect(writeFile(path + QStringLiteral(".bak"), goodBackup),
+ "valid recovery fixture is written");
+
+ AppSettings& settings = AppSettings::instance();
+ settings.load();
+ expect(settings.value(QStringLiteral("Key049")).toString()
+ == QStringLiteral("backup-49"),
+ "a corrupt live profile recovers all values from a valid backup");
+ settings.setValue(QStringLiteral("RecoveredKey"), QStringLiteral("yes"));
+ settings.save();
+
+ bool valid = false;
+ const QMap saved = parseSettings(readFile(path), &valid);
+ expect(valid && saved.size() == 51
+ && saved.value(QStringLiteral("Key000")) == QStringLiteral("backup-0")
+ && saved.value(QStringLiteral("RecoveredKey")) == QStringLiteral("yes"),
+ "a recovered profile may be safely saved without losing backup keys");
+ expect(readFile(path + QStringLiteral(".bak")) == goodBackup,
+ "saving a recovered profile preserves the known-good backup");
+}
+
+void testMissingLivePromotesValidTemp()
+{
+ const QString path = settingsPath();
+ const QByteArray pending = settingsDocument(50, QStringLiteral("pending"));
+ const QByteArray backup = settingsDocument(40, QStringLiteral("backup"));
+ expect(writeFile(path + QStringLiteral(".tmp"), pending),
+ "interrupted-commit temporary fixture is written");
+ expect(writeFile(path + QStringLiteral(".bak"), backup),
+ "interrupted-commit backup fixture is written");
+
+ AppSettings& settings = AppSettings::instance();
+ settings.load();
+
+ expect(settings.value(QStringLiteral("Key049")).toString()
+ == QStringLiteral("pending-49"),
+ "a valid pending commit wins when the live profile is missing");
+ expect(readFile(path) == pending,
+ "a valid pending commit is promoted to the live settings path");
+ expect(!QFile::exists(path + QStringLiteral(".tmp")),
+ "promoting a pending commit consumes the temporary file");
+ expect(readFile(path + QStringLiteral(".bak")) == backup,
+ "promoting a pending commit preserves the prior known-good backup");
+
+ settings.setValue(QStringLiteral("RecoveredKey"), QStringLiteral("yes"));
+ settings.save();
+
+ bool valid = false;
+ const QMap saved = parseSettings(readFile(path), &valid);
+ expect(valid && saved.size() == 51
+ && saved.value(QStringLiteral("Key000"))
+ == QStringLiteral("pending-0")
+ && saved.value(QStringLiteral("RecoveredKey"))
+ == QStringLiteral("yes"),
+ "saving a promoted pending commit preserves its complete state");
+ expect(readFile(path + QStringLiteral(".bak")) == backup,
+ "the first save after promotion preserves the known-good recovery backup");
+}
+
+void testMissingLiveRecoversBackup()
+{
+ const QString path = settingsPath();
+ const QByteArray corruptTemp("partial");
+ const QByteArray backup = settingsDocument(50, QStringLiteral("backup-only"));
+ expect(writeFile(path + QStringLiteral(".tmp"), corruptTemp),
+ "invalid newer temporary recovery fixture is written");
+ expect(writeFile(path + QStringLiteral(".bak"), backup),
+ "missing-live backup fixture is written");
+
+ AppSettings& settings = AppSettings::instance();
+ settings.load();
+ expect(settings.value(QStringLiteral("Key049")).toString()
+ == QStringLiteral("backup-only-49"),
+ "a missing live profile falls back from an invalid temp to its valid backup");
+
+ settings.setValue(QStringLiteral("RecoveredKey"), QStringLiteral("yes"));
+ settings.save();
+
+ bool valid = false;
+ const QMap saved = parseSettings(readFile(path), &valid);
+ expect(valid && saved.size() == 51
+ && saved.value(QStringLiteral("Key000"))
+ == QStringLiteral("backup-only-0")
+ && saved.value(QStringLiteral("RecoveredKey"))
+ == QStringLiteral("yes"),
+ "saving a missing-live recovery restores a complete live profile");
+ expect(readFile(path + QStringLiteral(".bak")) == backup,
+ "saving a missing-live recovery preserves the known-good backup");
+}
+
+void testMissingLiveCorruptRecoveryFailsClosed()
+{
+ const QString path = settingsPath();
+ const QByteArray corruptTemp("