Add the Avalonia application and the station network

The entry window types contacts and colours them as they are typed; the log,
check, bandmap, score and packet windows read the same session. Contacts are
shared with the other stations of a multi-operator entry in N1MM's contact
message, so an N1MM station on the same network sees them too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-08-27 10:56:35 +00:00
parent ffeeb2cdc1
commit 2834f63c8e
45 changed files with 2667 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
<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" Watermark="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="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>

View File

@@ -0,0 +1,37 @@
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();
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,
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);
}

View File

@@ -0,0 +1,14 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.ContestPickerDialog"
Title="Open contest" Width="440" Height="320"
WindowStartupLocation="CenterOwner">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="6" Margin="0,10,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Open" Click="OnOpen" IsDefault="True" />
</StackPanel>
<ListBox Name="ContestList" />
</DockPanel>
</Window>

View File

@@ -0,0 +1,31 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.Storage;
namespace Nonemm.App.Dialogs;
/// Picks one of the contests already in the log database.
public sealed partial class ContestPickerDialog : Window
{
private readonly IReadOnlyList<ContestInstance> contests;
public ContestPickerDialog(IReadOnlyList<ContestInstance> contests)
{
this.contests = contests;
InitializeComponent();
ContestList.ItemsSource = contests
.Select(c => $"{c.ContestNumber} {c.ContestName} {c.StartDate:yyyy-MM-dd} {c.OperatorCategory}")
.ToList();
ContestList.SelectedIndex = contests.Count - 1;
ContestList.DoubleTapped += (_, _) => OnOpen(this, new RoutedEventArgs());
}
private void OnOpen(object? sender, RoutedEventArgs e)
{
int at = ContestList.SelectedIndex;
Close(at >= 0 ? contests[at].ContestNumber : (int?)null);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}

View File

@@ -0,0 +1,42 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.ContestSetupDialog"
Title="New contest" Width="460" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<StackPanel Margin="14" Spacing="8">
<TextBlock Text="Contest" FontSize="11" Opacity="0.7" />
<ComboBox Name="ContestBox" HorizontalAlignment="Stretch" />
<Grid ColumnDefinitions="*,8,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock Text="Mode" FontSize="11" Opacity="0.7" />
<ComboBox Name="ModeBox" Grid.Row="1" HorizontalAlignment="Stretch" />
<TextBlock Grid.Column="2" Text="Operator category" FontSize="11" Opacity="0.7" />
<ComboBox Name="OperatorBox" Grid.Row="1" Grid.Column="2" HorizontalAlignment="Stretch" />
<TextBlock Grid.Row="2" Text="Band" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<ComboBox Name="BandBox" Grid.Row="3" HorizontalAlignment="Stretch" />
<TextBlock Grid.Row="2" Grid.Column="2" Text="Power" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<ComboBox Name="PowerBox" Grid.Row="3" Grid.Column="2" HorizontalAlignment="Stretch" />
<TextBlock Grid.Row="4" Text="Assisted" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<ComboBox Name="AssistedBox" Grid.Row="5" HorizontalAlignment="Stretch" />
<TextBlock Grid.Row="4" Grid.Column="2" Text="Transmitters" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<ComboBox Name="TransmitterBox" Grid.Row="5" Grid.Column="2" HorizontalAlignment="Stretch" />
<TextBlock Grid.Row="6" Text="Overlay" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<TextBox Name="OverlayBox" Grid.Row="7" />
<TextBlock Grid.Row="6" Grid.Column="2" Text="Operators" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<TextBox Name="OperatorsBox" Grid.Row="7" Grid.Column="2" />
</Grid>
<TextBlock Text="Sent exchange" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<TextBox Name="ExchangeBox" />
<TextBlock Name="HintText" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Start" Click="OnStart" IsDefault="True" />
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,77 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Storage;
namespace Nonemm.App.Dialogs;
/// Picks the contest and the entry categories the sponsor asks for in the
/// Cabrillo header.
public sealed partial class ContestSetupDialog : Window
{
private readonly AppSession session;
public ContestSetupDialog(AppSession session)
{
this.session = session;
InitializeComponent();
ContestBox.ItemsSource = session.Registry.Choices.Select(c => c.DisplayName).ToList();
ContestBox.SelectedIndex = 0;
ContestBox.SelectionChanged += (_, _) => ShowChosen();
ModeBox.ItemsSource = new[] { "CW", "SSB", "RTTY", "MIXED" };
ModeBox.SelectedIndex = 0;
OperatorBox.ItemsSource = new[] { "SINGLE-OP", "MULTI-OP", "CHECKLIST" };
OperatorBox.SelectedIndex = 0;
BandBox.ItemsSource = new[] { "ALL", "160M", "80M", "40M", "20M", "15M", "10M" };
BandBox.SelectedIndex = 0;
PowerBox.ItemsSource = new[] { "HIGH", "LOW", "QRP" };
PowerBox.SelectedIndex = 0;
AssistedBox.ItemsSource = new[] { "NON-ASSISTED", "ASSISTED" };
AssistedBox.SelectedIndex = 0;
TransmitterBox.ItemsSource = new[] { "ONE", "TWO", "LIMITED", "UNLIMITED", "SWL" };
TransmitterBox.SelectedIndex = 0;
OperatorsBox.Text = session.Settings.Station.Callsign;
ShowChosen();
}
private ContestChoice Chosen =>
session.Registry.Choices[Math.Max(0, ContestBox.SelectedIndex)];
private void ShowChosen()
{
Contest contest = Chosen.Create(ModeOf());
ExchangeBox.Text = contest.SentExchangeFor(session.Settings.Station.ToStationInfo());
HintText.Text = $"Cabrillo name {contest.CabrilloName} · exchange " +
string.Join(", ", contest.ExchangeFieldsFor(session.Settings.Station.ToStationInfo())
.Select(f => f.Label));
}
private ModeCategory ModeOf() => (ModeBox.SelectedItem as string) switch
{
"SSB" => ModeCategory.Phone,
"RTTY" => ModeCategory.Digital,
_ => ModeCategory.Cw,
};
private void OnStart(object? sender, RoutedEventArgs e) => Close(new ContestInstance
{
ContestNumber = 0,
ContestName = Chosen.Name,
StartDate = DateTime.UtcNow,
SentExchange = ExchangeBox.Text ?? "",
OperatorCategory = Text(OperatorBox),
BandCategory = Text(BandBox),
PowerCategory = Text(PowerBox),
ModeCategory = Text(ModeBox),
AssistedCategory = Text(AssistedBox),
TransmitterCategory = Text(TransmitterBox),
OverlayCategory = OverlayBox.Text ?? "",
Operators = OperatorsBox.Text ?? "",
});
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
private static string Text(ComboBox box) => box.SelectedItem as string ?? "";
}

View File

@@ -0,0 +1,23 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.NetworkDialog"
Title="Networked stations" Width="440" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<StackPanel Margin="14" Spacing="6">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="Each contact is sent to the other stations of the entry as it is logged, in N1MM's contact message, so an N1MM station on the same network sees them too. With no addresses listed the contacts are broadcast." />
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
<TextBlock Text="This station's name" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="NameBox" Grid.Row="1" />
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
</Grid>
<TextBlock Text="Other stations, one address per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="PeersBox" AcceptsReturn="True" Height="90" Watermark="192.168.1.11" />
<CheckBox Name="EnabledBox" Content="Share contacts with the other stations" 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>

View File

@@ -0,0 +1,37 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
namespace Nonemm.App.Dialogs;
public sealed partial class NetworkDialog : Window
{
private readonly Settings settings;
public NetworkDialog(Settings settings)
{
this.settings = settings;
InitializeComponent();
NameBox.Text = settings.NetworkStationName.Length > 0
? settings.NetworkStationName
: Environment.MachineName;
PortBox.Text = settings.NetworkPort.ToString();
PeersBox.Text = string.Join("\n", settings.NetworkPeers);
EnabledBox.IsChecked = settings.NetworkEnabled;
}
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
{
NetworkStationName = (NameBox.Text ?? "").Trim(),
NetworkPort = int.TryParse(PortBox.Text, out int port) ? port : 12060,
NetworkPeers = (PeersBox.Text ?? "")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => l.Length > 0)
.ToList(),
NetworkEnabled = EnabledBox.IsChecked == true,
});
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}

