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 boxes = []; private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) }; private readonly Dictionary 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); } } }