Files
Nonemm/src/Nonemm.App/Windows/DigitalWindow.axaml.cs
T
erikbrockoandClaude Opus 5 f49a8c10fd Start the transmit pane again on every new message
The macro buttons went wrong at the end of a transmission, both faults in
DigitalEngineSender:

- The engine reports the transmitter drop on its own thread.
  WhenTransmitChanged took the state lock, decided the message had ended,
  released the lock, and only then called Buffer.Ended(), which clears
  what has gone to the engine. A macro pressed on the last character got
  through StartAsync in that gap and had already flushed its own text into
  Sent, so Ended() wiped the new text off the pane while the engine
  transmitted it. Ended() is now called inside the same lock.

- The pane only started again when the engine reported a drop. Two macros
  in a row keep the transmitter up, so that report never came and Sent
  grew with every press. Everything in Sent is locked, because it is in
  the engine and cannot be taken back, so the whole pane became
  read-only. TypeAhead.Started() drops the last message's sent text and
  keeps what was typed ahead, and StartAsync calls it whenever it keys a
  new transmission.

The rest of this commit is the digital transmit work these fixes sit on:
the pane as one coloured box, the sender's three keying states, the
type-ahead feeder paced by the clock with the engine's count as a brake,
{RX} flushing what is left in one piece, and the entry window's function
keys reading the digital macros on a digital mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
2026-09-03 14:40:30 +00:00

572 lines
19 KiB
C#

