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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user