Give the telnet window the rest of what N1MM's has
The packet window showed traffic and took a command line, and everything else
about the cluster lived in a dialog under Config. It is now the window N1MM has,
with the same five tabs.
Telnet shows the traffic with spot lines in green, what went out in blue and
lines from a preferred spotter in bold. Double clicking a spot line, or "Jump to
this spot", puts the radio there with the call in the entry window. Scrolling
stops while the pointer is over the traffic. The client keeps the last two
hundred lines, so a window opened mid-contest is not blank.
Clusters keeps the operator's nodes with their ports, passwords and after-login
commands, connects and disconnects, and holds the logon settings. Download
fetches the published list of telnet nodes from NG3K — around fifty, with the
sysop's call and a note about each — and clicking one fills the boxes in. N1MM
downloads its list from its own web service, which asks the operator to opt in
to data collection and is N1MM's to run, so this reads a public page instead.
ClusterList takes any page with telnet:// links in a table; a page it cannot
read leaves the stored list alone.
Filters decide which spots reach the bandmap: bands, modes, beacons, busted
calls, stations outside the call history file, blacklisted spotters and calls,
spots from outside your country, continent or a list of prefixes, and how long a
spot stays on the map. A busted spot is a call the callsign database has never
heard that is one character away from one it knows; a call nothing resembles is
kept, because that is what a new station looks like. Nothing is filtered out of
the traffic itself — the operator sees everything the node sends.
Buttons edits the twelve command buttons. A button takes what N1MM's takes: the
message macros, several commands separated by semicolons, or {CONN} and the name
of a favourite, which connects to that node instead of sending anything. The
label takes the macros too. Right-clicking a button opens the editor.
Config ▸ Cluster now opens this window on the Clusters tab rather than a dialog
of its own, which is where N1MM keeps those settings.
Three things that could take the program down while a cluster was connected:
Settings.Load read a null where the property is not nullable. A file that names
a key with a null value — one written before the property existed and then
edited — put that null straight through, because the property's own default only
runs when the key is missing. Opening the telnet window then threw on the first
list it touched. Every null is now put back to the default the property
declares, walking into the stored records and the lists of them.
Bandmap was written from the cluster's thread and read from the window's, so a
dictionary could be modified while a window enumerated it. Every method locks
now.
ClusterClient disposed its token source while its own loop still used it, and
the retry delay sat outside the catch, so a disconnect faulted the loop task.
Along the way the message macros were checked against N1MM's function-key
documentation, and several were wrong. {LOGGEDCALL} is N1MM's {LASTCALL}, the
serial is #, and there is no {MYZONE}; {NAME} and {GRIDSQUARE} stand for the
other station's name and grid, not ours; {OTHERMHZ} is the radio the operator is
not on. The single-character macros * and ! were missing. Added from the same
table: {LASTCALL}, {PREVNR}, {NAMEANDSPACE}, {CHNAME}, {GRID}, the two grid
bearings and the grid distance, {FREQ}, {FREQROUND}, the other-radio
frequencies, {TIMESTAMP} and {TIME2}. Frequencies are formatted the way N1MM
formats them, with R for the decimal point on CW. The macros that pass a station
to the other band take the second radio as a new argument, and stand for nothing
at a one-radio station.
Left out, and written down: saving spots to a database, which N1MM keeps in its
admin database rather than in the log file the two programs share; the
special-calls list; the two-character busted check, which is a few hundred
thousand lookups per spot against a few hundred for one character; and N1MM's
action macros, which need a different shape than an expander that returns a
string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,12 @@ public sealed class AppSession : IDisposable
|
||||
private AlternatingCq? alternating;
|
||||
private So2rBox? box;
|
||||
|
||||
/// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
|
||||
/// the support files change.
|
||||
private SpotFilter spotFilter = new();
|
||||
|
||||
private readonly Random jitter = new();
|
||||
|
||||
public AppSession(UserPaths paths, Settings settings)
|
||||
{
|
||||
Paths = paths;
|
||||
@@ -38,6 +44,7 @@ public sealed class AppSession : IDisposable
|
||||
Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList<string> problems);
|
||||
UserDefinedContestProblems = problems;
|
||||
BandPlan = settings.ToBandPlan();
|
||||
ApplySpotSettings();
|
||||
}
|
||||
|
||||
public UserPaths Paths { get; }
|
||||
@@ -64,6 +71,11 @@ public sealed class AppSession : IDisposable
|
||||
/// The contest in progress: one log and one score, however many radios.
|
||||
public ContestSession? Logging { get; private set; }
|
||||
|
||||
/// The radio the operator is not on, or null at a one-radio station. The
|
||||
/// message macros that pass a station to the other band need it.
|
||||
public RadioPosition? Other(RadioPosition position) =>
|
||||
positions.FirstOrDefault(p => p.RadioNumber != position.RadioNumber);
|
||||
|
||||
/// One per radio, in radio-number order. There is always at least one, so
|
||||
/// the program works with no radio connected.
|
||||
public IReadOnlyList<RadioPosition> Positions => positions;
|
||||
@@ -111,6 +123,7 @@ public sealed class AppSession : IDisposable
|
||||
{
|
||||
Settings = settings;
|
||||
BandPlan = settings.ToBandPlan();
|
||||
ApplySpotSettings();
|
||||
settings.Save(Paths.SettingsFile);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -358,7 +371,7 @@ public sealed class AppSession : IDisposable
|
||||
}
|
||||
MoveToRadio(radioNumber);
|
||||
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
|
||||
await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false);
|
||||
await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// Puts both radios in the headphones, or goes back to one. An operator
|
||||
@@ -433,10 +446,12 @@ public sealed class AppSession : IDisposable
|
||||
cluster = new ClusterClient(
|
||||
Settings.ClusterHost,
|
||||
Settings.ClusterPort,
|
||||
Settings.Station.Callsign,
|
||||
Settings.ClusterLogonCall.Length > 0 ? Settings.ClusterLogonCall : Settings.Station.Callsign,
|
||||
Settings.ClusterCommands,
|
||||
Settings.ClusterPassword);
|
||||
cluster.SpotArrived += (_, spot) => Bandmap.Add(spot);
|
||||
Settings.ClusterPassword,
|
||||
autoLogon: Settings.ClusterAutoLogon,
|
||||
keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
|
||||
cluster.SpotArrived += (_, spot) => TakeSpot(spot);
|
||||
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
cluster.Start();
|
||||
}
|
||||
@@ -448,12 +463,30 @@ public sealed class AppSession : IDisposable
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// A spot the node sent, kept or dropped by the filters on the telnet
|
||||
/// window.
|
||||
private void TakeSpot(Spot spot)
|
||||
{
|
||||
if (!spotFilter.Accepts(spot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Bandmap.Add(Settings.RandomizeSpots ? SpotJitter.Shifted(spot, BandPlan, jitter) : spot);
|
||||
}
|
||||
|
||||
private void ApplySpotSettings()
|
||||
{
|
||||
spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History);
|
||||
Bandmap.Lifetime = TimeSpan.FromMinutes(Math.Max(1, Settings.SpotTimeoutMinutes));
|
||||
}
|
||||
|
||||
public void ReloadSupportFiles()
|
||||
{
|
||||
Countries = LoadCountryFile(Paths.CountryFile);
|
||||
Calls = LoadCallDatabase(Paths.CallDatabaseFile);
|
||||
History = LoadCallHistory(Settings.CallHistoryFile);
|
||||
Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _);
|
||||
ApplySpotSettings();
|
||||
if (Logging is not null)
|
||||
{
|
||||
OpenContest(Logging.Instance.ContestNumber);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Nonemm.Core;
|
||||
|
||||
@@ -21,6 +22,37 @@ public sealed record Settings
|
||||
|
||||
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
||||
|
||||
/// The nodes the operator keeps, listed on the telnet window's Clusters
|
||||
/// tab. Connecting to one copies it into `ClusterHost` and the rest.
|
||||
public IReadOnlyList<StoredClusterNode> ClusterNodes { get; init; } = [];
|
||||
|
||||
/// Off for a node that wants the call typed in by hand.
|
||||
public bool ClusterAutoLogon { get; init; } = true;
|
||||
|
||||
/// The call sent at login, or empty for the station callsign.
|
||||
public string ClusterLogonCall { get; init; } = "";
|
||||
|
||||
/// How often to send an empty line to a node that has said nothing. Nodes
|
||||
/// drop a connection that has been quiet for a quarter of an hour.
|
||||
public int ClusterKeepAliveMinutes { get; init; } = 4;
|
||||
|
||||
/// The buttons along the bottom of the telnet window. Empty means the ones
|
||||
/// in `StoredTelnetButton.Default`.
|
||||
public IReadOnlyList<StoredTelnetButton> TelnetButtons { get; init; } = [];
|
||||
|
||||
/// Which cluster spots reach the bandmap.
|
||||
public StoredSpotFilter SpotFilter { get; init; } = new();
|
||||
|
||||
/// How long a spot stays on the bandmap. N1MM's own default is 60.
|
||||
public int SpotTimeoutMinutes { get; init; } = 60;
|
||||
|
||||
/// Moves each incoming CW spot by a few tens of hertz, so the operator has
|
||||
/// to find the station by ear. N1MM calls it randomising.
|
||||
public bool RandomizeSpots { get; init; }
|
||||
|
||||
/// Sent with a spot the operator puts on the cluster with Alt+P.
|
||||
public string SpotComment { get; init; } = "";
|
||||
|
||||
/// One entry per radio, in radio-number order. A second radio makes the
|
||||
/// station SO2R.
|
||||
public IReadOnlyList<StoredRadio> Radios { get; init; } = [];
|
||||
@@ -103,8 +135,9 @@ public sealed record Settings
|
||||
}
|
||||
try
|
||||
{
|
||||
return Migrated(
|
||||
JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings());
|
||||
Settings read = JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings();
|
||||
FillNulls(read);
|
||||
return Migrated(read);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -131,6 +164,51 @@ public sealed record Settings
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
||||
|
||||
/// A settings file can hold a null where the property is not nullable: a
|
||||
/// file written before the property existed and then edited, or one an
|
||||
/// older version wrote. JSON puts the null in and the property's own
|
||||
/// default never runs, so the program reads a null it does not expect.
|
||||
/// Every null is put back to the default the property declares, top to
|
||||
/// bottom, so nothing downstream has to check.
|
||||
private static void FillNulls(object target)
|
||||
{
|
||||
Type type = target.GetType();
|
||||
object fresh = Activator.CreateInstance(type)!;
|
||||
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (!property.CanWrite || property.GetIndexParameters().Length > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
object? value = property.GetValue(target);
|
||||
if (value is null)
|
||||
{
|
||||
property.SetValue(target, property.GetValue(fresh));
|
||||
}
|
||||
else if (value is System.Collections.IEnumerable items and not string)
|
||||
{
|
||||
foreach (object? item in items)
|
||||
{
|
||||
FillOurOwn(item);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
FillOurOwn(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the types in this folder are walked into. A string, a number or a
|
||||
/// framework type has nothing to fill in.
|
||||
private static void FillOurOwn(object? value)
|
||||
{
|
||||
if (value is not null && value.GetType().Namespace == typeof(Settings).Namespace)
|
||||
{
|
||||
FillNulls(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Carries the single radio an older settings file holds into the list.
|
||||
private static Settings Migrated(Settings settings) =>
|
||||
settings.Radios.Count > 0 || settings.RigctldHost.Length == 0
|
||||
|
||||
24
src/Nonemm.App/Configuration/StoredClusterNode.cs
Normal file
24
src/Nonemm.App/Configuration/StoredClusterNode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// One cluster node in the operator's list. N1MM keeps the same list in its
|
||||
/// admin database and calls it Favorites.
|
||||
public sealed record StoredClusterNode
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Host { get; init; } = "";
|
||||
|
||||
public int Port { get; init; } = 7373;
|
||||
|
||||
/// Only the few nodes that ask for one; most take the callsign alone.
|
||||
public string Password { get; init; } = "";
|
||||
|
||||
/// Sent to this node after login, one command per entry.
|
||||
public IReadOnlyList<string> Commands { get; init; } = [];
|
||||
|
||||
/// How the node reads in the favourites list.
|
||||
[JsonIgnore]
|
||||
public string Label => Name.Length > 0 ? $"{Name} — {Host}:{Port}" : $"{Host}:{Port}";
|
||||
}
|
||||
70
src/Nonemm.App/Configuration/StoredSpotFilter.cs
Normal file
70
src/Nonemm.App/Configuration/StoredSpotFilter.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// The telnet window's filter settings as they are stored: band and mode names
|
||||
/// rather than the types, so the file keeps working when those change.
|
||||
public sealed record StoredSpotFilter
|
||||
{
|
||||
/// Band names as `Bands` spells them. Empty means every band.
|
||||
public IReadOnlyList<string> Bands { get; init; } = [];
|
||||
|
||||
/// `CW`, `PHONE` or `DIGITAL`. Empty means every mode.
|
||||
public IReadOnlyList<string> Modes { get; init; } = [];
|
||||
|
||||
public bool MyCountryOnly { get; init; }
|
||||
|
||||
public bool MyContinentOnly { get; init; }
|
||||
|
||||
public IReadOnlyList<string> CallAreas { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedSpotters { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedCalls { get; init; } = [];
|
||||
|
||||
public bool ShowBeacons { get; init; } = true;
|
||||
|
||||
public bool RemoveBustedSpots { get; init; }
|
||||
|
||||
public bool OnlyInCallHistory { get; init; }
|
||||
|
||||
/// Spotters whose lines the telnet window paints, so the ones worth
|
||||
/// believing stand out. N1MM takes up to six.
|
||||
public IReadOnlyList<string> PreferredSpotters { get; init; } = [];
|
||||
|
||||
public SpotFilter ToFilter(
|
||||
BandPlan plan,
|
||||
StoredStation station,
|
||||
CountryFile? countries,
|
||||
CallDatabase? calls,
|
||||
CallHistory? history) => new()
|
||||
{
|
||||
Bands = [.. Bands.Select(Core.Bands.Named).OfType<Band>()],
|
||||
Modes = [.. Modes.Select(ModeOf).OfType<ModeCategory>()],
|
||||
Plan = plan,
|
||||
Countries = countries,
|
||||
MyCountry = station.CountryPrefix,
|
||||
MyContinent = station.Continent,
|
||||
MyCountryOnly = MyCountryOnly,
|
||||
MyContinentOnly = MyContinentOnly,
|
||||
CallAreas = CallAreas,
|
||||
BlockedSpotters = BlockedSpotters,
|
||||
BlockedCalls = BlockedCalls,
|
||||
ShowBeacons = ShowBeacons,
|
||||
Calls = calls,
|
||||
RemoveBustedSpots = RemoveBustedSpots,
|
||||
History = history,
|
||||
OnlyInCallHistory = OnlyInCallHistory,
|
||||
};
|
||||
|
||||
private static ModeCategory? ModeOf(string name) => name.ToUpperInvariant() switch
|
||||
{
|
||||
"CW" => ModeCategory.Cw,
|
||||
"PHONE" => ModeCategory.Phone,
|
||||
"DIGITAL" => ModeCategory.Digital,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
24
src/Nonemm.App/Configuration/StoredTelnetButton.cs
Normal file
24
src/Nonemm.App/Configuration/StoredTelnetButton.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// One button on the telnet window. The command holds what N1MM's buttons hold:
|
||||
/// the message macros, several commands separated by semicolons, or `{CONN}` and
|
||||
/// the name of a favourite to connect to it.
|
||||
public sealed record StoredTelnetButton
|
||||
{
|
||||
/// What the operator gets before changing anything: the commands a node
|
||||
/// answers on any of the four cluster programs.
|
||||
public static readonly IReadOnlyList<StoredTelnetButton> Default =
|
||||
[
|
||||
new() { Label = "Sh/DX", Command = "sh/dx" },
|
||||
new() { Label = "Sh/DX/20", Command = "sh/dx/20" },
|
||||
new() { Label = "WWV", Command = "sh/wwv" },
|
||||
new() { Label = "Users", Command = "sh/users" },
|
||||
new() { Label = "Nodes", Command = "sh/c/n" },
|
||||
new() { Label = "Help", Command = "help" },
|
||||
new() { Label = "Bye", Command = "bye" },
|
||||
];
|
||||
|
||||
public string Label { get; init; } = "";
|
||||
|
||||
public string Command { get; init; } = "";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
@@ -13,6 +14,11 @@ public sealed class SupportFileDownloader
|
||||
public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat";
|
||||
public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP";
|
||||
|
||||
/// NG3K's list of telnet cluster nodes. N1MM downloads its list from its
|
||||
/// own web service, which asks the operator to opt in to data collection
|
||||
/// and is N1MM's to run; this is a public page anyone may read.
|
||||
public const string ClusterListUrl = "https://www.ng3k.com/misc/cluster.html";
|
||||
|
||||
private readonly HttpClient http;
|
||||
|
||||
public SupportFileDownloader(HttpClient http) => this.http = http;
|
||||
@@ -23,6 +29,9 @@ public sealed class SupportFileDownloader
|
||||
public Task<string> DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation);
|
||||
|
||||
public Task<string> DownloadClusterListAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(ClusterListUrl, path, text => ClusterList.Parse(text).Count, "cluster nodes", cancellation);
|
||||
|
||||
private async Task<string> DownloadAsync(
|
||||
string url,
|
||||
string path,
|
||||
|
||||
@@ -28,6 +28,10 @@ public sealed class UserPaths
|
||||
|
||||
public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP");
|
||||
|
||||
/// The published list of cluster nodes, kept as the page it was downloaded
|
||||
/// from so a later version can read more out of it.
|
||||
public string ClusterListFile => Path.Combine(SupportFiles, "cluster-list.html");
|
||||
|
||||
public void CreateFolders()
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.ClusterDialog"
|
||||
Title="DX cluster" Width="440" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" CanResize="False">
|
||||
<StackPanel Margin="14" Spacing="6">
|
||||
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Text="Node" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="HostBox" Grid.Row="1" PlaceholderText="dxc.example.net" />
|
||||
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
|
||||
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<TextBlock Text="Password, if the node asks for one" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="PasswordBox" PasswordChar="•" />
|
||||
<TextBlock Text="Commands sent after login, one per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
|
||||
<TextBox Name="CommandsBox" AcceptsReturn="True" Height="90" />
|
||||
<CheckBox Name="EnabledBox" Content="Connect" Margin="0,6,0,0" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -1,39 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// The cluster node to connect to. Filters are the node's business, so whatever
|
||||
/// the operator puts in the command box is sent after login and left alone.
|
||||
public sealed partial class ClusterDialog : Window
|
||||
{
|
||||
private readonly Settings settings;
|
||||
|
||||
public ClusterDialog(Settings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
InitializeComponent();
|
||||
HostBox.Text = settings.ClusterHost;
|
||||
PortBox.Text = settings.ClusterPort.ToString();
|
||||
PasswordBox.Text = settings.ClusterPassword;
|
||||
CommandsBox.Text = string.Join("\n", settings.ClusterCommands);
|
||||
EnabledBox.IsChecked = settings.ClusterEnabled;
|
||||
}
|
||||
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
|
||||
{
|
||||
ClusterHost = (HostBox.Text ?? "").Trim(),
|
||||
ClusterPort = int.TryParse(PortBox.Text, out int port) ? port : 7373,
|
||||
ClusterPassword = PasswordBox.Text ?? "",
|
||||
ClusterCommands = (CommandsBox.Text ?? "")
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList(),
|
||||
ClusterEnabled = EnabledBox.IsChecked == true,
|
||||
});
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Nonemm.App.Dialogs;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Formats.Adif;
|
||||
using Nonemm.Formats.Cabrillo;
|
||||
using Nonemm.Session;
|
||||
using Nonemm.Spotting;
|
||||
using Nonemm.Storage;
|
||||
|
||||
@@ -219,7 +220,7 @@ public sealed partial class EntryWindow
|
||||
Status("nothing to spot");
|
||||
return;
|
||||
}
|
||||
await cluster.SendSpotAsync(where, call);
|
||||
await cluster.SendSpotAsync(where, call, MessageExpander.Expand(session.Settings.SpotComment, Logging, session.Other(Logging)));
|
||||
session.Bandmap.Add(new Spot(call, where, DateTime.UtcNow, SpotSource.Operator));
|
||||
Status($"{call.Text} spotted on {where.Kilohertz:0.0}");
|
||||
}
|
||||
@@ -235,7 +236,7 @@ public sealed partial class EntryWindow
|
||||
|
||||
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
|
||||
|
||||
private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session));
|
||||
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
|
||||
|
||||
private async void OnStationSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -278,26 +279,10 @@ public sealed partial class EntryWindow
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnClusterSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ClusterDialog dialog = new(session.Settings);
|
||||
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
||||
if (updated is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(updated);
|
||||
if (updated.ClusterEnabled)
|
||||
{
|
||||
session.ConnectCluster();
|
||||
Status($"cluster: {updated.ClusterHost}:{updated.ClusterPort}");
|
||||
}
|
||||
else
|
||||
{
|
||||
session.DisconnectCluster();
|
||||
Status("cluster disconnected");
|
||||
}
|
||||
}
|
||||
/// N1MM keeps the cluster settings on the telnet window rather than in a
|
||||
/// dialog of their own, and so do we.
|
||||
private void OnClusterSettings(object? sender, RoutedEventArgs e) =>
|
||||
Show(() => new TelnetWindow(session, Tune)).ShowClusters();
|
||||
|
||||
private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -427,17 +412,19 @@ public sealed partial class EntryWindow
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Show<T>(Func<T> create) where T : Window
|
||||
/// The one window of that kind, opened if it was not open already.
|
||||
private T Show<T>(Func<T> create) where T : Window
|
||||
{
|
||||
if (openWindows.TryGetValue(typeof(T), out Window? existing))
|
||||
{
|
||||
existing.Activate();
|
||||
return;
|
||||
return (T)existing;
|
||||
}
|
||||
T window = create();
|
||||
openWindows[typeof(T)] = window;
|
||||
window.Closed += (_, _) => openWindows.Remove(typeof(T));
|
||||
window.Show(this);
|
||||
return window;
|
||||
}
|
||||
|
||||
private async Task<IStorageFolder?> Folder(string path) =>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
|
||||
<MenuItem Header="_Available Mults and Qs" Click="OnShowAvailable" />
|
||||
<MenuItem Header="_Score Summary" Click="OnShowScore" />
|
||||
<MenuItem Header="_Packet" Click="OnShowPacket" />
|
||||
<MenuItem Header="_Telnet" Click="OnShowTelnet" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Config">
|
||||
<MenuItem Header="_Station…" Click="OnStationSettings" />
|
||||
|
||||
@@ -559,7 +559,7 @@ public sealed partial class EntryWindow : Window
|
||||
{
|
||||
return;
|
||||
}
|
||||
string text = MessageExpander.Expand(template, Logging);
|
||||
string text = MessageExpander.Expand(template, Logging, session.Other(Logging));
|
||||
// the box has to point at this radio before the key does
|
||||
_ = session.PointTransmitAtAsync(radioNumber);
|
||||
Status($"sending {text}");
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.PacketWindow"
|
||||
Title="Packet" Width="640" Height="420">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="6" Spacing="6">
|
||||
<TextBox Name="CommandBox" Width="520" Watermark="command to the node" />
|
||||
<Button Content="Send" Click="OnSend" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Name="StateText" Margin="8,6,8,2" FontSize="11" Opacity="0.7" />
|
||||
<ScrollViewer Name="Scroller">
|
||||
<TextBlock Name="Traffic" FontFamily="monospace" FontSize="12" Margin="8,0"
|
||||
TextWrapping="NoWrap" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
@@ -1,61 +0,0 @@
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The cluster node's traffic as it arrived, and a line to send commands on.
|
||||
public sealed partial class PacketWindow : RefreshableWindow
|
||||
{
|
||||
private const int LinesKept = 500;
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly Queue<string> lines = new();
|
||||
|
||||
public PacketWindow(AppSession session)
|
||||
{
|
||||
this.session = session;
|
||||
InitializeComponent();
|
||||
if (session.Cluster is not null)
|
||||
{
|
||||
session.Cluster.LineArrived += OnLine;
|
||||
}
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
if (session.Cluster is not null)
|
||||
{
|
||||
session.Cluster.LineArrived -= OnLine;
|
||||
}
|
||||
};
|
||||
Refresh();
|
||||
}
|
||||
|
||||
|
||||
public override void Refresh() =>
|
||||
StateText.Text = session.Cluster is null
|
||||
? "no cluster node — Config ▸ Cluster"
|
||||
: session.Cluster.IsConnected
|
||||
? $"connected to {session.Settings.ClusterHost}:{session.Settings.ClusterPort}"
|
||||
: "connecting…";
|
||||
|
||||
private void OnLine(object? sender, string line) => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
lines.Enqueue(line);
|
||||
while (lines.Count > LinesKept)
|
||||
{
|
||||
lines.Dequeue();
|
||||
}
|
||||
Traffic.Text = string.Join('\n', lines);
|
||||
Scroller.ScrollToEnd();
|
||||
Refresh();
|
||||
});
|
||||
|
||||
private async void OnSend(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (session.Cluster is null || CommandBox.Text is not { Length: > 0 } command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await session.Cluster.SendAsync(command);
|
||||
CommandBox.Text = "";
|
||||
}
|
||||
}
|
||||
68
src/Nonemm.App/Windows/TelnetWindow.Buttons.cs
Normal file
68
src/Nonemm.App/Windows/TelnetWindow.Buttons.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The Buttons tab: the twelve command buttons under the traffic, as N1MM's
|
||||
/// telnet window has.
|
||||
public sealed partial class TelnetWindow
|
||||
{
|
||||
private const int ButtonCount = 12;
|
||||
|
||||
private readonly List<(TextBox Label, TextBox Command)> buttonRows = [];
|
||||
|
||||
private void LoadButtonRows()
|
||||
{
|
||||
buttonRows.Clear();
|
||||
ButtonRows.Children.Clear();
|
||||
ButtonRows.RowDefinitions.Clear();
|
||||
IReadOnlyList<StoredTelnetButton> stored = Buttons();
|
||||
for (int at = 0; at < ButtonCount; at++)
|
||||
{
|
||||
ButtonRows.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
StoredTelnetButton button = at < stored.Count ? stored[at] : new StoredTelnetButton();
|
||||
TextBox label = Box(at, 0, button.Label, "label");
|
||||
TextBox command = Box(at, 2, button.Command, "sh/dx; sh/wwv");
|
||||
buttonRows.Add((label, command));
|
||||
}
|
||||
}
|
||||
|
||||
private TextBox Box(int row, int column, string text, string placeholder)
|
||||
{
|
||||
TextBox box = new()
|
||||
{
|
||||
Text = text,
|
||||
PlaceholderText = placeholder,
|
||||
Margin = new Avalonia.Thickness(0, 0, 0, 4),
|
||||
};
|
||||
Grid.SetRow(box, row);
|
||||
Grid.SetColumn(box, column);
|
||||
ButtonRows.Children.Add(box);
|
||||
return box;
|
||||
}
|
||||
|
||||
private void OnSaveButtons(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
TelnetButtons = [.. buttonRows
|
||||
.Select(row => new StoredTelnetButton
|
||||
{
|
||||
Label = (row.Label.Text ?? "").Trim(),
|
||||
Command = (row.Command.Text ?? "").Trim(),
|
||||
})
|
||||
.Where(button => button.Label.Length > 0 && button.Command.Length > 0)],
|
||||
});
|
||||
LoadButtons();
|
||||
Show("*** buttons saved", NoticeColour);
|
||||
}
|
||||
|
||||
private void OnResetButtons(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.Save(session.Settings with { TelnetButtons = [] });
|
||||
LoadButtons();
|
||||
LoadButtonRows();
|
||||
Show("*** buttons back to the defaults", NoticeColour);
|
||||
}
|
||||
}
|
||||
232
src/Nonemm.App/Windows/TelnetWindow.Clusters.cs
Normal file
232
src/Nonemm.App/Windows/TelnetWindow.Clusters.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The Clusters tab: the nodes the operator keeps, the one being talked to, and
|
||||
/// how to log on to it.
|
||||
public sealed partial class TelnetWindow
|
||||
{
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
private readonly List<StoredClusterNode> nodes = [];
|
||||
private readonly List<ClusterNode> published = [];
|
||||
|
||||
private void LoadClusters()
|
||||
{
|
||||
nodes.Clear();
|
||||
nodes.AddRange(session.Settings.ClusterNodes);
|
||||
NodeList.ItemsSource = nodes.Select(n => n.Label).ToList();
|
||||
NodeList.SelectedIndex = nodes.FindIndex(Matches);
|
||||
|
||||
Settings settings = session.Settings;
|
||||
HostBox.Text = settings.ClusterHost;
|
||||
PortBox.Text = settings.ClusterPort.ToString(CultureInfo.InvariantCulture);
|
||||
PasswordBox.Text = settings.ClusterPassword;
|
||||
CommandsBox.Text = string.Join("\n", settings.ClusterCommands);
|
||||
LoadPublishedNodes();
|
||||
AutoLogonBox.IsChecked = settings.ClusterAutoLogon;
|
||||
LogonCallBox.Text = settings.ClusterLogonCall;
|
||||
KeepAliveBox.Text = settings.ClusterKeepAliveMinutes.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// True for the node the settings are pointing at, so the list opens on it.
|
||||
private bool Matches(StoredClusterNode node) =>
|
||||
string.Equals(node.Host, session.Settings.ClusterHost, StringComparison.OrdinalIgnoreCase)
|
||||
&& node.Port == session.Settings.ClusterPort;
|
||||
|
||||
private void OnNodePicked(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (NodeList.SelectedIndex < 0 || NodeList.SelectedIndex >= nodes.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
StoredClusterNode node = nodes[NodeList.SelectedIndex];
|
||||
NameBox.Text = node.Name;
|
||||
HostBox.Text = node.Host;
|
||||
PortBox.Text = node.Port.ToString(CultureInfo.InvariantCulture);
|
||||
PasswordBox.Text = node.Password;
|
||||
CommandsBox.Text = string.Join("\n", node.Commands);
|
||||
}
|
||||
|
||||
/// Adds what is in the boxes to the list, or replaces the entry that has
|
||||
/// the same host and port.
|
||||
private void OnStoreNode(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
StoredClusterNode node = new()
|
||||
{
|
||||
Name = (NameBox.Text ?? "").Trim(),
|
||||
Host = Host(),
|
||||
Port = Port(),
|
||||
Password = PasswordBox.Text ?? "",
|
||||
Commands = Lines(CommandsBox.Text),
|
||||
};
|
||||
if (node.Host.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int at = nodes.FindIndex(n =>
|
||||
string.Equals(n.Host, node.Host, StringComparison.OrdinalIgnoreCase) && n.Port == node.Port);
|
||||
if (at >= 0)
|
||||
{
|
||||
nodes[at] = node;
|
||||
}
|
||||
else
|
||||
{
|
||||
nodes.Add(node);
|
||||
}
|
||||
session.Save(session.Settings with { ClusterNodes = [.. nodes] });
|
||||
LoadClusters();
|
||||
}
|
||||
|
||||
private void OnRemoveNode(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (NodeList.SelectedIndex < 0 || NodeList.SelectedIndex >= nodes.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
nodes.RemoveAt(NodeList.SelectedIndex);
|
||||
session.Save(session.Settings with { ClusterNodes = [.. nodes] });
|
||||
LoadClusters();
|
||||
}
|
||||
|
||||
/// The list downloaded from the published page, if one has been. Its
|
||||
/// entries are not favourites until the operator saves one.
|
||||
private void LoadPublishedNodes()
|
||||
{
|
||||
published.Clear();
|
||||
if (File.Exists(session.Paths.ClusterListFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
published.AddRange(ClusterList.Parse(File.ReadAllText(session.Paths.ClusterListFile)));
|
||||
}
|
||||
catch (Exception e) when (e is FormatException or IOException)
|
||||
{
|
||||
Show($"*** the stored node list could not be read: {e.Message}", NoticeColour);
|
||||
}
|
||||
}
|
||||
PublishedNodeList.ItemsSource = published
|
||||
.Select(n => n.Description.Length > 0
|
||||
? $"{n.Name} — {n.Address} · {n.Description}"
|
||||
: $"{n.Name} — {n.Address}")
|
||||
.ToList();
|
||||
PublishedText.Text = published.Count > 0
|
||||
? $"Published list · {published.Count} nodes"
|
||||
: "Published list — nothing downloaded yet";
|
||||
}
|
||||
|
||||
private void OnPublishedNodePicked(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (PublishedNodeList.SelectedIndex < 0 || PublishedNodeList.SelectedIndex >= published.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ClusterNode node = published[PublishedNodeList.SelectedIndex];
|
||||
NameBox.Text = node.Name;
|
||||
HostBox.Text = node.Host;
|
||||
PortBox.Text = node.Port.ToString(CultureInfo.InvariantCulture);
|
||||
PasswordBox.Text = "";
|
||||
CommandsBox.Text = "";
|
||||
}
|
||||
|
||||
private async void OnDownloadNodes(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
PublishedText.Text = "fetching the node list…";
|
||||
try
|
||||
{
|
||||
string result = await new SupportFileDownloader(Http)
|
||||
.DownloadClusterListAsync(session.Paths.ClusterListFile);
|
||||
Show($"*** node list: {result} from {SupportFileDownloader.ClusterListUrl}", NoticeColour);
|
||||
}
|
||||
catch (Exception error) when (error is InvalidOperationException or IOException)
|
||||
{
|
||||
Show($"*** {error.Message}", NoticeColour);
|
||||
}
|
||||
LoadPublishedNodes();
|
||||
}
|
||||
|
||||
/// `{CONN}name` connects to the favourite of that name rather than sending
|
||||
/// anything, as it does on N1MM's telnet buttons. Anything after the
|
||||
/// semicolon is dropped, which is what N1MM does with it too.
|
||||
private bool ConnectToFavourite(string command)
|
||||
{
|
||||
const string marker = "{CONN}";
|
||||
if (!command.StartsWith(marker, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string name = command[marker.Length..].Split(';')[0].Trim();
|
||||
int at = nodes.FindIndex(node => string.Equals(node.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (at < 0)
|
||||
{
|
||||
Show($"*** no favourite is called '{name}'", NoticeColour);
|
||||
return true;
|
||||
}
|
||||
NodeList.SelectedIndex = at;
|
||||
Connect();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnConnect(object? sender, RoutedEventArgs e) => Connect();
|
||||
|
||||
private void OnDisconnect(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Forget(session.Cluster);
|
||||
session.Save(session.Settings with { ClusterEnabled = false });
|
||||
session.DisconnectCluster();
|
||||
Show("*** disconnected", NoticeColour);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnSaveOptions(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
ClusterAutoLogon = AutoLogonBox.IsChecked == true,
|
||||
ClusterLogonCall = (LogonCallBox.Text ?? "").Trim(),
|
||||
ClusterKeepAliveMinutes = Minutes(KeepAliveBox.Text, session.Settings.ClusterKeepAliveMinutes),
|
||||
});
|
||||
Show("*** options saved — they apply on the next connection", NoticeColour);
|
||||
}
|
||||
|
||||
/// Dials the node in the boxes and keeps it as the one to connect to on the
|
||||
/// next run.
|
||||
private void Connect()
|
||||
{
|
||||
if (Host().Length == 0)
|
||||
{
|
||||
Show("*** fill in a node on the Clusters tab", NoticeColour);
|
||||
ShowClusters();
|
||||
return;
|
||||
}
|
||||
Forget(session.Cluster);
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
ClusterHost = Host(),
|
||||
ClusterPort = Port(),
|
||||
ClusterPassword = PasswordBox.Text ?? "",
|
||||
ClusterCommands = Lines(CommandsBox.Text),
|
||||
ClusterEnabled = true,
|
||||
});
|
||||
Traffic.Items.Clear();
|
||||
session.ConnectCluster();
|
||||
Listen(session.Cluster);
|
||||
Tabs.SelectedIndex = 0;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private string Host() => (HostBox.Text ?? "").Trim();
|
||||
|
||||
private int Port() =>
|
||||
int.TryParse(PortBox.Text, out int port) && port > 0 ? port : session.Settings.ClusterPort;
|
||||
|
||||
private static int Minutes(string? text, int fallback) =>
|
||||
int.TryParse(text, out int minutes) && minutes > 0 ? minutes : fallback;
|
||||
|
||||
private static IReadOnlyList<string> Lines(string? text) =>
|
||||
[.. (text ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
}
|
||||
112
src/Nonemm.App/Windows/TelnetWindow.Filters.cs
Normal file
112
src/Nonemm.App/Windows/TelnetWindow.Filters.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The Filters and Spot comment tabs: which spots reach the bandmap, how long
|
||||
/// they stay there, and what goes out with a spot the operator sends.
|
||||
public sealed partial class TelnetWindow
|
||||
{
|
||||
/// The bands worth a tick box. The rest of the microwave bands would fill
|
||||
/// the tab and no cluster carries them.
|
||||
private static readonly IReadOnlyList<string> FilterBands =
|
||||
["160M", "80M", "60M", "40M", "30M", "20M", "17M", "15M", "12M", "10M", "6M", "4M", "2M", "70CM"];
|
||||
|
||||
private readonly List<CheckBox> bandBoxes = [];
|
||||
|
||||
private void LoadFilters()
|
||||
{
|
||||
StoredSpotFilter filter = session.Settings.SpotFilter;
|
||||
bandBoxes.Clear();
|
||||
BandBoxes.Children.Clear();
|
||||
foreach (string band in FilterBands)
|
||||
{
|
||||
CheckBox box = new()
|
||||
{
|
||||
Content = band,
|
||||
Margin = new Avalonia.Thickness(0, 0, 10, 0),
|
||||
IsChecked = filter.Bands.Contains(band, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
bandBoxes.Add(box);
|
||||
BandBoxes.Children.Add(box);
|
||||
}
|
||||
|
||||
CwBox.IsChecked = HasMode(filter, "CW");
|
||||
PhoneBox.IsChecked = HasMode(filter, "PHONE");
|
||||
DigitalBox.IsChecked = HasMode(filter, "DIGITAL");
|
||||
BeaconBox.IsChecked = filter.ShowBeacons;
|
||||
BustedBox.IsChecked = filter.RemoveBustedSpots;
|
||||
CallHistoryBox.IsChecked = filter.OnlyInCallHistory;
|
||||
RandomizeBox.IsChecked = session.Settings.RandomizeSpots;
|
||||
PreferredSpottersBox.Text = string.Join(' ', filter.PreferredSpotters);
|
||||
MyCountryBox.IsChecked = filter.MyCountryOnly;
|
||||
MyContinentBox.IsChecked = filter.MyContinentOnly;
|
||||
MyCountryBox.Content = Named("my country", session.Settings.Station.CountryPrefix);
|
||||
MyContinentBox.Content = Named("my continent", session.Settings.Station.Continent);
|
||||
CallAreasBox.Text = string.Join(' ', filter.CallAreas);
|
||||
BlockedSpottersBox.Text = string.Join(' ', filter.BlockedSpotters);
|
||||
BlockedCallsBox.Text = string.Join(' ', filter.BlockedCalls);
|
||||
TimeoutBox.Text = session.Settings.SpotTimeoutMinutes.ToString(CultureInfo.InvariantCulture);
|
||||
SpotCommentBox.Text = session.Settings.SpotComment;
|
||||
}
|
||||
|
||||
private void OnApplyFilters(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.Save(session.Settings with
|
||||
{
|
||||
SpotFilter = new StoredSpotFilter
|
||||
{
|
||||
Bands = [.. bandBoxes.Where(b => b.IsChecked == true).Select(b => (string)b.Content!)],
|
||||
Modes = Modes(),
|
||||
MyCountryOnly = MyCountryBox.IsChecked == true,
|
||||
MyContinentOnly = MyContinentBox.IsChecked == true,
|
||||
CallAreas = Words(CallAreasBox.Text),
|
||||
BlockedSpotters = Words(BlockedSpottersBox.Text),
|
||||
BlockedCalls = Words(BlockedCallsBox.Text),
|
||||
ShowBeacons = BeaconBox.IsChecked == true,
|
||||
RemoveBustedSpots = BustedBox.IsChecked == true,
|
||||
OnlyInCallHistory = CallHistoryBox.IsChecked == true,
|
||||
PreferredSpotters = Words(PreferredSpottersBox.Text),
|
||||
},
|
||||
RandomizeSpots = RandomizeBox.IsChecked == true,
|
||||
SpotTimeoutMinutes = Minutes(TimeoutBox.Text, session.Settings.SpotTimeoutMinutes),
|
||||
});
|
||||
Show("*** filters applied — they hold for the spots that arrive from now on", NoticeColour);
|
||||
}
|
||||
|
||||
private void OnSaveSpotComment(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
session.Save(session.Settings with { SpotComment = (SpotCommentBox.Text ?? "").Trim() });
|
||||
Show("*** spot comment saved", NoticeColour);
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> Modes()
|
||||
{
|
||||
List<string> modes = [];
|
||||
if (CwBox.IsChecked == true)
|
||||
{
|
||||
modes.Add("CW");
|
||||
}
|
||||
if (PhoneBox.IsChecked == true)
|
||||
{
|
||||
modes.Add("PHONE");
|
||||
}
|
||||
if (DigitalBox.IsChecked == true)
|
||||
{
|
||||
modes.Add("DIGITAL");
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
private static bool HasMode(StoredSpotFilter filter, string mode) =>
|
||||
filter.Modes.Contains(mode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static string Named(string what, string value) =>
|
||||
value.Length > 0 ? $"{what} ({value})" : what;
|
||||
|
||||
private static IReadOnlyList<string> Words(string? text) =>
|
||||
[.. (text ?? "").Split([' ', ',', '\n', '\r', '\t'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(word => word.ToUpperInvariant())];
|
||||
}
|
||||
148
src/Nonemm.App/Windows/TelnetWindow.axaml
Normal file
148
src/Nonemm.App/Windows/TelnetWindow.axaml
Normal file
@@ -0,0 +1,148 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.TelnetWindow"
|
||||
Title="Telnet" Width="760" Height="520">
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="Margin" Value="0,6,0,1" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<TabControl Name="Tabs">
|
||||
<TabItem Header="Telnet">
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="8,6,8,4">
|
||||
<TextBlock Name="StateText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Name="PausedText" Text="scrolling paused" FontSize="11"
|
||||
Margin="0,0,8,0" IsVisible="False" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="2" Name="ReconnectButton" Content="Reconnect" Click="OnReconnect" />
|
||||
</Grid>
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="8,4,8,8" Spacing="4">
|
||||
<WrapPanel Name="ButtonRow" />
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="Send" Click="OnSend" IsDefault="True"
|
||||
Margin="6,0,0,0" />
|
||||
<TextBox Name="CommandBox" PlaceholderText="command to the node" KeyDown="OnCommandKey" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<ListBox Name="Traffic" FontFamily="monospace" FontSize="12" Margin="8,0"
|
||||
DoubleTapped="OnJumpToSpot"
|
||||
PointerEntered="OnTrafficEntered" PointerExited="OnTrafficExited">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Jump to this spot" Click="OnJumpToSpot" />
|
||||
<MenuItem Header="Copy line" Click="OnCopyLine" />
|
||||
<MenuItem Header="Clear" Click="OnClearTraffic" />
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Clusters">
|
||||
<Grid ColumnDefinitions="*,12,300" Margin="10">
|
||||
<Grid RowDefinitions="Auto,2*,Auto,Auto,3*">
|
||||
<TextBlock Classes="label" Text="Favourites" Margin="0,0,0,1" />
|
||||
<ListBox Grid.Row="1" Name="NodeList" SelectionChanged="OnNodePicked" />
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
|
||||
<Button Content="Connect" Click="OnConnect" />
|
||||
<Button Content="Disconnect" Click="OnDisconnect" />
|
||||
<Button Content="Remove" Click="OnRemoveNode" />
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,1">
|
||||
<TextBlock Classes="label" Name="PublishedText" Text="Published list" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Content="Download" Click="OnDownloadNodes" />
|
||||
</Grid>
|
||||
<ListBox Grid.Row="4" Name="PublishedNodeList" SelectionChanged="OnPublishedNodePicked" />
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Column="2">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Name" Margin="0,0,0,1" />
|
||||
<TextBox Name="NameBox" />
|
||||
<Grid ColumnDefinitions="*,8,80" >
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Node" />
|
||||
<TextBox Name="HostBox" PlaceholderText="dxc.example.net" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Port" />
|
||||
<TextBox Name="PortBox" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Classes="label" Text="Password, if the node asks for one" />
|
||||
<TextBox Name="PasswordBox" PasswordChar="•" />
|
||||
<TextBlock Classes="label" Text="Commands sent after login, one per line" />
|
||||
<TextBox Name="CommandsBox" AcceptsReturn="True" Height="70" />
|
||||
<Button Content="Save to favourites" Click="OnStoreNode" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Classes="label" Text="Options" Margin="0,14,0,1" />
|
||||
<CheckBox Name="AutoLogonBox" Content="Log on automatically" />
|
||||
<TextBlock Classes="label" Text="Log on with" />
|
||||
<TextBox Name="LogonCallBox" PlaceholderText="the station callsign" />
|
||||
<TextBlock Classes="label" Text="Keep alive every (minutes)" />
|
||||
<TextBox Name="KeepAliveBox" Width="60" HorizontalAlignment="Left" />
|
||||
<Button Content="Save options" Click="OnSaveOptions" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Filters">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="10" Spacing="2">
|
||||
<TextBlock Classes="label" Text="Bands — none ticked means every band" />
|
||||
<WrapPanel Name="BandBoxes" />
|
||||
<TextBlock Classes="label" Text="Modes — none ticked means every mode" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<CheckBox Name="CwBox" Content="CW" />
|
||||
<CheckBox Name="PhoneBox" Content="Phone" />
|
||||
<CheckBox Name="DigitalBox" Content="Digital" />
|
||||
</StackPanel>
|
||||
<CheckBox Name="BeaconBox" Content="Show beacon spots" Margin="0,6,0,0" />
|
||||
<CheckBox Name="BustedBox" Content="Remove busted spots — a call one character off one the callsign database knows" />
|
||||
<CheckBox Name="CallHistoryBox" Content="Only the stations in the call history file" />
|
||||
<CheckBox Name="RandomizeBox" Content="Randomise incoming CW spot frequencies" />
|
||||
<TextBlock Classes="label" Text="Preferred spotters, whose lines the traffic paints" />
|
||||
<TextBox Name="PreferredSpottersBox" PlaceholderText="W3LPL K1TTT" />
|
||||
<TextBlock Classes="label" Text="Take spots only from" />
|
||||
<CheckBox Name="MyCountryBox" Content="my country" />
|
||||
<CheckBox Name="MyContinentBox" Content="my continent" />
|
||||
<TextBlock Classes="label" Text="these prefixes, separated by spaces" />
|
||||
<TextBox Name="CallAreasBox" PlaceholderText="K1 K2 VE3" />
|
||||
<TextBlock Classes="label" Text="Blacklisted spotters" />
|
||||
<TextBox Name="BlockedSpottersBox" />
|
||||
<TextBlock Classes="label" Text="Blacklisted calls" />
|
||||
<TextBox Name="BlockedCallsBox" />
|
||||
<TextBlock Classes="label" Text="Bandmap spot timeout (minutes)" />
|
||||
<TextBox Name="TimeoutBox" Width="60" HorizontalAlignment="Left" />
|
||||
<Button Content="Apply" Click="OnApplyFilters" Margin="0,10,0,0" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Buttons">
|
||||
<DockPanel Margin="10">
|
||||
<TextBlock DockPanel.Dock="Top" Classes="label" Margin="0,0,0,6" TextWrapping="Wrap"
|
||||
Text="The buttons under the traffic. A button takes the message macros — {MYCALL}, {CALL}, {FREQ} — several commands separated by semicolons, or {CONN} and the name of a favourite to connect to it. Leave the label empty to drop the button." />
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="6" Margin="0,10,0,0">
|
||||
<Button Content="Save" Click="OnSaveButtons" />
|
||||
<Button Content="Back to the defaults" Click="OnResetButtons" />
|
||||
</StackPanel>
|
||||
<ScrollViewer>
|
||||
<Grid Name="ButtonRows" ColumnDefinitions="140,8,*" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Spot comment">
|
||||
<StackPanel Margin="10">
|
||||
<TextBlock Classes="label" Text="Sent with the spots you put on the cluster" />
|
||||
<TextBox Name="SpotCommentBox" PlaceholderText="CQ contest" />
|
||||
<Button Content="Save" Click="OnSaveSpotComment" Margin="0,10,0,0" />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</local:RefreshableWindow>
|
||||
291
src/Nonemm.App/Windows/TelnetWindow.axaml.cs
Normal file
291
src/Nonemm.App/Windows/TelnetWindow.axaml.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
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 = Verdicts.NewMultiplier;
|
||||
private static readonly IBrush SentColour = Verdicts.Worth;
|
||||
private static readonly IBrush NoticeColour = Verdicts.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;
|
||||
StateText.Text = cluster is null
|
||||
? "not connected — pick a node on the Clusters tab"
|
||||
: cluster.IsConnected
|
||||
? $"connected to {cluster.Host}:{cluster.Port}"
|
||||
: $"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;
|
||||
}
|
||||
await cluster.SendAsync(command);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user