using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Threading; using Nonemm.App.Configuration; using Nonemm.Core; using Nonemm.Session; using Nonemm.Spotting; namespace Nonemm.App.Windows; /// The cluster node's traffic as it arrived, a line to send commands on, and /// the tabs that say which node to talk to and which spots to keep. Double /// clicking a spot line puts the radio on it. public sealed partial class TelnetWindow : RefreshableWindow { private const int LinesKept = 500; private static readonly IBrush SpotColour = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60)); private static readonly IBrush SentColour = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9)); private static readonly IBrush NoticeColour = new SolidColorBrush(Color.FromRgb(0x7F, 0x8C, 0x8D)); private readonly AppSession session; private readonly Action tune; private readonly List history = []; private int recalled = -1; private bool paused; public TelnetWindow(AppSession session, Action tune) { this.session = session; this.tune = tune; InitializeComponent(); LoadButtons(); LoadButtonRows(); LoadClusters(); LoadFilters(); Listen(session.Cluster); Closed += (_, _) => Forget(session.Cluster); Refresh(); } /// Config ▸ Cluster opens the window here, because this is where the node /// is chosen. public void ShowClusters() => Tabs.SelectedIndex = 1; public void ShowButtons() => Tabs.SelectedIndex = 3; public override void Refresh() { ClusterClient? cluster = session.Cluster; string kind = cluster?.Kind switch { ClusterKind.DxSpider => " · DXSpider", ClusterKind.ArCluster => " · AR-Cluster", ClusterKind.CcCluster => " · CC Cluster", ClusterKind.GoCluster => " · GoCluster", _ => "", }; StateText.Text = cluster is null ? "not connected — pick a node on the Clusters tab" : cluster.IsConnected ? $"connected to {cluster.Host}:{cluster.Port}{kind}" : $"connecting to {cluster.Host}:{cluster.Port}…"; ReconnectButton.Content = cluster is null ? "Connect" : "Reconnect"; } /// The traffic so far is put up first, so a window opened mid-contest is /// not blank. Taking it before subscribing keeps a line from appearing /// twice. private void Listen(ClusterClient? cluster) { if (cluster is null) { return; } IReadOnlyList already = cluster.Recent; cluster.LineArrived += OnLineArrived; cluster.LineSent += OnLineSent; cluster.ConnectionChanged += OnConnectionChanged; foreach (ClusterLine line in already) { if (line.WasSent) { OnLineSent(cluster, line.Text); } else { OnLineArrived(cluster, line.Text); } } } private void Forget(ClusterClient? cluster) { if (cluster is null) { return; } cluster.LineArrived -= OnLineArrived; cluster.LineSent -= OnLineSent; cluster.ConnectionChanged -= OnConnectionChanged; } private void OnLineArrived(object? sender, string line) { if (line.StartsWith("***", StringComparison.Ordinal)) { Show(line, NoticeColour); return; } if (SpotLine.Parse(line, DateTime.UtcNow) is not { } spot) { Show(line, Foreground); return; } Show(line, SpotColour, IsPreferred(spot.Spotter)); } /// A spotter the operator listed on the Filters tab. N1MM matches on the /// start of the call, so `W3LPL` also covers `W3LPL-#`. private bool IsPreferred(string spotter) => session.Settings.SpotFilter.PreferredSpotters.Any(one => one.Length > 0 && spotter.StartsWith(one, StringComparison.OrdinalIgnoreCase)); private void OnLineSent(object? sender, string line) => Show($"> {line}", SentColour); private void OnConnectionChanged(object? sender, bool connected) => Dispatcher.UIThread.Post(Refresh); /// `strong` marks the lines from a preferred spotter. private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() => { while (Traffic.Items.Count >= LinesKept) { Traffic.Items.RemoveAt(0); } Traffic.Items.Add(new TextBlock { Text = line, Foreground = colour, FontWeight = strong ? FontWeight.Bold : FontWeight.Normal, }); if (!paused && Traffic.Items.Count > 0) { Traffic.ScrollIntoView(Traffic.Items.Count - 1); } Refresh(); }); /// Scrolling stops while the pointer is over the traffic, so a line can be /// read or copied while the node keeps sending. private void OnTrafficEntered(object? sender, PointerEventArgs e) { paused = true; PausedText.IsVisible = true; } private void OnTrafficExited(object? sender, PointerEventArgs e) { paused = false; PausedText.IsVisible = false; if (Traffic.Items.Count > 0) { Traffic.ScrollIntoView(Traffic.Items.Count - 1); } } /// The label takes the macros as well, expanded once here, which is what /// N1MM does when it loads its buttons. private void LoadButtons() { ButtonRow.Children.Clear(); foreach (StoredTelnetButton stored in Buttons()) { Button button = new() { Content = Expanded(stored.Label), FontSize = 11, Padding = new Avalonia.Thickness(6, 3), Margin = new Avalonia.Thickness(0, 0, 3, 3), Tag = stored, }; ToolTip.SetTip(button, $"{stored.Command}\nright-click to edit the buttons"); button.Click += OnButtonPressed; button.PointerReleased += OnButtonRightClick; ButtonRow.Children.Add(button); } } /// Right-clicking a button opens the editor, as it does in N1MM. private void OnButtonRightClick(object? sender, PointerReleasedEventArgs e) { if (e.InitialPressMouseButton == MouseButton.Right) { ShowButtons(); } } private IReadOnlyList Buttons() => session.Settings.TelnetButtons.Count > 0 ? session.Settings.TelnetButtons : StoredTelnetButton.Default; /// A button's text is N1MM's: the message macros, and `{CONN}node` to /// connect to a favourite instead of sending anything. Several commands are /// separated by semicolons and go out one after the other. private async void OnButtonPressed(object? sender, RoutedEventArgs e) { if (sender is not Button { Tag: StoredTelnetButton stored }) { return; } if (ConnectToFavourite(stored.Command)) { return; } foreach (string command in Expanded(stored.Command).Split(';', StringSplitOptions.RemoveEmptyEntries)) { await SendAsync(command.Trim()); } } /// The same macros the function keys take. With no contest open there is /// nothing to fill them from, so the text goes out as it was typed. private string Expanded(string text) => session.Position is { } position ? MessageExpander.Expand(text, position, session.Other(position)) : text; private async void OnSend(object? sender, RoutedEventArgs e) { if (CommandBox.Text is not { Length: > 0 } command) { return; } history.Add(command); recalled = -1; CommandBox.Text = ""; // what is typed goes out as it was typed, the way N1MM sends it await SendAsync(command); } /// Up and down walk back through what has been typed, the way a terminal /// does. private void OnCommandKey(object? sender, KeyEventArgs e) { if (history.Count == 0 || (e.Key != Key.Up && e.Key != Key.Down)) { return; } recalled = e.Key == Key.Up ? Math.Min(recalled + 1, history.Count - 1) : Math.Max(recalled - 1, -1); CommandBox.Text = recalled < 0 ? "" : history[history.Count - 1 - recalled]; CommandBox.CaretIndex = CommandBox.Text.Length; e.Handled = true; } private async Task SendAsync(string command) { if (session.Cluster is not { } cluster) { Show("*** not connected to a node", NoticeColour); return; } // N1MM refuses this one too: asking the node for spots is what an // unassisted entry may not do, and the category is what the log claims if (command.Contains("SH/DX", StringComparison.OrdinalIgnoreCase) && !IsAssisted) { Show("*** you cannot use sh/dx while unassisted", NoticeColour); return; } await cluster.SendAsync(command); } /// True while no contest is open: nothing is being claimed yet. private bool IsAssisted => session.Logging?.Instance.IsAssisted ?? true; private void OnReconnect(object? sender, RoutedEventArgs e) => Connect(); /// Tunes the radio to the spot on the selected line, which is what N1MM's /// "Jump to this spot" does. private void OnJumpToSpot(object? sender, RoutedEventArgs e) { if (Traffic.SelectedItem is not TextBlock { Text: { } line }) { return; } if (SpotLine.Parse(line, DateTime.UtcNow) is not { } spot) { Show("*** that line is not a spot", NoticeColour); return; } tune(spot.Frequency, spot.Call.Text); } private async void OnCopyLine(object? sender, RoutedEventArgs e) { if (Traffic.SelectedItem is TextBlock { Text: { } line } && Clipboard is { } clipboard) { await clipboard.SetTextAsync(line); } } private void OnClearTraffic(object? sender, RoutedEventArgs e) => Traffic.Items.Clear(); }