View File

@@ -0,0 +1,21 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.RadioDialog"
Title="Radio" Width="420" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<StackPanel Margin="14" Spacing="6">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="The logger reads and tunes the radio through hamlib's rigctld, which is started separately for whichever radio is on the desk, for example: rigctld -m 2028 -r /dev/ttyUSB0" />
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
<TextBlock Text="Host" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="HostBox" Grid.Row="1" />
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
</Grid>
<CheckBox Name="EnabledBox" Content="Follow the radio" 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>

View File

@@ -0,0 +1,29 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
namespace Nonemm.App.Dialogs;
public sealed partial class RadioDialog : Window
{
private readonly Settings settings;
public RadioDialog(Settings settings)
{
this.settings = settings;
InitializeComponent();
HostBox.Text = settings.RigctldHost;
PortBox.Text = settings.RigctldPort.ToString();
EnabledBox.IsChecked = settings.RadioEnabled;
}
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
{
RigctldHost = (HostBox.Text ?? "127.0.0.1").Trim(),
RigctldPort = int.TryParse(PortBox.Text, out int port) ? port : 4532,
RadioEnabled = EnabledBox.IsChecked == true,
});
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}

View File

@@ -0,0 +1,56 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.StationDialog"
Title="Station" Width="440" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<Window.Styles>
<Style Selector="TextBlock.field">
<Setter Property="FontSize" Value="11" />
<Setter Property="Opacity" Value="0.7" />
<Setter Property="Margin" Value="0,6,0,1" />
</Style>
</Window.Styles>
<StackPanel Margin="14" Spacing="0">
<Grid ColumnDefinitions="*,8,*,8,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock Classes="field" Text="Callsign" />
<TextBox Name="CallsignBox" Grid.Row="1" />
<TextBlock Classes="field" Grid.Column="2" Text="CQ zone" />
<TextBox Name="CqZoneBox" Grid.Row="1" Grid.Column="2" />
<TextBlock Classes="field" Grid.Column="4" Text="ITU zone" />
<TextBox Name="ItuZoneBox" Grid.Row="1" Grid.Column="4" />
<TextBlock Classes="field" Grid.Row="2" Text="Continent" />
<TextBox Name="ContinentBox" Grid.Row="3" />
<TextBlock Classes="field" Grid.Row="2" Grid.Column="2" Text="Country prefix" />
<TextBox Name="CountryBox" Grid.Row="3" Grid.Column="2" />
<TextBlock Classes="field" Grid.Row="2" Grid.Column="4" Text="Grid square" />
<TextBox Name="GridBox" Grid.Row="3" Grid.Column="4" />
<TextBlock Classes="field" Grid.Row="4" Text="State" />
<TextBox Name="StateBox" Grid.Row="5" />
<TextBlock Classes="field" Grid.Row="4" Grid.Column="2" Text="Province" />
<TextBox Name="ProvinceBox" Grid.Row="5" Grid.Column="2" />
<TextBlock Classes="field" Grid.Row="4" Grid.Column="4" Text="ARRL section" />
<TextBox Name="SectionBox" Grid.Row="5" Grid.Column="4" />
<TextBlock Classes="field" Grid.Row="6" Text="Name" />
<TextBox Name="NameBox" Grid.Row="7" />
<TextBlock Classes="field" Grid.Row="6" Grid.Column="2" Text="Power" />
<TextBox Name="PowerBox" Grid.Row="7" Grid.Column="2" />
<TextBlock Classes="field" Grid.Row="6" Grid.Column="4" Text="Club" />
<TextBox Name="ClubBox" Grid.Row="7" Grid.Column="4" />
<TextBlock Classes="field" Grid.Row="8" Text="Check (year first licensed)" />
<TextBox Name="CheckBox" Grid.Row="9" />
<TextBlock Classes="field" Grid.Row="8" Grid.Column="2" Text="Precedence" />
<TextBox Name="PrecedenceBox" Grid.Row="9" Grid.Column="2" />
<TextBlock Classes="field" Grid.Row="8" Grid.Column="4" Text="County" />
<TextBox Name="CountyBox" Grid.Row="9" Grid.Column="4" />
</Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,14,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,54 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
namespace Nonemm.App.Dialogs;
/// The operator's own station: what goes in the sent exchange and what contest
/// rules compare a worked station against.
public sealed partial class StationDialog : Window
{
public StationDialog(StoredStation station)
{
InitializeComponent();
CallsignBox.Text = station.Callsign;
CqZoneBox.Text = station.CqZone.ToString();
ItuZoneBox.Text = station.ItuZone.ToString();
ContinentBox.Text = station.Continent;
CountryBox.Text = station.CountryPrefix;
GridBox.Text = station.GridSquare;
StateBox.Text = station.State;
ProvinceBox.Text = station.Province;
SectionBox.Text = station.ArrlSection;
NameBox.Text = station.Name;
PowerBox.Text = station.Power;
ClubBox.Text = station.Club;
CheckBox.Text = station.Check.ToString();
PrecedenceBox.Text = station.Precedence;
CountyBox.Text = station.County;
}
private void OnSave(object? sender, RoutedEventArgs e) => Close(new StoredStation
{
Callsign = (CallsignBox.Text ?? "").Trim().ToUpperInvariant(),
CqZone = Number(CqZoneBox),
ItuZone = Number(ItuZoneBox),
Continent = (ContinentBox.Text ?? "").Trim().ToUpperInvariant(),
CountryPrefix = (CountryBox.Text ?? "").Trim().ToUpperInvariant(),
GridSquare = (GridBox.Text ?? "").Trim(),
State = (StateBox.Text ?? "").Trim().ToUpperInvariant(),
Province = (ProvinceBox.Text ?? "").Trim().ToUpperInvariant(),
ArrlSection = (SectionBox.Text ?? "").Trim().ToUpperInvariant(),
Name = (NameBox.Text ?? "").Trim(),
Power = (PowerBox.Text ?? "").Trim(),
Club = (ClubBox.Text ?? "").Trim(),
Check = Number(CheckBox),
Precedence = (PrecedenceBox.Text ?? "").Trim().ToUpperInvariant(),
County = (CountyBox.Text ?? "").Trim().ToUpperInvariant(),
});
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
private static int Number(TextBox box) => int.TryParse(box.Text, out int value) ? value : 0;
}