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..7998c6e2 --- /dev/null +++ b/src/ui/dialogs/cwsenddialog.cpp @@ -0,0 +1,287 @@ +#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); + + 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; " + "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_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->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); + + // 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]->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) { + 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; + + 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) { + const QString existing = m_display->toPlainText(); + if (!existing.isEmpty() && !existing.back().isSpace()) + commitText(QStringLiteral(" ")); + } + + 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 new file mode 100644 index 00000000..f734f6f9 --- /dev/null +++ b/src/ui/dialogs/cwsenddialog.h @@ -0,0 +1,66 @@ +#ifndef CWSENDDIALOG_H +#define CWSENDDIALOG_H + +#include +#include +#include +#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(); + +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; + + 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..b0729b3c --- /dev/null +++ b/src/ui/pages/cwmacrospage.cpp @@ -0,0 +1,95 @@ +#include "ui/pages/cwmacrospage.h" + +#include "settings/radiosettings.h" +#include "ui/styling/k4styles.h" +#include "utils/macroids.h" + +#include +#include +#include + +namespace { +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("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(); + + 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->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); + } +} diff --git a/src/ui/pages/cwmacrospage.h b/src/ui/pages/cwmacrospage.h new file mode 100644 index 00000000..5c20de20 --- /dev/null +++ b/src/ui/pages/cwmacrospage.h @@ -0,0 +1,35 @@ +#ifndef CWMACROSPAGE_H +#define CWMACROSPAGE_H + +#include +#include +#include + +/** + * @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 + +public: + explicit CwMacrosPage(QWidget *parent = nullptr); + + void refresh(); + +private: + struct MacroRow { + QString functionId; + QLineEdit *labelEdit = nullptr; + QLineEdit *textEdit = nullptr; + }; + + QVector m_rows; +}; + +#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"