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,13 @@
<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.BandmapWindow"
Title="Bandmap" Width="320" Height="520">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="6,6,6,2" Spacing="6">
<TextBlock Name="BandText" FontSize="12" VerticalAlignment="Center" />
<CheckBox Name="ThisBandOnly" Content="this band" IsChecked="True" FontSize="11" />
</StackPanel>
<ListBox Name="Spots" FontFamily="monospace" FontSize="13" />
</DockPanel>
</local:RefreshableWindow>

View File

@@ -0,0 +1,92 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Spotting;
namespace Nonemm.App.Windows;
/// The band as a list of stations in frequency order: cluster spots and every
/// station this log has worked. Clicking one puts the radio on it with the
/// callsign already in the entry window.
public sealed partial class BandmapWindow : RefreshableWindow
{
private readonly AppSession session;
private readonly Action<Frequency, string> tune;
public BandmapWindow(AppSession session, Action<Frequency, string> tune)
{
this.session = session;
this.tune = tune;
InitializeComponent();
Spots.SelectionChanged += (_, _) => OnPicked();
ThisBandOnly.IsCheckedChanged += (_, _) => Refresh();
session.Bandmap.Changed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(Refresh);
Refresh();
}
public override void Refresh()
{
session.Bandmap.DropOlderThan(DateTime.UtcNow);
Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency);
BandText.Text = band?.Name ?? "no band";
IReadOnlyList<Spot> spots = ThisBandOnly.IsChecked == true && band is not null
? session.Bandmap.On(band)
: session.Bandmap.All();
Spots.ItemsSource = spots.Select(BuildRow).ToList();
}
private Control BuildRow(Spot spot)
{
StackPanel row = new() { Orientation = Orientation.Horizontal, Tag = spot };
row.Children.Add(new TextBlock
{
Text = spot.Frequency.Kilohertz.ToString("0.0").PadLeft(8),
FontFamily = new FontFamily("monospace"),
Opacity = 0.75,
Margin = new Avalonia.Thickness(0, 0, 8, 0),
});
row.Children.Add(new TextBlock
{
Text = spot.Call.Text,
FontFamily = new FontFamily("monospace"),
Foreground = Verdicts.Colour(VerdictFor(spot)),
});
row.Children.Add(new TextBlock
{
Text = spot.Source == SpotSource.Log ? " worked" : $" {spot.Spotter}",
FontSize = 11,
Opacity = 0.6,
Margin = new Avalonia.Thickness(6, 2, 0, 0),
});
return row;
}
private Verdict? VerdictFor(Spot spot)
{
if (session.Logging is null)
{
return null;
}
return session.Logging.Log.Judge(new Qso
{
Id = "",
TimestampUtc = DateTime.UtcNow,
Call = spot.Call,
Frequency = spot.Frequency,
Mode = session.Logging.Mode,
ContestName = session.Logging.Contest.Name,
});
}
private void OnPicked()
{
if (Spots.SelectedItem is Control { Tag: Spot spot })
{
tune(spot.Frequency, spot.Call.Text);
}
}
}

View File

@@ -0,0 +1,7 @@
<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.CheckWindow"
Title="Check" Width="520" Height="300">
<Grid Name="Columns" ColumnDefinitions="*,*,*" Margin="6" />
</local:RefreshableWindow>

View File

@@ -0,0 +1,76 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// What the callsign being typed could be, a column per source. The columns
/// stay apart because they answer different questions, and merged into one list
/// they would all read as equally reliable.
public sealed partial class CheckWindow : RefreshableWindow
{
private readonly AppSession session;
private readonly Func<string> typed;
public CheckWindow(AppSession session, Func<string> typed)
{
this.session = session;
this.typed = typed;
InitializeComponent();
Refresh();
}
public override void Refresh()
{
Columns.Children.Clear();
if (session.Check is null)
{
return;
}
int at = 0;
foreach (CheckColumn column in session.Check.Columns(typed()))
{
Control panel = BuildColumn(column);
Grid.SetColumn(panel, at++);
Columns.Children.Add(panel);
}
}
private static Control BuildColumn(CheckColumn column)
{
StackPanel panel = new() { Margin = new Avalonia.Thickness(0, 0, 8, 0) };
panel.Children.Add(new TextBlock
{
Text = $"{Heading(column.Source)} {Count(column)}",
FontSize = 11,
Opacity = 0.7,
Margin = new Avalonia.Thickness(0, 0, 0, 4),
});
foreach (CheckCandidate candidate in column.Candidates)
{
panel.Children.Add(new TextBlock
{
Text = candidate.Call,
FontFamily = new FontFamily("monospace"),
FontSize = 14,
Foreground = Verdicts.Colour(candidate.Verdict),
HorizontalAlignment = HorizontalAlignment.Left,
});
}
return new ScrollViewer { Content = panel };
}
/// Before anything is typed the heading says how many calls the source
/// holds; once it is offering candidates it says how many.
private static string Count(CheckColumn column) =>
column.Candidates.Count > 0 ? column.Candidates.Count.ToString() : $"({column.Held})";
private static string Heading(CheckSource source) => source switch
{
CheckSource.Log => "Log",
CheckSource.Database => "Master",
_ => "Bandmap",
};
}

