diff --git a/.gitignore b/.gitignore
index 65f9c94..ee20af7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ obj/
*.user
/N1MM/
/N1MMsln/
+/bridge/nonemm-mmtty-bridge.exe
+/mmtty/
+/bridge/XMMT.ocx
diff --git a/Nonemm.slnx b/Nonemm.slnx
index 839928a..7531998 100644
--- a/Nonemm.slnx
+++ b/Nonemm.slnx
@@ -8,6 +8,7 @@
+
@@ -19,6 +20,7 @@
+
diff --git a/README.md b/README.md
index f118b25..6e5f4e7 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,11 @@ Nonemm.slnx`.
To work a contest: **Config → Station** for your callsign and zones, then
**File → New Database** and **File → New Contest**. In the entry window, type a
callsign and press **space** to move to the exchange, then **Enter** to log.
-Type a frequency into the callsign box and press Enter to change band. **View**
-opens the log, check, bandmap, score and telnet windows.
+Type a frequency into the callsign box and press Enter to change band, or a
+mode — `CW`, `SSB`, `USB`, `LSB`, `RTTY`, `FT8` and the rest — to change mode.
+A radio that is connected is moved with it; with no radio it is the way to
+reach a mode at all. **View** opens the log, check, bandmap, score and telnet
+windows.
Everything that judges a station — the bar under the callsign box, a row in the
check window, a spot on the bandmap, a line in the log — is coloured by the same
@@ -44,13 +47,43 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, OK/OM DX (CW), YOTA, IOTA, EU DX Contest, CQ 160, ARRL 10M, REF, Ukrainian DX, Russian DX RTTY, RDAC, YO DX HF, Oceania DX, All Asian, SARTG RTTY, BARTG Sprint, Mexico RTTY, IARU Region 1 Field Day, general logging, and user-defined `.udc` contests |
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
-| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet |
+| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet, digital interface |
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros |
+| Digital | N1MM's digital interface window, running MMTTY or 2Tone under Wine through the same `XMMT.ocx` N1MM drives them with |
+
+### The digital window
+
+**Window ▸ Digital Interface** opens N1MM's digital interface. **Engine ▸
+Settings** says where MMTTY or 2Tone is; **Engine ▸ Start** runs it under Wine.
+`docs/digital-bridge.md` covers setting the Wine prefix up.
+
+What the window does, and what each part of it is for:
+
+| Part | What it does |
+|---|---|
+| Receive pane | the decoded text. Every word shaped like a callsign is coloured by what working it would bring, the same four colours the entry window uses, and our own call is coloured as ours |
+| Left click on a call | puts that call in the entry window. With call stacking on, the call already being typed goes on the stack first, so neither is lost |
+| Left click on anything else | copies the word into the exchange box the contest keeps for that kind of value: a serial number to the number box, a section or a state to the box that holds the list it is in. The box the cursor is in takes the word where it fits, which is what N1MM does. 599, a colon and the Z on a time are cut off first |
+| Left click on a gap | stops the pane scrolling so a call can be read while text keeps arriving. Moving the pointer off the pane starts it again |
+| Right click | presses Enter in the entry window, which is ESM's next message. Off by default: Options ▸ Right click presses Enter |
+| Pointer | the word under the pointer is underlined and shown at the bottom left |
+| Grab list | the calls heard, newest first and coloured the same way. Click one to work it, right-click to drop it, or press Grab for the next one |
+| Macro buttons | three rows of eight. Right-click any of them to edit all twenty-four. `{TX}` starts the transmission, `{RX}` ends it, `{ENTER}` sends a carriage return, and the rest are the macros the function keys use |
+| Transmit pane | what is typed goes out as it is typed. Escape stops sending |
+| Buttons | Clr RX and CLR empty the two panes, Align puts the demodulator back on the mark tone, TX and RX key and unkey, Ham loads MMTTY's default profile, Lock is AFC and Rev swaps the tones |
+
+Options ▸ Grab list decides which calls are kept: everything heard, everything
+but dupes, or only calls the callsign database knows. Call stacking chooses the
+order stacked calls come back off: multipliers first, first in, last in, or off.
+
+On a digital mode the function keys, ESM and the QTC window all send through the
+engine rather than through the CW keyer, so the whole entry window works the way
+it does on CW.
### The country file and the callsign database
@@ -776,6 +809,7 @@ windows redraw where they stand.
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer |
+| `Nonemm.Digital` | MMTTY and 2Tone, over the Wine bridge in `bridge/` |
| `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit |
| `Nonemm.App` | The Avalonia windows |
diff --git a/bridge/ControlSite.cpp b/bridge/ControlSite.cpp
new file mode 100644
index 0000000..a45414b
--- /dev/null
+++ b/bridge/ControlSite.cpp
@@ -0,0 +1,44 @@
+#include "ControlSite.h"
+
+STDMETHODIMP ControlSite::QueryInterface(REFIID iid, void** out) {
+ if (iid == IID_IUnknown || iid == IID_IOleClientSite) {
+ *out = static_cast(this);
+ } else if (iid == IID_IOleWindow || iid == IID_IOleInPlaceSite) {
+ *out = static_cast(this);
+ } else if (iid == IID_IOleInPlaceUIWindow || iid == IID_IOleInPlaceFrame) {
+ *out = static_cast(this);
+ } else {
+ *out = nullptr;
+ return E_NOINTERFACE;
+ }
+ AddRef();
+ return S_OK;
+}
+
+STDMETHODIMP_(ULONG) ControlSite::AddRef() {
+ return ++references_;
+}
+
+STDMETHODIMP_(ULONG) ControlSite::Release() {
+ ULONG left = --references_;
+ if (left == 0) {
+ delete this;
+ }
+ return left;
+}
+
+STDMETHODIMP ControlSite::GetWindowContext(IOleInPlaceFrame** frame,
+ IOleInPlaceUIWindow** document, LPRECT position,
+ LPRECT clip, LPOLEINPLACEFRAMEINFO info) {
+ *frame = static_cast(this);
+ AddRef();
+ *document = nullptr;
+ GetClientRect(parent_, position);
+ *clip = *position;
+ info->cb = sizeof(OLEINPLACEFRAMEINFO);
+ info->fMDIApp = FALSE;
+ info->hwndFrame = parent_;
+ info->haccel = nullptr;
+ info->cAccelEntries = 0;
+ return S_OK;
+}
diff --git a/bridge/ControlSite.h b/bridge/ControlSite.h
new file mode 100644
index 0000000..cd7b6a6
--- /dev/null
+++ b/bridge/ControlSite.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include
+#include
+
+// The least a window has to provide for an ActiveX control to activate in it.
+// XMMT.ocx draws nothing we look at, so every method that decides layout,
+// menus or scrolling answers with the default.
+class ControlSite : public IOleClientSite, public IOleInPlaceSite, public IOleInPlaceFrame {
+public:
+ explicit ControlSite(HWND parent) : parent_(parent) {}
+ virtual ~ControlSite() = default;
+
+ STDMETHODIMP QueryInterface(REFIID iid, void** out) override;
+ STDMETHODIMP_(ULONG) AddRef() override;
+ STDMETHODIMP_(ULONG) Release() override;
+
+ // IOleClientSite
+ STDMETHODIMP SaveObject() override { return S_OK; }
+ STDMETHODIMP GetMoniker(DWORD, DWORD, IMoniker** moniker) override {
+ *moniker = nullptr;
+ return E_NOTIMPL;
+ }
+ STDMETHODIMP GetContainer(IOleContainer** container) override {
+ *container = nullptr;
+ return E_NOINTERFACE;
+ }
+ STDMETHODIMP ShowObject() override { return S_OK; }
+ STDMETHODIMP OnShowWindow(BOOL) override { return S_OK; }
+ STDMETHODIMP RequestNewObjectLayout() override { return E_NOTIMPL; }
+
+ // IOleWindow, shared by the site and the frame
+ STDMETHODIMP GetWindow(HWND* window) override {
+ *window = parent_;
+ return S_OK;
+ }
+ STDMETHODIMP ContextSensitiveHelp(BOOL) override { return E_NOTIMPL; }
+
+ // IOleInPlaceSite
+ STDMETHODIMP CanInPlaceActivate() override { return S_OK; }
+ STDMETHODIMP OnInPlaceActivate() override { return S_OK; }
+ STDMETHODIMP OnUIActivate() override { return S_OK; }
+ STDMETHODIMP GetWindowContext(IOleInPlaceFrame** frame, IOleInPlaceUIWindow** document,
+ LPRECT position, LPRECT clip,
+ LPOLEINPLACEFRAMEINFO info) override;
+ STDMETHODIMP Scroll(SIZE) override { return E_NOTIMPL; }
+ STDMETHODIMP OnUIDeactivate(BOOL) override { return S_OK; }
+ STDMETHODIMP OnInPlaceDeactivate() override { return S_OK; }
+ STDMETHODIMP DiscardUndoState() override { return E_NOTIMPL; }
+ STDMETHODIMP DeactivateAndUndo() override { return E_NOTIMPL; }
+ STDMETHODIMP OnPosRectChange(LPCRECT) override { return S_OK; }
+
+ // IOleInPlaceUIWindow
+ STDMETHODIMP GetBorder(LPRECT) override { return E_NOTIMPL; }
+ STDMETHODIMP RequestBorderSpace(LPCBORDERWIDTHS) override { return E_NOTIMPL; }
+ STDMETHODIMP SetBorderSpace(LPCBORDERWIDTHS) override { return E_NOTIMPL; }
+ STDMETHODIMP SetActiveObject(IOleInPlaceActiveObject*, LPCOLESTR) override { return S_OK; }
+
+ // IOleInPlaceFrame
+ STDMETHODIMP InsertMenus(HMENU, LPOLEMENUGROUPWIDTHS) override { return E_NOTIMPL; }
+ STDMETHODIMP SetMenu(HMENU, HOLEMENU, HWND) override { return S_OK; }
+ STDMETHODIMP RemoveMenus(HMENU) override { return E_NOTIMPL; }
+ STDMETHODIMP SetStatusText(LPCOLESTR) override { return S_OK; }
+ STDMETHODIMP EnableModeless(BOOL) override { return S_OK; }
+ STDMETHODIMP TranslateAccelerator(LPMSG, WORD) override { return S_FALSE; }
+
+private:
+ HWND parent_;
+ ULONG references_ = 1;
+};
diff --git a/bridge/EventSink.cpp b/bridge/EventSink.cpp
new file mode 100644
index 0000000..2b0f379
--- /dev/null
+++ b/bridge/EventSink.cpp
@@ -0,0 +1,123 @@
+#include "EventSink.h"
+
+namespace {
+
+// Turns a dispid into the event name the type library gives it, so the caller
+// works in names rather than in numbers the OCX is free to renumber.
+class EventSink : public IDispatch {
+public:
+ EventSink(ITypeInfo* events, SinkHandler handler)
+ : events_(events), handler_(std::move(handler)) {
+ if (events_ != nullptr) {
+ events_->AddRef();
+ }
+ }
+
+ virtual ~EventSink() {
+ if (events_ != nullptr) {
+ events_->Release();
+ }
+ }
+
+ STDMETHODIMP QueryInterface(REFIID iid, void** out) override {
+ if (iid == IID_IUnknown || iid == IID_IDispatch || iid == source_) {
+ *out = static_cast(this);
+ AddRef();
+ return S_OK;
+ }
+ *out = nullptr;
+ return E_NOINTERFACE;
+ }
+
+ STDMETHODIMP_(ULONG) AddRef() override { return ++references_; }
+
+ STDMETHODIMP_(ULONG) Release() override {
+ ULONG left = --references_;
+ if (left == 0) {
+ delete this;
+ }
+ return left;
+ }
+
+ STDMETHODIMP GetTypeInfoCount(UINT* count) override {
+ *count = 0;
+ return S_OK;
+ }
+
+ STDMETHODIMP GetTypeInfo(UINT, LCID, ITypeInfo**) override { return E_NOTIMPL; }
+
+ STDMETHODIMP GetIDsOfNames(REFIID, LPOLESTR*, UINT, LCID, DISPID*) override {
+ return E_NOTIMPL;
+ }
+
+ STDMETHODIMP Invoke(DISPID dispid, REFIID, LCID, WORD, DISPPARAMS* arguments, VARIANT*,
+ EXCEPINFO*, UINT*) override {
+ handler_(nameOf(dispid), arguments);
+ return S_OK;
+ }
+
+ void setSource(REFIID source) { source_ = source; }
+
+private:
+ std::wstring nameOf(DISPID dispid) {
+ BSTR name = nullptr;
+ if (events_ == nullptr ||
+ FAILED(events_->GetDocumentation(dispid, &name, nullptr, nullptr, nullptr))) {
+ return L"";
+ }
+ std::wstring found(name, SysStringLen(name));
+ SysFreeString(name);
+ return found;
+ }
+
+ ITypeInfo* events_;
+ SinkHandler handler_;
+ IID source_ = IID_NULL;
+ ULONG references_ = 1;
+};
+
+} // namespace
+
+ITypeInfo* sourceTypeInfo(IDispatch* control, IID* source) {
+ IProvideClassInfo2* provider = nullptr;
+ if (FAILED(control->QueryInterface(IID_IProvideClassInfo2,
+ reinterpret_cast(&provider)))) {
+ return nullptr;
+ }
+ ITypeInfo* events = nullptr;
+ if (SUCCEEDED(provider->GetGUID(GUIDKIND_DEFAULT_SOURCE_DISP_IID, source))) {
+ ITypeInfo* coclass = nullptr;
+ if (SUCCEEDED(provider->GetClassInfo(&coclass))) {
+ TYPEATTR* attributes = nullptr;
+ if (SUCCEEDED(coclass->GetTypeAttr(&attributes))) {
+ for (UINT i = 0; i < attributes->cImplTypes && events == nullptr; i++) {
+ HREFTYPE reference = 0;
+ ITypeInfo* implemented = nullptr;
+ if (FAILED(coclass->GetRefTypeOfImplType(i, &reference)) ||
+ FAILED(coclass->GetRefTypeInfo(reference, &implemented))) {
+ continue;
+ }
+ TYPEATTR* theirs = nullptr;
+ if (SUCCEEDED(implemented->GetTypeAttr(&theirs))) {
+ if (theirs->guid == *source) {
+ events = implemented;
+ implemented->AddRef();
+ }
+ implemented->ReleaseTypeAttr(theirs);
+ }
+ implemented->Release();
+ }
+ coclass->ReleaseTypeAttr(attributes);
+ }
+ coclass->Release();
+ }
+ }
+ provider->Release();
+ return events;
+}
+
+IDispatch* createEventSink(ITypeInfo* events, REFIID source, SinkHandler handler) {
+ EventSink* sink = new EventSink(events, std::move(handler));
+ sink->setSource(source);
+ return sink;
+}
diff --git a/bridge/EventSink.h b/bridge/EventSink.h
new file mode 100644
index 0000000..a7bf16c
--- /dev/null
+++ b/bridge/EventSink.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include
+#include
+
+using SinkHandler = std::function;
+
+// The events interface the control declares as its default source. Its type
+// info is where the event names come from.
+ITypeInfo* sourceTypeInfo(IDispatch* control, IID* source);
+
+// A sink that turns a dispid into the event name before handing the call on, so
+// the caller works in names rather than in numbers the OCX is free to renumber.
+IDispatch* createEventSink(ITypeInfo* events, REFIID source, SinkHandler handler);
diff --git a/bridge/Makefile b/bridge/Makefile
new file mode 100644
index 0000000..97751db
--- /dev/null
+++ b/bridge/Makefile
@@ -0,0 +1,10 @@
+# The bridge is a 32-bit Windows program because XMMT.ocx is a 32-bit control.
+CXX = i686-w64-mingw32-g++
+CXXFLAGS = -std=c++17 -O2 -Wall -Wextra -DUNICODE -D_UNICODE
+LDFLAGS = -static-libgcc -static-libstdc++ -static -lole32 -loleaut32 -luuid
+
+nonemm-mmtty-bridge.exe: main.cpp XmmrControl.cpp EventSink.cpp ControlSite.cpp Protocol.cpp
+ $(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS)
+
+clean:
+ rm -f nonemm-mmtty-bridge.exe
diff --git a/bridge/Protocol.cpp b/bridge/Protocol.cpp
new file mode 100644
index 0000000..3d8b648
--- /dev/null
+++ b/bridge/Protocol.cpp
@@ -0,0 +1,86 @@
+#include "Protocol.h"
+
+#include
+#include
+#include
+
+namespace protocol {
+namespace {
+
+std::mutex writing;
+
+std::string escape(const std::string& field) {
+ std::string escaped;
+ for (char c : field) {
+ switch (c) {
+ case '\\': escaped += "\\\\"; break;
+ case '\t': escaped += "\\t"; break;
+ case '\r': escaped += "\\r"; break;
+ case '\n': escaped += "\\n"; break;
+ default: escaped += c;
+ }
+ }
+ return escaped;
+}
+
+std::string unescape(const std::string& field) {
+ std::string plain;
+ for (size_t i = 0; i < field.size(); i++) {
+ if (field[i] != '\\' || i + 1 == field.size()) {
+ plain += field[i];
+ continue;
+ }
+ switch (field[++i]) {
+ case 't': plain += '\t'; break;
+ case 'r': plain += '\r'; break;
+ case 'n': plain += '\n'; break;
+ default: plain += field[i];
+ }
+ }
+ return plain;
+}
+
+} // namespace
+
+std::string Line::field(size_t index) const {
+ return index < fields.size() ? fields[index] : std::string();
+}
+
+long Line::number(size_t index) const {
+ return std::strtol(field(index).c_str(), nullptr, 10);
+}
+
+Line read(const std::string& text) {
+ Line line;
+ size_t start = 0;
+ while (true) {
+ size_t tab = text.find('\t', start);
+ std::string part = text.substr(start, tab == std::string::npos ? tab : tab - start);
+ if (start == 0) {
+ line.verb = part;
+ } else {
+ line.fields.push_back(unescape(part));
+ }
+ if (tab == std::string::npos) {
+ return line;
+ }
+ start = tab + 1;
+ }
+}
+
+void write(const std::string& verb, const std::vector& fields) {
+ std::string line = verb;
+ for (const std::string& field : fields) {
+ line += '\t' + escape(field);
+ }
+ line += '\n';
+ std::lock_guard held(writing);
+ std::fwrite(line.data(), 1, line.size(), stdout);
+ std::fflush(stdout);
+}
+
+void write(const std::string& verb, long value) {
+ write(verb, {std::to_string(value)});
+}
+
+} // namespace protocol
diff --git a/bridge/Protocol.h b/bridge/Protocol.h
new file mode 100644
index 0000000..d1a2595
--- /dev/null
+++ b/bridge/Protocol.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#include
+#include
+
+// One line of the bridge protocol: a verb and its fields, separated by tabs.
+// Nonemm.Digital/BridgeLine.cs is the other end of the same format.
+namespace protocol {
+
+struct Line {
+ std::string verb;
+ std::vector fields;
+
+ // Empty when the field is not there, which keeps a short line from being an
+ // error on either side.
+ std::string field(size_t index) const;
+ long number(size_t index) const;
+};
+
+Line read(const std::string& line);
+
+// Writes one line to standard output and flushes it. Safe to call from any
+// thread.
+void write(const std::string& verb, const std::vector& fields = {});
+void write(const std::string& verb, long value);
+
+} // namespace protocol
diff --git a/bridge/XmmrControl.cpp b/bridge/XmmrControl.cpp
new file mode 100644
index 0000000..ae520a8
--- /dev/null
+++ b/bridge/XmmrControl.cpp
@@ -0,0 +1,205 @@
+#include "XmmrControl.h"
+
+#include
+#include
+#include "EventSink.h"
+std::string toUtf8(const wchar_t* text) {
+ if (text == nullptr) {
+ return "";
+ }
+ int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
+ std::string converted(size > 0 ? size - 1 : 0, '\0');
+ WideCharToMultiByte(CP_UTF8, 0, text, -1, converted.data(), size, nullptr, nullptr);
+ return converted;
+}
+
+std::wstring toWide(const std::string& text) {
+ int size = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0);
+ std::wstring converted(size > 0 ? size - 1 : 0, L'\0');
+ MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, converted.data(), size);
+ return converted;
+}
+
+bool XmmrControl::create(HWND parent, std::string* error) {
+ CLSID clsid;
+ HRESULT result = CLSIDFromProgID(L"XMMR.XMMRCtrl.1", &clsid);
+ if (FAILED(result)) {
+ *error = "XMMR.XMMRCtrl.1 is not registered in this Wine prefix";
+ return false;
+ }
+ result = CoCreateInstance(clsid, nullptr, CLSCTX_INPROC_SERVER, IID_IOleObject,
+ reinterpret_cast(&object_));
+ if (FAILED(result)) {
+ *error = "XMMT.ocx would not load";
+ return false;
+ }
+ // MFC controls reject every dispatch call with E_UNEXPECTED until they are
+ // initialised, and this one is never loaded from a stream.
+ IPersistStreamInit* fresh = nullptr;
+ if (SUCCEEDED(object_->QueryInterface(IID_IPersistStreamInit,
+ reinterpret_cast(&fresh)))) {
+ fresh->InitNew();
+ fresh->Release();
+ }
+ OleSetContainedObject(object_, TRUE);
+ site_ = new ControlSite(parent);
+ object_->SetHostNames(L"Nonemm", L"Nonemm");
+ object_->SetClientSite(site_);
+ RECT area = {0, 0, 0, 0};
+ GetClientRect(parent, &area);
+ result = object_->DoVerb(OLEIVERB_INPLACEACTIVATE, nullptr, site_, 0, parent, &area);
+ if (FAILED(result)) {
+ *error = "the control would not activate";
+ return false;
+ }
+ if (FAILED(object_->QueryInterface(IID_IDispatch, reinterpret_cast(&dispatch_)))) {
+ *error = "the control has no IDispatch";
+ return false;
+ }
+ return listen(error);
+}
+
+bool XmmrControl::listen(std::string* error) {
+ IID source = IID_NULL;
+ ITypeInfo* events = sourceTypeInfo(dispatch_, &source);
+ IConnectionPointContainer* container = nullptr;
+ if (FAILED(dispatch_->QueryInterface(IID_IConnectionPointContainer,
+ reinterpret_cast(&container)))) {
+ *error = "the control has no connection points";
+ return false;
+ }
+ HRESULT found = container->FindConnectionPoint(source, &point_);
+ container->Release();
+ if (FAILED(found)) {
+ *error = "the control's event interface was not found";
+ return false;
+ }
+ sink_ = createEventSink(events, source, handler_);
+ if (events != nullptr) {
+ events->Release();
+ }
+ if (FAILED(point_->Advise(sink_, &cookie_))) {
+ *error = "the control refused the event sink";
+ return false;
+ }
+ return true;
+}
+
+void XmmrControl::destroy() {
+ if (point_ != nullptr && cookie_ != 0) {
+ point_->Unadvise(cookie_);
+ cookie_ = 0;
+ }
+ if (point_ != nullptr) {
+ point_->Release();
+ point_ = nullptr;
+ }
+ if (sink_ != nullptr) {
+ sink_->Release();
+ sink_ = nullptr;
+ }
+ if (dispatch_ != nullptr) {
+ dispatch_->Release();
+ dispatch_ = nullptr;
+ }
+ if (object_ != nullptr) {
+ object_->SetClientSite(nullptr);
+ object_->Release();
+ object_ = nullptr;
+ }
+ if (site_ != nullptr) {
+ site_->Release();
+ site_ = nullptr;
+ }
+}
+
+HRESULT XmmrControl::invoke(const wchar_t* name, WORD flags, DISPPARAMS* arguments,
+ VARIANT* result) {
+ if (dispatch_ == nullptr) {
+ return E_POINTER;
+ }
+ DISPID dispid = 0;
+ LPOLESTR wanted = const_cast(name);
+ HRESULT found = dispatch_->GetIDsOfNames(IID_NULL, &wanted, 1, LOCALE_USER_DEFAULT, &dispid);
+ if (FAILED(found)) {
+ return found;
+ }
+ return dispatch_->Invoke(dispid, IID_NULL, LOCALE_USER_DEFAULT, flags, arguments, result,
+ nullptr, nullptr);
+}
+
+HRESULT XmmrControl::put(const wchar_t* name, VARIANT value) {
+ DISPID assigned = DISPID_PROPERTYPUT;
+ DISPPARAMS arguments = {&value, &assigned, 1, 1};
+ HRESULT result = invoke(name, DISPATCH_PROPERTYPUT, &arguments, nullptr);
+ VariantClear(&value);
+ return result;
+}
+
+HRESULT XmmrControl::putBool(const wchar_t* name, bool value) {
+ VARIANT wanted;
+ VariantInit(&wanted);
+ wanted.vt = VT_BOOL;
+ wanted.boolVal = value ? VARIANT_TRUE : VARIANT_FALSE;
+ return put(name, wanted);
+}
+
+HRESULT XmmrControl::putLong(const wchar_t* name, long value) {
+ VARIANT wanted;
+ VariantInit(&wanted);
+ wanted.vt = VT_I4;
+ wanted.lVal = value;
+ return put(name, wanted);
+}
+
+HRESULT XmmrControl::putString(const wchar_t* name, const std::string& value) {
+ VARIANT wanted;
+ VariantInit(&wanted);
+ wanted.vt = VT_BSTR;
+ wanted.bstrVal = SysAllocString(toWide(value).c_str());
+ return put(name, wanted);
+}
+
+HRESULT XmmrControl::call(const wchar_t* name, VARIANT* arguments, unsigned count) {
+ DISPPARAMS parameters = {arguments, nullptr, count, 0};
+ return invoke(name, DISPATCH_METHOD, ¶meters, nullptr);
+}
+
+// Arguments go to a dispatch call last one first.
+HRESULT XmmrControl::callLong(const wchar_t* name, long first, long second) {
+ VARIANT arguments[2];
+ VariantInit(&arguments[0]);
+ VariantInit(&arguments[1]);
+ arguments[0].vt = VT_I4;
+ arguments[0].lVal = second;
+ arguments[1].vt = VT_I4;
+ arguments[1].lVal = first;
+ return call(name, arguments, 2);
+}
+
+long XmmrControl::getLong(const wchar_t* name) {
+ VARIANT value;
+ VariantInit(&value);
+ DISPPARAMS none = {nullptr, nullptr, 0, 0};
+ if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
+ return 0;
+ }
+ long found = SUCCEEDED(VariantChangeType(&value, &value, 0, VT_I4)) ? value.lVal : 0;
+ VariantClear(&value);
+ return found;
+}
+
+std::string XmmrControl::getString(const wchar_t* name) {
+ VARIANT value;
+ VariantInit(&value);
+ DISPPARAMS none = {nullptr, nullptr, 0, 0};
+ if (FAILED(invoke(name, DISPATCH_PROPERTYGET, &none, &value))) {
+ return "";
+ }
+ std::string found;
+ if (SUCCEEDED(VariantChangeType(&value, &value, 0, VT_BSTR))) {
+ found = toUtf8(value.bstrVal);
+ }
+ VariantClear(&value);
+ return found;
+}
diff --git a/bridge/XmmrControl.h b/bridge/XmmrControl.h
new file mode 100644
index 0000000..42c559b
--- /dev/null
+++ b/bridge/XmmrControl.h
@@ -0,0 +1,51 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include
+#include
+
+#include "ControlSite.h"
+#include "EventSink.h"
+
+// The XMMR control out of XMMT.ocx, which is what N1MM drives MMTTY and 2Tone
+// with. The control starts the engine, and the engine reports back through the
+// control's events.
+class XmmrControl {
+public:
+ using EventHandler = SinkHandler;
+
+ explicit XmmrControl(EventHandler handler) : handler_(std::move(handler)) {}
+ ~XmmrControl() { destroy(); }
+
+ bool create(HWND parent, std::string* error);
+ void destroy();
+
+ HRESULT put(const wchar_t* name, VARIANT value);
+ HRESULT putBool(const wchar_t* name, bool value);
+ HRESULT putLong(const wchar_t* name, long value);
+ HRESULT putString(const wchar_t* name, const std::string& value);
+
+ HRESULT call(const wchar_t* name, VARIANT* arguments, unsigned count);
+ HRESULT callLong(const wchar_t* name, long first, long second);
+
+ long getLong(const wchar_t* name);
+ std::string getString(const wchar_t* name);
+
+private:
+ HRESULT invoke(const wchar_t* name, WORD flags, DISPPARAMS* arguments, VARIANT* result);
+ bool listen(std::string* error);
+
+ EventHandler handler_;
+ ControlSite* site_ = nullptr;
+ IOleObject* object_ = nullptr;
+ IDispatch* dispatch_ = nullptr;
+ IConnectionPoint* point_ = nullptr;
+ IUnknown* sink_ = nullptr;
+ DWORD cookie_ = 0;
+};
+
+std::string toUtf8(const wchar_t* text);
+std::wstring toWide(const std::string& text);
diff --git a/bridge/main.cpp b/bridge/main.cpp
new file mode 100644
index 0000000..be52e2f
--- /dev/null
+++ b/bridge/main.cpp
@@ -0,0 +1,256 @@
+// Runs XMMT.ocx under Wine so that Nonemm, which is a Linux program, can drive
+// MMTTY or 2Tone. Everything arrives on standard input and leaves on standard
+// output, one protocol line at a time. Wine's own diagnostics go to standard
+// error, which keeps them out of the protocol.
+//
+// The startup is N1MM's, in N1MM's order: title, PTT port and command line on
+// the control, then bActive, then the host window handle to the engine.
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include "Protocol.h"
+#include "XmmrControl.h"
+
+namespace {
+
+// One protocol line, handed from the reader thread to the thread the control
+// lives on. COM calls have to stay on the one thread.
+constexpr UINT WM_BRIDGE_LINE = WM_APP + 1;
+
+constexpr long MessageHostWindow = 0;
+constexpr long MessageShutdown = 2;
+
+constexpr UINT_PTR QuitTimer = 1;
+constexpr UINT QuitPatience = 5000;
+
+void killEngine();
+
+XmmrControl* control = nullptr;
+HWND bridgeWindow = nullptr;
+DWORD enginePid = 0;
+bool quitting = false;
+
+// Dispatch arguments arrive last one first; `position` counts them the way the
+// event declares them.
+long argument(DISPPARAMS* arguments, unsigned position) {
+ if (arguments == nullptr || position >= arguments->cArgs) {
+ return 0;
+ }
+ VARIANT wanted;
+ VariantInit(&wanted);
+ if (FAILED(VariantChangeType(&wanted, &arguments->rgvarg[arguments->cArgs - 1 - position], 0,
+ VT_I4))) {
+ return 0;
+ }
+ long value = wanted.lVal;
+ VariantClear(&wanted);
+ return value;
+}
+
+// The control turns each message MMTTY sends it into an event of its own. Only
+// the messages it has no event for reach OnTranslateMessage, and every message
+// the logger needs has one, so that event is not listened to.
+void onEvent(const std::wstring& name, DISPPARAMS* arguments) {
+ if (name == L"OnConnected") {
+ protocol::write("connected", {control->getString(L"verMMTTY")});
+ } else if (name == L"OnDisconnected") {
+ protocol::write("disconnected", argument(arguments, 0));
+ if (quitting) {
+ killEngine();
+ PostQuitMessage(0);
+ }
+ } else if (name == L"OnCharRcvd") {
+ protocol::write("rx", argument(arguments, 0));
+ } else if (name == L"OnPttEvent") {
+ protocol::write("tx", argument(arguments, 0));
+ } else if (name == L"OnFreqChanged") {
+ protocol::write("mark", argument(arguments, 0));
+ protocol::write("space", argument(arguments, 1));
+ } else if (name == L"OnSwitchChanged") {
+ protocol::write("switch", argument(arguments, 0));
+ } else if (name == L"OnViewChanged") {
+ protocol::write("view", argument(arguments, 0));
+ }
+}
+
+// The bridge started the engine, so the bridge stops it. MMTTY is asked first
+// and killed only if it is still there when the wait runs out; the control's
+// own shutdown call blocks forever under Wine, waiting for a message loop that
+// is by then no longer pumping.
+BOOL CALLBACK closeWindow(HWND window, LPARAM wanted) {
+ DWORD owner = 0;
+ GetWindowThreadProcessId(window, &owner);
+ if (owner == static_cast(wanted)) {
+ PostMessageW(window, WM_CLOSE, 0, 0);
+ }
+ return TRUE;
+}
+
+void stopEngine() {
+ control->callLong(L"PostMmttyMessage", MessageShutdown, 0);
+ HWND engine = reinterpret_cast(static_cast(control->getLong(L"hWndMmtty")));
+ if (engine == nullptr) {
+ return;
+ }
+ GetWindowThreadProcessId(engine, &enginePid);
+ EnumWindows(closeWindow, static_cast(enginePid));
+}
+
+void killEngine() {
+ if (enginePid == 0) {
+ return;
+ }
+ HANDLE engine = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, enginePid);
+ if (engine == nullptr) {
+ return;
+ }
+ if (WaitForSingleObject(engine, 0) != WAIT_OBJECT_0) {
+ TerminateProcess(engine, 0);
+ }
+ CloseHandle(engine);
+ enginePid = 0;
+}
+
+void report(const char* what, HRESULT result) {
+ if (FAILED(result)) {
+ char message[128];
+ std::snprintf(message, sizeof(message), "%s failed: 0x%08lx", what,
+ static_cast(result));
+ protocol::write("error", {message});
+ }
+}
+
+void open(const protocol::Line& line) {
+ report("Visible", control->putBool(L"Visible", true));
+ report("VisibleMmtty", control->putBool(L"VisibleMmtty", true));
+ report("InvokeCommand", control->putString(L"InvokeCommand", line.field(2)));
+ report("ComName", control->putString(L"ComName", line.field(1)));
+ report("Title", control->putString(L"Title", line.field(0)));
+ report("bActive", control->putBool(L"bActive", true));
+ report("PostMmttyMessage",
+ control->callLong(L"PostMmttyMessage", MessageHostWindow, control->getLong(L"hwnd")));
+}
+
+void send(const std::string& text) {
+ VARIANT argument;
+ VariantInit(&argument);
+ argument.vt = VT_BSTR;
+ argument.bstrVal = SysAllocString(toWide(text).c_str());
+ control->call(L"SendString", &argument, 1);
+ VariantClear(&argument);
+}
+
+void setPtt(long on) {
+ VARIANT argument;
+ VariantInit(&argument);
+ argument.vt = VT_I4;
+ argument.lVal = on;
+ control->call(L"SetMmttyPTT", &argument, 1);
+}
+
+void act(const std::string& text) {
+ protocol::Line line = protocol::read(text);
+ if (line.verb == "open") {
+ open(line);
+ } else if (line.verb == "send") {
+ send(line.field(0));
+ } else if (line.verb == "ptt") {
+ setPtt(line.number(0));
+ } else if (line.verb == "post") {
+ control->callLong(L"PostMmttyMessage", line.number(0), line.number(1));
+ } else if (line.verb == "close") {
+ stopEngine();
+ } else if (line.verb == "quit") {
+ quitting = true;
+ stopEngine();
+ SetTimer(bridgeWindow, QuitTimer, QuitPatience, nullptr);
+ } else {
+ protocol::write("error", {"unknown verb: " + line.verb});
+ }
+}
+
+LRESULT CALLBACK onMessage(HWND window, UINT message, WPARAM first, LPARAM second) {
+ // The engine did not report itself gone, so stop waiting for it.
+ if (message == WM_TIMER && first == QuitTimer) {
+ killEngine();
+ PostQuitMessage(0);
+ return 0;
+ }
+ if (message == WM_BRIDGE_LINE) {
+ std::string* line = reinterpret_cast(second);
+ act(*line);
+ delete line;
+ return 0;
+ }
+ return DefWindowProcW(window, message, first, second);
+}
+
+HWND createWindow(bool show) {
+ WNDCLASSEXW description = {};
+ description.cbSize = sizeof(description);
+ description.lpfnWndProc = onMessage;
+ description.hInstance = GetModuleHandleW(nullptr);
+ description.lpszClassName = L"NonemmMmttyBridge";
+ RegisterClassExW(&description);
+ HWND window = CreateWindowExW(0, description.lpszClassName, L"Nonemm MMTTY bridge",
+ WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 320, 200,
+ nullptr, nullptr, description.hInstance, nullptr);
+ if (window != nullptr && show) {
+ ShowWindow(window, SW_SHOW);
+ }
+ return window;
+}
+
+void readLines(HWND window) {
+ std::string line;
+ while (std::getline(std::cin, line)) {
+ if (!line.empty() && line.back() == '\r') {
+ line.pop_back();
+ }
+ PostMessageW(window, WM_BRIDGE_LINE, 0, reinterpret_cast(new std::string(line)));
+ }
+ PostMessageW(window, WM_QUIT, 0, 0);
+}
+
+} // namespace
+
+int main(int count, char** arguments) {
+ bool show = count > 1 && std::string(arguments[1]) == "--show";
+ HRESULT started = CoInitialize(nullptr);
+ if (FAILED(started)) {
+ protocol::write("error", {"COM would not start"});
+ return 1;
+ }
+ OleInitialize(nullptr);
+ HWND window = createWindow(show);
+ bridgeWindow = window;
+ if (window == nullptr) {
+ protocol::write("error", {"the bridge window would not open"});
+ return 1;
+ }
+ XmmrControl xmmr(onEvent);
+ control = &xmmr;
+ std::string error;
+ if (!xmmr.create(window, &error)) {
+ protocol::write("error", {error});
+ return 1;
+ }
+ protocol::write("ready");
+ std::thread reader(readLines, window);
+ reader.detach();
+ MSG message;
+ while (GetMessageW(&message, nullptr, 0, 0) > 0) {
+ TranslateMessage(&message);
+ DispatchMessageW(&message);
+ }
+ // The control's own teardown waits for an engine that has already been told
+ // to shut down, and never returns. The bridge has nothing left to do here,
+ // so it leaves and lets Wine reclaim what it held.
+ protocol::write("closed");
+ ExitProcess(0);
+}
diff --git a/docs/digital-bridge.md b/docs/digital-bridge.md
new file mode 100644
index 0000000..a33d831
--- /dev/null
+++ b/docs/digital-bridge.md
@@ -0,0 +1,141 @@
+# MMTTY and 2Tone through Wine
+
+MMTTY is a Windows program with no socket, pipe or file interface. N1MM does not
+talk to it directly either: it hosts `XMMT.ocx`, a 32-bit ActiveX control that
+starts `MMTTY.EXE` and exchanges window messages with it. A Linux process cannot
+load that control, so Nonemm runs a small Windows program under Wine —
+`bridge/nonemm-mmtty-bridge.exe` — which hosts the control and passes lines over
+its standard input and output.
+
+```
+Nonemm ──stdin/stdout──▶ bridge.exe (Wine) ──XMMT.ocx──▶ MMTTY.EXE / 2Tone
+```
+
+2Tone uses the same control, so it runs the same way. The only differences are
+the command line and the window title, both in `MmttyOptions`.
+
+## The line protocol
+
+One line per message: a verb, then fields, separated by tabs. Backslash, tab,
+carriage return and newline inside a field are backslash-escaped, so a field
+never splits a line. `BridgeLine.cs` and `bridge/Protocol.cpp` are the two ends
+of it.
+
+Wine writes its own diagnostics to standard error, which leaves standard output
+to the protocol.
+
+To the bridge:
+
+| Line | What the bridge does |
+|---|---|
+| `open ` | sets `Title`, `ComName` and `InvokeCommand`, sets `bActive`, then posts the host window handle |
+| `send ` | `SendString` |
+| `ptt <0\|1>` | `SetMmttyPTT` |
+| `post ` | `PostMmttyMessage`, for everything in `MmttyMessage` |
+| `close` | shuts the engine down and leaves the bridge running |
+| `quit` | shuts the engine down and exits |
+
+From the bridge:
+
+| Line | Where it comes from |
+|---|---|
+| `ready` | the control is created and listening |
+| `connected ` | `OnConnected`, with `verMMTTY` |
+| `disconnected ` | `OnDisconnected`: 0 failed to close, 1 closed, 2 failed to start |
+| `rx ` | one decoded character |
+| `tx <0\|1>` | the engine started or stopped transmitting |
+| `mark `, `space ` | the tone pair moved |
+| `switch `, `view ` | AFC (4), net (8) and reverse (256), and the engine's view state |
+| `log `, `error ` | anything the bridge has to say |
+
+Each of those comes from an event of the control's own: `OnCharRcvd`,
+`OnPttEvent`, `OnFreqChanged` (mark and space in one event), `OnSwitchChanged`
+and `OnViewChanged`. The control raises `OnTranslateMessage` only for the
+MMTTY messages it has no event for — width, resolution, thread and the like —
+so the bridge does not listen to it. N1MM reads the characters the same way, in
+`DigitalMultiRXWindow.XMMR_OnCharRcvd`; the `OnTranslateMessage` handler in
+`DigitalInterface.cs` that maps codes 32771 to 32778 never runs with this
+version of the control.
+
+## Starting the engine
+
+The order is N1MM's, from `DigitalInterface.cs`:
+
+1. Read `Mmtty.INI` beside the program. `[Define] PTT` is the serial port, and
+ `AFC`, `TxNet` and `Rev` are the switch word the engine starts with. Without
+ them, the first toggle moves the wrong way.
+2. Build the command line: the quoted program, the window size (`-r` normal,
+ `-t` small, `-s` medium 1, `-u` medium 2), `-a` when the window is not on
+ top, and `-Z` for MMTTY but not for 2Tone.
+3. `open`. The control appends `-h` of its own, which is how the engine
+ finds its way back.
+4. An engine that answers `disconnected 2` is started again, up to ten times.
+ That is N1MM's retry, and it is needed: MMTTY does not always come up first
+ time.
+
+## What the control needs that is not obvious
+
+Three things cost a day between them, so they are written down:
+
+- **`IPersistStreamInit::InitNew` before anything else.** The control is MFC
+ based, and until it is initialised every dispatch call returns `E_UNEXPECTED`
+ — property gets included. Creating and activating it is not enough.
+- **A real window.** In-place activation needs a parent window, so the bridge
+ needs an X display even though it shows nothing.
+- **The control's own shutdown blocks.** `IOleObject::Close` and setting
+ `bActive` to false wait for the engine while holding the thread that would
+ have to pump the messages, so neither returns. The bridge posts the shutdown
+ message, closes the engine's windows, and kills the process if it is still
+ there five seconds later.
+
+## Setting it up
+
+Config > Digital has a Register button. It does what the next paragraph
+describes, in the prefix named in the same dialog, and writes the result under
+the button. Press it once, before the first start.
+
+The steps are in `WinePrefixSetup`. A prefix that is not there yet is made with
+`wineboot -u` and `WINEARCH=win32`, because the control is 32 bit. The control
+is then copied into the Windows folder and registered:
+
+```sh
+export WINEPREFIX=~/.wine-nonemm WINEARCH=win32
+wineboot -u
+cp bridge/XMMT.ocx "$WINEPREFIX/drive_c/windows/system32/"
+wine regsvr32 'C:\windows\system32\XMMT.ocx'
+```
+
+A 64-bit prefix keeps 32-bit code in `syswow64` and has a second `regsvr32.exe`
+there. That is the one to register the control with: the 64-bit one cannot load
+it.
+
+`XMMT.ocx` ships with N1MM Logger+ and is distributed with Nonemm in the
+`bridge` folder. MMTTY itself is downloaded separately and can live anywhere in
+the prefix.
+
+Build the bridge with the mingw-w64 cross compiler:
+
+```sh
+make -C bridge
+```
+
+## Where the program looks
+
+MMTTY, the bridge and `XMMT.ocx` are distributed with Nonemm, in `mmtty` and
+`bridge` folders beside the program, and the build copies them there from the
+working copy. Config > Digital starts at those three paths, so an operator who
+runs the distributed copy has nothing to fill in. An engine kept somewhere else, or
+2Tone in place of MMTTY, is browsed for in the same dialog. Neither program is
+in the repository.
+
+The bridge keeps its own window hidden. Started with `--show` it shows it, which
+is the way to see what the control is doing.
+
+## What has not been tested
+
+The bridge has been run against MMTTY 1.70 under Wine 10: it starts the engine,
+connects, reports the version, takes text, opens MMTTY's setup window, moves
+the tone pair and reports `mark`, `space`, `switch`, `view` and `tx` back. It
+has never been run with a sound card, so nothing has been decoded and no `rx`
+line has ever come from a real signal. FSK keying through EXTFSK and PTT on a
+serial port have not been tried either, and neither has 2Tone.
diff --git a/docs/unfinished.md b/docs/unfinished.md
index f23ab90..dc8994a 100644
--- a/docs/unfinished.md
+++ b/docs/unfinished.md
@@ -17,12 +17,23 @@ is still sitting there undiscovered.
| `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. |
| `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. |
| `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `h` reply request | no `cwdaemon`, no radio keyed. |
+| `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10, started and stopped through `XMMT.ocx` | no sound card, so nothing has been decoded or transmitted and no `rx` or `tx` line has come from a real signal. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. `docs/digital-bridge.md` |
| `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. |
The Cabrillo output has not been put in front of a contest sponsor's robot.
## Half-built
+**The digital window: what N1MM has and this does not.** The window is here —
+receive pane, coloured callsigns, grab list, call stacking, twenty-four macro
+buttons, the engine controls — and the function keys, ESM and the QTC window
+send through the engine on a digital mode. Left out: the waterfall, the spectrum
+and the XY scope, which the engine reports and nothing draws; more than one
+receive pane, which is MMVARI's multi-channel decoding rather than MMTTY's;
+sending a text file; and N1MM's Send All, which keys a whole QTC series at once.
+The other interfaces N1MM offers — fldigi, MMVARI, the hardware TNCs — are not
+here either, though `DigitalEngine` is the place to add them.
+
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
recording, and nothing implements it. No DVK support either, so the second radio
of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW
diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs
index eeb90b5..b3e1a1f 100644
--- a/src/Nonemm.App/AppSession.cs
+++ b/src/Nonemm.App/AppSession.cs
@@ -4,6 +4,7 @@ using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country;
+using Nonemm.Digital;
using Nonemm.Keying;
using Nonemm.Network;
using Nonemm.Rig;
@@ -26,6 +27,8 @@ public sealed class AppSession : IDisposable
private StationNetwork? network;
private MessageSender? keyer;
private AlternatingCq? alternating;
+ private MmttyEngine? digital;
+ private DigitalEngineSender? digitalSender;
private So2rBox? box;
/// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
@@ -123,6 +126,14 @@ public sealed class AppSession : IDisposable
public MessageSender? Keyer => keyer;
+ /// The digital modem, started by the digital window rather than at startup:
+ /// it runs an engine under Wine, which is not something to do to an
+ /// operator who is working CW.
+ public MmttyEngine? Digital => digital;
+
+ /// The same engine as something a macro can be sent through.
+ public MessageSender? DigitalKeyer => digitalSender;
+
/// Alternating CQ, or null while there is no keyer. It needs two radios to
/// do anything, and a keyer that reports when a message has gone out.
public AlternatingCq? Alternating => alternating;
@@ -291,6 +302,46 @@ public sealed class AppSession : IDisposable
Changed?.Invoke(this, EventArgs.Empty);
}
+ /// Starts the digital engine under Wine and returns it. The engine that is
+ /// already running is handed back rather than started again.
+ public async Task StartDigitalAsync()
+ {
+ if (digital is { IsConnected: true } running)
+ {
+ return running;
+ }
+ StopDigital();
+ MmttyOptions options = new()
+ {
+ EnginePath = Settings.DigitalEnginePath,
+ Number = ActiveRadioNumber,
+ Window = Enum.TryParse(Settings.DigitalEngineWindow, ignoreCase: true, out EngineWindow window)
+ ? window
+ : EngineWindow.Normal,
+ OnTop = Settings.DigitalEngineOnTop,
+ PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null,
+ };
+ WineBridgeChannel channel = new(
+ Settings.DigitalBridgePath,
+ Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
+ Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
+ MmttyEngine started = new(channel, options);
+ await started.StartAsync();
+ digital = started;
+ digitalSender = new DigitalEngineSender(started);
+ Changed?.Invoke(this, EventArgs.Empty);
+ return started;
+ }
+
+ public void StopDigital()
+ {
+ digitalSender?.Dispose();
+ digitalSender = null;
+ digital?.Dispose();
+ digital = null;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
/// Opens a connection per enabled radio. A second one makes the station
/// SO2R: both are read, but only the one the operator is on drives the
/// entry window.
@@ -534,6 +585,7 @@ public sealed class AppSession : IDisposable
public void Dispose()
{
+ StopDigital();
spotFlush.Dispose();
DisposeRadios();
cluster?.Dispose();
diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs
index abecea4..4a9b396 100644
--- a/src/Nonemm.App/Configuration/Settings.cs
+++ b/src/Nonemm.App/Configuration/Settings.cs
@@ -186,6 +186,79 @@ public sealed record Settings
public IReadOnlyList PhoneMessages { get; init; } = [];
+ /// The digital window's twenty-four macro buttons, in the same
+ /// `label,message` lines as a function key file. Empty means the built-in
+ /// set.
+ public string DigitalMessageFile { get; init; } = "";
+
+ public bool DigitalEnabled { get; init; }
+
+ /// The engine to run: MMTTY or 2Tone. The path is a Linux path; it is
+ /// handed to Wine in the form Wine expects. It starts at the copy shipped
+ /// beside the program.
+ public string DigitalEnginePath { get; init; } = ProgramFile("mmtty", "mmtty.exe");
+
+ public string DigitalBridgePath { get; init; } =
+ ProgramFile("bridge", "nonemm-mmtty-bridge.exe");
+
+ /// The XMMT.ocx the bridge hosts, as it sits beside the program. It is
+ /// only read when the control is registered in the Wine prefix; after that
+ /// the copy in the prefix is the one that is loaded.
+ public string DigitalControlPath { get; init; } = ProgramFile("bridge", "XMMT.ocx");
+
+ /// Empty means Wine's own default prefix.
+ public string DigitalWinePrefix { get; init; } = "";
+
+ public string DigitalWineCommand { get; init; } = "wine";
+
+ /// `Normal`, `Small`, `Medium1` or `Medium2`, which are MMTTY's four
+ /// window sizes.
+ public string DigitalEngineWindow { get; init; } = "Normal";
+
+ public bool DigitalEngineOnTop { get; init; } = true;
+
+ /// The port MMTTY keys FSK and PTT on. Empty reads it from Mmtty.INI, which
+ /// is where MMTTY itself keeps it.
+ public string DigitalPttPort { get; init; } = "";
+
+ /// The mark tone the Align button moves the engine to. MMTTY's own default.
+ public int DigitalMarkHertz { get; init; } = 2125;
+
+ /// Colour the callsign itself, or the ground behind it. N1MM offers both.
+ public bool DigitalHighlightBackground { get; init; }
+
+ /// `everything`, `notdupes` or `knowncalls`.
+ public string DigitalGrabFilter { get; init; } = "everything";
+
+ public bool DigitalGrabNewestFirst { get; init; } = true;
+
+ /// `multipliers`, `firstin`, `lastin` or `disabled`.
+ public string DigitalCallStacking { get; init; } = "disabled";
+
+ /// N1MM's right-click option: a right click in the receive pane presses
+ /// Enter in the entry window instead of opening the menu.
+ public bool DigitalRightClickSendsEnter { get; init; }
+
+ /// With the right click sending Enter, whether the call stays in the box
+ /// afterwards. N1MM's "don't drop call".
+ public bool DigitalRightClickKeepsCall { get; init; }
+
+ /// A grabbed call is followed by a space, which moves to the exchange and
+ /// fills the report in, the way typing one does.
+ public bool DigitalGrabSendsSpace { get; init; } = true;
+
+ /// N1MM caps the receive pane's font at 14 points.
+ public int DigitalFontSize { get; init; } = 12;
+
+ /// A file shipped beside the program. MMTTY and the bridge are distributed
+ /// with Nonemm, so the paths are filled in already; a file that is not
+ /// there leaves the setting empty and the operator browses for it.
+ private static string ProgramFile(string folder, string name)
+ {
+ string path = Path.Combine(AppContext.BaseDirectory, folder, name);
+ return File.Exists(path) ? path : "";
+ }
+
/// Reflection rather than a generated serializer: the generated one hands
/// back null for every property the file leaves out instead of the value
/// the property is declared with.
@@ -322,6 +395,21 @@ public sealed record Settings
PhoneMessages = [],
};
}
+ if (settings.DigitalEnginePath.Length == 0)
+ {
+ settings = settings with { DigitalEnginePath = ProgramFile("mmtty", "mmtty.exe") };
+ }
+ if (settings.DigitalBridgePath.Length == 0)
+ {
+ settings = settings with
+ {
+ DigitalBridgePath = ProgramFile("bridge", "nonemm-mmtty-bridge.exe"),
+ };
+ }
+ if (settings.DigitalControlPath.Length == 0)
+ {
+ settings = settings with { DigitalControlPath = ProgramFile("bridge", "XMMT.ocx") };
+ }
return settings;
}
}
diff --git a/src/Nonemm.App/Dialogs/DigitalDialog.axaml b/src/Nonemm.App/Dialogs/DigitalDialog.axaml
new file mode 100644
index 0000000..02d0c24
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/DigitalDialog.axaml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs b/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs
new file mode 100644
index 0000000..900f523
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs
@@ -0,0 +1,105 @@
+using System.Globalization;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Nonemm.App.Configuration;
+using Nonemm.Digital;
+
+namespace Nonemm.App.Dialogs;
+
+/// Where the digital engine is and how it is started. It closes with the
+/// settings, or with null when nothing is to be changed.
+public sealed partial class DigitalDialog : Window
+{
+ private static readonly string[] WindowSizes = ["Normal", "Small", "Medium1", "Medium2"];
+
+ private readonly Settings settings;
+
+ public DigitalDialog(Settings settings)
+ {
+ this.settings = settings;
+ InitializeComponent();
+ EngineBox.Text = settings.DigitalEnginePath;
+ BridgeBox.Text = settings.DigitalBridgePath;
+ ControlBox.Text = settings.DigitalControlPath;
+ PrefixBox.Text = settings.DigitalWinePrefix;
+ WineBox.Text = settings.DigitalWineCommand;
+ PttBox.Text = settings.DigitalPttPort;
+ MarkBox.Text = settings.DigitalMarkHertz.ToString(CultureInfo.InvariantCulture);
+ WindowBox.ItemsSource = WindowSizes;
+ WindowBox.SelectedItem =
+ WindowSizes.FirstOrDefault(s => s == settings.DigitalEngineWindow) ?? WindowSizes[0];
+ OnTopBox.IsChecked = settings.DigitalEngineOnTop;
+ }
+
+ private async void OnBrowseEngine(object? sender, RoutedEventArgs e) =>
+ EngineBox.Text = await PickAsync("The MMTTY or 2Tone program") ?? EngineBox.Text;
+
+ private async void OnBrowseBridge(object? sender, RoutedEventArgs e) =>
+ BridgeBox.Text = await PickAsync("The bridge program") ?? BridgeBox.Text;
+
+ private async void OnBrowseControl(object? sender, RoutedEventArgs e) =>
+ ControlBox.Text = await PickAsync("The XMMT.ocx control", "ActiveX controls", "*.ocx") ?? ControlBox.Text;
+
+ /// Copies XMMT.ocx into the prefix and registers it. It takes a few seconds
+ /// on a prefix Wine has to make first, so the button is held down until it
+ /// is over and the result is written under it.
+ private async void OnRegister(object? sender, RoutedEventArgs e)
+ {
+ RegisterButton.IsEnabled = false;
+ Say("Registering…");
+ WinePrefixSetup setup = new(
+ PrefixBox.Text?.Trim() is { Length: > 0 } prefix ? prefix : null,
+ WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine");
+ try
+ {
+ Say(await setup.RegisterAsync(ControlBox.Text?.Trim() ?? ""));
+ }
+ catch (Exception failure) when (failure is IOException or InvalidOperationException)
+ {
+ Say(failure.Message);
+ }
+ finally
+ {
+ RegisterButton.IsEnabled = true;
+ }
+ }
+
+ private void Say(string text)
+ {
+ RegisterText.Text = text;
+ RegisterText.IsVisible = true;
+ }
+
+ private async Task PickAsync(
+ string title,
+ string kind = "Windows programs",
+ string pattern = "*.exe")
+ {
+ IReadOnlyList picked = await StorageProvider.OpenFilePickerAsync(
+ new FilePickerOpenOptions
+ {
+ Title = title,
+ AllowMultiple = false,
+ FileTypeFilter = [new FilePickerFileType(kind) { Patterns = [pattern] }],
+ });
+ return picked.Count > 0 ? picked[0].Path.LocalPath : null;
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+
+ private void OnSave(object? sender, RoutedEventArgs e) =>
+ Close(settings with
+ {
+ DigitalEnginePath = EngineBox.Text?.Trim() ?? "",
+ DigitalBridgePath = BridgeBox.Text?.Trim() ?? "",
+ DigitalControlPath = ControlBox.Text?.Trim() ?? "",
+ DigitalWinePrefix = PrefixBox.Text?.Trim() ?? "",
+ DigitalWineCommand = WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine",
+ DigitalPttPort = PttBox.Text?.Trim() ?? "",
+ DigitalMarkHertz = int.TryParse(MarkBox.Text, out int mark) ? mark : settings.DigitalMarkHertz,
+ DigitalEngineWindow = WindowBox.SelectedItem as string ?? "Normal",
+ DigitalEngineOnTop = OnTopBox.IsChecked == true,
+ DigitalEnabled = (EngineBox.Text?.Trim().Length ?? 0) > 0,
+ });
+}
diff --git a/src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml b/src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml
new file mode 100644
index 0000000..94a5559
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml.cs b/src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml.cs
new file mode 100644
index 0000000..0fffc43
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/DigitalMacrosDialog.axaml.cs
@@ -0,0 +1,22 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace Nonemm.App.Dialogs;
+
+/// The digital window's macro buttons, edited as their lines. It closes with
+/// the text, or with null when nothing is to be changed.
+public sealed partial class DigitalMacrosDialog : Window
+{
+ public DigitalMacrosDialog(string text)
+ {
+ InitializeComponent();
+ TextArea.Text = text.Trim().Length > 0 ? text : Messages.DefaultDigital;
+ }
+
+ private void OnDefaults(object? sender, RoutedEventArgs e) =>
+ TextArea.Text = Messages.DefaultDigital;
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+
+ private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? "");
+}
diff --git a/src/Nonemm.App/Messages.cs b/src/Nonemm.App/Messages.cs
index f85ab09..b8cdd91 100644
--- a/src/Nonemm.App/Messages.cs
+++ b/src/Nonemm.App/Messages.cs
@@ -46,6 +46,45 @@ public static class Messages
F12 Wipe,
""";
+ /// The digital window's twenty-four buttons. N1MM keeps its own set in its
+ /// admin database, which is not a file this program reads, so these are
+ /// written from N1MM's published digital macro list: `{TX}` starts the
+ /// transmission, `{RX}` ends it, and the text macros are the ones every
+ /// other message uses.
+ public const string DefaultDigital =
+ """
+ # The digital window's macro buttons, three rows of eight.
+ CQ,{TX}CQ CQ DE {MYCALL} {MYCALL} CQ K{ENTER}{RX}
+ Exch,{TX}{CALL} DE {MYCALL} {SENTRST} {EXCH} {SENTRST} {EXCH} K{ENTER}{RX}
+ TU,{TX}{CALL} TU DE {MYCALL} QRZ{ENTER}{RX}{LOG}
+ MyCall,{TX}{MYCALL} {MYCALL} K{ENTER}{RX}
+ HisCall,{TX}{CALL} DE {MYCALL} K{ENTER}{RX}
+ QSO B4,{TX}{CALL} QSO B4 QRZ DE {MYCALL}{ENTER}{RX}
+ Agn?,{TX}AGN AGN DE {MYCALL} K{ENTER}{RX}
+ Nr?,{TX}NR? NR? DE {MYCALL} K{ENTER}{RX}
+ Call?,{TX}CALL? CALL? DE {MYCALL} K{ENTER}{RX}
+ RST?,{TX}RST? RST? DE {MYCALL} K{ENTER}{RX}
+ QRZ,{TX}QRZ DE {MYCALL} {MYCALL} K{ENTER}{RX}
+ 5NN,{TX}{SENTRST} {SENTRST} K{ENTER}{RX}
+ Test,{TX}RYRYRYRYRYRYRYRY{ENTER}{RX}
+ Grab,{TX}{CALL} DE {MYCALL} K{ENTER}{RX}
+ Wipe,{WIPE}
+ Log,{LOG}
+ Run,{RUN}
+ S&&P,{S&P}
+ Stack,{STACKANOTHER}
+ Pop,{LOGTHENPOP}
+ Spot,{SPOTME}
+ 73,{TX}73 GL DE {MYCALL}{ENTER}{RX}
+ QRL?,{TX}QRL? DE {MYCALL}{ENTER}{RX}
+ Stop,{STOPTX}
+ """;
+
+ /// The stored digital macros, or the built-in ones while the operator has
+ /// not written their own.
+ public static DigitalMacros Digital(string stored) =>
+ DigitalMacros.Parse(stored.Trim().Length > 0 ? stored : DefaultDigital);
+
public static string DefaultFor(ModeCategory mode) =>
mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;
diff --git a/src/Nonemm.App/Nonemm.App.csproj b/src/Nonemm.App/Nonemm.App.csproj
index 2d51ea1..fcc869f 100644
--- a/src/Nonemm.App/Nonemm.App.csproj
+++ b/src/Nonemm.App/Nonemm.App.csproj
@@ -18,6 +18,15 @@
+
+
+
+
+
+
@@ -28,5 +37,6 @@
+
diff --git a/src/Nonemm.App/Verdicts.cs b/src/Nonemm.App/Verdicts.cs
index 299e605..b5625d2 100644
--- a/src/Nonemm.App/Verdicts.cs
+++ b/src/Nonemm.App/Verdicts.cs
@@ -22,6 +22,10 @@ public static class Verdicts
/// The frequency we are calling CQ on.
public static IBrush Cq => Themes.Brush(Themes.Current.Cq);
+ /// Our own call, heard back from the other station. It is neither worth
+ /// working nor a dupe, so it gets the colour that means us.
+ public static IBrush MyCall => Cq;
+
/// Nothing typed yet is the same colour as a station worth working: that is
/// what N1MM leaves the callsign box.
public static IBrush Colour(Verdict? verdict) =>
diff --git a/src/Nonemm.App/Windows/DigitalWindow.Grab.cs b/src/Nonemm.App/Windows/DigitalWindow.Grab.cs
new file mode 100644
index 0000000..e68ef6d
--- /dev/null
+++ b/src/Nonemm.App/Windows/DigitalWindow.Grab.cs
@@ -0,0 +1,228 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Nonemm.Session;
+
+namespace Nonemm.App.Windows;
+
+/// The calls heard and what the pointer does to them: the grab list beside the
+/// transmit pane, and the clicking and hovering in the receive pane.
+public sealed partial class DigitalWindow
+{
+ /// A callsign the modem decoded goes in the grab list, unless it is ours,
+ /// the one already being typed, or the filter turns it away.
+ private void Heard(string call)
+ {
+ if (Radio is not { } position
+ || !GrabList.Wanted(call, position.Me.Callsign, position.Entry.Call)
+ || !PassesFilter(position, call))
+ {
+ return;
+ }
+ grab.NewestFirst = session.Settings.DigitalGrabNewestFirst;
+ if (grab.Add(call))
+ {
+ ShowGrabList();
+ }
+ }
+
+ private bool PassesFilter(OperatingPosition position, string call) =>
+ session.Settings.DigitalGrabFilter.ToLowerInvariant() switch
+ {
+ "notdupes" => position.JudgeCall(call)?.IsDupe != true,
+ "knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0,
+ _ => true,
+ };
+
+ private void ShowGrabList()
+ {
+ GrabCalls.Children.Clear();
+ if (Radio is not { } position)
+ {
+ return;
+ }
+ foreach (string call in grab.Calls)
+ {
+ GrabCalls.Children.Add(GrabRow(position, call));
+ }
+ }
+
+ private Control GrabRow(OperatingPosition position, string call)
+ {
+ TextBlock text = new()
+ {
+ Text = call,
+ FontFamily = new FontFamily("monospace"),
+ FontSize = session.Settings.DigitalFontSize,
+ Foreground = Verdicts.Colour(position.JudgeCall(call)),
+ };
+ Border row = new()
+ {
+ Padding = new Thickness(4, 1),
+ Cursor = new Cursor(StandardCursorType.Hand),
+ // a border with no background of its own is not clickable away from
+ // the letters, and the whole row is the target
+ Background = Brushes.Transparent,
+ Child = text,
+ };
+ row.PointerPressed += (_, e) => GrabFromList(call, e);
+ return row;
+ }
+
+ /// Left click grabs the call, right click drops it from the list, which is
+ /// what N1MM's grab window does.
+ private void GrabFromList(string call, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
+ {
+ grab.Remove(call);
+ ShowGrabList();
+ return;
+ }
+ Grab(call);
+ }
+
+ /// Puts a call in the entry window. With call stacking on and a call
+ /// already being typed, the one being typed goes on the stack first, so
+ /// neither is lost — N1MM's digital call stacking.
+ private void Grab(string call)
+ {
+ if (Radio is not { } position)
+ {
+ return;
+ }
+ position.Stack.Order = StackingOrder();
+ if (position.Stack.Order != StackOrder.Disabled
+ && position.Entry.Call.Trim().Length > 0)
+ {
+ position.StackAnother();
+ }
+ grab.Remove(call);
+ ShowGrabList();
+ entry.GrabCall(call, session.Settings.DigitalGrabSendsSpace);
+ entry.Refresh();
+ }
+
+ /// N1MM's Grab button: the next call off the list.
+ private void OnGrabNext(object? sender, RoutedEventArgs e)
+ {
+ if (grab.Count > 0)
+ {
+ Grab(grab.Calls[0]);
+ }
+ }
+
+ private void OnReceivePointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (e.Source is not TextBlock block || block.Text is not { } text || text.Trim().Length == 0)
+ {
+ Unhighlight();
+ return;
+ }
+ if (ReferenceEquals(block, hovered))
+ {
+ return;
+ }
+ Unhighlight();
+ hovered = block;
+ block.TextDecorations = TextDecorations.Underline;
+ MouseOverText.Text = DigitalReceiver.Scrub(text);
+ }
+
+ private void OnReceivePointerExited(object? sender, PointerEventArgs e)
+ {
+ Unhighlight();
+ Resume();
+ }
+
+ private void Unhighlight()
+ {
+ if (hovered is not null)
+ {
+ hovered.TextDecorations = null;
+ hovered = null;
+ }
+ MouseOverText.Text = "";
+ }
+
+ /// One left click copies the word under the pointer, the way N1MM does: a
+ /// callsign goes in the callsign box, anything else in the exchange box the
+ /// contest keeps for it. A click on nothing pauses the pane instead, so
+ /// text can be read while more arrives. A right click presses Enter in the
+ /// entry window when that option is on.
+ private void OnReceivePointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
+ {
+ RightClick();
+ return;
+ }
+ string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
+ if (clicked.Length == 0)
+ {
+ paused = true;
+ return;
+ }
+ Resume();
+ Copy(clicked);
+ }
+
+ /// Where a clicked word goes. N1MM's order: an empty callsign box takes a
+ /// call, a callsign box that already holds one takes another only onto the
+ /// call stack, and everything else is an exchange element.
+ private void Copy(string clicked)
+ {
+ if (Radio is not { } position)
+ {
+ return;
+ }
+ string call = DigitalReceiver.TrimToCall(clicked);
+ bool isCall = call.Length is >= 3 and < 13
+ && !call.All(char.IsAsciiDigit)
+ && DigitalReceiver.IsCallsign(call)
+ && !string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase);
+ bool waiting = position.Entry.Call.Trim().Length == 0;
+ if (isCall && (waiting || StackingOrder() != StackOrder.Disabled))
+ {
+ Grab(call);
+ return;
+ }
+ if (!waiting)
+ {
+ entry.GrabExchange(DigitalElement.Trim(clicked));
+ }
+ }
+
+ private void RightClick()
+ {
+ if (!session.Settings.DigitalRightClickSendsEnter)
+ {
+ return;
+ }
+ entry.PressEnter();
+ if (!session.Settings.DigitalRightClickKeepsCall && Radio is { } position)
+ {
+ position.Wipe();
+ entry.Refresh();
+ }
+ }
+
+ /// Lets the pane scroll again and prints what arrived while it was stopped.
+ private void Resume()
+ {
+ if (!paused)
+ {
+ return;
+ }
+ paused = false;
+ if (held.Length == 0)
+ {
+ return;
+ }
+ string waiting = held.ToString();
+ held.Clear();
+ Print(waiting);
+ }
+}
diff --git a/src/Nonemm.App/Windows/DigitalWindow.Macros.cs b/src/Nonemm.App/Windows/DigitalWindow.Macros.cs
new file mode 100644
index 0000000..8985670
--- /dev/null
+++ b/src/Nonemm.App/Windows/DigitalWindow.Macros.cs
@@ -0,0 +1,180 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Nonemm.App.Dialogs;
+using Nonemm.Digital;
+using Nonemm.Session;
+
+namespace Nonemm.App.Windows;
+
+/// The macro buttons and the transmit pane: what goes out, and the engine
+/// controls beside them.
+public sealed partial class DigitalWindow
+{
+ private const int MacroColumns = 8;
+
+ private readonly List