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, }; }