View File

@@ -0,0 +1,328 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Formats.Adif;
using Nonemm.Formats.Cabrillo;
using Nonemm.Storage;
namespace Nonemm.App.Windows;
/// The entry window's menu. Each item does one thing and says what happened in
/// the status line.
public sealed partial class EntryWindow
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
private async void OnNewDatabase(object? sender, RoutedEventArgs e)
{
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "New log database",
SuggestedFileName = "ham.s3db",
DefaultExtension = "s3db",
SuggestedStartLocation = await Folder(session.Paths.Databases),
});
OpenDatabaseAt(file);
}
private async void OnOpenDatabase(object? sender, RoutedEventArgs e)
{
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open log database",
AllowMultiple = false,
SuggestedStartLocation = await Folder(session.Paths.Databases),
});
OpenDatabaseAt(files.FirstOrDefault());
}
private void OpenDatabaseAt(IStorageFile? file)
{
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
try
{
session.OpenDatabase(path);
Status($"log database {Path.GetFileName(path)}");
}
catch (Exception error) when (error is IOException or InvalidOperationException)
{
Status($"could not open {path}: {error.Message}");
}
}
private async void OnNewContest(object? sender, RoutedEventArgs e)
{
if (!HasDatabase())
{
return;
}
ContestSetupDialog dialog = new(session);
ContestInstance? chosen = await dialog.ShowDialog<ContestInstance?>(this);
if (chosen is null)
{
return;
}
session.StartContest(chosen);
Status($"{chosen.ContestName} started");
}
private async void OnOpenContest(object? sender, RoutedEventArgs e)
{
if (!HasDatabase())
{
return;
}
ContestPickerDialog dialog = new(session.Store.Contests());
int? chosen = await dialog.ShowDialog<int?>(this);
if (chosen is not null)
{
session.OpenContest(chosen.Value);
}
}
private async void OnExportCabrillo(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Cabrillo",
SuggestedFileName = $"{session.Settings.Station.Callsign}.log",
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
CabrilloWriter writer = new(Logging.Contest, Logging.Me);
CabrilloHeader header = new()
{
Contest = Logging.Contest.CabrilloName,
Callsign = Logging.Me.Callsign,
OperatorCategory = Logging.Instance.OperatorCategory,
AssistedCategory = Logging.Instance.AssistedCategory,
BandCategory = Logging.Instance.BandCategory,
ModeCategory = Logging.Instance.ModeCategory,
PowerCategory = Logging.Instance.PowerCategory,
StationCategory = Logging.Instance.StationCategory,
TransmitterCategory = Logging.Instance.TransmitterCategory,
OverlayCategory = Logging.Instance.OverlayCategory,
TimeCategory = Logging.Instance.TimeCategory,
ClaimedScore = Logging.Log.TotalScore,
Club = Logging.Me.Club,
Name = Logging.Me.Name,
Operators = Logging.Instance.Operators,
Soapbox = Logging.Instance.Soapbox,
};
await File.WriteAllTextAsync(path, writer.Write(header, Logging.Log.Qsos));
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
}
private async void OnExportAdif(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export ADIF",
SuggestedFileName = $"{session.Settings.Station.Callsign}.adi",
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
await File.WriteAllTextAsync(path, new AdifWriter(Logging.Me).Write(Logging.Log.Qsos));
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
}
private async void OnImportAdif(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Import ADIF",
AllowMultiple = false,
});
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
{
return;
}
IReadOnlyList<Qso> read = AdifReader.Read(
await File.ReadAllTextAsync(path),
Logging.Contest.Name,
Logging.Instance.ContestNumber);
foreach (Qso qso in read)
{
session.Store.Add(qso);
}
session.OpenContest(Logging.Instance.ContestNumber);
Status($"{read.Count} contacts read from {Path.GetFileName(path)}");
}
private void OnExit(object? sender, RoutedEventArgs e) => Close();
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session));
private async void OnStationSettings(object? sender, RoutedEventArgs e)
{
StationDialog dialog = new(session.Settings.Station);
StoredStation? updated = await dialog.ShowDialog<StoredStation?>(this);
if (updated is not null)
{
session.Save(session.Settings with { Station = updated });
if (Logging is not null)
{
session.OpenContest(Logging.Instance.ContestNumber);
}
}
}
private async void OnRadioSettings(object? sender, RoutedEventArgs e)
{
RadioDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is null)
{
return;
}
session.Save(updated);
if (updated.RadioEnabled)
{
session.ConnectRadio();
Status($"radio: rigctld at {updated.RigctldHost}:{updated.RigctldPort}");
}
else
{
session.DisconnectRadio();
Status("radio disconnected");
}
}
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");
}
}
private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
{
NetworkDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is not null)
{
session.Save(updated);
session.ApplyNetworkSettings();
Status(updated.NetworkEnabled ? "networked with the other stations" : "networking off");
}
}
private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
await Download(
downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),
"country file");
private async void OnDownloadCallDatabase(object? sender, RoutedEventArgs e) =>
await Download(
downloader => downloader.DownloadCallDatabaseAsync(session.Paths.CallDatabaseFile),
"callsign database");
private void OnReloadSupportFiles(object? sender, RoutedEventArgs e)
{
session.ReloadSupportFiles();
Status($"country file {(session.Countries is null ? "missing" : "loaded")}, " +
$"{session.Calls.Count} callsigns");
}
private async Task Download(Func<SupportFileDownloader, Task<string>> fetch, string what)
{
Status($"fetching the {what}…");
try
{
string result = await fetch(new SupportFileDownloader(Http));
session.ReloadSupportFiles();
Status($"{what}: {result}");
}
catch (Exception error) when (error is InvalidOperationException or IOException)
{
Status(error.Message);
}
}
private void Tune(Frequency frequency, string call)
{
if (Logging is null)
{
return;
}
Logging.Tune(frequency);
_ = session.Radio?.TuneAsync(frequency);
Logging.Entry.Call = call;
SyncBoxes();
boxes[0].Focus();
Refresh();
}
private bool HasDatabase()
{
if (session.Settings.DatabasePath.Length > 0)
{
return true;
}
Status("open a log database first: File ▸ New Database");
return false;
}
private void Show<T>(Func<T> create) where T : Window
{
if (openWindows.TryGetValue(typeof(T), out Window? existing))
{
existing.Activate();
return;
}
T window = create();
openWindows[typeof(T)] = window;
window.Closed += (_, _) => openWindows.Remove(typeof(T));
window.Show(this);
}
private async Task<IStorageFolder?> Folder(string path) =>
await StorageProvider.TryGetFolderFromPathAsync(path);
}

