using System.Text; using Nonemm.Core; namespace Nonemm.Session; /// The decoded text as it arrives, and the callsigns in it. Characters come /// from the modem one at a time; a word ends at a space or a line break, and a /// word shaped like a callsign is reported so the window can colour it and put /// it in the grab list. N1MM's `DigitalProcessing.RxComData` does the same. public sealed class DigitalReceiver { /// How much decoded text is kept. N1MM keeps a scrollback of its own; this /// is enough for a long run and keeps the pane from growing without end. public const int Scrollback = 20_000; private readonly StringBuilder text = new(); private readonly StringBuilder word = new(); public string Text => text.ToString(); /// Text that has just arrived, for a window that appends rather than /// redraws. public event EventHandler? Added; /// A word shaped like a callsign, uppercased and cut back to the call. public event EventHandler? CallHeard; public void Receive(string arrived) { if (arrived.Length == 0) { return; } text.Append(arrived); if (text.Length > Scrollback) { text.Remove(0, text.Length - Scrollback); } Added?.Invoke(this, arrived); foreach (char c in arrived) { if (c is ' ' or '\r' or '\n') { Finish(); continue; } word.Append(c); } } public void Clear() { text.Clear(); word.Clear(); } /// The word around a position in the text, which is what the mouse points /// at. Empty when the position is on a space. public static string WordAt(string text, int position) { if (position < 0 || position >= text.Length || IsBreak(text[position])) { return ""; } int start = position; while (start > 0 && !IsBreak(text[start - 1])) { start--; } int end = position; while (end + 1 < text.Length && !IsBreak(text[end + 1])) { end++; } return Scrub(text[start..(end + 1)]); } /// N1MM's `ScrubSelectedData`: what the operator clicked, without the line /// breaks and in upper case. public static string Scrub(string word) => word.Replace("\r", "").Replace("\n", "").Trim().ToUpperInvariant(); /// True when the word could be a callsign. RTTY prints noise, so a word is /// only offered as a call when it is shaped like one. public static bool IsCallsign(string word) => word.Length > 0 && Callsign.Parse(word).IsPlausible; /// N1MM's `Trimcall`. RTTY runs words together, so a long word with a digit /// in it is cut to three characters past the last digit, which is as long /// as a callsign gets after its number. A word with a `/` is left alone: a /// portable call is long for a reason. public static string TrimToCall(string word) { if (word.Contains('/') || word.Length < 7) { return word; } int lastDigit = -1; for (int at = 0; at < word.Length; at++) { if (char.IsAsciiDigit(word[at])) { lastDigit = at; } } return lastDigit < 0 ? word : word[..Math.Min(word.Length, lastDigit + 4)]; } private static bool IsBreak(char c) => c is ' ' or '\r' or '\n' or '\t'; private void Finish() { string found = Scrub(word.ToString()); word.Clear(); if (found.Length == 0) { return; } string call = TrimToCall(found); if (IsCallsign(call)) { CallHeard?.Invoke(this, call); } } }