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>
1305 lines
45 KiB
C#
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");
|
|
}
|
|
}
|