Colours came from static brushes scattered over the windows. They now come from a Theme, and everything that paints a station, a spot or a log row asks Themes.Current as it paints, so changing the theme changes every window without reopening it. light is N1MM's own palette, read out of ApplicationStyles: the pale blue behind a window, ivory on a button, white boxes, and the four colours a station is painted in. dark is the same states in colours for a dark screen, which is roughly what the program looked like before. Config > Manage Skins, Colors and Fonts picks one, under N1MM's name for the same dialog, and shows what each theme paints a callsign. The choice is kept in settings.json as Theme, and light is the default. The window chrome follows through resources the Fluent controls read, so a text box and a button take the theme's colours in every state. Fonts and skins are not part of it. Looked at under Xvfb in both themes: the entry window, the theme dialog, and the log window with its rows still coloured by what each contact was worth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
313 lines
10 KiB
C#
313 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.App.Theming;
|
|
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 IBrush SpotColour => Themes.Brush(Themes.Current.Multipliers);
|
|
|
|
private static IBrush SentColour => Themes.Brush(Themes.Current.Worth);
|
|
|
|
private static IBrush NoticeColour => Themes.Brush(Themes.Current.Dupe);
|
|
|
|
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();
|
|
}
|