Add the digital interface, running MMTTY under Wine

MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and
exchanges window messages with the engine. A Linux process cannot load that
control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes
lines over its standard input and output. docs/digital-bridge.md states the
protocol and what the control needs.

The window is N1MM's: receive pane with coloured callsigns, grab list, call
stacking, twenty-four macro buttons and the engine controls. One left click
copies what is under it — a callsign to the callsign box, anything else to the
exchange box the contest keeps for that kind of value.

Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the
prefix first if it is not there. The engine, the bridge and the control start
at the copies shipped beside the program.

The bridge reads the control's own events. OnTranslateMessage carries only the
messages the control has no event for, so nothing was ever decoded through it.

hamlib's data mode names read as digital modes now, and a mode typed into the
callsign box changes mode the way a frequency changes band, so a station with
no radio can reach RTTY at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 21:35:45 +00:00
co-authored by Claude Opus 5
parent 76b57cee1d
commit c8de74e24a
70 changed files with 4560 additions and 27 deletions
@@ -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);
}
}