Files
Nonemm/src/Nonemm.App/Windows/EntryWindow.axaml.cs
ericek111 ff46cb00ed Put N1MM's status bar along the bottom of the entry window
Three parts, as N1MM has them: what just happened on the left, the contacts
and multipliers in the middle, the score on the right.

The left says where the call being typed is from, in N1MM's own shape:
OM: EU -> Slovak Republic, Zn 15. The zone is the one the contest counts in, so
Contest.UsesItuZones answers that the way N1MM's ZoneType does, and IARU
overrides it. A message from something the operator just did stands there until
the next keystroke.

The middle is N1MM's breakdown: the contacts and then one number per multiplier
the contest counts, separated by slashes, and No Score before the first
contact. Points are not in it, because they are not in N1MM's; the score window
has them.

Those numbers were in the line at the top of the window, which now carries the
contest name alone, and the country was in the bar under the callsign box,
which now says only what working the call would bring. Neither is said twice.

Looked at under Xvfb: the country line for OM3KFF and then G4AB, 10/7/8 and
300 beside them, and a Mark message standing in the left until the next
keystroke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
2026-08-30 22:46:13 +00:00

1084 lines
36 KiB
C#

using System.Diagnostics;
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.App.Theming;
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 int radioNumber;
private EntryWindow? secondRadio;
private readonly List<TextBox> boxes = [];
private readonly List<Button> functionButtons = [];
private const double MilesPerKilometre = 0.621371;
/// The width of the function key panel, in buttons. N1MM's is six wide and
/// two deep; the XAML holds the same six columns.
private const int FunctionKeyColumns = 6;
private static readonly IBrush Dark = new SolidColorBrush(Color.FromArgb(0x33, 0x80, 0x80, 0x80));
private bool sending;
private TextBlock? callMark;
private TextBox? nameBox;
private TextBox? commentBox;
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly Dictionary<Type, Window> openWindows = [];
private bool updating;
/// What the last thing the operator did had to say. It stands in the status
/// bar until the next keystroke, and then the country of the call being
/// typed takes the place of it.
private string message = "";
public EntryWindow(AppSession session, int radioNumber = 1)
{
this.session = session;
this.radioNumber = radioNumber;
InitializeComponent();
BuildFunctionKeys();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
{
BuildEntryBoxes();
ShowSecondRadio();
});
Themes.Changed += OnThemeChanged;
Closed += (_, _) => Themes.Changed -= OnThemeChanged;
clock.Tick += (_, _) => UpdateClock();
clock.Start();
session.ActiveRadioChanged += (_, number) =>
Dispatcher.UIThread.Post(() => FollowActiveRadio(number));
// only the window for radio 1 opens the log and the connections; the
// second window is another view of the same session
Opened += (_, _) => Reopen();
if (radioNumber > 1)
{
MainMenu.IsVisible = false;
}
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
}
private void OnThemeChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Refresh);
/// Resolved every time rather than held, because opening a contest builds
/// new positions.
private OperatingPosition? Logging =>
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
/// Reopens the database and contest the operator was last in.
private void Reopen()
{
if (radioNumber > 1)
{
BuildEntryBoxes();
return;
}
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}");
}
StartConnections();
BuildEntryBoxes();
ShowSecondRadio();
}
/// A second radio gets its own entry window: the two share one log and one
/// score, but what is typed on each belongs to that radio.
private void ShowSecondRadio()
{
if (radioNumber > 1)
{
return;
}
bool wanted = session.Positions.Count > 1;
if (wanted && secondRadio is null)
{
secondRadio = new EntryWindow(session, 2);
secondRadio.Closed += (_, _) => secondRadio = null;
secondRadio.Show(this);
}
else if (!wanted && secondRadio is not null)
{
Title = "Nonemm";
secondRadio.Close();
secondRadio = null;
}
}
/// The keyboard follows the operator to the other radio.
private void FollowActiveRadio(int number)
{
if (number != radioNumber || Logging is null)
{
return;
}
Activate();
boxes.ElementAtOrDefault(Logging.Entry.Focus)?.Focus();
}
/// Brings up whatever the operator had connected last time. Each is
/// reported on its own so one that fails does not stop the others.
private void StartConnections()
{
foreach ((string what, Action start) in Connections())
{
try
{
start();
}
catch (Exception e) when (e is InvalidOperationException or IOException or UnauthorizedAccessException)
{
Status($"could not start the {what}: {e.Message}");
}
}
}
private IEnumerable<(string What, Action Start)> Connections()
{
if (session.Settings.Radios.Any(r => r.IsEnabled))
{
yield return ("radios", session.ConnectRadios);
}
if (session.Settings.ClusterEnabled)
{
yield return ("cluster", session.ConnectCluster);
}
if (session.Settings.NetworkEnabled)
{
yield return ("station network", session.ApplyNetworkSettings);
}
if (session.Settings.KeyerKind != "none")
{
yield return ("keyer", session.ApplyKeyerSettings);
}
if (session.Settings.So2rBoxPort.Length > 0)
{
yield return ("SO2R box", session.ApplySo2rSettings);
}
}
private void BuildEntryBoxes()
{
EntryGrid.Children.Clear();
EntryGrid.ColumnDefinitions.Clear();
boxes.Clear();
callMark = null;
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);
}
AddNotes();
boxes[0].Focus();
Refresh();
}
/// N1MM keeps a name and a comment beside the exchange, whatever the
/// contest is. Neither is part of the exchange: they fill the log columns
/// of the same names, and a contest that exchanges a name already has one.
private void AddNotes()
{
nameBox = Logging!.Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name)
? null
: AddNote("Name", 8, EntryGrid.ColumnDefinitions.Count);
commentBox = AddNote("Comment", 12, EntryGrid.ColumnDefinitions.Count);
}
private TextBox AddNote(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 += (_, _) => OnNoteChanged();
Grid.SetColumn(box, index);
Grid.SetRow(box, 1);
EntryGrid.Children.Add(box);
return box;
}
private void OnNoteChanged()
{
if (updating || Logging is null)
{
return;
}
Logging.OtherName = nameBox?.Text ?? "";
Logging.Comment = commentBox?.Text ?? "";
}
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);
if (index == 0)
{
AddCallMark(index);
}
}
/// The mark N1MM puts at the right-hand end of the callsign box: a tick
/// when the call is in the check partial file, a question mark while it is
/// not. It sits over the box, in the same colour as the call.
private void AddCallMark(int index)
{
callMark = new TextBlock
{
FontSize = 21,
FontWeight = FontWeight.Bold,
Margin = new Avalonia.Thickness(0, 0, 12, 0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
IsHitTestVisible = false,
};
Grid.SetColumn(callMark, index);
Grid.SetRow(callMark, 1);
EntryGrid.Children.Add(callMark);
}
private void OnBoxChanged(int index, TextBox box)
{
if (updating || Logging is null)
{
return;
}
// the callsign and the exchange go out in upper case, and N1MM puts
// them in the box that way as they are typed
string typed = box.Text ?? "";
string upper = typed.ToUpperInvariant();
if (upper != typed)
{
int caret = box.CaretIndex;
updating = true;
box.Text = upper;
box.CaretIndex = caret;
updating = false;
}
Logging.Entry[index] = upper;
if (index == 0 && Logging.Entry.Call.Trim().Length == 0)
{
ResetEsm();
}
// typing answers whatever the status bar was reporting
message = "";
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 when Logging.Editing is not null:
e.Handled = true;
Logging.LeaveQuickEdit();
SyncBoxes();
Refresh();
boxes[0].Focus();
Status("quick edit left");
break;
case Key.Escape:
e.Handled = true;
sending = false;
session.Alternating?.Stop();
_ = session.Keyer?.AbortAsync();
Logging.Wipe();
ResetEsm();
SyncBoxes();
boxes[0].Focus();
break;
case Key.OemPlus when IsEsmOn && FocusedIsEntryBox():
e.Handled = true;
RepeatLastMessage();
break;
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift):
e.Handled = true;
session.ToggleListenToBoth();
Status(session.IsListeningToBoth ? "both radios" : $"radio {session.ActiveRadioNumber}");
break;
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
session.SwapRadio();
break;
case Key.Tab when FocusedIsEntryBox() || FocusedIsNoteBox():
e.Handled = true;
MoveToNextBox(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
break;
case Key.Z when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
_ = OpenQtcWindow();
break;
case Key.B when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
ToggleAlternatingCq();
break;
case Key.N when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnAddNote(this, new RoutedEventArgs());
break;
case Key.Q when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnQuickEditBack(this, new RoutedEventArgs());
break;
case Key.A when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnQuickEditForward(this, new RoutedEventArgs());
break;
case Key.U when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnBumpNumber(this, new RoutedEventArgs());
break;
case Key.F when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnFind(this, new RoutedEventArgs());
break;
case Key.W when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnWipe(this, new RoutedEventArgs());
break;
case Key.L when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnShowLog(this, new RoutedEventArgs());
break;
case Key.M when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnToggleEsm(this, new RoutedEventArgs());
break;
case Key.Y when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnEditLastContact(this, new RoutedEventArgs());
break;
case Key.P when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
OnSpotIt(this, new RoutedEventArgs());
break;
case Key.G when e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
StackAnother();
break;
case Key.G when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
GrabStacked();
break;
case Key.D when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
DropStacked();
break;
case Key.M when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
OnMark(this, new RoutedEventArgs());
break;
case Key.O when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
OnStore(this, new RoutedEventArgs());
break;
case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
ToggleRun();
break;
case >= Key.F1 and <= Key.F12:
e.Handled = true;
RunFunctionKey(e.Key - Key.F1);
break;
}
}
private void OnEnter()
{
if (Logging is null)
{
return;
}
if (Logging.Editing is not null)
{
SaveQuickEdit();
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 (IsEsmOn)
{
_ = RunEsmAsync();
return;
}
if (!Logging.Entry.IsComplete)
{
MoveFocus(forward: true);
return;
}
LogContact();
}
/// Logs what is in the boxes, which is what Enter and N1MM's `{LOG}` macro
/// both do.
private void SaveQuickEdit()
{
if (Logging?.SaveQuickEdit() is not { } saved)
{
return;
}
Status($"{saved.Call.Text} updated");
SyncBoxes();
Refresh();
boxes[0].Focus();
}
private void LogContact()
{
if (Logging is null)
{
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;
}
ResetEsm();
SyncBoxes();
boxes[0].Focus();
}
/// What space does: the callsign box and the exchange boxes, stepping over
/// the reports and over a box this station is not asked for, and round to
/// the callsign again. This is N1MM's NextTab.
private void MoveFocus(bool forward)
{
if (Logging is null || boxes.Count == 0)
{
return;
}
if (forward)
{
// leaving the callsign box is the moment the call is settled, which
// is when the call history can say what the exchange will be and
// the reports can be filled in
if (Logging.Entry.Focus == 0)
{
bool filled = TakeFramedCall();
filled |= Logging.FillReports();
filled |= Logging.FillFromHistory();
filled |= FillNameFromHistory();
if (filled)
{
SyncBoxes();
}
}
Logging.MoveFocus(forward: true);
}
else
{
Logging.MoveFocus(forward: false);
}
FocusEntryBox();
}
/// What tab does: every box in turn, reports, name and comment included,
/// and round again. N1MM does not touch the tab key, so tab there walks
/// the boxes in the order they are laid out, and this walks the same ones.
private void MoveToNextBox(bool forward)
{
if (Logging is null)
{
return;
}
List<TextBox> all = [.. boxes, .. new[] { nameBox, commentBox }.OfType<TextBox>()];
int at = all.FindIndex(box => box.IsFocused);
int next = ((at < 0 ? 0 : at) + (forward ? 1 : -1) + all.Count) % all.Count;
if (next < boxes.Count)
{
Logging.Entry.FocusOn(next);
FocusEntryBox();
return;
}
all[next].Focus();
all[next].CaretIndex = all[next].Text?.Length ?? 0;
}
private void FocusEntryBox()
{
if (Logging is null)
{
return;
}
boxes[Logging.Entry.Focus].Focus();
boxes[Logging.Entry.Focus].CaretIndex = boxes[Logging.Entry.Focus].Text?.Length ?? 0;
}
/// The call history knows the names in a lot of contests, and the name box
/// is where N1MM shows it when the contest does not exchange one.
private bool FillNameFromHistory()
{
if (Logging is null || nameBox is null || Logging.OtherName.Trim().Length > 0)
{
return false;
}
string known = Logging.Session.History.Find(Logging.Entry.Call)?.Name ?? "";
if (known.Length == 0)
{
return false;
}
Logging.OtherName = known;
return true;
}
private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused);
private bool FocusedIsNoteBox() => nameBox?.IsFocused == true || commentBox?.IsFocused == true;
/// The buttons N1MM puts under its function keys. Each one does what its
/// key does, so there is one place deciding what happens.
private void OnStopSending(object? sender, RoutedEventArgs e)
{
sending = false;
session.Alternating?.Stop();
_ = session.Keyer?.AbortAsync();
Status("stopped sending");
}
private void OnWipe(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
return;
}
Logging.Wipe();
ResetEsm();
SyncBoxes();
boxes[0].Focus();
Refresh();
}
private void OnLogIt(object? sender, RoutedEventArgs e) => LogContact();
/// Opens the callsign's page in a browser, as N1MM's QRZ button does.
private void OnLookUpCall(object? sender, RoutedEventArgs e)
{
if (Logging is null || Logging.Entry.Call.Trim() is not { Length: > 0 } call)
{
Status("no callsign to look up");
return;
}
try
{
Process.Start(new ProcessStartInfo($"https://www.qrz.com/db/{call}")
{
UseShellExecute = true,
});
}
catch (Exception error) when (error is System.ComponentModel.Win32Exception or IOException)
{
Status($"could not open a browser: {error.Message}");
}
}
private void OnRun(object? sender, RoutedEventArgs e) => SetRunning(true);
private void OnSearch(object? sender, RoutedEventArgs e) => SetRunning(false);
private void SetRunning(bool running)
{
if (Logging is null)
{
return;
}
Logging.IsRunning = running;
boxes[Logging.Entry.Focus].Focus();
Refresh();
}
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];
}
if (nameBox is not null)
{
nameBox.Text = Logging.OtherName;
}
if (commentBox is not null)
{
commentBox.Text = Logging.Comment;
}
updating = false;
}
private void Refresh()
{
ShowFunctionKeys();
ShowBandButtons();
if (Logging is null)
{
VerdictText.Text = "";
return;
}
foreach (TextBox box in boxes)
{
if (Logging.Editing is null)
{
// clearing rather than assigning null: a null brush would paint
// no text at all
box.ClearValue(BackgroundProperty);
box.ClearValue(ForegroundProperty);
}
else
{
box.Background = Themes.Brush(Themes.Current.QuickEdit);
box.Foreground = Themes.Brush(Themes.Current.FormForeground);
}
}
FrequencyText.Text = Logging.TransmitFrequency.Hertz > 0
? $"{Logging.Frequency.Kilohertz:0.00} ▸ {Logging.TransmitFrequency.Kilohertz:0.0}"
: Logging.Frequency.Kilohertz.ToString("0.00");
ModeText.Text = Logging.Mode.Name;
RunButton.Classes.Set("here", Logging.IsRunning);
SearchButton.Classes.Set("here", !Logging.IsRunning);
ContestText.Text = ContestLine();
Title = TitleLine();
ShowCallFrame();
PathText.Text = PathLine();
UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? "";
ShowLights();
ShowScore();
StatusText.Text = message.Length > 0 ? message : CountryLine();
Verdict? verdict = Logging.Verdict();
ShowCallColour(verdict);
VerdictBorder.Background = Verdicts.Background(verdict);
VerdictText.Text = VerdictLine(verdict);
VerdictText.Foreground = Themes.Brush(Themes.Current.FormForeground);
foreach (Window window in openWindows.Values)
{
(window as RefreshableWindow)?.Refresh();
}
}
/// N1MM colours the callsign itself rather than the box: blue for a station
/// worth working, red for one multiplier, green for more than one, grey for
/// a dupe. A call too short to judge stays blue.
private void ShowCallColour(Verdict? verdict)
{
if (Logging is null || boxes.Count == 0)
{
return;
}
string call = Logging.Entry.Call.Trim();
IBrush colour = call.Length < 3 || call.All(char.IsAsciiDigit)
? Verdicts.Worth
: Verdicts.Colour(verdict);
boxes[0].Foreground = colour;
ShowCallMark(call, colour);
}
/// The tick and the question mark, which N1MM only shows while the check
/// window is open, and only while the call is short enough to leave room
/// for them.
private void ShowCallMark(string call, IBrush colour)
{
if (callMark is null)
{
return;
}
bool known = call.Length > 0 && session.Calls.Holds(call);
callMark.Text = known ? "✓" : "?";
callMark.Foreground = colour;
callMark.IsVisible = Logging?.Editing is null
&& openWindows.ContainsKey(typeof(CheckWindow))
&& call.Length is >= 3 and <= 9
&& !call.All(char.IsAsciiDigit);
}
/// The contacts and multipliers so far, and the score. N1MM keeps them in
/// the status bar in this shape: the contacts, then one number per
/// multiplier the contest counts, separated by slashes.
private void ShowScore()
{
if (Logging is null)
{
BreakdownText.Text = "";
ScoreText.Text = "";
return;
}
if (Logging.Log.Qsos.Count == 0)
{
BreakdownText.Text = "No Score";
ScoreText.Text = "";
return;
}
BreakdownText.Text = string.Join(
"/",
new[] { Logging.Log.Tally.Qsos }.Concat(
Logging.Contest.MultiplierNames.Select(
(_, at) => Logging.Log.Tally.MultiplierCount(at + 1))));
ScoreText.Text = $"{Logging.Log.TotalScore:N0}";
}
private string ContestLine()
{
if (Logging is null)
{
return "";
}
string radio = session.Radios.Count > 1 ? $" · radio {session.ActiveRadioNumber}" : "";
return $"{Logging.Contest.DisplayName}{radio}";
}
/// N1MM's status bar says where the call being typed is from, in this
/// shape: `OM: EU -> Slovak Republic, Zn 15`. The zone is the one the
/// contest counts in. A message from something the operator just did takes
/// the place of it until the next keystroke.
private string CountryLine()
{
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
{
return "";
}
if (Logging.Country() is not { } found)
{
return "unknown country";
}
int zone = Logging.Contest.UsesItuZones ? found.ItuZone : found.CqZone;
return $"{found.Entity.PrimaryPrefix}: {found.Continent} -> {found.Entity.Name}, Zn {zone}";
}
/// The two lights N1MM has beside the run switch: the left one says a radio
/// is answering, the right one that something is going out on the air.
private void ShowLights()
{
bool radio = session.Radios.Any(r => r.Number == radioNumber && r.IsConnected);
RadioLight.Fill = radio ? Themes.Brush(Themes.Current.RadioLight) : Dark;
TransmitLight.Fill = sending ? Themes.Brush(Themes.Current.TransmitLight) : Dark;
}
/// What N1MM writes in its title bar: the frequency, the mode, whether the
/// frequency came from a radio or was typed, and which radio this window
/// is.
private string TitleLine()
{
if (Logging is null)
{
return "Nonemm";
}
string source = session.Radios.Any(r => r.Number == radioNumber && r.IsConnected)
? "rigctld"
: "manual";
string radio = session.Positions.Count > 1 ? $" — radio {radioNumber}" : "";
return $"{Logging.Frequency.Kilohertz:0.00} {Logging.Mode.Name} {source}{radio}";
}
/// Where the station being worked is: the beam heading, the heading the
/// long way round, the distance, and the sun there. N1MM writes the same
/// line under its entry window.
private string PathLine()
{
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
{
return "";
}
if (StationPath.For(Logging, DateTime.UtcNow) is not { } path)
{
return WhyNoPath();
}
string sun = path.Sunrise is { } rise && path.Sunset is { } set
? $" · SR {rise:HH:mm}Z SS {set:HH:mm}Z"
: " · no sunrise or sunset today";
return $"Hdg {path.Heading:0}° · LP {path.LongPath:0}° · "
+ $"{path.DistanceKm:N0} km · {path.DistanceKm * MilesPerKilometre:N0} mi{sun}";
}
/// A blank line where a bearing should be is a puzzle. This says which of
/// the two ends could not be placed and what to do about it.
private string WhyNoPath()
{
if (session.Countries is null)
{
return "no bearing — download the country file, in Config ▸ Download Country File";
}
return Logging?.Me.GridSquare.Trim().Length == 0
&& session.Countries.Find(Logging.Me.Callsign) is null
? "no bearing — your own station has no position, so set your grid in Config ▸ Station"
: "no bearing — the country file does not place this callsign";
}
/// What working the call being typed would bring. Where it is from is in
/// the status bar rather than here, so neither says it twice.
private string VerdictLine(Verdict? verdict) =>
Logging is null || Logging.Entry.Call.Trim().Length == 0
? $"sending {Logging?.SentNumber}"
: Verdicts.Describe(verdict);
private void UpdateClock() => ClockText.Text = DateTime.UtcNow.ToString("HH:mm:ss") + "Z";
/// What just happened, in the left of the status bar.
private void Status(string text)
{
message = text;
StatusText.Text = text;
}
/// The labels come from the message file, so what the buttons say is what
/// the operator wrote. Right-clicking one opens the editor, as it does on
/// the telnet window's buttons. Twelve keys in two rows of six, as N1MM
/// lays them out.
private void BuildFunctionKeys()
{
FunctionKeys.Children.Clear();
functionButtons.Clear();
for (int at = 0; at < MessageFile.KeyCount; at++)
{
int index = at;
Button button = new();
button.Classes.Add("fkey");
button.Click += (_, _) => RunFunctionKey(index);
button.PointerReleased += (_, e) =>
{
if (e.InitialPressMouseButton == MouseButton.Right)
{
_ = EditMessages();
}
};
Grid.SetColumn(button, at % FunctionKeyColumns);
Grid.SetRow(button, at / FunctionKeyColumns);
FunctionKeys.Children.Add(button);
functionButtons.Add(button);
}
}
/// F11 spots the station being worked, F12 wipes the entry, and the rest
/// send their message.
private void RunFunctionKey(int index)
{
if (Logging is null)
{
return;
}
switch (index)
{
case 10:
SpotCurrentCall();
return;
case 11:
Logging.Wipe();
ResetEsm();
SyncBoxes();
boxes[0].Focus();
return;
default:
// N1MM reserves F1 for CQ: pressing it while searching starts
// running
if (index == Esm.CallCq)
{
Logging.IsRunning = true;
RememberCqFrequency();
}
SendMessage(index);
Refresh();
return;
}
}
/// WAE QTC traffic, on Ctrl+Z as in N1MM. The contact is logged first if it
/// is still in the boxes: the traffic belongs to a station that has been
/// worked.
private async Task OpenQtcWindow()
{
if (Logging is null)
{
return;
}
QtcTraffic traffic = new(Logging);
if (!traffic.IsWaeContest)
{
Status("QTC traffic is a WAE thing");
return;
}
if (Logging.Entry.Call.Trim().Length > 0 && Logging.Entry.IsComplete)
{
OnEnter();
}
string typed = Logging.Entry.Call.Trim();
Callsign station = Callsign.Parse(
typed.Length > 0 ? typed : Logging.Log.Qsos.LastOrDefault()?.Call.Text ?? "");
QtcDirection direction = traffic.DirectionFor(station, out string why);
if (direction == QtcDirection.None)
{
Status(why);
return;
}
if (direction is QtcDirection.Send && traffic.ToSend(station).Count == 0)
{
Status("nothing left to report to " + station.Text);
return;
}
QtcWindow window = new(session, Logging, station, direction);
bool saved = await window.ShowDialog<bool>(this);
Logging.Wipe();
SyncBoxes();
boxes[0].Focus();
Refresh();
Status(saved
? $"QTC traffic with {station.Text} logged"
: $"QTC traffic with {station.Text} left alone");
}
/// Alternating CQ: CQ on this radio, then the other as each message ends.
/// N1MM calls it dueling CQs and puts it on the same key.
private void ToggleAlternatingCq()
{
if (session.Alternating is not { } alternating)
{
Status("no keyer — Config ▸ Keyer");
return;
}
if (alternating.IsRunning)
{
alternating.Stop();
Status("alternating CQ off");
return;
}
if (session.Positions.Count < 2)
{
Status("alternating CQ needs two radios");
return;
}
if (!alternating.IsPossible)
{
Status("this keyer does not report when a message has gone out");
return;
}
alternating.Stopped -= OnAlternatingStopped;
alternating.Stopped += OnAlternatingStopped;
alternating.Start(radioNumber);
Status($"alternating CQ · radio {radioNumber} first");
}
private void OnAlternatingStopped(object? sender, string reason)
{
if (sender is AlternatingCq alternating)
{
alternating.Stopped -= OnAlternatingStopped;
}
Dispatcher.UIThread.Post(() => Status($"alternating CQ stopped: {reason}"));
}
private void SpotCurrentCall()
{
if (Logging is null || Logging.Entry.Call.Trim().Length == 0)
{
return;
}
session.Bandmap.Add(new Nonemm.Spotting.Spot(
Core.Callsign.Parse(Logging.Entry.Call.Trim()),
Logging.Frequency,
DateTime.UtcNow,
Nonemm.Spotting.SpotSource.Operator,
Logging.Me.Callsign));
Status($"{Logging.Entry.Call.Trim()} put on the bandmap");
}
}