using System.Globalization;
using System.Text;
using Avalonia;
using Avalonia.Controls;
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;
namespace Nonemm.App.Windows;
/// N1MM's digital interface window: what the modem decodes, what is about to go
/// out, the calls heard, and the macro buttons. Every callsign in the receive
/// pane is coloured by what working it would bring, and double-clicking one
/// puts it in the entry window.
public sealed partial class DigitalWindow : RefreshableWindow
{
/// 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;
/// What is drawn over the text that has already gone to the engine.
private static readonly IBrush Transmitted = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B));
private readonly AppSession session;
private readonly EntryWindow entry;
private readonly int radioNumber;
private readonly DigitalReceiver receiver = 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 Border? line;
private TextBlock? word;
private TextBlock? hovered;
private MmttyEngine? engine;
private bool paused;
private bool lastWasReturn;
/// True while the transmit box is being redrawn from the buffer, so the
/// redraw is not read back as an edit by the operator.
private bool showingBuffer;
/// The transmit pane as it was last drawn, so an edit can be told from a
/// redraw and the changed character found.
private string pane = "";
/// How much of the pane is in the engine's hands and cannot be edited. It
/// is more than what is coloured, which is only what has gone out over the
/// air.
private int locked;
/// The buffer this window is drawing, or null while no engine is running.
private TypeAhead? buffer;
/// 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;
this.entry = entry;
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 += (_, _) =>
{
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);
TransmitBox.Background = Themes.Brush(Themes.Current.FieldBackground);
TransmitBox.SentBrush = Transmitted;
TransmitBox.FontSize = Settings.DigitalFontSize;
if (buffer is not null)
{
buffer.Baud = Settings.DigitalBaud;
}
FollowRunAndFrequency();
ShowState();
ShowGrabList();
LabelMacros();
}
/// Starts the engine if it is not running, and connects this window to it.
private async Task StartAsync()
{
if (Settings.DigitalEnginePath.Trim().Length == 0
|| Settings.DigitalBridgePath.Trim().Length == 0)
{
StateText.Text = "no engine — Setup ▸ Settings";
return;
}
StateText.Text = "starting the engine…";
try
{
Attach(await session.StartDigitalAsync().WaitAsync(Patience));
}
catch (Exception failure) when (failure is InvalidOperationException or TimeoutException)
{
StateText.Text = failure.Message;
return;
}
ShowState();
}
private void Attach(MmttyEngine? started)
{
if (started is null || ReferenceEquals(started, engine))
{
return;
}
Detach();
engine = started;
buffer = session.DigitalKeyer?.Buffer;
if (buffer is not null)
{
buffer.Baud = Settings.DigitalBaud;
buffer.Changed += WhenBufferChanged;
}
started.Received += WhenReceived;
started.TransmitChanged += WhenTransmitChanged;
started.ConnectionChanged += WhenConnectionChanged;
started.Reported += WhenReported;
}
private void Detach()
{
if (engine is null)
{
return;
}
if (buffer is not null)
{
buffer.Changed -= WhenBufferChanged;
buffer = null;
}
engine.Received -= WhenReceived;
engine.TransmitChanged -= WhenTransmitChanged;
engine.ConnectionChanged -= WhenConnectionChanged;
engine.Reported -= WhenReported;
engine = null;
}
private void WhenReceived(object? sender, string text) =>
Dispatcher.UIThread.Post(() =>
{
receiver.Receive(text);
WriteTranscript(text);
Print(text);
});
private void WhenBufferChanged(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(ShowBuffer);
/// The transmitter dropping ends the message. The buffer clears what has
/// gone out and keeps what the operator typed ahead, so the pane is left
/// with the next message in it rather than empty.
private void WhenTransmitChanged(object? sender, bool transmitting) =>
Dispatcher.UIThread.Post(() =>
{
TransmitDot.Background = transmitting
? Themes.Brush(Themes.Current.TransmitLight)
: Brushes.Transparent;
ShowBuffer();
});
private void WhenConnectionChanged(object? sender, bool connected) =>
Dispatcher.UIThread.Post(ShowState);
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. 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
? TitleFrequency(position).ToString("0.00", CultureInfo.InvariantCulture)
: "";
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(Settings.DigitalEnginePath)} {running.Version}"
+ $" · mark {running.MarkHertz} Hz",
not null => "the engine is not connected",
_ => "the engine is not running",
};
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;
}
private bool IsSwitchOn(int bit) => engine is { } running && (running.Switches & bit) != 0;
/// Prints what the modem decoded, one word at a time so each word is its
/// own control and can be coloured, pointed at and clicked.
private void Print(string text)
{
if (paused)
{
held.Append(text);
return;
}
foreach (char c in text)
{
switch (c)
{
case '\r':
NewLine();
lastWasReturn = true;
continue;
// a modem that sends both ends one line, not two
case '\n' when lastWasReturn:
lastWasReturn = false;
continue;
case '\n':
NewLine();
continue;
case ' ':
lastWasReturn = false;
EndWord();
Body(Line()).Children.Add(Space());
continue;
default:
lastWasReturn = false;
Word().Text += c;
continue;
}
}
if (Settings.DigitalScrolling)
{
TrimLines();
ReceiveScroller.ScrollToEnd();
}
}
private void NewLine()
{
EndWord();
line = null;
}
/// 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 not null)
{
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("");
Body(Line()).Children.Add(word);
}
return word;
}
private TextBlock Space() => NewBlock(" ");
private TextBlock NewBlock(string text) => new()
{
Text = text,
FontFamily = new FontFamily("monospace"),
FontSize = Settings.DigitalFontSize,
};
/// A finished word is judged: one shaped like a callsign is coloured the
/// way every other window colours a station.
private void EndWord()
{
if (word is null)
{
return;
}
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)
{
return;
}
if (string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase))
{
block.Foreground = Verdicts.MyCall;
return;
}
Contests.Verdict? verdict = position.JudgeCall(call);
bool mark = Settings.DigitalHighlightRoutine.ToLowerInvariant() == "both" && !Known(call);
if (Settings.DigitalHighlightBackground)
{
block.Background = Verdicts.Background(verdict);
block.Foreground = mark ? Unknown : Themes.TextOn(Verdicts.BackgroundColour(verdict));
return;
}
block.Foreground = Verdicts.Colour(verdict);
if (mark)
{
block.Background = Unknown;
}
}
private void TrimLines()
{
while (ReceiveLines.Children.Count > KeptLines)
{
ReceiveLines.Children.RemoveAt(0);
}
}
/// 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() =>
Settings.DigitalCallStacking.ToLowerInvariant() switch
{
"multipliers" => StackOrder.MultipliersFirst,
"firstin" => StackOrder.FirstIn,
"lastin" => StackOrder.LastIn,
_ => StackOrder.Disabled,
};
}