Both are checkboxes in Manage Skins, Colors and Fonts and default to on. The entry window minimizes and restores the other windows with itself. Activating any window raises the others that are not minimized. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1371 lines
47 KiB
C#
1371 lines
47 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));
|
|
PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.Property == WindowStateProperty
|
|
&& session.Settings.MinimizeWindowsTogether
|
|
&& WindowState is WindowState.Minimized or WindowState.Normal)
|
|
{
|
|
FollowWindowState(WindowState);
|
|
}
|
|
};
|
|
Activated += (_, _) => RaiseTogether(this);
|
|
Closed += (_, _) =>
|
|
{
|
|
foreach (Window window in openWindows.Values.ToList())
|
|
{
|
|
window.Close();
|
|
}
|
|
secondRadio?.Close();
|
|
};
|
|
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);
|
|
AddHandler(KeyUpEvent, OnWindowKeyUp, 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();
|
|
}
|
|
else if (!wanted && secondRadio is not null)
|
|
{
|
|
Title = "Nonemm";
|
|
secondRadio.Close();
|
|
secondRadio = null;
|
|
}
|
|
}
|
|
|
|
/// The tool windows have no owner, so the window manager does not minimize
|
|
/// or restore them with the entry window. This does it.
|
|
private void FollowWindowState(WindowState state)
|
|
{
|
|
foreach (Window window in openWindows.Values.Concat(secondRadio is null ? [] : [secondRadio]))
|
|
{
|
|
window.WindowState = state;
|
|
}
|
|
}
|
|
|
|
private long lastRaise;
|
|
|
|
/// Brings the other windows that are not minimized above whatever else is
|
|
/// on screen, then puts `front` back on top. Activating them fires their
|
|
/// own Activated events, so raises within half a second are ignored.
|
|
private void RaiseTogether(Window front)
|
|
{
|
|
if (!session.Settings.RaiseWindowsTogether || Environment.TickCount64 - lastRaise < 500)
|
|
{
|
|
return;
|
|
}
|
|
lastRaise = Environment.TickCount64;
|
|
IEnumerable<Window> others = Placed().Concat(secondRadio is null ? [] : [secondRadio])
|
|
.Where(w => w != front && w.WindowState != WindowState.Minimized);
|
|
foreach (Window window in others)
|
|
{
|
|
window.Activate();
|
|
}
|
|
front.Activate();
|
|
}
|
|
|
|
/// 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.AutoConnectNode is not null)
|
|
{
|
|
yield return ("cluster", session.ConnectAutoCluster);
|
|
}
|
|
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();
|
|
}
|
|
|
|
/// Set by Alt+W. Releasing Alt moves focus to the menu bar, so the
|
|
/// callsign box takes focus back when Alt comes up.
|
|
private bool refocusOnAltUp;
|
|
|
|
private void OnWindowKeyUp(object? sender, KeyEventArgs e)
|
|
{
|
|
if (!refocusOnAltUp || e.Key is not (Key.LeftAlt or Key.RightAlt))
|
|
{
|
|
return;
|
|
}
|
|
refocusOnAltUp = false;
|
|
e.Handled = true;
|
|
boxes[0].Focus();
|
|
}
|
|
|
|
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;
|
|
refocusOnAltUp = 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");
|
|
}
|
|
}
|