View File

@@ -0,0 +1,92 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Windows.EntryWindow"
Title="Nonemm"
Width="620" SizeToContent="Height"
CanResize="True"
FontFamily="Inter, sans-serif">
<Window.Styles>
<Style Selector="TextBlock.label">
<Setter Property="FontSize" Value="11" />
<Setter Property="Opacity" Value="0.7" />
<Setter Property="Margin" Value="2,0,0,1" />
</Style>
<Style Selector="TextBox.entry">
<Setter Property="FontFamily" Value="monospace" />
<Setter Property="FontSize" Value="20" />
<Setter Property="Padding" Value="6,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.fkey">
<Setter Property="FontSize" Value="11" />
<Setter Property="Padding" Value="6,3" />
<Setter Property="Margin" Value="0,0,3,0" />
</Style>
</Window.Styles>
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />
<Separator />
<MenuItem Header="New _Contest…" Click="OnNewContest" />
<MenuItem Header="Open Con_test…" Click="OnOpenContest" />
<Separator />
<MenuItem Header="Export _Cabrillo…" Click="OnExportCabrillo" />
<MenuItem Header="Export _ADIF…" Click="OnExportAdif" />
<MenuItem Header="_Import ADIF…" Click="OnImportAdif" />
<Separator />
<MenuItem Header="E_xit" Click="OnExit" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="_Log" Click="OnShowLog" />
<MenuItem Header="_Check" Click="OnShowCheck" />
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
<MenuItem Header="_Score Summary" Click="OnShowScore" />
<MenuItem Header="_Packet" Click="OnShowPacket" />
</MenuItem>
<MenuItem Header="_Config">
<MenuItem Header="_Station…" Click="OnStationSettings" />
<MenuItem Header="_Radio…" Click="OnRadioSettings" />
<MenuItem Header="_Cluster…" Click="OnClusterSettings" />
<MenuItem Header="_Network…" Click="OnNetworkSettings" />
<Separator />
<MenuItem Header="Download Country _File" Click="OnDownloadCountryFile" />
<MenuItem Header="Download Check _Partial File" Click="OnDownloadCallDatabase" />
<MenuItem Header="Reload Support Files" Click="OnReloadSupportFiles" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Bottom" Padding="6,4" BorderThickness="0,1,0,0"
BorderBrush="#33808080">
<WrapPanel Name="FunctionKeys" />
</Border>
<StackPanel Margin="6">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" Margin="0,0,0,6">
<TextBlock Name="FrequencyText" FontFamily="monospace" FontSize="20" Text="14025.00" />
<TextBlock Grid.Column="1" Name="ModeText" FontFamily="monospace" FontSize="20"
Margin="10,0,0,0" Text="CW" />
<Border Grid.Column="2" Name="RunBorder" Margin="10,2" Padding="6,1" CornerRadius="3"
Background="#22808080">
<TextBlock Name="RunText" FontSize="12" Text="S&amp;P" />
</Border>
<TextBlock Grid.Column="3" Name="ContestText" FontSize="12" Opacity="0.75"
VerticalAlignment="Center" Margin="12,0,12,0" Text="no contest"
TextTrimming="CharacterEllipsis" ClipToBounds="True" />
<TextBlock Grid.Column="4" Name="ClockText" FontFamily="monospace" FontSize="20"
Text="00:00:00Z" />
</Grid>
<Grid Name="EntryGrid" ColumnDefinitions="Auto" RowDefinitions="Auto,Auto" />
<Border Name="VerdictBorder" Margin="0,6,0,0" Padding="6,3" CornerRadius="3"
Background="#22808080">
<TextBlock Name="VerdictText" FontSize="12" Text="" />
</Border>
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" Margin="2,4,0,0" Text="" />
</StackPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,292 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// The window the operator types in. Every key that does something in a contest
/// is handled here; the decisions behind them are in `LoggingSession`.
public sealed partial class EntryWindow : Window
{
private readonly AppSession session;
private readonly List<TextBox> boxes = [];
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly Dictionary<Type, Window> openWindows = [];
private bool updating;
public EntryWindow(AppSession session)
{
this.session = session;
InitializeComponent();
BuildFunctionKeys();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(BuildEntryBoxes);
clock.Tick += (_, _) => UpdateClock();
clock.Start();
Opened += (_, _) => Reopen();
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
}
private LoggingSession? Logging => session.Logging;
/// Reopens the database and contest the operator was last in.
private void Reopen()
{
try
{
int contestNumber = session.Settings.ContestNumber;
if (session.Settings.DatabasePath.Length > 0 && File.Exists(session.Settings.DatabasePath))
{
session.OpenDatabase(session.Settings.DatabasePath);
if (contestNumber > 0)
{
session.OpenContest(contestNumber);
}
}
}
catch (Exception e) when (e is IOException or InvalidOperationException or KeyNotFoundException)
{
Status($"could not reopen the last log: {e.Message}");
}
BuildEntryBoxes();
}
private void BuildEntryBoxes()
{
EntryGrid.Children.Clear();
EntryGrid.ColumnDefinitions.Clear();
boxes.Clear();
if (Logging is null)
{
ContestText.Text = "no contest — File ▸ New Contest";
Refresh();
return;
}
AddBox("Call", 12, 0);
for (int at = 0; at < Logging.Entry.Exchange.Count; at++)
{
ExchangeField field = Logging.Entry.Exchange[at];
AddBox(field.Label, field.Width, at + 1);
}
boxes[0].Focus();
Refresh();
}
private void AddBox(string label, int width, int index)
{
EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
TextBlock caption = new() { Text = label };
caption.Classes.Add("label");
Grid.SetColumn(caption, index);
Grid.SetRow(caption, 0);
EntryGrid.Children.Add(caption);
TextBox box = new()
{
Width = (width * 13) + 20,
Margin = new Avalonia.Thickness(0, 0, 6, 0),
HorizontalAlignment = HorizontalAlignment.Left,
};
box.Classes.Add("entry");
box.TextChanged += (_, _) => OnBoxChanged(index, box);
box.GotFocus += (_, _) => Logging?.Entry.FocusOn(index);
Grid.SetColumn(box, index);
Grid.SetRow(box, 1);
EntryGrid.Children.Add(box);
boxes.Add(box);
}
private void OnBoxChanged(int index, TextBox box)
{
if (updating || Logging is null)
{
return;
}
Logging.Entry[index] = box.Text ?? "";
Refresh();
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
if (Logging is null)
{
return;
}
switch (e.Key)
{
case Key.Space when FocusedIsEntryBox():
e.Handled = true;
MoveFocus(forward: true);
break;
case Key.Enter:
e.Handled = true;
OnEnter();
break;
case Key.Escape:
e.Handled = true;
Logging.Wipe();
SyncBoxes();
boxes[0].Focus();
break;
case Key.Tab when FocusedIsEntryBox():
e.Handled = true;
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
break;
case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
ToggleRun();
break;
}
}
private void OnEnter()
{
if (Logging is null)
{
return;
}
Frequency? qsy = Logging.PendingQsy();
if (qsy is not null)
{
Logging.Tune(qsy.Value);
_ = session.Radio?.TuneAsync(qsy.Value);
Logging.Entry.Call = "";
SyncBoxes();
boxes[0].Focus();
return;
}
if (!Logging.Entry.IsComplete)
{
MoveFocus(forward: true);
return;
}
try
{
Qso logged = Logging.LogContact();
Status($"logged {logged.Call} for {logged.Points} points");
}
catch (Exception e) when (e is InvalidOperationException or IOException)
{
Status($"could not log the contact: {e.Message}");
return;
}
SyncBoxes();
boxes[0].Focus();
}
private void MoveFocus(bool forward)
{
if (Logging is null || boxes.Count == 0)
{
return;
}
if (forward)
{
Logging.Entry.Advance();
}
else
{
Logging.Entry.Retreat();
}
boxes[Logging.Entry.Focus].Focus();
boxes[Logging.Entry.Focus].CaretIndex = boxes[Logging.Entry.Focus].Text?.Length ?? 0;
}
private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused);
private void ToggleRun()
{
if (Logging is null)
{
return;
}
Logging.IsRunning = !Logging.IsRunning;
Refresh();
}
private void SyncBoxes()
{
if (Logging is null)
{
return;
}
updating = true;
for (int at = 0; at < boxes.Count; at++)
{
boxes[at].Text = Logging.Entry[at];
}
updating = false;
}
private void Refresh()
{
if (Logging is null)
{
VerdictText.Text = "";
return;
}
FrequencyText.Text = Logging.Frequency.Kilohertz.ToString("0.00");
ModeText.Text = Logging.Mode.Name;
RunText.Text = Logging.IsRunning ? "RUN" : "S&P";
RunBorder.Background = Logging.IsRunning ? Verdicts.Worth : new SolidColorBrush(Color.FromArgb(0x22, 0x80, 0x80, 0x80));
ContestText.Text = ContestLine();
Verdict? verdict = Logging.Verdict();
VerdictBorder.Background = Verdicts.Colour(verdict);
VerdictText.Text = VerdictLine(verdict);
VerdictText.Foreground = Brushes.White;
foreach (Window window in openWindows.Values)
{
(window as RefreshableWindow)?.Refresh();
}
}
private string ContestLine()
{
if (Logging is null)
{
return "";
}
string mults = string.Join(
" ",
Logging.Contest.MultiplierNames.Select(
(name, at) => $"{name} {Logging.Log.Tally.MultiplierCount(at + 1)}"));
return $"{Logging.Contest.DisplayName} · {Logging.Log.Tally.Qsos} Q · " +
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}";
}
private string VerdictLine(Verdict? verdict)
{
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
{
return $"sending {Logging?.SentNumber}";
}
string country = Logging.Country() is { } found
? $"{found.Entity.Name} · {found.Continent} · CQ {found.CqZone} · ITU {found.ItuZone}"
: "unknown country";
return $"{country} — {Verdicts.Describe(verdict)}";
}
private void UpdateClock() => ClockText.Text = DateTime.UtcNow.ToString("HH:mm:ss") + "Z";
private void Status(string text) => StatusText.Text = text;
private void BuildFunctionKeys()
{
FunctionKeys.Children.Clear();
foreach ((string key, string label, _) in Messages.Defaults)
{
Button button = new() { Content = $"{key} {label}" };
button.Classes.Add("fkey");
FunctionKeys.Children.Add(button);
}
}
}

