Files
Nonemm/src/Nonemm.App/Windows/TelnetWindow.axaml.cs
T
erikbrockoandClaude Opus 5 da04764756 Two themes, and one place that decides what colour a station is
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>
2026-08-28 13:06:32 +00:00

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();
}