feat(esc): click-to-adjust polar display for phase and gain (#3091). Principle III.#3134
Conversation
…Principle III. Make PhaseKnob interactive: clicking or dragging anywhere inside the ESC polar circle now sets phase (angle) and gain (radius) in a single action, matching SmartSDR's UX pattern. The widget emits phaseChanged/gainChanged so VfoWidget can mirror the value into the existing phase/gain sliders and push the un-quantized radian/gain to the SliceModel. Slider signals are blocked during the mirror so the slider→knob path doesn't bounce back. Coordinate conversion is exposed as a static helper (deltaToPolar) for unit testing; it is the algebraic inverse of the painted dot position (angle = phase − π/2, radius = gain/2 · maxRadius). Blast radius: risk_score=0.025, 2 high-risk affected (top: VfoWidget::buildTabContent, MainWindow::onSliceAdded) — both are the established integration points and only see additive signal wiring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Nice, focused change — thanks for the work here. The polar↔(phase, gain) inverse math checks out against the paint code (angle = phase − π/2 forward, phase = atan2(dy, dx) + π/2 inverse), the static deltaToPolar is well-isolated for testing, and the slider/model wiring in VfoWidget correctly uses QSignalBlocker plus the existing m_updatingFromModel guard to avoid feedback loops. RAII, C++20 idioms, and Qt6 mouse-event API all look right.
A couple of small observations, none of them blockers:
-
Center-click sets phase to π/2. When the user clicks (or releases) exactly at the center,
atan2(0, 0)is 0, sophasesnaps to π/2 even though the click conveys no angular intent. The visual is identical (gain == 0puts the dot at center), but the persisted model phase changes. A tiny pixel deadband inupdateFromMousewould preserve the prior phase in that case:constexpr double kDeadbandPx = 2.0; const double r = std::sqrt(dx*dx + dy*dy); float phase = m_phase; // keep prior on center-click if (r > kDeadbandPx) { phase = std::atan2(dy, dx) + M_PI/2.0; // … normalize … }
Worth deciding consciously; either choice is defensible.
-
qFuzzyCompare(m_gain, gain)near zero.qFuzzyCompare's tolerance scales with the magnitude of the inputs, so it returnsfalsefor(0.0f, tiny)and can spuriously emitgainChangedwhen dragging across center. Quantization downstream makes this mostly benign, but an absolute epsilon (std::abs(a-b) > 1e-4f) would be more predictable for both phase and gain. Same point form_phasenear 0 / wraparound. -
deltaToPolaris described in the header as "exposed for unit tests" but no test was added. Worth a short follow-up undertests/covering the four cardinal angles plus the clamp, since the function is pure and trivial to exercise.
LGTM as-is — the items above are polish that can land in a follow-up if you prefer.
🤖 aethersdr-agent · cost: $5.9672 · model: claude-opus-4-7
There was a problem hiding this comment.
Clean implementation — the static deltaToPolar is a nice touch for testability, the QSignalBlocker pattern around the slider write is the right way to avoid the bounce-back, and the slight dot-size bump during drag is a tasteful affordance. Mouse handling uses the modern event->position() Qt6 API and accepts unhandled clicks correctly. Files in scope match the stated change.
A couple of small things worth a look — none blocking:
-
Model update rate during drag.
mouseMoveEvent→updateFromMouse→setEscPhaseShift/setEscGainfires on every mouse-move event, which on a fast device can be a lot of calls per second to the slice (and onward to the radio). The slider path was already step-quantized to 5° / 0.01 increments so was naturally throttled. Worth considering whether the knob should debounce — e.g. only push to the model when the value changes beyond a small epsilon, or rate-limit emissions. Probably fine in practice, but easy to verify by watching CAT/VITA traffic while dragging. -
Click at exact center → phase jumps to 90°.
atan2(0, 0)returns 0, sophase = atan2(0,0) + π/2 = π/2. A user clicking dead-center to "reset" gain to zero will also rotate phase to 90°. A short-circuit atr ≈ 0to leave phase unchanged would avoid the surprise:
if (r < some_small_threshold) {
outPhase = /* keep existing */; // or skip emit entirely
}Thanks for the clear comment block in VfoWidget.cpp explaining the slider-blocker rationale and the raw-vs-quantized model write — makes the intent obvious on review.
🤖 aethersdr-agent · cost: $5.1334 · model: claude-opus-4-7
…r#3091). Principle III. (aethersdr#3134) ## Summary Fixes aethersdr#3091 ### What was changed feat(esc): click-to-adjust polar display for phase and gain (aethersdr#3091). Principle III. ### Files modified - `src/gui/PhaseKnob.cpp` - `src/gui/PhaseKnob.h` - `src/gui/VfoWidget.cpp` ``` src/gui/PhaseKnob.cpp | 98 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/gui/PhaseKnob.h | 24 +++++++++++-- src/gui/VfoWidget.cpp | 25 +++++++++++++ 3 files changed, 140 insertions(+), 7 deletions(-) ``` --- Generated by AetherClaude (automated agent for AetherSDR) --- <sub>🤖 aethersdr-agent · cost: $13.9261 · model: claude-opus-4-7</sub> Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Fixes #3091
What was changed
feat(esc): click-to-adjust polar display for phase and gain (#3091). Principle III.
Files modified
src/gui/PhaseKnob.cppsrc/gui/PhaseKnob.hsrc/gui/VfoWidget.cppGenerated by AetherClaude (automated agent for AetherSDR)
🤖 aethersdr-agent · cost: $13.9261 · model: claude-opus-4-7