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:
13
src/Nonemm.App/Windows/BandmapWindow.axaml
Normal file
13
src/Nonemm.App/Windows/BandmapWindow.axaml
Normal 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>
|
||||
92
src/Nonemm.App/Windows/BandmapWindow.axaml.cs
Normal file
92
src/Nonemm.App/Windows/BandmapWindow.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/Nonemm.App/Windows/CheckWindow.axaml
Normal file
7
src/Nonemm.App/Windows/CheckWindow.axaml
Normal 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>
|
||||
76
src/Nonemm.App/Windows/CheckWindow.axaml.cs
Normal file
76
src/Nonemm.App/Windows/CheckWindow.axaml.cs
Normal 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",
|
||||
};
|
||||
}
|
||||
328
src/Nonemm.App/Windows/EntryWindow.Menu.cs
Normal file
328
src/Nonemm.App/Windows/EntryWindow.Menu.cs
Normal 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);
|
||||
}
|
||||
92
src/Nonemm.App/Windows/EntryWindow.axaml
Normal file
92
src/Nonemm.App/Windows/EntryWindow.axaml
Normal 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&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>
|
||||
292
src/Nonemm.App/Windows/EntryWindow.axaml.cs
Normal file
292
src/Nonemm.App/Windows/EntryWindow.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Nonemm.App/Windows/LogRow.cs
Normal file
59
src/Nonemm.App/Windows/LogRow.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
src/Nonemm.App/Windows/LogWindow.axaml
Normal file
26
src/Nonemm.App/Windows/LogWindow.axaml
Normal 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>
|
||||
48
src/Nonemm.App/Windows/LogWindow.axaml.cs
Normal file
48
src/Nonemm.App/Windows/LogWindow.axaml.cs
Normal 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, "", "")]
|
||||
: []);
|
||||
}
|
||||
17
src/Nonemm.App/Windows/PacketWindow.axaml
Normal file
17
src/Nonemm.App/Windows/PacketWindow.axaml
Normal 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>
|
||||
61
src/Nonemm.App/Windows/PacketWindow.axaml.cs
Normal file
61
src/Nonemm.App/Windows/PacketWindow.axaml.cs
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
9
src/Nonemm.App/Windows/RefreshableWindow.cs
Normal file
9
src/Nonemm.App/Windows/RefreshableWindow.cs
Normal 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();
|
||||
}
|
||||
10
src/Nonemm.App/Windows/ScoreWindow.axaml
Normal file
10
src/Nonemm.App/Windows/ScoreWindow.axaml
Normal 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>
|
||||
87
src/Nonemm.App/Windows/ScoreWindow.axaml.cs
Normal file
87
src/Nonemm.App/Windows/ScoreWindow.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user