Files
Nonemm/src/Nonemm.App/Windows/EntryWindow.axaml.cs
Erik 2834f63c8e 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>
2026-08-27 10:56:35 +00:00

293 lines
8.6 KiB
C#

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);
}
}
}