Files
Nonemm/src/Nonemm.App/Windows/EntryWindow.axaml.cs
T
erikbrockoandClaude Sonnet 5.5 a189f63732 Show local spots in bold and name the computer that made them
A spot an operator put on a bandmap, here or on another computer, is
drawn in bold. Its hover text names the computer instead of the
spotter's callsign. A spot from another computer carries the name from
the ADDSPOT message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:51:13 +00:00

1305 lines
45 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.Contests.Rules;
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));
/// True while this station has its key down. Every station of the entry is
/// told, because two signals where the entry may have one is a contact the
/// sponsor takes back off both of them.
private bool sending
{
get;
set
{
if (field == value)
{
return;
}
field = value;
_ = session.Link?.SendTransmittingAsync(value, sendingWhat, session.IsRun2);
ShowTransmitWarning();
}
}
/// What is going out, which is what the other operators read in the
/// network status window.
private string sendingWhat = "";
private TextBlock? callMark;
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);
// a keyer speaks on its own thread, and a recording that would not
// play has nowhere else to be said
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
{
BuildEntryBoxes();
ShowSecondRadio();
});
Themes.Changed += OnThemeChanged;
Closed += (_, _) => Themes.Changed -= OnThemeChanged;
// the windows are where this operator left them, and that is what he
// gets back next time he takes the radio
Closing += (_, _) => session.Save(WithLayout(session.Settings));
clock.Tick += (_, _) => UpdateClock();
clock.Start();
session.EntryWiped += (_, wiped) => Dispatcher.UIThread.Post(() =>
{
if (wiped == Logging)
{
ResetEsm();
SyncBoxes();
Refresh();
}
});
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();
Restore(Current());
}
/// 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.StationLinkEnabled)
{
yield return ("station link", session.ApplyStationLinkSettings);
}
if (session.Settings.BroadcastChannels().Values.Any(c => c.IsEnabled))
{
yield return ("data broadcast", session.ApplyBroadcastSettings);
}
if (session.Settings.MirrorRadioEnabled)
{
yield return ("radio mirror", session.ApplyMirrorRadioSettings);
}
// always, whatever the CW keyer is: this is also what builds the voice
// keyer, which plays out of the sound card rather than a keying port
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);
}
boxes[0].Focus();
SyncBoxes();
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);
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;
StopRecording();
// N1MM's Escape holds alternating CQ where it is rather than
// switching it off; the next CQ carries it on
session.Alternating?.Hold();
_ = Sender?.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():
e.Handled = true;
if (Logging is not null && boxes[0].IsFocused)
{
session.SendLookup(Logging);
}
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.W when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
OnWipeOrRestore(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.O when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnChangeOperator(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;
// N1MM opens the function key editor on Alt+K, for the mode the
// radio is on. Right-clicking a key button opens the same one
case Key.K when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
_ = EditMessages();
break;
// N1MM records into the file this key plays, and stops on the
// same pair
case >= Key.F1 and <= Key.F12 when e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift):
e.Handled = true;
RecordFunctionKey(e.Key - Key.F1);
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 (Logging.PendingMode() is { } mode)
{
Logging.Tune(Logging.Frequency, mode);
// a radio that is connected is moved as well, or its next answer
// would put the mode back
_ = session.Radio?.SetModeAsync(mode);
Logging.Entry.Call = "";
SyncBoxes();
boxes[0].Focus();
Status($"mode {mode.Name}");
return;
}
if (IsEsmOn)
{
_ = RunEsmAsync();
return;
}
if (!Logging.IsEntryComplete)
{
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 (!IsEntryUsable() || Logging?.SaveQuickEdit() is not { } saved)
{
return;
}
Status($"{saved.Call.Text} updated");
SyncBoxes();
Refresh();
boxes[0].Focus();
}
private void LogContact()
{
if (Logging is null || !IsEntryUsable())
{
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();
}
/// N1MM's `{FORCELOG}`: the contact goes in the log with whatever is in the
/// boxes, holes and all. The status line says it was forced, because the
/// row will need fixing before the log is sent in.
private void LogContactAnyway()
{
if (Logging is null)
{
return;
}
try
{
Qso logged = Logging.LogContactAnyway();
Status($"logged {logged.Call} as it stands — check it before sending the log in");
}
catch (Exception e) when (e is InvalidOperationException or IOException)
{
Status($"could not log the contact: {e.Message}");
return;
}
ResetEsm();
SyncBoxes();
boxes[0].Focus();
}
/// True when everything typed can go in the log. A value the contest will
/// not take puts the reason in the status line and the cursor in its box,
/// which is where the operator has to fix it.
private bool IsEntryUsable()
{
if (Logging?.EntryProblem() is not { } problem)
{
return true;
}
Status(problem.Message);
Logging.Entry.FocusOn(problem.Field);
boxes[problem.Field].Focus();
boxes[problem.Field].SelectAll();
return false;
}
/// 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.
/// The digital window hands a callsign over: the boxes are wiped and the
/// call typed in, which is what double-clicking one in N1MM's receive pane
/// does. With `space` the focus moves on to the exchange as though the
/// operator had pressed space, so the reports and the call history fill in.
internal void GrabCall(string call, bool space)
{
if (Logging is null || call.Trim().Length == 0)
{
return;
}
Logging.Wipe();
ResetEsm();
Logging.Entry.Call = call.Trim().ToUpperInvariant();
SyncBoxes();
if (space)
{
MoveFocus(forward: true);
}
boxes[Logging.Entry.Focus].Focus();
Refresh();
}
/// The digital window's click on something that is not a callsign: it goes
/// in the exchange box the contest keeps for that kind of value, and the
/// cursor follows it. False when the contest has no box that takes it.
internal bool GrabExchange(string text)
{
if (Logging is null || DigitalElement.FieldFor(Logging.Entry, text) is not { } field)
{
return false;
}
Logging.Entry[field] = text;
Logging.Entry.FocusOn(field);
SyncBoxes();
boxes[field].Focus();
Refresh();
return true;
}
/// The digital window's Ctrl+click: whatever was clicked goes in the box
/// the cursor is in, whatever kind of value the box normally holds.
internal void GrabIntoFocused(string text)
{
if (Logging is null || text.Length == 0)
{
return;
}
int field = Math.Clamp(Logging.Entry.Focus, 0, boxes.Count - 1);
Logging.Entry[field] = field == 0 ? text.ToUpperInvariant() : text;
SyncBoxes();
boxes[field].Focus();
Refresh();
}
/// The digital window's right click, which N1MM makes the same as pressing
/// Enter here.
internal void PressEnter() => OnEnter();
/// A CQ has gone out. The digital window clears its grab list on it, the
/// way N1MM does: a run that starts again brings new callers.
internal event EventHandler? CqSent;
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();
if (filled)
{
SyncBoxes();
}
}
Logging.MoveFocus(forward: true);
}
else
{
Logging.MoveFocus(forward: false);
}
FocusEntryBox();
}
/// What tab does: every box in turn, the reports 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;
}
int at = boxes.FindIndex(box => box.IsFocused);
int next = ((at < 0 ? 0 : at) + (forward ? 1 : -1) + boxes.Count) % boxes.Count;
Logging.Entry.FocusOn(next);
FocusEntryBox();
}
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;
}
private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused);
/// 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?.Hold();
_ = Sender?.AbortAsync();
Status("stopped sending");
}
private void OnWipe(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
return;
}
Logging.Wipe();
ResetEsm();
SyncBoxes();
boxes[0].Focus();
Refresh();
}
/// N1MM's Alt+W: it wipes the boxes, and pressed again on empty boxes it
/// puts back what it wiped.
private void OnWipeOrRestore(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
return;
}
Logging.WipeOrRestore();
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];
}
updating = false;
}
internal void Refresh()
{
ShowFunctionKeys();
ShowBandButtons();
QtcExportItem.IsVisible = Logging?.Contest.HasQtcTraffic == true;
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();
ShowQtcInfo();
UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? "";
ShowLights();
ShowScore();
ShowBandTimer();
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.MultiplierNamesFor(Logging.Me).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}";
}
/// WAE's QTC tally for the station being worked, which N1MM keeps in a
/// blue label under the entry boxes: how many lines have gone with it, how
/// many more may go, and how many contacts are left to report to anybody.
/// The line is only there in WAE, because no other contest has traffic.
private void ShowQtcInfo()
{
if (Logging is null || Logging.Contest is not Wae)
{
QtcInfoText.IsVisible = false;
return;
}
string call = Logging.Entry.Call.Trim();
QtcCounts counts = new QtcTraffic(Logging)
.CountsFor(Callsign.Parse(call.Length >= 3 ? call : ""));
QtcInfoText.Text = counts.IsFull ? $"{counts.Text} — at the limit" : counts.Text;
QtcInfoText.IsVisible = true;
}
/// 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";
ShowBandTimer();
// another station keying up is not something this window is told about,
// so the box is worked out again every second as well
ShowTransmitWarning();
}
/// The red box: this station is transmitting and the entry is over the
/// signals it is allowed, or two of its stations are on one band. N1MM
/// blocks the transmitter for this; this program says so and leaves the
/// operator to stop, because a logger that decides for itself when an
/// operator may not send loses a contact every time it is wrong.
private void ShowTransmitWarning()
{
string? why = TransmitViolation();
TransmitWarning.IsVisible = why is not null;
TransmitWarningText.Text = why is null ? "" : $"STOP — {why}";
}
private string? TransmitViolation()
{
if (!sending || Logging is null || session.Link is not { } link)
{
return null;
}
List<Transmitter> others = link.Stations
.Where(s => !s.IsMine && s.IsTransmitting)
.Select(s => new Transmitter(s.ComputerName, s.Band, s.Mode?.Category))
.ToList();
return TransmitRules.Violation(
Logging.Log.Entry,
new Transmitter(link.ComputerName, Bands.ForFrequency(Logging.Frequency), Logging.Mode.Category),
others);
}
/// The countdown N1MM puts in its info window: how long the station still
/// has to stay on the band it last moved to. It is here only while the
/// contest asks for a stay and the entry's category is one the contest
/// counts band changes for, so most contests show nothing.
private void ShowBandTimer()
{
BandChangeRules rules = Logging is null
? BandChangeRules.None
: Logging.Contest.BandChangesFor(Logging.Log.Entry);
if (!rules.HasStayTimer)
{
BandTimerText.Text = "";
return;
}
TimeSpan left = rules.StayLeft(Logging!.Log.Qsos, DateTime.UtcNow);
string what = rules.CountsModeChanges ? "band/mode" : "band";
BandTimerText.Text = $"{what} {left.Minutes + (left.Hours * 60):00}:{left.Seconds:00}";
BandTimerText.Opacity = left > TimeSpan.Zero ? 1.0 : 0.5;
}
/// 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.
///
/// `wanted` is the way N1MM's `{DIGQTCS}` and `{DIGQTCR}` ask for, which
/// is only allowed where the contest and the station leave it open. Ctrl+Z
/// passes null and the rules decide.
private async Task OpenQtcWindow(QtcDirection? wanted = null)
{
if (Logging is null)
{
return;
}
QtcTraffic traffic = new(Logging);
if (!traffic.IsWaeContest)
{
Status("QTC traffic is a WAE thing");
return;
}
// logging it here rather than through Enter: with ESM on, Enter sends
// the next message of the contact instead of logging it, and Ctrl+Z
// means log it and open the window. N1MM calls `LogContact` at the
// same point
if (Logging.Entry.Call.Trim().Length > 0 && Logging.IsEntryComplete)
{
LogContact();
}
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 (wanted is { } asked)
{
if (direction is not QtcDirection.Either && direction != asked)
{
Status($"traffic with {station.Text} can only go the other way");
return;
}
direction = asked;
}
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, text => SendTextAsync(text), Refresh);
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)
{
Computer = session.ComputerName,
});
Status($"{Logging.Entry.Call.Trim()} put on the bandmap");
}
}