Both put something on our own bandmap and nothing on the cluster, which is what separates them from Spot It. Mark, Alt+M, leaves a mark where the radio is to say the frequency is busy. The label is N1MM's, Busy@ and the time. It is not a station, so Spot carries IsStation: nothing judges it, the check window does not offer it as a call, available mults and Qs leaves it out, space does not take it into the callsign box, and the bandmap paints it like a station already worked, which is how N1MM marks it. The station count under the bandmap counts stations. Store, Alt+O, puts the call being typed on the bandmap at this frequency so it can be worked later. N1MM writes "Local spot" in the comment and this does the same. The action row now holds the eight buttons N1MM has, in N1MM's order and at N1MM's widths: 23 per cent to Esc: Stop and 11 to each of the rest. Left out of Store: N1MM also adds the call to what the check window's Master column offers for the rest of the session. The call is on the bandmap, which the check window already reads, so it is offered either way. Driven under Xvfb: Alt+M leaving BUSY@22:32:27 on 14000, Alt+O storing SP9XYZ there, and both drawn on the bandmap in the worked colour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
1036 lines
34 KiB
C#
1036 lines
34 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;
|
|
|
|
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();
|
|
}
|
|
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();
|
|
|
|
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);
|
|
}
|
|
|
|
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)}"));
|
|
string radio = session.Radios.Count > 1 ? $" · radio {session.ActiveRadioNumber}" : "";
|
|
return $"{Logging.Contest.DisplayName} · {Logging.Log.Tally.Qsos} Q · " +
|
|
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}{radio}";
|
|
}
|
|
|
|
/// 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";
|
|
}
|
|
|
|
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;
|
|
|
|
/// 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");
|
|
}
|
|
}
|