View File

@@ -0,0 +1,59 @@
using Avalonia.Media;
using Nonemm.Contests;
using Nonemm.Core;
namespace Nonemm.App.Windows;
/// One line of the log window. It carries the colour so the log is coloured by
/// the same scorer as the entry window and the bandmap.
public sealed record LogRow(Qso Qso, Verdict? Verdict)
{
public string Time => Qso.TimestampUtc.ToString("MM-dd HH:mm");
public string Call => Qso.Call.Text;
public string Frequency => Qso.Frequency.Kilohertz.ToString("0.0");
public string Mode => Qso.Mode.Name;
public string Sent => $"{Qso.SentReport} {SentExchange()}".Trim();
public string Received => $"{Qso.ReceivedReport} {ReceivedExchange()}".Trim();
public string Country => Qso.CountryPrefix;
public int Points => Qso.Points;
public string Multipliers => string.Concat(
Qso.IsMultiplier1 ? "1" : "",
Qso.IsMultiplier2 ? "2" : "",
Qso.IsMultiplier3 ? "3" : "");
public string Operator => Qso.Operator;
public IBrush Colour => Verdicts.Colour(Verdict);
private string SentExchange() =>
Qso.SentNumber > 0 ? Qso.SentNumber.ToString() : "";
private string ReceivedExchange()
{
List<string> parts = [];
if (Qso.ReceivedNumber > 0)
{
parts.Add(Qso.ReceivedNumber.ToString());
}
if (Qso.Zone > 0)
{
parts.Add(Qso.Zone.ToString());
}
foreach (string value in new[] { Qso.Section, Qso.Exchange1, Qso.Name, Qso.GridSquare, Qso.MiscText })
{
if (value.Length > 0)
{
parts.Add(value);
}
}
return string.Join(' ', parts);
}
}

