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
+168 -32
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.VisualTree;
using Nonemm.Session;
namespace Nonemm.App.Windows;
@@ -11,6 +12,10 @@ namespace Nonemm.App.Windows;
/// transmit pane, and the clicking and hovering in the receive pane.
public sealed partial class DigitalWindow
{
/// The call the pointer is resting on, which hover mode takes and the
/// modifier keys act on.
private string underPointer = "";
/// 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)
@@ -21,7 +26,7 @@ public sealed partial class DigitalWindow
{
return;
}
grab.NewestFirst = session.Settings.DigitalGrabNewestFirst;
grab.NewestFirst = Settings.DigitalGrabNewestFirst;
if (grab.Add(call))
{
ShowGrabList();
@@ -29,7 +34,7 @@ public sealed partial class DigitalWindow
}
private bool PassesFilter(OperatingPosition position, string call) =>
session.Settings.DigitalGrabFilter.ToLowerInvariant() switch
Settings.DigitalGrabFilter.ToLowerInvariant() switch
{
"notdupes" => position.JudgeCall(call)?.IsDupe != true,
"knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0,
@@ -55,7 +60,7 @@ public sealed partial class DigitalWindow
{
Text = call,
FontFamily = new FontFamily("monospace"),
FontSize = session.Settings.DigitalFontSize,
FontSize = Settings.DigitalFontSize,
Foreground = Verdicts.Colour(position.JudgeCall(call)),
};
Border row = new()
@@ -65,29 +70,59 @@ public sealed partial class DigitalWindow
// 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,
Tag = call,
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.
/// Left click grabs the call; the right click menu is on the list itself.
private void GrabFromList(string call, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
if (!e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
Grab(call);
}
}
/// N1MM's grab list menu: clear the whole list, clear the call under the
/// pointer, or change the order calls come off it in.
private void OnGrabListPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
return;
}
string call = (e.Source as Control)?.FindAncestorOfType<Border>()?.Tag as string ?? "";
MenuFlyout menu = new();
menu.Items.Add(Item("Clear list", () =>
{
grab.Clear();
ShowGrabList();
}));
menu.Items.Add(Item($"Clear selected call{(call.Length > 0 ? $" ({call})" : "")}", () =>
{
grab.Remove(call);
ShowGrabList();
return;
}
Grab(call);
}, call.Length > 0));
menu.Items.Add(new Separator());
menu.Items.Add(Item("Last in, first out", () => GrabOrder(newestFirst: true)));
menu.Items.Add(Item("First in, first out", () => GrabOrder(newestFirst: false)));
menu.ShowAt(GrabCalls, showAtPointer: true);
}
private static MenuItem Item(string header, Action act, bool enabled = true)
{
MenuItem item = new() { Header = header, IsEnabled = enabled };
item.Click += (_, _) => act();
return item;
}
/// 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)
private void Grab(string call, bool space = true)
{
if (Radio is not { } position)
{
@@ -101,7 +136,7 @@ public sealed partial class DigitalWindow
}
grab.Remove(call);
ShowGrabList();
entry.GrabCall(call, session.Settings.DigitalGrabSendsSpace);
entry.GrabCall(call, space && Settings.DigitalGrabSendsSpace);
entry.Refresh();
}
@@ -114,6 +149,9 @@ public sealed partial class DigitalWindow
}
}
/// What the pointer rests on: the Letters/Figs and MouseOver boxes follow
/// it, and with hover mode on a callsign goes to the entry window without a
/// click at all.
private void OnReceivePointerMoved(object? sender, PointerEventArgs e)
{
if (e.Source is not TextBlock block || block.Text is not { } text || text.Trim().Length == 0)
@@ -128,7 +166,35 @@ public sealed partial class DigitalWindow
Unhighlight();
hovered = block;
block.TextDecorations = TextDecorations.Underline;
MouseOverText.Text = DigitalReceiver.Scrub(text);
underPointer = DigitalReceiver.Scrub(text);
MouseOverText.Text = underPointer;
// the box is Baudot's, so it says nothing in a mode that is not RTTY,
// which is what N1MM writes in it
FigsText.Text = (Radio?.Mode ?? Nonemm.Core.Modes.Rtty) == Nonemm.Core.Modes.Rtty
? Baudot.LettersFigs(underPointer)
: "NOT USED";
WhenPointerRestsOnCall(underPointer);
}
/// N1MM's hover mode and its callsign pause routines, both worked off the
/// call the pointer is resting on: the call goes into an empty callsign box
/// on its own, and the pane stops so the text under the pointer stays where
/// it is.
private void WhenPointerRestsOnCall(string text)
{
if (Radio is not { } position || CallToTake(position, text) is not { Length: > 0 } call)
{
return;
}
if (Settings.DigitalPauseOnCallsign && position.Entry.Call.Trim().Length == 0)
{
paused = true;
ShowPauseBar();
}
if (Settings.DigitalHoverMode && position.Entry.Call.Trim().Length == 0)
{
Grab(call, Settings.DigitalClickSendsSpace);
}
}
private void OnReceivePointerExited(object? sender, PointerEventArgs e)
@@ -144,49 +210,70 @@ public sealed partial class DigitalWindow
hovered.TextDecorations = null;
hovered = null;
}
underPointer = "";
MouseOverText.Text = "";
FigsText.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.
/// contest keeps for it. Ctrl puts it in the box the cursor is in whatever
/// it holds, shift converts it to the other Baudot case first, alt puts a
/// call on the call stack, and a double click replaces a call already in
/// the box. A right click presses Enter in the entry window when that
/// option is on, and opens the menu when it is not.
private void OnReceivePointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
RightClick();
RightClick(e);
return;
}
string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
if (clicked.Length == 0)
{
paused = true;
ShowPauseBar();
return;
}
Resume();
Copy(clicked);
KeyModifiers keys = e.KeyModifiers;
if (keys.HasFlag(KeyModifiers.Shift))
{
clicked = Baudot.LettersFigs(clicked);
}
if (keys.HasFlag(KeyModifiers.Control))
{
entry.GrabIntoFocused(DigitalElement.Trim(clicked));
return;
}
Copy(clicked, keys.HasFlag(KeyModifiers.Alt), e.ClickCount > 1);
}
/// 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)
/// call stack or on a double click, and everything else is an exchange
/// element.
private void Copy(string clicked, bool toStack, bool replaces)
{
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);
string call = CallToTake(position, clicked);
bool waiting = position.Entry.Call.Trim().Length == 0;
if (isCall && (waiting || StackingOrder() != StackOrder.Disabled))
if (call.Length > 0 && toStack)
{
Grab(call);
position.Stack.Order = StackingOrder();
position.Stack.Add(call, position.JudgeCall(call)?.IsNewMultiplier == true);
grab.Remove(call);
ShowGrabList();
entry.Refresh();
return;
}
if (call.Length > 0 && (waiting || replaces || StackingOrder() != StackOrder.Disabled))
{
Grab(call, Settings.DigitalClickSendsSpace);
return;
}
if (!waiting)
@@ -195,18 +282,66 @@ public sealed partial class DigitalWindow
}
}
private void RightClick()
/// The callsign in a clicked or hovered word, or empty when it is not one
/// this station would work. Our own call is never taken.
private string CallToTake(OperatingPosition position, string clicked)
{
if (!session.Settings.DigitalRightClickSendsEnter)
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);
return isCall ? call : "";
}
private void RightClick(PointerPressedEventArgs e)
{
if (Settings.DigitalRightClickSendsEnter)
{
entry.PressEnter();
if (!Settings.DigitalRightClickKeepsCall && Radio is { } position)
{
position.Wipe();
entry.Refresh();
}
return;
}
entry.PressEnter();
if (!session.Settings.DigitalRightClickKeepsCall && Radio is { } position)
MenuFlyout menu = new();
menu.Items.Add(Item("Clear RX", () => OnClearReceive(this, new RoutedEventArgs())));
menu.Items.Add(Item("Output to text file", () =>
Store(Settings with { DigitalOutputToTextFile = !Settings.DigitalOutputToTextFile })));
menu.Items.Add(Item(paused ? "Let the pane run" : "Hold the pane still", () =>
{
position.Wipe();
entry.Refresh();
if (paused)
{
Resume();
}
else
{
paused = true;
ShowPauseBar();
}
}));
menu.ShowAt(ReceiveLines, showAtPointer: true);
}
/// N1MM's pause bar: the strip beside the receive pane, green while text is
/// printing and yellow while it is held.
private void OnPauseBarPressed(object? sender, PointerPressedEventArgs e)
{
if (paused)
{
Resume();
return;
}
paused = true;
ShowPauseBar();
}
private void ShowPauseBar()
{
PauseBar.Background = paused ? Brushes.Gold : new SolidColorBrush(Color.FromRgb(0x2E, 0x8B, 0x57));
PauseText.Text = paused ? "the pane is held still" : "";
}
/// Lets the pane scroll again and prints what arrived while it was stopped.
@@ -217,6 +352,7 @@ public sealed partial class DigitalWindow
return;
}
paused = false;
ShowPauseBar();
if (held.Length == 0)
{
return;