The colours are N1MM's own defaults, read out of ApplicationStyles: blue for a station worth working, red for one new multiplier, green for more than one, grey for a dupe, and red on the CQ frequency line. A call under three characters stays blue, as N1MM leaves it. The mark at the right-hand end of the callsign box is N1MM's OKCall: a tick when the call is in MASTER.SCP, a question mark while it is not, in the colour of the call. Like N1MM it only shows while the check window is open, for three characters or more, and only while the call is short enough to leave room for it. The band under the boxes takes N1MM's background colours instead of white on the text colours. The two lights, the telnet window and the message editor were borrowing the verdict brushes for things that are not verdicts; they have their own colours now. The callsign and the exchange boxes uppercase what is typed. Name and comment do not, which is what N1MM does. Scored contest 63 of om5m.s3db (CQ WW SSB, 3775 QSOs) against the points and multiplier flags N1MM stored: points identical, multipliers 601 against 602. The difference is B1Z on 10m, which N1MM logged with an empty country prefix and counted as a country of its own though BY was already worked on that band. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
10 KiB
C#
310 lines
10 KiB
C#
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<Frequency, string> tune;
|
|
private readonly List<string> history = [];
|
|
private int recalled = -1;
|
|
private bool paused;
|
|
|
|
public TelnetWindow(AppSession session, Action<Frequency, string> 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<ClusterLine> 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<StoredTelnetButton> 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();
|
|
}
|