View File

@@ -0,0 +1,26 @@
<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.LogWindow"
Title="Log" Width="900" Height="420">
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
x:DataType="local:LogRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Time" Binding="{Binding Time}" Width="125" />
<DataGridTextColumn Header="Freq" Binding="{Binding Frequency}" Width="95" />
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" Width="60" />
<DataGridTextColumn Header="Call" Binding="{Binding Call}" Width="120"
Foreground="{Binding Colour}" />
<DataGridTextColumn Header="Sent" Binding="{Binding Sent}" Width="100" />
<DataGridTextColumn Header="Received" Binding="{Binding Received}" Width="160" />
<DataGridTextColumn Header="Cty" Binding="{Binding Country}" Width="70" />
<DataGridTextColumn Header="Pts" Binding="{Binding Points}" Width="60" />
<DataGridTextColumn Header="Mult" Binding="{Binding Multipliers}" Width="70" />
<DataGridTextColumn Header="Op" Binding="{Binding Operator}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</local:RefreshableWindow>

View File

@@ -0,0 +1,48 @@
using Nonemm.Core;
namespace Nonemm.App.Windows;
/// The contacts of the contest in progress, newest last.
public sealed partial class LogWindow : RefreshableWindow
{
private readonly AppSession session;
public LogWindow(AppSession session)
{
this.session = session;
InitializeComponent();
Refresh();
}
public override void Refresh()
{
if (session.Logging is null)
{
Rows.ItemsSource = Array.Empty<LogRow>();
SummaryText.Text = "no contest is open";
return;
}
List<LogRow> rows = session.Logging.Log.Qsos
.Select(q => new LogRow(q, VerdictFor(q)))
.ToList();
Rows.ItemsSource = rows;
if (rows.Count > 0)
{
Rows.ScrollIntoView(rows[^1], null);
}
SummaryText.Text =
$"{session.Logging.Log.Tally.Qsos} contacts · {session.Logging.Log.Tally.Points} points · " +
$"{session.Logging.Log.Tally.TotalMultipliers} multipliers · score {session.Logging.Log.TotalScore:N0}";
}
/// A logged contact is coloured by what it turned out to be worth, not by
/// judging it again as if it were new.
private static Contests.Verdict VerdictFor(Qso qso) =>
new(
IsDupe: false,
Points: qso.Points,
NewMultipliers: qso.IsMultiplier1 || qso.IsMultiplier2 || qso.IsMultiplier3
? [new Contests.Multiplier(1, "", "")]
: []);
}

