From 27a1ca688f229a03214fe94430dd21d8ca500e22 Mon Sep 17 00:00:00 2001 From: Fred Klassen Date: Wed, 26 Aug 2026 08:51:29 -0700 Subject: [PATCH 1/2] feat(cw): add CW Send dialog for typing text directly to the K4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repurposes the bottom-bar PTT button as a CW button when the radio is in CW mode (PTT is meaningless there — the K4 is keyed via CAT, not mic audio). Clicking it opens a modeless CW Send dialog: type or fire one of 8 macro buttons (F1-F8, editable in a new Options > CW Macros page) and the text is sent to the K4's own KY text buffer and rendered as CW. Confirmation is driven entirely by the K4's real KY0/KY1 buffer-status replies — text shown in the dialog goes grey (queued) -> mid-tone (in the K4's buffer) -> amber (KY0 confirmed it was actually keyed), or red if that confirmation times out. Nothing brightens on a local timing guess. Sends are strictly serial (one chunk in the K4's buffer at a time) so behavior doesn't depend on unverified K4 append/replace semantics. Abort sends "RX;" to force TX off and halts the pipeline without clearing what's already on screen. CwSendController is deliberately decoupled from ConnectionController/ TcpClient — it only emits sendCatRequested(QString) and expects onCatResponse()/onDisconnected() fed from outside — so its chunking/ confirm/timeout state machine is unit-testable with plain Qt6::Core. Hardware-confirmed fix: the K4's TX relay/ALC ramp-up clips the first real character of every fresh KY send, so a throwaway leading 'E' (shortest element) is sacrificed to absorb it instead of a real letter. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TwJeVFnBk868bnkYDKaX12 --- CMakeLists.txt | 6 + src/controllers/connectioncontroller.h | 9 + src/controllers/cwcontroller.cpp | 8 +- src/controllers/cwcontroller.h | 7 + src/controllers/cwsendcontroller.cpp | 216 +++++++++++++++++++++ src/controllers/cwsendcontroller.h | 170 +++++++++++++++++ src/mainwindow.cpp | 56 ++++++ src/mainwindow.h | 6 + src/settings/radiosettings.cpp | 71 +++++++ src/settings/radiosettings.h | 19 ++ src/ui/dialogs/cwsenddialog.cpp | 210 +++++++++++++++++++++ src/ui/dialogs/cwsenddialog.h | 54 ++++++ src/ui/dialogs/optionsdialog.cpp | 10 + src/ui/dialogs/optionsdialog.h | 3 + src/ui/pages/cwmacrospage.cpp | 122 ++++++++++++ src/ui/pages/cwmacrospage.h | 35 ++++ src/ui/widgets/bottommenubar.cpp | 38 +++- src/ui/widgets/bottommenubar.h | 10 + src/utils/macroids.h | 11 ++ tests/CMakeLists.txt | 10 + tests/test_cwsendcontroller.cpp | 252 +++++++++++++++++++++++++ 21 files changed, 1316 insertions(+), 7 deletions(-) create mode 100644 src/controllers/cwsendcontroller.cpp create mode 100644 src/controllers/cwsendcontroller.h create mode 100644 src/ui/dialogs/cwsenddialog.cpp create mode 100644 src/ui/dialogs/cwsenddialog.h create mode 100644 src/ui/pages/cwmacrospage.cpp create mode 100644 src/ui/pages/cwmacrospage.h create mode 100644 tests/test_cwsendcontroller.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f67037d..827c47e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,7 @@ set(SOURCES src/controllers/rightsidecontroller.cpp src/controllers/memorybuttonscontroller.cpp src/controllers/cwcontroller.cpp + src/controllers/cwsendcontroller.cpp src/network/tcpclient.cpp src/network/protocol.cpp src/network/networkmetrics.cpp @@ -166,6 +167,8 @@ set(SOURCES src/ui/pages/rigcontrolpage.cpp src/ui/pages/cwkeyerpage.cpp src/ui/pages/straightkeypage.cpp + src/ui/pages/cwmacrospage.cpp + src/ui/dialogs/cwsenddialog.cpp src/ui/widgets/keyerconnectionwidget.cpp src/ui/pages/kpodpage.cpp src/ui/pages/kpa1500page.cpp @@ -251,6 +254,7 @@ set(HEADERS src/controllers/rightsidecontroller.h src/controllers/memorybuttonscontroller.h src/controllers/cwcontroller.h + src/controllers/cwsendcontroller.h src/network/tcpclient.h src/network/protocol.h src/network/networkmetrics.h @@ -303,6 +307,8 @@ set(HEADERS src/ui/pages/rigcontrolpage.h src/ui/pages/cwkeyerpage.h src/ui/pages/straightkeypage.h + src/ui/pages/cwmacrospage.h + src/ui/dialogs/cwsenddialog.h src/ui/widgets/keyerconnectionwidget.h src/ui/pages/kpodpage.h src/ui/pages/kpa1500page.h diff --git a/src/controllers/connectioncontroller.h b/src/controllers/connectioncontroller.h index 5111ad46..3fb61cb6 100644 --- a/src/controllers/connectioncontroller.h +++ b/src/controllers/connectioncontroller.h @@ -60,6 +60,14 @@ class ConnectionController : public QObject { void setKpodPlusKeyerActive(bool active) { m_kpodPlusKeyerActive.store(active, std::memory_order_release); } bool isKpodPlusKeyerActive() const { return m_kpodPlusKeyerActive.load(std::memory_order_acquire); } + // CwSendController ownership gate. Written from the main thread (wired to + // CwSendController::activeChanged) while a typed CW message is queued/in-flight/pending. + // Read on the I/O thread by the same iambic/straight-key gate checks as + // m_kpodPlusKeyerActive above, so a text send and hardware-driven keying can't interleave + // onto the wire. Same acquire/release discipline. + void setTextSendActive(bool active) { m_textSendActive.store(active, std::memory_order_release); } + bool isTextSendActive() const { return m_textSendActive.load(std::memory_order_acquire); } + signals: void radioReady(); // Auth succeeded, K4 is live void connectionError(const QString &error); // Connection error @@ -83,6 +91,7 @@ private slots: RadioState *m_radioState; // not owned RadioEntry m_currentRadio; std::atomic m_kpodPlusKeyerActive{false}; + std::atomic m_textSendActive{false}; }; #endif // CONNECTIONCONTROLLER_H diff --git a/src/controllers/cwcontroller.cpp b/src/controllers/cwcontroller.cpp index 2f5a0a67..3885d39c 100644 --- a/src/controllers/cwcontroller.cpp +++ b/src/controllers/cwcontroller.cpp @@ -163,7 +163,7 @@ CwController::CwController(RadioState *radioState, ConnectionController *connect connect( m_keyer, &IambicKeyer::elementStarted, tc, [tc, cc](bool isDit) { - if (cc->isKpodPlusKeyerActive()) + if (cc->isKpodPlusKeyerActive() || cc->isTextSendActive()) return; tc->sendCAT(isDit ? QStringLiteral("KZ.;") : QStringLiteral("KZ-;")); }, @@ -171,7 +171,7 @@ CwController::CwController(RadioState *radioState, ConnectionController *connect connect( m_keyer, &IambicKeyer::characterSpace, tc, [tc, cc]() { - if (cc->isKpodPlusKeyerActive()) + if (cc->isKpodPlusKeyerActive() || cc->isTextSendActive()) return; // WHY space, not underscore: the Elecraft KPodKeyerInterface.pdf renders the // letter-space marker as "KZ_;" but a hexdump of EP02 traffic from a live KPOD+ @@ -185,7 +185,7 @@ CwController::CwController(RadioState *radioState, ConnectionController *connect connect( m_keyer, &IambicKeyer::restartAfterPause, tc, [tc, cc](int ms) { - if (cc->isKpodPlusKeyerActive()) + if (cc->isKpodPlusKeyerActive() || cc->isTextSendActive()) return; tc->sendCAT(QStringLiteral("KZP%1;").arg(ms, 4, 10, QChar('0'))); }, @@ -239,7 +239,7 @@ CwController::CwController(RadioState *radioState, ConnectionController *connect connect( m_straightKeyDevice, &HalikeyDevice::dahStateChanged, this, [this](bool pressed) { - if (kpodPlusActive()) + if (kpodPlusActive() || m_connection->isTextSendActive()) return; // Unlike the iambic path, this emits elements directly, so it must not leak // into voice/data modes. diff --git a/src/controllers/cwcontroller.h b/src/controllers/cwcontroller.h index b9252893..eb8e06db 100644 --- a/src/controllers/cwcontroller.h +++ b/src/controllers/cwcontroller.h @@ -99,6 +99,13 @@ class KpodPlusDevice; // (the iambic state machine still runs; only its KZ output and sidetone // playback drop). KPOD+ owns the entire chain when active. // +// A second, symmetric gate — `ConnectionController::m_textSendActive`, +// written by CwSendController's activeChanged signal — suppresses the same +// KZ emission points (plus the straight-key element send) while a typed CW +// message is queued/in-flight via the K4's KY text buffer, so hardware +// keying and a text send can never interleave onto the wire. See +// cwsendcontroller.h. +// // State moved from HardwareController // ----------------------------------- // std::atomic m_cachedMode diff --git a/src/controllers/cwsendcontroller.cpp b/src/controllers/cwsendcontroller.cpp new file mode 100644 index 00000000..6aa35389 --- /dev/null +++ b/src/controllers/cwsendcontroller.cpp @@ -0,0 +1,216 @@ +#include "cwsendcontroller.h" + +#include +#include + +CwSendController::CwSendController(QObject *parent) : QObject(parent) { + m_pollTimer = new QTimer(this); + m_pollTimer->setSingleShot(true); // re-armed manually each tick — see pollTick() + connect(m_pollTimer, &QTimer::timeout, this, &CwSendController::pollTick); + + m_timeoutTimer = new QTimer(this); + m_timeoutTimer->setSingleShot(true); + connect(m_timeoutTimer, &QTimer::timeout, this, &CwSendController::onChunkTimeout); +} + +void CwSendController::appendChar(QChar ch) { + if (m_stalled || !m_cwModeActive) + return; + + m_pendingText += ch; + + const bool boundary = m_immediateMode || ch == QChar(' ') || ch == QChar('\n') || ch == QChar('\r'); + if (boundary) + cutPendingChunk(); + + maybeDispatchNext(); +} + +void CwSendController::flush() { + if (m_stalled || !m_cwModeActive) + return; + cutPendingChunk(); + maybeDispatchNext(); +} + +void CwSendController::cutPendingChunk() { + if (m_pendingText.isEmpty()) + return; + + // Split on the KY text limit — a "word" typed with no spaces could otherwise exceed it. + int offset = 0; + while (offset < m_pendingText.length()) { + const int len = qMin(kMaxChunkChars, m_pendingText.length() - offset); + Chunk c; + c.text = m_pendingText.mid(offset, len); + c.start = m_pendingStart + offset; + c.length = len; + c.immediate = m_immediateMode; + m_queue.append(c); + offset += len; + } + + m_pendingStart += m_pendingText.length(); + m_pendingText.clear(); +} + +void CwSendController::maybeDispatchNext() { + if (m_stalled || m_hasInFlight) { + updateActiveState(); + return; + } + if (m_queue.isEmpty()) { + m_pollTimer->stop(); + updateActiveState(); + return; + } + + Chunk next = m_queue.takeFirst(); + if (next.immediate) { + // Merge whatever else is already queued (typed ahead of the confirm round trip) + // into one send, capped at the KY text limit — keeps immediate mode from paying a + // confirm round trip per keystroke while still reporting a real confirmed range. + while (!m_queue.isEmpty() && m_queue.first().immediate && + next.length + m_queue.first().length <= kMaxChunkChars) { + Chunk merged = m_queue.takeFirst(); + next.text += merged.text; + next.length += merged.length; + } + } + + m_inFlight = next; + m_hasInFlight = true; + + QString sanitized = next.text; + sanitized.remove(QChar(';')); // must never break CAT command framing + // Hardware-confirmed (twice, cleanly reproduced with no other keying involved): the K4's + // TX relay/ALC ramp-up isn't complete by the time it starts keying the first real element + // of a fresh KY send, so the actual first character gets clipped/eaten on the air. + // Sacrifice a throwaway leading 'E' (single dit, shortest possible element) to absorb the + // ramp-up instead of a real letter — the same idea as CwController's straight-key KZL + // pre-roll, applied here since KY text sends have no equivalent pre-roll parameter of + // their own. + emit sendCatRequested(QStringLiteral("KYE%1;").arg(sanitized)); + emit chunkInFlight(next.start, next.length); + + startTimeoutForCurrentChunk(); + // First poll deliberately delayed longer than the steady-state cadence — see the + // "first KY; poll" note in cwsendcontroller.h. + m_pollTimer->start(kFirstPollDelayMs); + + updateActiveState(); +} + +void CwSendController::pollTick() { + if (!m_hasInFlight) + return; // nothing to confirm — timer simply doesn't get re-armed + emit sendCatRequested(QStringLiteral("KY;")); + // Re-arm unconditionally on the steady cadence regardless of whether the previous poll + // was ever answered — a lost reply must not permanently stop polling (the chunk-level + // timeout, not this, is what eventually calls a truly dead link). + m_pollTimer->start(kPollIntervalMs); +} + +void CwSendController::onCatResponse(const QString &response) { + const QStringList commands = response.split(';', Qt::SkipEmptyParts); + for (const QString &cmd : commands) { + if (cmd == QLatin1String("KY0")) { + if (!m_hasInFlight) + continue; + m_timeoutTimer->stop(); + m_pollTimer->stop(); + emit chunkConfirmed(m_inFlight.start, m_inFlight.length); + m_hasInFlight = false; + maybeDispatchNext(); + } + // KY1 (still busy) needs no action — pollTick()'s own re-arm keeps asking. + } + updateActiveState(); +} + +void CwSendController::onDisconnected() { + // A stalled chunk already dropped m_active to false (nothing left queued/in-flight to + // suppress hardware CW over), but the dialog is still showing a stalled banner and a + // disabled input at that point — a disconnect must still reach it via aborted(). + if (!m_active && !m_stalled) + return; + resetAll(); + emit aborted(); +} + +void CwSendController::abort() { + resetAll(); + // Always sent, even with nothing queued — this is the panic button, and matches the + // existing ESC-shortcut convention elsewhere in the app of unconditionally forcing RX; + // rather than trying to infer whether the radio actually needs it. + emit sendCatRequested(QStringLiteral("RX;")); + emit aborted(); +} + +void CwSendController::setImmediateMode(bool immediate) { + m_immediateMode = immediate; +} + +void CwSendController::setKeyerSpeed(int wpm) { + if (wpm > 0) + m_wpm = wpm; +} + +void CwSendController::startTimeoutForCurrentChunk() { + const double ditMs = 1200.0 / qBound(8, m_wpm, 100); + // ~10 dit-units per character is a rough PARIS-timing average (letters + inter-element/ + // inter-character spacing); the slack factor absorbs R/W waits and network jitter on top. + const double expectedMs = qMax(1, m_inFlight.length) * 10.0 * ditMs; + const int timeoutMs = qBound(kMinTimeoutMs, static_cast(expectedMs * kTimeoutSlackFactor), kMaxTimeoutMs); + m_timeoutTimer->start(timeoutMs); +} + +void CwSendController::onChunkTimeout() { + if (!m_hasInFlight) + return; + m_stalled = true; + m_pollTimer->stop(); + emit chunkStalled(m_inFlight.start, m_inFlight.length); + m_hasInFlight = false; + // Nothing queued behind this is trustworthy once the link looks unhealthy — drop it too, + // rather than leaving activeChanged(true) latched with nothing actually in flight (that + // would permanently suppress hardware-driven CW for no reason). The dialog still shows + // the stalled chunk in its own color; this only clears internal bookkeeping. + m_queue.clear(); + m_pendingText.clear(); + updateActiveState(); +} + +void CwSendController::setCwModeActive(bool active) { + if (m_cwModeActive == active) + return; + m_cwModeActive = active; + if (!active && (m_active || m_stalled)) { + // Leaving CW mode is the operator's own action, not a link fault — reset silently, + // no "RX;" panic-stop (mirrors how the paddle/straight-key paths just stop emitting + // on a mode change rather than forcing TX off). + resetAll(); + emit aborted(); + } +} + +void CwSendController::resetAll() { + m_queue.clear(); + m_pendingText.clear(); + m_hasInFlight = false; + m_stalled = false; + m_pollTimer->stop(); + m_timeoutTimer->stop(); + // m_pendingStart is NOT reset here — it's a monotonic character offset shared with the + // dialog's own display-length bookkeeping (see CwSendDialog), so text already shown stays + // at the same coordinates and abort() doesn't have to wipe the screen to stay in sync. + updateActiveState(); +} + +void CwSendController::updateActiveState() { + const bool active = m_hasInFlight || !m_queue.isEmpty() || !m_pendingText.isEmpty(); + if (active != m_active) { + m_active = active; + emit activeChanged(active); + } +} diff --git a/src/controllers/cwsendcontroller.h b/src/controllers/cwsendcontroller.h new file mode 100644 index 00000000..6a9d94d7 --- /dev/null +++ b/src/controllers/cwsendcontroller.h @@ -0,0 +1,170 @@ +#ifndef CWSENDCONTROLLER_H +#define CWSENDCONTROLLER_H + +#include +#include +#include +#include + +// ============================================================================= +// CwSendController — types text into the K4's own CW/DATA text buffer (KY) +// ============================================================================= +// +// Distinct from CwController: that class orchestrates hardware-driven keying +// (paddle/straight key) on tight latency budgets across multiple threads. This +// controller drives the K4's `KY*[text];` buffer instead — the radio renders +// the text to CW itself, so there is no local Morse-timing engine here. It +// lives entirely on the main thread and is deliberately decoupled from +// ConnectionController/TcpClient: it only emits `sendCatRequested(QString)` +// and expects `onCatResponse()`/`onDisconnected()` to be fed from outside +// (MainWindow wires these to ConnectionController). This keeps the class +// testable with plain Qt6::Core — no network stack needed to drive it with +// synthetic KY0/KY1 strings. +// +// Confirmation model +// ------------------ +// The K4 protocol's only real feedback is `KY;` -> `KY0;`/`KY1;` (buffer +// empty / has data) — whole-buffer granularity, not per-character. To turn +// that into meaningful progress, typed text is cut into chunks (one word in +// word-complete mode, one merged run of characters in immediate mode) and +// sent STRICTLY SERIALLY: only one chunk is ever outstanding in the K4's +// buffer at a time, and the next chunk is not sent until `KY0` confirms the +// previous one landed. This is deliberately conservative — it works +// correctly regardless of whether the K4 appends or replaces on a second +// `KY` send while busy (that behavior has not been verified against real +// hardware). Once verified, word-complete mode could pipeline one chunk +// ahead to close the small inter-chunk gap this causes; see the "Step 0" +// protocol probe in the CW Send Dialog plan. +// +// A chunk that never gets its `KY0` within a speed-derived deadline is +// marked stalled: the queue is dropped (nothing is trusted to still be +// sittable in the K4's buffer once the link looks unhealthy), the active +// gate drops so hardware-driven CW un-suppresses itself, and the pipeline +// stays halted — no silent retry — until abort() or setCwModeActive(true) +// resets it. +// +// The first `KY;` poll after a dispatch is deliberately delayed longer than +// later ones (kFirstPollDelayMs vs kPollIntervalMs): querying too soon after +// the write risks reading a stale "buffer empty" status that predates the +// K4 actually ingesting the text, which would confirm a chunk that was +// never really keyed. This is a heuristic, not a verified protocol +// guarantee — the "Step 0" protocol probe in the CW Send Dialog plan should +// confirm whether it's necessary/sufficient on real hardware. +// +// Also gated on CW mode via setCwModeActive(): the dialog is modeless and +// can outlive a mode change, so this mirrors the mode check every +// hardware-driven CW path in cwcontroller.cpp already does — leaving CW +// resets the pipeline rather than continuing to push KY text at a +// phone-mode radio. +// ============================================================================= + +class CwSendController : public QObject { + Q_OBJECT + +public: + explicit CwSendController(QObject *parent = nullptr); + + // Feeds one typed character into the pipeline. In word-complete mode a + // chunk is cut (and dispatch attempted) on space/CR/LF; in immediate + // mode every character is its own chunk, merged with any others still + // queued at the moment it's actually dispatched. No-ops while stalled. + void appendChar(QChar ch); + + // Cuts and dispatches whatever's been typed since the last boundary, + // even without a trailing space — e.g. a manual "Send" action, or + // finalizing a trailing word before closing the dialog. + void flush(); + + // Clears all queued/pending/in-flight state, sends "RX;" to force the + // K4 out of TX immediately, and resets to idle. This is the panic + // button — it does not try to gracefully finish what was in flight. + void abort(); + + void setImmediateMode(bool immediate); + bool immediateMode() const { return m_immediateMode; } + +public slots: + // Feed raw CAT responses here (connect from ConnectionController::catResponseReceived). + // Filters for bare "KY0"/"KY1" tokens; everything else is ignored. + void onCatResponse(const QString &response); + + // Call when the radio link drops (e.g. ConnectionController::connectionStateChanged + // reports Disconnected). Same reset as abort() but does not attempt to send "RX;" + // over a dead link, and does not fire it at all if nothing was in progress. + void onDisconnected(); + + // Keeps the stall-timeout estimate honest as the operator's WPM changes. + void setKeyerSpeed(int wpm); + + // Wire to RadioState::modeChanged (CW/CW_R -> true, everything else -> false). appendChar()/ + // flush() no-op while false; going false while something is in progress resets exactly like + // abort(), minus the "RX;" (leaving CW mode is the operator's own doing, not a fault to + // panic-stop over). + void setCwModeActive(bool active); + +signals: + // Emit whatever CAT string needs to go to the K4 — caller wires this to + // ConnectionController::sendCAT. + void sendCatRequested(const QString &command); + + // Character ranges [start, start+length) in the operator's typed text, + // for the dialog to recolor. Queued/not-yet-sent text is the dialog's + // own concern (it's whatever hasn't had one of these signals fired for + // it yet); this class only reports on chunks once they're dispatched. + void chunkInFlight(int start, int length); + void chunkConfirmed(int start, int length); + void chunkStalled(int start, int length); + + // Fires after abort(), onDisconnected(), or setCwModeActive(false) actually reset + // non-idle state (in-flight/queued/pending, or a still-showing stalled chunk). + void aborted(); + + // True while anything is queued, pending, or in flight. Wire this to + // ConnectionController::setTextSendActive() so hardware-driven CW + // (paddle/straight key) can suppress itself while a text send is live. + void activeChanged(bool active); + +private slots: + void pollTick(); + void onChunkTimeout(); + +private: + struct Chunk { + QString text; + int start = 0; + int length = 0; + bool immediate = false; + }; + + void cutPendingChunk(); + void maybeDispatchNext(); + void startTimeoutForCurrentChunk(); + void resetAll(); + void updateActiveState(); + + QVector m_queue; + QString m_pendingText; + int m_pendingStart = 0; + + Chunk m_inFlight; + bool m_hasInFlight = false; + bool m_stalled = false; + bool m_immediateMode = false; + bool m_cwModeActive = false; + bool m_active = false; + int m_wpm = 25; + + QTimer *m_pollTimer; + QTimer *m_timeoutTimer; + + static constexpr int kFirstPollDelayMs = 300; + static constexpr int kPollIntervalMs = 120; + // KY command text limit is 60 chars (K4 Programmer's Reference); reserve 1 for the + // sacrificial lead-in character prepended at send time (see maybeDispatchNext()). + static constexpr int kMaxChunkChars = 59; + static constexpr int kTimeoutSlackFactor = 4; + static constexpr int kMinTimeoutMs = 800; + static constexpr int kMaxTimeoutMs = 20000; +}; + +#endif // CWSENDCONTROLLER_H diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0ed673f9..c0da2e5b 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -38,6 +38,8 @@ #include "ui/widgets/frequencydisplaywidget.h" #include "controllers/audiocontroller.h" #include "controllers/cwcontroller.h" +#include "controllers/cwsendcontroller.h" +#include "ui/dialogs/cwsenddialog.h" #include "controllers/hardwarecontroller.h" #include "controllers/kpa1500uicontroller.h" #include "network/catserver.h" @@ -115,6 +117,7 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), m_radioState(new m_connectionController->sendCAT("RX;"); m_audioController->setPttActive(false); m_bottomMenuBar->setPttActive(false); + m_cwSendController->abort(); }); setupNotificationWidget(); @@ -352,6 +355,38 @@ void MainWindow::setupHardwareController() { // Hardware-side errors (HaliKey port-open failures today) → notification overlay connect(m_hardwareController, &HardwareController::hardwareError, this, &MainWindow::onHardwareError); + + // CwSendController types text into the K4's KY buffer instead of driving hardware — see + // cwsendcontroller.h. Deliberately decoupled from ConnectionController/TcpClient (it only + // emits sendCatRequested and expects onCatResponse/onDisconnected fed from outside), so all + // the wiring to the real connection lives here rather than in its constructor. + m_cwSendController = new CwSendController(this); + connect(m_cwSendController, &CwSendController::sendCatRequested, m_connectionController, + &ConnectionController::sendCAT); + connect(m_connectionController, &ConnectionController::catResponseReceived, m_cwSendController, + &CwSendController::onCatResponse); + connect(m_connectionController, &ConnectionController::connectionStateChanged, m_cwSendController, + [this](TcpClient::ConnectionState state) { + if (state == TcpClient::Disconnected) + m_cwSendController->onDisconnected(); + }); + connect(m_radioState, &RadioState::keyerSpeedChanged, m_cwSendController, &CwSendController::setKeyerSpeed); + m_cwSendController->setKeyerSpeed(m_radioState->keyerSpeed()); + m_cwSendController->setImmediateMode(RadioSettings::instance()->cwSendImmediateMode()); + connect(RadioSettings::instance(), &RadioSettings::cwSendImmediateModeChanged, m_cwSendController, + &CwSendController::setImmediateMode); + // Suppresses hardware-driven CW (paddle/straight key) while a text send is queued/in-flight, + // and vice versa never happens since CwSendController only sends while nothing else is — + // see the gate additions in cwcontroller.cpp. + connect(m_cwSendController, &CwSendController::activeChanged, m_connectionController, + &ConnectionController::setTextSendActive); + + // Prime CW-mode state now that both m_bottomMenuBar (built in setupUi(), called before + // this) and m_cwSendController exist — the connect() above only reacts to future + // transitions, not the mode already in effect at startup. + const bool startingInCw = (m_radioState->mode() == RadioState::CW || m_radioState->mode() == RadioState::CW_R); + m_bottomMenuBar->setCwMode(startingInCw); + m_cwSendController->setCwModeActive(startingInCw); } void MainWindow::setupCatServer() { @@ -659,6 +694,27 @@ void MainWindow::setupUi() { m_bottomMenuBar->setPttActive(false); }); + // PTT is meaningless in CW (K4 is keyed via CAT, not mic audio) — the same button + // relabels to "CW" and opens the CW Send dialog instead. m_cwSendController doesn't + // exist yet at this point in setupUi() (it's constructed later in + // setupHardwareController()) — this only registers the connection; the initial-state + // priming call happens there instead, once it's safe to dereference. + connect(m_radioState, &RadioState::modeChanged, this, [this](RadioState::Mode mode) { + const bool isCw = (mode == RadioState::CW || mode == RadioState::CW_R); + m_bottomMenuBar->setCwMode(isCw); + m_cwSendController->setCwModeActive(isCw); + }); + connect(m_bottomMenuBar, &BottomMenuBar::cwSendRequested, this, [this]() { + if (!m_cwSendDialog) { + m_cwSendDialog = new CwSendDialog(m_cwSendController, this); + } else { + m_cwSendDialog->refreshMacros(); + } + m_cwSendDialog->show(); + m_cwSendDialog->raise(); + m_cwSendDialog->activateWindow(); + }); + // WHY: no audio flush on mode/filter change. AudioEngine runs on a dedicated thread with // a properly sized jitter buffer, so stale audio doesn't accumulate; a flush here would // cause a brief dropout on every mode/filter switch. diff --git a/src/mainwindow.h b/src/mainwindow.h index 3ebcd264..2ded762b 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -43,6 +43,8 @@ class FeatureMenuController; class ModePopupController; class HardwareController; class CwController; +class CwSendController; +class CwSendDialog; class DxClusterController; class KPA1500UiController; class CatServer; @@ -194,6 +196,10 @@ private slots: // Hardware controller (owns KPOD, HaliKey, IambicKeyer, SidetoneGenerator and their threads) HardwareController *m_hardwareController; CwController *m_cwController; + CwSendController *m_cwSendController; + + // CW Send dialog (lazy-created on first open, launched from the CW-mode BottomMenuBar button) + CwSendDialog *m_cwSendDialog = nullptr; // KPA1500 amplifier UI controller (owns the KPA1500Client) KPA1500UiController *m_kpa1500UiController; diff --git a/src/settings/radiosettings.cpp b/src/settings/radiosettings.cpp index 3c5d15f3..d3cc7574 100644 --- a/src/settings/radiosettings.cpp +++ b/src/settings/radiosettings.cpp @@ -328,6 +328,49 @@ void RadioSettings::setMacro(const QString &functionId, const QString &label, co } } +QMap RadioSettings::cwMacros() const { + return m_cwMacros; +} + +MacroEntry RadioSettings::cwMacro(const QString &functionId) const { + return m_cwMacros.value(functionId); +} + +void RadioSettings::setCwMacro(const QString &functionId, const QString &label, const QString &text) { + MacroEntry entry; + entry.functionId = functionId; + entry.label = label; + entry.command = text; + + if (text.isEmpty()) { + if (m_cwMacros.contains(functionId)) { + m_cwMacros.remove(functionId); + save(); + emit cwMacrosChanged(); + } + } else { + const bool changed = !m_cwMacros.contains(functionId) || m_cwMacros[functionId].label != label || + m_cwMacros[functionId].command != text; + if (changed) { + m_cwMacros[functionId] = entry; + save(); + emit cwMacrosChanged(); + } + } +} + +bool RadioSettings::cwSendImmediateMode() const { + return m_cwSendImmediateMode; +} + +void RadioSettings::setCwSendImmediateMode(bool immediate) { + if (m_cwSendImmediateMode != immediate) { + m_cwSendImmediateMode = immediate; + save(); + emit cwSendImmediateModeChanged(immediate); + } +} + namespace { // QSettings group per keyer role. Historical note: both roles were once a single "halikey" // group, migrated forward in load(). @@ -671,6 +714,22 @@ void RadioSettings::load() { } m_settings.endArray(); + // CW Send macros — own array, kept distinct from "macros" above. + int cwMacroCount = m_settings.beginReadArray("cwMacros"); + m_cwMacros.clear(); + for (int i = 0; i < cwMacroCount; ++i) { + m_settings.setArrayIndex(i); + MacroEntry entry; + entry.functionId = m_settings.value("functionId").toString(); + entry.label = m_settings.value("label").toString(); + entry.command = m_settings.value("command").toString(); + if (!entry.functionId.isEmpty()) { + m_cwMacros[entry.functionId] = entry; + } + } + m_settings.endArray(); + m_cwSendImmediateMode = m_settings.value("cwSend/immediateMode", false).toBool(); + // RX EQ Presets (4 slots) for (int i = 0; i < 4; ++i) { QString prefix = QString("rxEqPresets/%1/").arg(i); @@ -772,6 +831,18 @@ void RadioSettings::save() { } m_settings.endArray(); + // CW Send macros — own array, kept distinct from "macros" above. + m_settings.beginWriteArray("cwMacros"); + int cwI = 0; + for (auto it = m_cwMacros.constBegin(); it != m_cwMacros.constEnd(); ++it, ++cwI) { + m_settings.setArrayIndex(cwI); + m_settings.setValue("functionId", it->functionId); + m_settings.setValue("label", it->label); + m_settings.setValue("command", it->command); + } + m_settings.endArray(); + m_settings.setValue("cwSend/immediateMode", m_cwSendImmediateMode); + // DX Cluster settings m_settings.beginWriteArray("dxClusters"); for (int j = 0; j < m_dxClusters.size(); ++j) { diff --git a/src/settings/radiosettings.h b/src/settings/radiosettings.h index e269db84..616e2b31 100644 --- a/src/settings/radiosettings.h +++ b/src/settings/radiosettings.h @@ -107,6 +107,19 @@ class RadioSettings : public QObject { MacroEntry macro(const QString &functionId) const; void setMacro(const QString &functionId, const QString &label, const QString &command); + // CW Send macros — canned messages for the CW Send dialog. Reuses MacroEntry (its + // functionId/label/command fields map onto slot-id/label/message-text cleanly) but is + // stored under its own QSettings array so it can never cross-contaminate the PF/Fn/K-pod + // CAT-command macro table above. See src/utils/macroids.h for the CwMacro1..5 slot ids. + QMap cwMacros() const; + MacroEntry cwMacro(const QString &functionId) const; + void setCwMacro(const QString &functionId, const QString &label, const QString &text); + + // CW Send dialog: whether typed characters key immediately, or only once a word is + // completed (space/Enter). Default false (word-complete) — see CW Send Dialog design. + bool cwSendImmediateMode() const; + void setCwSendImmediateMode(bool immediate); + // Keyer device roles. Two HaliKey-class interfaces may be connected at once: a paddle // driving the iambic keyer, and a straight key/bug driving KZD/U elements directly. // Which role an input plays is now decided by the device it arrived on, replacing the @@ -194,6 +207,8 @@ class RadioSettings : public QObject { void catServerEnabledChanged(bool enabled); void catServerPortChanged(quint16 port); void macrosChanged(); + void cwMacrosChanged(); + void cwSendImmediateModeChanged(bool immediate); // Port, device type or auto-connect changed for one role (RadioSettings::KeyerRole). void keyerConfigChanged(int role); void straightKeyTimingChanged(); @@ -238,6 +253,10 @@ class RadioSettings : public QObject { // Macro settings QMap m_macros; + // CW Send macros (separate from m_macros — see cwMacros() above) and mode setting. + QMap m_cwMacros; + bool m_cwSendImmediateMode = false; + // RX EQ Presets (4 slots) EqPreset m_rxEqPresets[4]; diff --git a/src/ui/dialogs/cwsenddialog.cpp b/src/ui/dialogs/cwsenddialog.cpp new file mode 100644 index 00000000..2929fe09 --- /dev/null +++ b/src/ui/dialogs/cwsenddialog.cpp @@ -0,0 +1,210 @@ +#include "ui/dialogs/cwsenddialog.h" + +#include "controllers/cwsendcontroller.h" +#include "settings/radiosettings.h" +#include "ui/styling/k4styles.h" +#include "utils/macroids.h" + +#include +#include +#include +#include + +namespace { +const QVector kSlotIds = {MacroIds::CwMacro1, MacroIds::CwMacro2, MacroIds::CwMacro3, MacroIds::CwMacro4, + MacroIds::CwMacro5, MacroIds::CwMacro6, MacroIds::CwMacro7, MacroIds::CwMacro8}; + +const Qt::Key kSlotKeys[] = {Qt::Key_F1, Qt::Key_F2, Qt::Key_F3, Qt::Key_F4, + Qt::Key_F5, Qt::Key_F6, Qt::Key_F7, Qt::Key_F8}; +} + +CwSendDialog::CwSendDialog(CwSendController *controller, QWidget *parent) + : QDialog(parent), m_controller(controller) { + setWindowTitle("CW Send"); + setWindowModality(Qt::NonModal); + resize(560, 400); + + setStyleSheet(QString("QDialog { background-color: %1; }").arg(K4Styles::Colors::Background)); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(K4Styles::Dimensions::DialogMargin, K4Styles::Dimensions::DialogMargin, + K4Styles::Dimensions::DialogMargin, K4Styles::Dimensions::DialogMargin); + + m_stalledBanner = new QLabel(this); + m_stalledBanner->setWordWrap(true); + m_stalledBanner->setStyleSheet(QString("QLabel { background-color: %1; color: %2; " + "padding: 6px; border-radius: 4px; font-weight: bold; }") + .arg(K4Styles::Colors::ErrorBgDark, K4Styles::Colors::ErrorRed)); + m_stalledBanner->hide(); + layout->addWidget(m_stalledBanner); + + m_display = new QTextEdit(this); + m_display->setReadOnly(true); + m_display->setStyleSheet(QString("QTextEdit { background-color: %1; color: %2; border: 1px solid %3; " + "font-size: 14px; padding: 6px; }") + .arg(K4Styles::Colors::DarkBackground, K4Styles::Colors::TextGray, + K4Styles::Colors::DialogBorder)); + layout->addWidget(m_display, 1); + + m_input = new QLineEdit(this); + m_input->setPlaceholderText("Type here to send CW..."); + m_input->setStyleSheet(K4Styles::Dialog::lineEdit()); + layout->addWidget(m_input); + + auto *macroRow1 = new QHBoxLayout(); + auto *macroRow2 = new QHBoxLayout(); + for (int i = 0; i < kSlotIds.size(); ++i) { + auto *btn = new QPushButton(QStringLiteral("F%1").arg(i + 1), this); + btn->setStyleSheet(K4Styles::menuBarButton()); + // Without this, Qt makes the first QPushButton in the dialog its implicit "default" + // button — pressing Enter in m_input then fires both returnPressed() AND a click on + // this button, sending its macro on every Enter regardless of which button it is. + btn->setAutoDefault(false); + connect(btn, &QPushButton::clicked, this, [this, i]() { onMacroClicked(i); }); + (i < 4 ? macroRow1 : macroRow2)->addWidget(btn); + m_macroButtons.append(btn); + + auto *shortcut = new QShortcut(QKeySequence(kSlotKeys[i]), this); + connect(shortcut, &QShortcut::activated, this, [this, i]() { onMacroClicked(i); }); + } + layout->addLayout(macroRow1); + layout->addLayout(macroRow2); + + auto *bottomRow = new QHBoxLayout(); + m_abortBtn = new QPushButton("Abort", this); + m_abortBtn->setStyleSheet(QString("QPushButton { background-color: %1; color: %2; font-weight: bold; " + "border-radius: 4px; padding: 6px 16px; }") + .arg(K4Styles::Colors::ErrorBgDark, K4Styles::Colors::ErrorRed)); + m_abortBtn->setAutoDefault(false); // same Enter-in-lineedit gotcha as the macro buttons + connect(m_abortBtn, &QPushButton::clicked, this, [this]() { m_controller->abort(); }); + bottomRow->addStretch(); + bottomRow->addWidget(m_abortBtn); + layout->addLayout(bottomRow); + + connect(m_input, &QLineEdit::textEdited, this, &CwSendDialog::onInputTextEdited); + connect(m_input, &QLineEdit::returnPressed, this, &CwSendDialog::onReturnPressed); + + connect(m_controller, &CwSendController::chunkInFlight, this, + [this](int start, int length) { recolorRange(start, length, K4Styles::Colors::TextFaded); }); + connect(m_controller, &CwSendController::chunkConfirmed, this, + [this](int start, int length) { recolorRange(start, length, K4Styles::Colors::AccentAmber); }); + connect(m_controller, &CwSendController::chunkStalled, this, [this](int start, int length) { + recolorRange(start, length, K4Styles::Colors::ErrorRed); + m_stalled = true; + m_stalledBanner->setText("No confirmation from the K4 — sending stopped. Click Abort to reset."); + m_stalledBanner->show(); + m_input->setEnabled(false); + }); + connect(m_controller, &CwSendController::aborted, this, &CwSendDialog::onAborted); + + refreshMacros(); +} + +void CwSendDialog::refreshMacros() { + auto *rs = RadioSettings::instance(); + for (int i = 0; i < m_macroButtons.size(); ++i) { + const MacroEntry entry = rs->cwMacro(kSlotIds[i]); + const QString label = entry.label.isEmpty() ? QStringLiteral("F%1").arg(i + 1) : entry.label; + m_macroButtons[i]->setText(label); + m_macroButtons[i]->setToolTip(entry.command); + m_macroButtons[i]->setEnabled(!entry.command.isEmpty()); + } +} + +void CwSendDialog::commitText(const QString &text) { + if (m_stalled) + return; // controller already ignores appendChar() while stalled — don't echo grey + // text the K4 will never actually see + for (const QChar &ch : text) { + appendToDisplay(ch); + m_controller->appendChar(ch); + } +} + +void CwSendDialog::appendToDisplay(QChar ch) { + QTextCursor cursor(m_display->document()); + cursor.movePosition(QTextCursor::End); + QTextCharFormat fmt; + fmt.setForeground(QColor(K4Styles::Colors::TextGray)); + cursor.insertText(QString(ch), fmt); + m_displayLength++; +} + +void CwSendDialog::recolorRange(int start, int length, const QString &colorHex) { + if (length <= 0 || start < 0 || start + length > m_displayLength) + return; + QTextCursor cursor(m_display->document()); + cursor.setPosition(start); + cursor.setPosition(start + length, QTextCursor::KeepAnchor); + QTextCharFormat fmt; + fmt.setForeground(QColor(colorHex)); + cursor.mergeCharFormat(fmt); +} + +void CwSendDialog::onAborted() { + // Abort stops sending — it doesn't erase what's already been typed or shown. Whatever was + // queued/in-flight at the moment of abort simply stays frozen in whatever color it last + // had (grey/faded); nothing further will recolor it since the controller's pipeline is + // now empty. m_input's own not-yet-committed text is left untouched too. + m_stalled = false; + m_stalledBanner->hide(); + m_input->setEnabled(true); + refreshMacros(); +} + +void CwSendDialog::finishPendingWord() { + const QString remaining = m_input->text(); + if (!remaining.isEmpty()) { + commitText(remaining); + m_input->clear(); + } + m_controller->flush(); +} + +void CwSendDialog::onInputTextEdited(const QString &text) { + if (m_controller->immediateMode()) { + // Every keystroke commits right away — the field never actually accumulates. + commitText(text); + m_input->blockSignals(true); + m_input->clear(); + m_input->blockSignals(false); + return; + } + + // Word-complete: commit any complete (space-terminated) prefix, leave the rest editable + // (including normal backspacing of a not-yet-committed word). + const int lastSpace = text.lastIndexOf(QChar(' ')); + if (lastSpace < 0) + return; + + const QString toCommit = text.left(lastSpace + 1); + const QString remainder = text.mid(lastSpace + 1); + commitText(toCommit); + m_input->blockSignals(true); + m_input->setText(remainder); + m_input->blockSignals(false); +} + +void CwSendDialog::onReturnPressed() { + finishPendingWord(); +} + +void CwSendDialog::onMacroClicked(int slotIndex) { + // Finish whatever's mid-typing first so macro text doesn't interleave into a partial word. + finishPendingWord(); + + const MacroEntry entry = RadioSettings::instance()->cwMacro(kSlotIds[slotIndex]); + if (entry.command.isEmpty()) + return; + + // Macro text doesn't carry its own leading space, so back-to-back macro clicks (or a + // macro right after manually typed text) would otherwise run together with no word gap. + if (m_displayLength > 0) { + const QString existing = m_display->toPlainText(); + if (!existing.isEmpty() && !existing.back().isSpace()) + commitText(QStringLiteral(" ")); + } + + commitText(entry.command); + m_controller->flush(); // dispatch a trailing partial word if the macro doesn't end in a space +} diff --git a/src/ui/dialogs/cwsenddialog.h b/src/ui/dialogs/cwsenddialog.h new file mode 100644 index 00000000..8eb7e054 --- /dev/null +++ b/src/ui/dialogs/cwsenddialog.h @@ -0,0 +1,54 @@ +#ifndef CWSENDDIALOG_H +#define CWSENDDIALOG_H + +#include +#include +#include +#include +#include +#include + +class CwSendController; + +/** + * @brief Modeless dialog for typing text to be keyed as CW on the K4, via CwSendController. + * + * The line edit holds only the word currently being typed (freely editable, including + * backspace) — a finished word (space/Enter) moves into the read-only history area below as + * grey text and is simultaneously handed to the controller. The controller's signals then + * recolor that same range: mid-tone while the K4 has the chunk in its KY buffer, amber once + * `KY0` confirms it was actually keyed, red if that confirmation times out. Nothing brightens + * on a local timing guess — only on real K4 feedback. + */ +class CwSendDialog : public QDialog { + Q_OBJECT + +public: + explicit CwSendDialog(CwSendController *controller, QWidget *parent = nullptr); + + void refreshMacros(); + +private: + void commitText(const QString &text); + void appendToDisplay(QChar ch); + void recolorRange(int start, int length, const QString &colorHex); + void onAborted(); // stops sending / clears the stall state — does NOT clear the display + void finishPendingWord(); // commits + flushes whatever's left in the input, unconditionally + + void onInputTextEdited(const QString &text); + void onReturnPressed(); + void onMacroClicked(int slotIndex); + + CwSendController *m_controller; // not owned + + QTextEdit *m_display = nullptr; + QLineEdit *m_input = nullptr; + QPushButton *m_abortBtn = nullptr; + QLabel *m_stalledBanner = nullptr; + QVector m_macroButtons; + + int m_displayLength = 0; // character offsets here must match CwSendController's own count + bool m_stalled = false; // blocks further typing/macros until abort() resets the dialog +}; + +#endif // CWSENDDIALOG_H diff --git a/src/ui/dialogs/optionsdialog.cpp b/src/ui/dialogs/optionsdialog.cpp index 50aa5262..2c4fcc47 100644 --- a/src/ui/dialogs/optionsdialog.cpp +++ b/src/ui/dialogs/optionsdialog.cpp @@ -10,6 +10,7 @@ #include "ui/pages/rigcontrolpage.h" #include "ui/pages/cwkeyerpage.h" #include "ui/pages/straightkeypage.h" +#include "ui/pages/cwmacrospage.h" #include "ui/pages/kpodpage.h" #include "ui/pages/kpa1500page.h" #include "ui/pages/dxclusterpage.h" @@ -65,6 +66,7 @@ void OptionsDialog::setupUi() { m_tabList->addItem("Rig Control"); m_tabList->addItem("Keyer"); m_tabList->addItem("Straight Key"); + m_tabList->addItem("CW Macros"); m_tabList->addItem("K-Pod"); m_tabList->addItem("KPA1500"); m_tabList->addItem("DX Cluster"); @@ -151,6 +153,10 @@ void OptionsDialog::ensurePageCreated(int index) { m_hardwareController->keyerDevice(), this); page = m_straightKeyPage; break; + case PageCwMacros: + m_cwMacrosPage = new CwMacrosPage(this); + page = m_cwMacrosPage; + break; case PageKpod: m_kpodPage = new KpodPage(m_hardwareController->kpodDevice(), m_hardwareController->kpodPlusDevice(), this); page = m_kpodPage; @@ -210,6 +216,10 @@ void OptionsDialog::refreshPage(int index) { if (m_straightKeyPage) m_straightKeyPage->refresh(); break; + case PageCwMacros: + if (m_cwMacrosPage) + m_cwMacrosPage->refresh(); + break; case PageKpod: if (m_kpodPage) m_kpodPage->refresh(); diff --git a/src/ui/dialogs/optionsdialog.h b/src/ui/dialogs/optionsdialog.h index f09db65c..7273f045 100644 --- a/src/ui/dialogs/optionsdialog.h +++ b/src/ui/dialogs/optionsdialog.h @@ -22,6 +22,7 @@ class RigControlPage; class ConnectionController; class CwKeyerPage; class StraightKeyPage; +class CwMacrosPage; class KpodPage; class Kpa1500Page; class DxClusterPage; @@ -43,6 +44,7 @@ class OptionsDialog : public QDialog { PageRigControl, PageCwKeyer, PageStraightKey, + PageCwMacros, PageKpod, PageKpa1500, PageDxCluster, @@ -84,6 +86,7 @@ class OptionsDialog : public QDialog { RigControlPage *m_rigControlPage = nullptr; StraightKeyPage *m_straightKeyPage = nullptr; CwKeyerPage *m_cwKeyerPage = nullptr; + CwMacrosPage *m_cwMacrosPage = nullptr; KpodPage *m_kpodPage = nullptr; Kpa1500Page *m_kpa1500Page = nullptr; DxClusterPage *m_dxClusterPage = nullptr; diff --git a/src/ui/pages/cwmacrospage.cpp b/src/ui/pages/cwmacrospage.cpp new file mode 100644 index 00000000..723fc787 --- /dev/null +++ b/src/ui/pages/cwmacrospage.cpp @@ -0,0 +1,122 @@ +#include "ui/pages/cwmacrospage.h" + +#include "settings/radiosettings.h" +#include "ui/styling/k4styles.h" +#include "utils/macroids.h" + +#include +#include +#include +#include + +namespace { +QFrame *separator(QWidget *parent) { + auto *line = new QFrame(parent); + line->setFrameShape(QFrame::HLine); + line->setStyleSheet(K4Styles::Dialog::separator()); + line->setFixedHeight(K4Styles::Dimensions::SeparatorHeight); + return line; +} + +const QVector kSlotIds = {MacroIds::CwMacro1, MacroIds::CwMacro2, MacroIds::CwMacro3, MacroIds::CwMacro4, + MacroIds::CwMacro5, MacroIds::CwMacro6, MacroIds::CwMacro7, MacroIds::CwMacro8}; +} // namespace + +CwMacrosPage::CwMacrosPage(QWidget *parent) : QWidget(parent) { + setStyleSheet(K4Styles::Dialog::pageBackground()); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(K4Styles::Dimensions::DialogMargin, K4Styles::Dimensions::DialogMargin, + K4Styles::Dimensions::DialogMargin, K4Styles::Dimensions::DialogMargin); + layout->setSpacing(K4Styles::Dimensions::PaddingLarge); + + auto *title = new QLabel("CW Macros", this); + title->setStyleSheet(K4Styles::Dialog::titleLabel()); + layout->addWidget(title); + + auto *desc = new QLabel("Canned messages for the CW Send dialog's macro buttons. Label is what shows " + "on the button; message is what gets keyed.", + this); + desc->setWordWrap(true); + desc->setStyleSheet(K4Styles::Dialog::helpText()); + layout->addWidget(desc); + + for (int i = 0; i < kSlotIds.size(); ++i) { + auto *row = new QHBoxLayout(); + + auto *slotLabel = new QLabel(QStringLiteral("F%1").arg(i + 1), this); + slotLabel->setStyleSheet(K4Styles::Dialog::formLabel()); + slotLabel->setFixedWidth(30); + row->addWidget(slotLabel); + + MacroRow macroRow; + macroRow.functionId = kSlotIds[i]; + + macroRow.labelEdit = new QLineEdit(this); + macroRow.labelEdit->setPlaceholderText("Button label"); + macroRow.labelEdit->setStyleSheet(K4Styles::Dialog::lineEdit()); + macroRow.labelEdit->setFixedWidth(140); + row->addWidget(macroRow.labelEdit); + + macroRow.textEdit = new QLineEdit(this); + macroRow.textEdit->setPlaceholderText("Message to send"); + macroRow.textEdit->setStyleSheet(K4Styles::Dialog::lineEdit()); + row->addWidget(macroRow.textEdit, 1); + + layout->addLayout(row); + + const QString functionId = macroRow.functionId; + connect(macroRow.labelEdit, &QLineEdit::editingFinished, this, [this, functionId]() { + for (const MacroRow &r : m_rows) { + if (r.functionId == functionId) { + RadioSettings::instance()->setCwMacro(functionId, r.labelEdit->text(), r.textEdit->text()); + break; + } + } + }); + connect(macroRow.textEdit, &QLineEdit::editingFinished, this, [this, functionId]() { + for (const MacroRow &r : m_rows) { + if (r.functionId == functionId) { + RadioSettings::instance()->setCwMacro(functionId, r.labelEdit->text(), r.textEdit->text()); + break; + } + } + }); + + m_rows.append(macroRow); + } + + layout->addWidget(separator(this)); + + auto *modeHeader = new QLabel("Send Mode", this); + modeHeader->setStyleSheet(K4Styles::Dialog::sectionHeader()); + layout->addWidget(modeHeader); + + m_immediateModeCheck = new QCheckBox("Send characters immediately as typed", this); + m_immediateModeCheck->setStyleSheet(K4Styles::Dialog::checkBox()); + m_immediateModeCheck->setChecked(RadioSettings::instance()->cwSendImmediateMode()); + connect(m_immediateModeCheck, &QCheckBox::toggled, this, + [](bool checked) { RadioSettings::instance()->setCwSendImmediateMode(checked); }); + layout->addWidget(m_immediateModeCheck); + + auto *modeHelp = new QLabel("Off (default): a word is sent once you finish it with a space or Enter. " + "On: every character is sent as you type it. Either way, text only " + "brightens on screen once the K4 actually confirms it was keyed.", + this); + modeHelp->setWordWrap(true); + modeHelp->setStyleSheet(K4Styles::Dialog::helpText()); + layout->addWidget(modeHelp); + + layout->addStretch(); + refresh(); +} + +void CwMacrosPage::refresh() { + auto *rs = RadioSettings::instance(); + for (const MacroRow &row : m_rows) { + const MacroEntry entry = rs->cwMacro(row.functionId); + row.labelEdit->setText(entry.label); + row.textEdit->setText(entry.command); + } + m_immediateModeCheck->setChecked(rs->cwSendImmediateMode()); +} diff --git a/src/ui/pages/cwmacrospage.h b/src/ui/pages/cwmacrospage.h new file mode 100644 index 00000000..7b57d61f --- /dev/null +++ b/src/ui/pages/cwmacrospage.h @@ -0,0 +1,35 @@ +#ifndef CWMACROSPAGE_H +#define CWMACROSPAGE_H + +#include +#include +#include +#include + +/** + * @brief Options page for the CW Send dialog: five canned-message macro slots (label + + * message text) and the persistent immediate-vs-word-complete send mode setting. + * + * Macro editing lives here, not in the CW Send dialog itself — mirrors how the Keyer/ + * Straight Key pages hold settings for the CW Send dialog's hardware-keying counterparts. + */ +class CwMacrosPage : public QWidget { + Q_OBJECT + +public: + explicit CwMacrosPage(QWidget *parent = nullptr); + + void refresh(); + +private: + struct MacroRow { + QString functionId; + QLineEdit *labelEdit = nullptr; + QLineEdit *textEdit = nullptr; + }; + + QVector m_rows; + QCheckBox *m_immediateModeCheck = nullptr; +}; + +#endif // CWMACROSPAGE_H diff --git a/src/ui/widgets/bottommenubar.cpp b/src/ui/widgets/bottommenubar.cpp index 60af12fa..943c31d7 100644 --- a/src/ui/widgets/bottommenubar.cpp +++ b/src/ui/widgets/bottommenubar.cpp @@ -51,9 +51,20 @@ void BottomMenuBar::setupUi() { connect(m_subRxBtn, &QPushButton::clicked, this, &BottomMenuBar::subRxClicked); connect(m_txBtn, &QPushButton::clicked, this, &BottomMenuBar::txClicked); - // PTT uses press/release for momentary activation - connect(m_pttBtn, &QPushButton::pressed, this, &BottomMenuBar::pttPressed); - connect(m_pttBtn, &QPushButton::released, this, &BottomMenuBar::pttReleased); + // PTT uses press/release for momentary activation. In CW mode the same button becomes + // "CW" and a plain click opens the CW Send dialog instead — see setCwMode(). + connect(m_pttBtn, &QPushButton::pressed, this, [this]() { + if (!m_cwMode) + emit pttPressed(); + }); + connect(m_pttBtn, &QPushButton::released, this, [this]() { + if (!m_cwMode) + emit pttReleased(); + }); + connect(m_pttBtn, &QPushButton::clicked, this, [this]() { + if (m_cwMode) + emit cwSendRequested(); + }); // Right-click toggle (latch) mode for PTT with 180-second safety timeout m_pttLockTimer = new QTimer(this); @@ -143,8 +154,29 @@ void BottomMenuBar::setPttActive(bool active) { } } +void BottomMenuBar::setCwMode(bool cwMode) { + if (m_cwMode == cwMode) + return; + + if (cwMode && m_pttLocked) { + // The right-click PTT latch has no meaning in CW mode (there's no audio-PTT to hold), + // and leaving it engaged would strand the radio in TX behind a relabeled button that + // no longer offers a way to drop it — force-release first. + m_pttLocked = false; + m_pttLockTimer->stop(); + setPttActive(false); + emit pttReleased(); + } + + m_cwMode = cwMode; + m_pttBtn->setText(cwMode ? "CW" : "PTT"); + m_pttBtn->setStyleSheet(K4Styles::menuBarButton()); +} + bool BottomMenuBar::eventFilter(QObject *watched, QEvent *event) { if (watched == m_pttBtn) { + if (m_cwMode) + return QWidget::eventFilter(watched, event); // no PTT latch concept in CW mode if (event->type() == QEvent::MouseButtonPress) { auto *me = static_cast(event); if (me->button() == Qt::RightButton) { diff --git a/src/ui/widgets/bottommenubar.h b/src/ui/widgets/bottommenubar.h index 76740ca4..ba27ec8d 100644 --- a/src/ui/widgets/bottommenubar.h +++ b/src/ui/widgets/bottommenubar.h @@ -42,6 +42,14 @@ public slots: void setTxActive(bool active); // Toggle TX button inverse colors void setPttActive(bool active); // Toggle PTT button inverse colors + // Relabels the far-right button PTT <-> CW and switches its click behavior. PTT is + // meaningless in CW (K4 is keyed via CAT, not mic audio) so the button becomes a launcher + // for the CW Send dialog instead. If the right-click PTT latch is engaged at the moment + // this flips to true, it is force-released first so the radio can't be stranded in TX + // with no way to drop it (mirrors CwController's V1.4 PTT-destination cleanup on mode + // change — same class of hazard). + void setCwMode(bool cwMode); + signals: void menuClicked(); void fnClicked(); @@ -52,6 +60,7 @@ public slots: void txClicked(); void pttPressed(); // PTT button pressed (start TX audio) void pttReleased(); // PTT button released (stop TX audio) + void cwSendRequested(); // CW button clicked while in CW mode — open the CW Send dialog protected: bool eventFilter(QObject *watched, QEvent *event) override; @@ -71,6 +80,7 @@ public slots: QPushButton *m_pttBtn; bool m_pttLocked = false; + bool m_cwMode = false; QTimer *m_pttLockTimer = nullptr; }; diff --git a/src/utils/macroids.h b/src/utils/macroids.h index 332c3aeb..363b7c24 100644 --- a/src/utils/macroids.h +++ b/src/utils/macroids.h @@ -28,6 +28,17 @@ const QString FnF8 = "Fn.F8"; // Special buttons const QString RemAnt = "REM_ANT"; +// CW Send dialog macro slots (canned CW messages, distinct from the CAT-command +// macros above — see RadioSettings::cwMacro()/setCwMacro()). +const QString CwMacro1 = "CW.Macro1"; +const QString CwMacro2 = "CW.Macro2"; +const QString CwMacro3 = "CW.Macro3"; +const QString CwMacro4 = "CW.Macro4"; +const QString CwMacro5 = "CW.Macro5"; +const QString CwMacro6 = "CW.Macro6"; +const QString CwMacro7 = "CW.Macro7"; +const QString CwMacro8 = "CW.Macro8"; + // KPOD buttons (T=Tap, H=Hold) const QString Kpod1T = "K-pod.1T"; const QString Kpod1H = "K-pod.1H"; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cb1e6670..142a2895 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -216,6 +216,16 @@ target_include_directories(test_iambickeyer PRIVATE ${CMAKE_SOURCE_DIR}/src) target_link_libraries(test_iambickeyer Qt6::Core Qt6::Test) add_test(NAME IambicKeyerTests COMMAND test_iambickeyer) +# CwSendController state machine — chunking/confirm/timeout logic, driven with synthetic +# KY0/KY1 responses. Decoupled from ConnectionController/TcpClient by design, so no network +# linkage is needed here. +add_executable(test_cwsendcontroller test_cwsendcontroller.cpp + ${CMAKE_SOURCE_DIR}/src/controllers/cwsendcontroller.cpp +) +target_include_directories(test_cwsendcontroller PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(test_cwsendcontroller Qt6::Core Qt6::Test) +add_test(NAME CwSendControllerTests COMMAND test_cwsendcontroller) + # KPOD+ USB worker — pure-logic tests (command builders, EP02 trim, response decoder) add_executable(test_kpodplususbworker test_kpodplususbworker.cpp ${CMAKE_SOURCE_DIR}/src/hardware/kpodplususbworker.cpp diff --git a/tests/test_cwsendcontroller.cpp b/tests/test_cwsendcontroller.cpp new file mode 100644 index 00000000..76eff3fe --- /dev/null +++ b/tests/test_cwsendcontroller.cpp @@ -0,0 +1,252 @@ +// Behavioral tests for CwSendController — the state machine that turns typed text into +// K4 `KY*[text];` sends, confirmed strictly serially by polling `KY;` for `KY0`/`KY1`. +// +// CwSendController is deliberately decoupled from ConnectionController/TcpClient (see its +// header): it only emits sendCatRequested(QString) and expects onCatResponse()/ +// onDisconnected() fed from outside. That means these tests drive it exactly like a real +// K4 link would — feeding synthetic "KY0;"/"KY1;" strings back in — without any network +// stack or live radio. + +#include "controllers/cwsendcontroller.h" +#include +#include + +class TestCwSendController : public QObject { + Q_OBJECT + +private slots: + // Nothing sends until the radio is confirmed in CW mode — the dialog is modeless and can + // outlive a mode change, so this must not depend on the dialog closing itself. + void appendCharNoOpsUntilCwModeConfirmed() { + CwSendController ctrl; + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), 0); + + ctrl.setCwModeActive(true); + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), 1); + } + + // Leaving CW mode mid-send resets the pipeline (like abort()) but must NOT send "RX;" — + // that's the operator's own mode change, not a fault to panic-stop over. + void leavingCwModeResetsWithoutSendingRx() { + CwSendController ctrl; + ctrl.setCwModeActive(true); + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + QSignalSpy aborted(&ctrl, &CwSendController::aborted); + + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + const int sentBeforeModeChange = sent.count(); + + ctrl.setCwModeActive(false); + QCOMPARE(aborted.count(), 1); + QCOMPARE(sent.count(), sentBeforeModeChange); // no "RX;" sent + + // Still inert while mode is off. + for (QChar ch : QStringLiteral("DE ")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), sentBeforeModeChange); + } + + // Typing a word without a trailing boundary character sends nothing yet — word-complete + // mode (the default) only cuts a chunk on space/CR/LF. + void wordCompleteWaitsForBoundary() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + for (QChar ch : QStringLiteral("HELLO")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), 0); + } + + // The first KY; poll after a dispatch is delayed longer than steady-state cadence — see + // the header note on why querying too soon after the write is risky. This only checks + // the delay is real, not the exact hardware-dependent duration. + void firstPollIsDelayedLongerThanStandardCadence() { + CwSendController ctrl; + ctrl.setCwModeActive(true); + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), 1); // just the KY write, no poll yet + + QTest::qWait(150); // well under the first-poll delay + QCOMPARE(sent.count(), 1); + + QVERIFY(sent.wait(2000)); // the delayed first "KY;" poll eventually goes out + QCOMPARE(sent.count(), 2); + QCOMPARE(sent.at(1).at(0).toString(), QStringLiteral("KY;")); + } + + // A completed word sends exactly one KY chunk and reports it in-flight; it only reports + // confirmed once a real KY0 arrives, never before. + void wordCompleteSendsOnBoundaryAndConfirmsOnKY0() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + QSignalSpy inFlight(&ctrl, &CwSendController::chunkInFlight); + QSignalSpy confirmed(&ctrl, &CwSendController::chunkConfirmed); + + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + + QCOMPARE(sent.count(), 1); + QVERIFY(sent.at(0).at(0).toString().startsWith(QStringLiteral("KYECQ "))); + QCOMPARE(inFlight.count(), 1); + QCOMPARE(inFlight.at(0).at(0).toInt(), 0); + QCOMPARE(inFlight.at(0).at(1).toInt(), 3); + QCOMPARE(confirmed.count(), 0); + + ctrl.onCatResponse("KY1;"); + QCOMPARE(confirmed.count(), 0); // still busy — must not confirm on KY1 + + ctrl.onCatResponse("KY0;"); + QCOMPARE(confirmed.count(), 1); + QCOMPARE(confirmed.at(0).at(0).toInt(), 0); + QCOMPARE(confirmed.at(0).at(1).toInt(), 3); + } + + // Strictly serial: a second word typed while the first is still in flight must not be + // sent until the first is confirmed. + void secondChunkWaitsForFirstConfirm() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), 1); // just the KY send, no poll yet in this count path + + for (QChar ch : QStringLiteral("DE ")) + ctrl.appendChar(ch); + // "DE " is queued, not dispatched — sendCatRequested should not have grown from a + // second KY send yet (only polls, if any arrived, would add to this spy). + int sendsBeforeConfirm = sent.count(); + + ctrl.onCatResponse("KY0;"); + QVERIFY(sent.count() > sendsBeforeConfirm); // the second chunk went out after confirm + const QString secondSend = sent.at(sent.count() - 1).at(0).toString(); + QVERIFY(secondSend.startsWith(QStringLiteral("KYEDE "))); + } + + // Immediate mode cuts a chunk per character but merges whatever's queued at dispatch time + // into one send — confirming the whole outstanding run at once, not per character. + void immediateModeMergesQueuedCharsIntoOneSend() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + ctrl.setImmediateMode(true); + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + QSignalSpy inFlight(&ctrl, &CwSendController::chunkInFlight); + + // First char dispatches immediately (nothing else queued yet). + ctrl.appendChar('A'); + QCOMPARE(sent.count(), 1); + QCOMPARE(sent.at(0).at(0).toString(), QStringLiteral("KYEA;")); + + // Typed while 'A' is still unconfirmed — must queue, not send yet. + ctrl.appendChar('B'); + ctrl.appendChar('C'); + QCOMPARE(sent.count(), 1); + + ctrl.onCatResponse("KY0;"); + QCOMPARE(sent.count(), 2); + QCOMPARE(sent.at(1).at(0).toString(), QStringLiteral("KYEBC;")); + QCOMPARE(inFlight.at(1).at(0).toInt(), 1); // starts right after 'A' + QCOMPARE(inFlight.at(1).at(1).toInt(), 2); // covers 'B' and 'C' + } + + // A chunk that never gets its KY0 must be marked stalled, not silently confirmed, and + // must stop the pipeline (no further sends) until abort() clears it. + void timeoutMarksChunkStalledAndHaltsPipeline() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + ctrl.setKeyerSpeed(60); // fast WPM keeps the derived timeout short but still bounded + QSignalSpy stalled(&ctrl, &CwSendController::chunkStalled); + QSignalSpy confirmed(&ctrl, &CwSendController::chunkConfirmed); + + for (QChar ch : QStringLiteral("K ")) + ctrl.appendChar(ch); + QVERIFY(stalled.wait(25000)); + QCOMPARE(confirmed.count(), 0); + + // Pipeline halted: further typed text must not dispatch until abort() resets it. + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + for (QChar ch : QStringLiteral("MORE ")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), 0); + + ctrl.abort(); + QCOMPARE(sent.count(), 1); + QCOMPARE(sent.last().at(0).toString(), QStringLiteral("RX;")); + for (QChar ch : QStringLiteral("OK ")) + ctrl.appendChar(ch); + QCOMPARE(sent.count(), 2); // RX; from abort(), then the new KYOK ; send + } + + // abort() clears everything queued/pending and forces the K4 out of TX via RX;. + void abortClearsQueueAndSendsRx() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + QSignalSpy aborted(&ctrl, &CwSendController::aborted); + + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + for (QChar ch : QStringLiteral("DE ")) // queued behind the in-flight chunk + ctrl.appendChar(ch); + + ctrl.abort(); + QCOMPARE(aborted.count(), 1); + QVERIFY(sent.last().at(0).toString() == QStringLiteral("RX;")); + + // Nothing left to confirm — a stray KY0 arriving late must be a no-op, not a crash. + ctrl.onCatResponse("KY0;"); + } + + // activeChanged tracks whether anything is queued/pending/in-flight — this drives the + // hardware-CW suppression gate, so it must go true on the first send and false once + // everything drains. + void activeChangedTracksQueueLifecycle() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + QSignalSpy active(&ctrl, &CwSendController::activeChanged); + + for (QChar ch : QStringLiteral("HI ")) + ctrl.appendChar(ch); + QCOMPARE(active.count(), 1); + QCOMPARE(active.at(0).at(0).toBool(), true); + + ctrl.onCatResponse("KY0;"); + QCOMPARE(active.count(), 2); + QCOMPARE(active.at(1).at(0).toBool(), false); + } + + // A disconnect mid-send must abort exactly like the manual abort path, but must not try + // to send "RX;" over a dead link. + void disconnectAbortsWithoutSendingRx() { + CwSendController ctrl; + ctrl.setCwModeActive(true); // appendChar()/flush() no-op until the radio is confirmed in CW mode + QSignalSpy sent(&ctrl, &CwSendController::sendCatRequested); + QSignalSpy aborted(&ctrl, &CwSendController::aborted); + + for (QChar ch : QStringLiteral("CQ ")) + ctrl.appendChar(ch); + const int sentBeforeDisconnect = sent.count(); + + ctrl.onDisconnected(); + QCOMPARE(aborted.count(), 1); + QCOMPARE(sent.count(), sentBeforeDisconnect); // no extra "RX;" sent + + // Idempotent — a second disconnect notification with nothing in progress is a no-op. + ctrl.onDisconnected(); + QCOMPARE(aborted.count(), 1); + } +}; + +QTEST_GUILESS_MAIN(TestCwSendController) +#include "test_cwsendcontroller.moc" From a45d54c5c79e3d71e88d887ed8bee9c3a4b6e5af Mon Sep 17 00:00:00 2001 From: Fred Klassen Date: Wed, 26 Aug 2026 10:09:31 -0700 Subject: [PATCH 2/2] feat(cw): add macro token substitution and a working-station Callsign field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macros can now contain ~ (expands to the operator's own callsign) and * (expands to a new Callsign field in the CW Send dialog, representing the station currently being worked). Loosely follows the token convention used by contest logging software (N1MM, SkookumLogger), scoped down to just the two tokens that map to something real in QK4 today. Callsign is session-local state owned by CwSendDialog (not persisted — it changes every contact and has no K4 CAT command behind it, so it belongs in neither RadioState nor RadioSettings). CwSendController stays token-unaware; expansion happens in the dialog before text is committed. Also: macro button tooltips now show the expanded preview with an (F1)-style shortcut prefix; Escape aborts sending instead of closing the dialog; and the immediate-vs-word-complete send mode checkbox moved from the CW Macros options page into the CW Send dialog itself, since it's something an operator wants to flip live rather than dig into Options for. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TwJeVFnBk868bnkYDKaX12 --- src/ui/dialogs/cwsenddialog.cpp | 87 +++++++++++++++++++++++++++++++-- src/ui/dialogs/cwsenddialog.h | 12 +++++ src/ui/pages/cwmacrospage.cpp | 41 +++------------- src/ui/pages/cwmacrospage.h | 8 +-- 4 files changed, 105 insertions(+), 43 deletions(-) diff --git a/src/ui/dialogs/cwsenddialog.cpp b/src/ui/dialogs/cwsenddialog.cpp index 2929fe09..7998c6e2 100644 --- a/src/ui/dialogs/cwsenddialog.cpp +++ b/src/ui/dialogs/cwsenddialog.cpp @@ -30,6 +30,19 @@ CwSendDialog::CwSendDialog(CwSendController *controller, QWidget *parent) layout->setContentsMargins(K4Styles::Dimensions::DialogMargin, K4Styles::Dimensions::DialogMargin, K4Styles::Dimensions::DialogMargin, K4Styles::Dimensions::DialogMargin); + auto *callsignRow = new QHBoxLayout(); + auto *callsignLabel = new QLabel("Callsign:", this); + callsignLabel->setStyleSheet(K4Styles::Dialog::formLabel()); + callsignRow->addWidget(callsignLabel); + m_callsignEdit = new QLineEdit(this); + m_callsignEdit->setPlaceholderText("Working..."); + m_callsignEdit->setStyleSheet(K4Styles::Dialog::lineEdit()); + m_callsignEdit->setMaximumWidth(120); + connect(m_callsignEdit, &QLineEdit::textChanged, this, &CwSendDialog::onCallsignTextChanged); + callsignRow->addWidget(m_callsignEdit); + callsignRow->addStretch(); + layout->addLayout(callsignRow); + m_stalledBanner = new QLabel(this); m_stalledBanner->setWordWrap(true); m_stalledBanner->setStyleSheet(QString("QLabel { background-color: %1; color: %2; " @@ -71,13 +84,19 @@ CwSendDialog::CwSendDialog(CwSendController *controller, QWidget *parent) layout->addLayout(macroRow2); auto *bottomRow = new QHBoxLayout(); - m_abortBtn = new QPushButton("Abort", this); + m_immediateModeCheck = new QCheckBox("Send immediately", this); + m_immediateModeCheck->setStyleSheet(K4Styles::Dialog::checkBox()); + m_immediateModeCheck->setChecked(RadioSettings::instance()->cwSendImmediateMode()); + connect(m_immediateModeCheck, &QCheckBox::toggled, this, + [](bool checked) { RadioSettings::instance()->setCwSendImmediateMode(checked); }); + bottomRow->addWidget(m_immediateModeCheck); + bottomRow->addStretch(); + m_abortBtn = new QPushButton("Abort (Esc)", this); m_abortBtn->setStyleSheet(QString("QPushButton { background-color: %1; color: %2; font-weight: bold; " "border-radius: 4px; padding: 6px 16px; }") .arg(K4Styles::Colors::ErrorBgDark, K4Styles::Colors::ErrorRed)); m_abortBtn->setAutoDefault(false); // same Enter-in-lineedit gotcha as the macro buttons connect(m_abortBtn, &QPushButton::clicked, this, [this]() { m_controller->abort(); }); - bottomRow->addStretch(); bottomRow->addWidget(m_abortBtn); layout->addLayout(bottomRow); @@ -97,18 +116,72 @@ CwSendDialog::CwSendDialog(CwSendController *controller, QWidget *parent) }); connect(m_controller, &CwSendController::aborted, this, &CwSendDialog::onAborted); + // m_callsignEdit sits above m_input in the layout, which would otherwise steal default + // focus — the operator opening this dialog and typing immediately expects it to go to CW, + // not the callsign box. + setTabOrder(m_callsignEdit, m_input); + refreshMacros(); } +void CwSendDialog::showEvent(QShowEvent *event) { + QDialog::showEvent(event); + // The dialog is created once and reused (hidden/shown) for the whole app session, so this + // must run on every show, not just construction. + m_input->setFocus(); + m_immediateModeCheck->setChecked(RadioSettings::instance()->cwSendImmediateMode()); +} + +void CwSendDialog::keyPressEvent(QKeyEvent *event) { + if (event->key() == Qt::Key_Escape) { + m_controller->abort(); // same as the Abort button — stop sending, don't close the dialog + event->accept(); + return; + } + QDialog::keyPressEvent(event); +} + void CwSendDialog::refreshMacros() { auto *rs = RadioSettings::instance(); for (int i = 0; i < m_macroButtons.size(); ++i) { const MacroEntry entry = rs->cwMacro(kSlotIds[i]); const QString label = entry.label.isEmpty() ? QStringLiteral("F%1").arg(i + 1) : entry.label; m_macroButtons[i]->setText(label); - m_macroButtons[i]->setToolTip(entry.command); - m_macroButtons[i]->setEnabled(!entry.command.isEmpty()); + m_macroButtons[i]->setEnabled(!entry.command.isEmpty()); // raw command — unaffected by token expansion + } + refreshMacroTooltips(); +} + +void CwSendDialog::refreshMacroTooltips() { + auto *rs = RadioSettings::instance(); + for (int i = 0; i < m_macroButtons.size(); ++i) { + const MacroEntry entry = rs->cwMacro(kSlotIds[i]); + m_macroButtons[i]->setToolTip(QStringLiteral("(F%1) %2").arg(i + 1).arg(expandTokens(entry.command))); + } +} + +void CwSendDialog::onCallsignTextChanged(const QString &text) { + const QString upper = text.toUpper(); + if (upper != text) { + m_callsignEdit->blockSignals(true); + m_callsignEdit->setText(upper); + m_callsignEdit->blockSignals(false); + } + refreshMacroTooltips(); +} + +QString CwSendDialog::expandTokens(const QString ¯oText) const { + QString result; + result.reserve(macroText.size()); + for (const QChar &ch : macroText) { + if (ch == QChar('~')) + result += RadioSettings::instance()->callSign(); + else if (ch == QChar('*')) + result += m_callsignEdit->text(); + else + result += ch; } + return result; } void CwSendDialog::commitText(const QString &text) { @@ -197,6 +270,10 @@ void CwSendDialog::onMacroClicked(int slotIndex) { if (entry.command.isEmpty()) return; + const QString expanded = expandTokens(entry.command); + if (expanded.isEmpty()) // e.g. a macro that's just "*" with an empty Callsign field + return; + // Macro text doesn't carry its own leading space, so back-to-back macro clicks (or a // macro right after manually typed text) would otherwise run together with no word gap. if (m_displayLength > 0) { @@ -205,6 +282,6 @@ void CwSendDialog::onMacroClicked(int slotIndex) { commitText(QStringLiteral(" ")); } - commitText(entry.command); + commitText(expanded); m_controller->flush(); // dispatch a trailing partial word if the macro doesn't end in a space } diff --git a/src/ui/dialogs/cwsenddialog.h b/src/ui/dialogs/cwsenddialog.h index 8eb7e054..f734f6f9 100644 --- a/src/ui/dialogs/cwsenddialog.h +++ b/src/ui/dialogs/cwsenddialog.h @@ -1,10 +1,13 @@ #ifndef CWSENDDIALOG_H #define CWSENDDIALOG_H +#include #include +#include #include #include #include +#include #include #include @@ -28,22 +31,31 @@ class CwSendDialog : public QDialog { void refreshMacros(); +protected: + void showEvent(QShowEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; // Escape aborts sending instead of closing + private: void commitText(const QString &text); void appendToDisplay(QChar ch); void recolorRange(int start, int length, const QString &colorHex); void onAborted(); // stops sending / clears the stall state — does NOT clear the display void finishPendingWord(); // commits + flushes whatever's left in the input, unconditionally + QString expandTokens(const QString ¯oText) const; // ~ -> my call, * -> Callsign field + void refreshMacroTooltips(); void onInputTextEdited(const QString &text); void onReturnPressed(); void onMacroClicked(int slotIndex); + void onCallsignTextChanged(const QString &text); CwSendController *m_controller; // not owned + QLineEdit *m_callsignEdit = nullptr; // station currently being worked; session-local, not persisted QTextEdit *m_display = nullptr; QLineEdit *m_input = nullptr; QPushButton *m_abortBtn = nullptr; + QCheckBox *m_immediateModeCheck = nullptr; QLabel *m_stalledBanner = nullptr; QVector m_macroButtons; diff --git a/src/ui/pages/cwmacrospage.cpp b/src/ui/pages/cwmacrospage.cpp index 723fc787..b0729b3c 100644 --- a/src/ui/pages/cwmacrospage.cpp +++ b/src/ui/pages/cwmacrospage.cpp @@ -4,20 +4,11 @@ #include "ui/styling/k4styles.h" #include "utils/macroids.h" -#include #include #include #include namespace { -QFrame *separator(QWidget *parent) { - auto *line = new QFrame(parent); - line->setFrameShape(QFrame::HLine); - line->setStyleSheet(K4Styles::Dialog::separator()); - line->setFixedHeight(K4Styles::Dimensions::SeparatorHeight); - return line; -} - const QVector kSlotIds = {MacroIds::CwMacro1, MacroIds::CwMacro2, MacroIds::CwMacro3, MacroIds::CwMacro4, MacroIds::CwMacro5, MacroIds::CwMacro6, MacroIds::CwMacro7, MacroIds::CwMacro8}; } // namespace @@ -34,13 +25,17 @@ CwMacrosPage::CwMacrosPage(QWidget *parent) : QWidget(parent) { title->setStyleSheet(K4Styles::Dialog::titleLabel()); layout->addWidget(title); - auto *desc = new QLabel("Canned messages for the CW Send dialog's macro buttons. Label is what shows " - "on the button; message is what gets keyed.", - this); + auto *desc = new QLabel("Macro buttons for the CW Send dialog.", this); desc->setWordWrap(true); desc->setStyleSheet(K4Styles::Dialog::helpText()); layout->addWidget(desc); + auto *legend = new QLabel("~ My Call * Callsign", this); + legend->setStyleSheet(QString("QLabel { background-color: %1; color: %2; padding: 6px 10px; " + "border-radius: 4px; font-family: monospace; }") + .arg(K4Styles::Colors::DarkBackground, K4Styles::Colors::TextGray)); + layout->addWidget(legend); + for (int i = 0; i < kSlotIds.size(); ++i) { auto *row = new QHBoxLayout(); @@ -86,27 +81,6 @@ CwMacrosPage::CwMacrosPage(QWidget *parent) : QWidget(parent) { m_rows.append(macroRow); } - layout->addWidget(separator(this)); - - auto *modeHeader = new QLabel("Send Mode", this); - modeHeader->setStyleSheet(K4Styles::Dialog::sectionHeader()); - layout->addWidget(modeHeader); - - m_immediateModeCheck = new QCheckBox("Send characters immediately as typed", this); - m_immediateModeCheck->setStyleSheet(K4Styles::Dialog::checkBox()); - m_immediateModeCheck->setChecked(RadioSettings::instance()->cwSendImmediateMode()); - connect(m_immediateModeCheck, &QCheckBox::toggled, this, - [](bool checked) { RadioSettings::instance()->setCwSendImmediateMode(checked); }); - layout->addWidget(m_immediateModeCheck); - - auto *modeHelp = new QLabel("Off (default): a word is sent once you finish it with a space or Enter. " - "On: every character is sent as you type it. Either way, text only " - "brightens on screen once the K4 actually confirms it was keyed.", - this); - modeHelp->setWordWrap(true); - modeHelp->setStyleSheet(K4Styles::Dialog::helpText()); - layout->addWidget(modeHelp); - layout->addStretch(); refresh(); } @@ -118,5 +92,4 @@ void CwMacrosPage::refresh() { row.labelEdit->setText(entry.label); row.textEdit->setText(entry.command); } - m_immediateModeCheck->setChecked(rs->cwSendImmediateMode()); } diff --git a/src/ui/pages/cwmacrospage.h b/src/ui/pages/cwmacrospage.h index 7b57d61f..5c20de20 100644 --- a/src/ui/pages/cwmacrospage.h +++ b/src/ui/pages/cwmacrospage.h @@ -1,17 +1,18 @@ #ifndef CWMACROSPAGE_H #define CWMACROSPAGE_H -#include #include #include #include /** - * @brief Options page for the CW Send dialog: five canned-message macro slots (label + - * message text) and the persistent immediate-vs-word-complete send mode setting. + * @brief Options page for the CW Send dialog: eight canned-message macro slots (label + + * message text, with token substitution — see CwSendDialog::expandTokens()). * * Macro editing lives here, not in the CW Send dialog itself — mirrors how the Keyer/ * Straight Key pages hold settings for the CW Send dialog's hardware-keying counterparts. + * The immediate-vs-word-complete send mode toggle lives in the CW Send dialog itself + * instead (an operator wants that live, not buried in Options). */ class CwMacrosPage : public QWidget { Q_OBJECT @@ -29,7 +30,6 @@ class CwMacrosPage : public QWidget { }; QVector m_rows; - QCheckBox *m_immediateModeCheck = nullptr; }; #endif // CWMACROSPAGE_H