Lay the digital window out the way N1MM does

The window now carries what N1MM's does. Across the top: the TX indicator, the
Letters/Figs box showing the word under the pointer in the other Baudot case,
and the MouseOver box showing the word itself. Down the left of the receive
pane is the pause bar, green while text prints and yellow while it is held.
Under the panes are the message buttons and the row of engine controls, in
N1MM's order.

Both of N1MM's window scroll types are here. The scrolling pane adds lines at
the bottom; the non-scrolling pane keeps text where it was printed, underlines
the line being printed to and walks it down the window, so a call does not move
as the pointer reaches it.

A callsign is checked before it is coloured: anything shaped like a call under
the generic routine, only calls in the files that are ticked under the
resources routine, and under the combination everything, with a call the files
do not hold marked in yellow. The garbage text search finds a Master.scp call
inside a run of noise.

The mouse and the keys are N1MM's. Shift+click converts the case before the
word is transferred, Ctrl+click puts it in the box the cursor is in, Alt+click
puts a call on the stack, a double click replaces the call in the box. The
receive pane and the grab list have their right click menus. Alt+T and Ctrl+K
turn the transmitter on with the cursor in the transmit pane, Alt+G grabs, Esc
aborts.

New from the menus: hover mode, the callsign pause routines, sending a text
file, a date-stamped copy of the receive pane in ExportFiles, clearing the grab
list on a CQ or a QSY, the ham default and AFC off on a run to S&P change, AFC
on with CQ, and raising the window with the entry window it belongs to. The
grab list holds ten calls, as N1MM's does.

The setup window is N1MM's dialog: a General / MMTTY Setup tab, an Engine tab
for the Wine and bridge paths, and a Message Setup tab with the buttons laid
out as they sit in the window, a caption and text box for the one being edited,
the macro list, the count of buttons to show, and import and save of a .mc
file. Right-clicking a message button opens that tab, so the free-form macro
dialog is gone.

