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>
293 lines
8.6 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|