View File

@@ -0,0 +1,17 @@
<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>

View File

@@ -0,0 +1,61 @@
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 = "";
}
}

View File

@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace Nonemm.App.Windows;
/// A window the entry window tells to redraw when the log changes.
public abstract class RefreshableWindow : Window
{
public abstract void Refresh();
}

View File

@@ -0,0 +1,10 @@
<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.ScoreWindow"
Title="Score summary" Width="480" SizeToContent="Height">
<StackPanel Margin="10" Spacing="6">
<Grid Name="Table" />
<TextBlock Name="TotalText" FontSize="14" Margin="0,6,0,0" />
</StackPanel>
</local:RefreshableWindow>

View File

@@ -0,0 +1,87 @@
using Avalonia.Controls;
using Nonemm.Core;
namespace Nonemm.App.Windows;
/// Contacts, points and multipliers band by band.
public sealed partial class ScoreWindow : RefreshableWindow
{
private readonly AppSession session;
public ScoreWindow(AppSession session)
{
this.session = session;
InitializeComponent();
Refresh();
}
public override void Refresh()
{
Table.Children.Clear();
Table.ColumnDefinitions.Clear();
Table.RowDefinitions.Clear();
if (session.Logging is null)
{
TotalText.Text = "no contest is open";
return;
}
List<Band> bands = session.Logging.Log.Qsos
.Select(q => q.Band)
.OfType<Band>()
.Distinct()
.OrderBy(b => b.MegahertzLabel)
.ToList();
foreach (string _ in new[] { "band", "qsos", "points", "mults" })
{
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
}
AddRow(0, "Band", "QSOs", "Points", "Mults", header: true);
int row = 1;
foreach (Band band in bands)
{
IReadOnlyList<Qso> onBand = session.Logging.Log.Qsos.Where(q => q.Band == band).ToList();
AddRow(
row++,
band.Name,
onBand.Count.ToString(),
onBand.Sum(q => q.Points).ToString(),
onBand.Sum(MultiplierCount).ToString());
}
AddRow(
row,
"Total",
session.Logging.Log.Tally.Qsos.ToString(),
session.Logging.Log.Tally.Points.ToString(),
session.Logging.Log.Tally.TotalMultipliers.ToString(),
header: true);
TotalText.Text = $"Claimed score {session.Logging.Log.TotalScore:N0}";
}
private static int MultiplierCount(Qso qso) =>
(qso.IsMultiplier1 ? 1 : 0) + (qso.IsMultiplier2 ? 1 : 0) + (qso.IsMultiplier3 ? 1 : 0);
private void AddRow(int row, string band, string qsos, string points, string mults, bool header = false)
{
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
string[] cells = [band, qsos, points, mults];
for (int column = 0; column < cells.Length; column++)
{
TextBlock text = new()
{
Text = cells[column],
FontFamily = new Avalonia.Media.FontFamily("monospace"),
FontSize = 13,
Opacity = header ? 1.0 : 0.85,
Margin = new Avalonia.Thickness(0, 2, 8, 2),
};
Grid.SetRow(text, row);
Grid.SetColumn(text, column);
Table.Children.Add(text);
}
}
}