The title carries the dial frequency with the mark tone added to it, or the
dial frequency alone when the radio already reads out what it transmits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-09-01 00:03:30 +00:00
co-authored by Claude Opus 5
parent 1da35e6c45
commit f3908840b3
9 changed files with 1154 additions and 248 deletions
+257 -28
View File
@@ -6,7 +6,9 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.App.Configuration;
using Nonemm.App.Theming;
using Nonemm.Core;
using Nonemm.Digital;
using Nonemm.Session;
@@ -18,28 +20,47 @@ namespace Nonemm.App.Windows;
/// puts it in the entry window.
public sealed partial class DigitalWindow : RefreshableWindow
{
/// How many lines of decoded text are kept in the pane. A contest weekend
/// of RTTY is more than any window needs to hold.
/// How many lines of decoded text are kept in a scrolling pane. A contest
/// weekend of RTTY is more than any window needs to hold.
private const int KeptLines = 300;
/// The fewest lines a non-scrolling pane is divided into, for a window too
/// short to fit more.
private const int LeastLines = 4;
/// The longest run of noise that is looked at as a callsign.
private const int LongestCall = 12;
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(45);
/// What N1MM marks a call its resource files do not hold with.
private static readonly IBrush Unknown = Brushes.Gold;
private readonly AppSession session;
private readonly EntryWindow entry;
private readonly int radioNumber;
private readonly DigitalReceiver receiver = new();
private readonly GrabList grab = new();
private readonly GrabList grab = new(GrabList.N1mmDepth);
/// What arrived while the pane was paused, held until it starts again.
private readonly StringBuilder held = new();
private WrapPanel? line;
private Border? line;
private TextBlock? word;
private TextBlock? hovered;
private MmttyEngine? engine;
private bool paused;
private bool lastWasReturn;
/// Where the next line goes in a non-scrolling pane. It walks down the
/// window and starts again at the top; -1 is before the first line.
private int lineAt = -1;
/// The run state and the frequency as they were at the last refresh, so a
/// change to either can be acted on. Null until the first refresh.
private bool? wasRunning;
private double wasKilohertz;
public DigitalWindow(AppSession session, EntryWindow entry, int radioNumber = 1)
{
this.session = session;
@@ -47,20 +68,30 @@ public sealed partial class DigitalWindow : RefreshableWindow
this.radioNumber = radioNumber;
InitializeComponent();
receiver.CallHeard += (_, call) => Dispatcher.UIThread.Post(() => Heard(call));
entry.CqSent += WhenCqSent;
entry.Activated += WhenEntryActivated;
BuildMacros();
ApplySettings();
Attach(session.Digital);
Refresh();
Closed += (_, _) => Detach();
Closed += (_, _) =>
{
entry.CqSent -= WhenCqSent;
entry.Activated -= WhenEntryActivated;
Detach();
};
}
private OperatingPosition? Radio =>
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
private Settings Settings => session.Settings;
public override void Refresh()
{
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
FollowRunAndFrequency();
ShowState();
ShowGrabList();
LabelMacros();
@@ -69,10 +100,10 @@ public sealed partial class DigitalWindow : RefreshableWindow
/// Starts the engine if it is not running, and connects this window to it.
private async Task StartAsync()
{
if (session.Settings.DigitalEnginePath.Trim().Length == 0
|| session.Settings.DigitalBridgePath.Trim().Length == 0)
if (Settings.DigitalEnginePath.Trim().Length == 0
|| Settings.DigitalBridgePath.Trim().Length == 0)
{
StateText.Text = "no engine — Engine ▸ Settings";
StateText.Text = "no engine — Setup ▸ Settings";
return;
}
StateText.Text = "starting the engine…";
@@ -119,6 +150,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
Dispatcher.UIThread.Post(() =>
{
receiver.Receive(text);
WriteTranscript(text);
Print(text);
});
@@ -136,33 +168,104 @@ public sealed partial class DigitalWindow : RefreshableWindow
private void WhenReported(object? sender, string text) =>
Dispatcher.UIThread.Post(() => StateText.Text = text);
/// N1MM's "clear grab window on CQ": a run that starts again is a new set
/// of callers, and the calls from the last one are of no more use.
private void WhenCqSent(object? sender, EventArgs e)
{
if (Settings.DigitalAfcOnCq)
{
Switch(MmttySettings.AfcBit, on: true);
}
if (!Settings.DigitalClearGrabOnCq)
{
return;
}
grab.Clear();
ShowGrabList();
}
/// N1MM raises the digital window with the entry window it belongs to, so
/// the right one of two is in front.
private void WhenEntryActivated(object? sender, EventArgs e)
{
if (Settings.DigitalBringToForeground)
{
Activate();
}
}
/// The two changes N1MM acts on: leaving run for search and pounce puts the
/// engine back where it started, and a QSY empties the grab list.
private void FollowRunAndFrequency()
{
if (Radio is not { } position)
{
return;
}
if (wasRunning == true && !position.IsRunning)
{
if (Settings.DigitalHamDefaultOnSearch)
{
_ = engine?.PostAsync(MmttyMessage.DefaultProfile, 0);
}
if (Settings.DigitalAfcOffOnSearch)
{
Switch(MmttySettings.AfcBit, on: false);
}
}
wasRunning = position.IsRunning;
double now = position.Frequency.Kilohertz;
if (Settings.DigitalClearGrabOnQsy && wasKilohertz != 0 && Math.Abs(now - wasKilohertz) > 0.05)
{
grab.Clear();
}
wasKilohertz = now;
}
/// N1MM titles the window with where the radio is and what is decoding,
/// which is how an operator with two of them tells them apart.
/// which is how an operator with two of them tells them apart. The
/// frequency is the dial frequency with the audio tone added to it, unless
/// the radio itself already reads out what it transmits.
private void ShowTitle()
{
string radio = $"DI{radioNumber}";
string mode = Radio?.Mode.Name ?? "RTTY";
string frequency = Radio is { } position
? position.Frequency.Kilohertz.ToString("0.00", CultureInfo.InvariantCulture)
? TitleFrequency(position).ToString("0.00", CultureInfo.InvariantCulture)
: "";
string what = session.Settings.DigitalEnginePath.Trim().Length > 0
? Path.GetFileNameWithoutExtension(session.Settings.DigitalEnginePath)
string what = Settings.DigitalEnginePath.Trim().Length > 0
? Path.GetFileNameWithoutExtension(Settings.DigitalEnginePath)
: "no engine";
Title = $"{frequency} {radio} {mode} Mode - {what}";
}
private double TitleFrequency(OperatingPosition position)
{
double dial = position.Frequency.Kilohertz;
if (Settings.DigitalRadioFrequencyInTitle || engine is not { MarkHertz: > 0 } running)
{
return dial;
}
// RTTY is worked on the lower sideband, so the tone is below the dial
// frequency; the other digital modes are worked on the upper sideband
double tone = running.MarkHertz / 1000.0;
return position.Mode == Modes.Rtty ? dial - tone : dial + tone;
}
private void ShowState()
{
ShowTitle();
StateText.Text = engine switch
{
{ IsConnected: true } running =>
$"{Path.GetFileName(session.Settings.DigitalEnginePath)} {running.Version}"
$"{Path.GetFileName(Settings.DigitalEnginePath)} {running.Version}"
+ $" · mark {running.MarkHertz} Hz",
not null => "the engine is not connected",
_ => "the engine is not running",
};
LockButton.FontWeight = IsSwitchOn(MmttySettings.AfcBit) ? FontWeight.Bold : FontWeight.Normal;
PauseText.Text = paused ? "the pane is held still" : "";
AfcButton.FontWeight = IsSwitchOn(MmttySettings.AfcBit) ? FontWeight.Bold : FontWeight.Normal;
NetButton.FontWeight = IsSwitchOn(MmttySettings.NetBit) ? FontWeight.Bold : FontWeight.Normal;
ReverseButton.FontWeight = IsSwitchOn(MmttySettings.ReverseBit) ? FontWeight.Bold : FontWeight.Normal;
}
@@ -195,7 +298,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
case ' ':
lastWasReturn = false;
EndWord();
Line().Children.Add(Space());
Body(Line()).Children.Add(Space());
continue;
default:
lastWasReturn = false;
@@ -203,8 +306,11 @@ public sealed partial class DigitalWindow : RefreshableWindow
continue;
}
}
TrimLines();
ReceiveScroller.ScrollToEnd();
if (Settings.DigitalScrolling)
{
TrimLines();
ReceiveScroller.ScrollToEnd();
}
}
private void NewLine()
@@ -213,22 +319,71 @@ public sealed partial class DigitalWindow : RefreshableWindow
line = null;
}
private WrapPanel Line()
/// The line being printed to. A scrolling pane adds one at the bottom; a
/// non-scrolling pane walks down the lines it already has, clearing each
/// one as it reaches it, and starts again at the top.
private Border Line()
{
if (line is null)
if (line is not null)
{
line = new WrapPanel();
ReceiveLines.Children.Add(line);
return line;
}
if (Settings.DigitalScrolling)
{
line = NewLineBorder();
ReceiveLines.Children.Add(line);
return line;
}
FitLines();
lineAt = ReceiveLines.Children.Count == 0 ? 0 : (lineAt + 1) % ReceiveLines.Children.Count;
line = (Border)ReceiveLines.Children[lineAt];
Body(line).Children.Clear();
Underline();
return line;
}
private static Border NewLineBorder() => new() { Child = new WrapPanel() };
private static WrapPanel Body(Border border) => (WrapPanel)border.Child!;
/// Fills a non-scrolling pane with as many empty lines as the window holds,
/// so text stays where it was printed rather than moving under the pointer.
private void FitLines()
{
double height = session.Settings.DigitalFontSize * 1.6;
int wanted = Math.Max(LeastLines, (int)(ReceiveScroller.Bounds.Height / height));
while (ReceiveLines.Children.Count > wanted && ReceiveLines.Children.Count > LeastLines)
{
ReceiveLines.Children.RemoveAt(ReceiveLines.Children.Count - 1);
}
while (ReceiveLines.Children.Count < wanted)
{
ReceiveLines.Children.Add(NewLineBorder());
}
lineAt = Math.Min(lineAt, ReceiveLines.Children.Count - 1);
}
/// N1MM underlines the line it is printing to in a non-scrolling pane,
/// which is the only mark of where the text is arriving.
private void Underline()
{
foreach (Control child in ReceiveLines.Children)
{
((Border)child).BorderThickness = new Thickness(0);
}
if (line is not null)
{
line.BorderThickness = new Thickness(0, 0, 0, 1);
line.BorderBrush = Themes.Brush(Themes.Current.FormForeground);
}
}
private TextBlock Word()
{
if (word is null)
{
word = NewBlock("");
Line().Children.Add(word);
Body(Line()).Children.Add(word);
}
return word;
}
@@ -239,7 +394,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
{
Text = text,
FontFamily = new FontFamily("monospace"),
FontSize = session.Settings.DigitalFontSize,
FontSize = Settings.DigitalFontSize,
};
/// A finished word is judged: one shaped like a callsign is coloured the
@@ -250,14 +405,63 @@ public sealed partial class DigitalWindow : RefreshableWindow
{
return;
}
string call = DigitalReceiver.TrimToCall(DigitalReceiver.Scrub(word.Text ?? ""));
if (DigitalReceiver.IsCallsign(call))
if (CallIn(word.Text ?? "") is { Length: > 0 } call)
{
Colour(word, call);
}
word = null;
}
/// The callsign in a word, or empty when there is none. N1MM checks between
/// the spaces, and with the garbage search on it also looks for a known
/// call inside a run of noise.
private string CallIn(string text)
{
string call = DigitalReceiver.TrimToCall(DigitalReceiver.Scrub(text));
if (DigitalReceiver.IsCallsign(call) && Wanted(call))
{
return call;
}
return Settings.DigitalSearchGarbage ? InGarbage(DigitalReceiver.Scrub(text)) : "";
}
/// N1MM's search for a Master.scp call inside garbage text. The last call
/// in the run wins, which is what N1MM's own routine does.
private string InGarbage(string text)
{
string found = "";
for (int start = 0; start < text.Length; start++)
{
for (int length = Math.Min(text.Length - start, LongestCall); length >= 3; length--)
{
string part = text.Substring(start, length);
if (session.Calls.Holds(part))
{
found = part;
break;
}
}
}
return found;
}
/// Whether a call passes the highlight routine: everything shaped like a
/// call under the generic routine, only calls in the ticked files under the
/// resources routine, and everything under the combination, where a call
/// the files do not hold is marked instead of dropped.
private bool Wanted(string call) =>
Settings.DigitalHighlightRoutine.ToLowerInvariant() != "resources" || Known(call);
private bool Known(string call) =>
(Settings.DigitalUseMasterScp && session.Calls.Holds(call))
|| (Settings.DigitalUseCallHistory && session.History.Find(call) is not null)
|| (Settings.DigitalUseTelnetCalls && Spotted(call))
|| (Settings.DigitalUseLoggedCalls && Radio?.Log.WorkedBefore(call).Count > 0);
private bool Spotted(string call) =>
session.Bandmap.All().Any(spot =>
string.Equals(spot.Call.Text, call, StringComparison.OrdinalIgnoreCase));
private void Colour(TextBlock block, string call)
{
if (Radio is not { } position)
@@ -270,13 +474,18 @@ public sealed partial class DigitalWindow : RefreshableWindow
return;
}
Contests.Verdict? verdict = position.JudgeCall(call);
if (session.Settings.DigitalHighlightBackground)
bool mark = Settings.DigitalHighlightRoutine.ToLowerInvariant() == "both" && !Known(call);
if (Settings.DigitalHighlightBackground)
{
block.Background = Verdicts.Background(verdict);
block.Foreground = Themes.TextOn(Verdicts.BackgroundColour(verdict));
block.Foreground = mark ? Unknown : Themes.TextOn(Verdicts.BackgroundColour(verdict));
return;
}
block.Foreground = Verdicts.Colour(verdict);
if (mark)
{
block.Background = Unknown;
}
}
private void TrimLines()
@@ -287,8 +496,28 @@ public sealed partial class DigitalWindow : RefreshableWindow
}
}
/// N1MM keeps a copy of the receive pane in a date-stamped file, so what
/// was on the screen can be read again after the program stops.
private void WriteTranscript(string text)
{
if (!Settings.DigitalOutputToTextFile || text.Length == 0)
{
return;
}
string name = $"{DateTime.UtcNow:MMddyyyy}DigitalInterface{radioNumber}Output.txt";
try
{
Directory.CreateDirectory(session.Paths.ExportFiles);
File.AppendAllText(Path.Combine(session.Paths.ExportFiles, name), text);
}
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
{
StateText.Text = $"the transcript could not be written: {failure.Message}";
}
}
private StackOrder StackingOrder() =>
session.Settings.DigitalCallStacking.ToLowerInvariant() switch
Settings.DigitalCallStacking.ToLowerInvariant() switch
{
"multipliers" => StackOrder.MultipliersFirst,
"firstin" => StackOrder.FirstIn,