using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using Nonemm.App.Theming; using Nonemm.Session; namespace Nonemm.App.Windows; /// The calls heard and what the pointer does to them: the grab list beside the /// 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) { if (Radio is not { } position || !GrabList.Wanted(call, position.Me.Callsign, position.Entry.Call) || !PassesFilter(position, call)) { return; } grab.NewestFirst = Settings.DigitalGrabNewestFirst; if (grab.Add(call)) { ShowGrabList(); } } private bool PassesFilter(OperatingPosition position, string call) => Settings.DigitalGrabFilter.ToLowerInvariant() switch { "notdupes" => position.JudgeCall(call)?.IsDupe != true, "knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0, _ => true, }; private void ShowGrabList() { GrabCalls.Children.Clear(); if (Radio is not { } position) { return; } foreach (string call in grab.Calls) { GrabCalls.Children.Add(GrabRow(position, call)); } } private Control GrabRow(OperatingPosition position, string call) { TextBlock text = new() { Text = call, FontFamily = new FontFamily("monospace"), FontSize = Settings.DigitalFontSize, Foreground = Verdicts.Colour(position.JudgeCall(call)), }; Border row = new() { Padding = new Thickness(4, 1), Cursor = new Cursor(StandardCursorType.Hand), // 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; the right click menu is on the list itself. private void GrabFromList(string call, PointerPressedEventArgs e) { 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()?.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(); }, 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, bool space = true) { if (Radio is not { } position) { return; } position.Stack.Order = StackingOrder(); if (position.Stack.Order != StackOrder.Disabled && position.Entry.Call.Trim().Length > 0) { position.StackAnother(); } grab.Remove(call); ShowGrabList(); entry.GrabCall(call, space && Settings.DigitalGrabSendsSpace); entry.Refresh(); } /// N1MM's Grab button: the next call off the list. private void OnGrabNext(object? sender, RoutedEventArgs e) { if (grab.Count > 0) { Grab(grab.Calls[0]); } } /// 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) { Unhighlight(); return; } if (ReferenceEquals(block, hovered)) { return; } Unhighlight(); hovered = block; block.TextDecorations = TextDecorations.Underline; 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) { Unhighlight(); Resume(); } private void Unhighlight() { if (hovered is not null) { 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. 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(e); return; } if (TakenAsQtc(e)) { return; } string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : ""; if (clicked.Length == 0) { paused = true; ShowPauseBar(); return; } Resume(); 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); } /// While the QTC window is taking traffic down on RTTY, a click anywhere in /// a line hands the whole line to it rather than picking the word out: the /// other station sends the header and the whole series in one /// transmission, and the operator clicks each line as it prints. The line /// flashes to show which one was taken, which is what N1MM does with it. /// False when there is no QTC window, or the line is not a QTC, and then /// the click means what it always means. private bool TakenAsQtc(PointerPressedEventArgs e) { if (session.QtcLines is not { } take || LineUnder(e.Source as Visual) is not { } border) { return false; } string text = DigitalReceiver.Scrub(string.Concat( Body(border).Children.OfType().Select(b => b.Text ?? ""))); if (text.Trim().Length == 0 || !take(text)) { return false; } Flash(border); return true; } /// The line a clicked control belongs to: the border a printed line lives /// in, walking up from whatever was under the pointer. private static Border? LineUnder(Visual? clicked) { for (Visual? at = clicked; at is not null; at = at.GetVisualParent()) { if (at is Border border && border.Child is WrapPanel) { return border; } } return null; } /// A moment of colour behind the line that was taken. private static void Flash(Border border) { IBrush was = border.Background ?? Brushes.Transparent; border.Background = Themes.Brush(Themes.Current.QuickEdit); DispatcherTimer.RunOnce(() => border.Background = was, TimeSpan.FromMilliseconds(400)); } /// 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 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 = CallToTake(position, clicked); bool waiting = position.Entry.Call.Trim().Length == 0; if (call.Length > 0 && toStack) { 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) { entry.GrabExchange(DigitalElement.Trim(clicked)); } } /// 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) { 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; } 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", () => { 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. private void Resume() { if (!paused) { return; } paused = false; ShowPauseBar(); if (held.Length == 0) { return; } string waiting = held.ToString(); held.Clear(); Print(waiting); } }