The QTC window put nothing on the air on RTTY except the lines themselves,
so the operator typed the rest into the digital window's transmit pane and
copied the lines by hand. N1MM sends a message for each step and fills the
lines in from a click. Both are here now, with N1MM's messages, N1MM's
defaults and N1MM's settings behind them.
The messages, all of them settings in the QTC window's setup area:
- RX Ready, `WAERXReadyText`, `{ENTERLF}I AM QRV BK`
- All Agn, `WAEAllAgnText`, `{ENTERLF}ALL AGN ALL AGN BK`
- Agn n, `WAEAGNText`, `{ENTERLF}AGN QTC: $ $ $ `, where `$` is the line
- Save, `WAESaveQTCText`, `{ENTERLF}R R R ALL OK BK`
- R U QRV, `WAERUQRVText`, `{ENTERLF}R U QRV BK`
- Save, reading traffic out, `WAESSaveQTCText`, `{ENTERLF}GL DE {MYCALL}`
Each keys the transmitter, sends the text and drops it again, and an empty
one sends nothing. The line spacing default is N1MM's `{ENTERLF}` rather
than `{ENTER}`, which was wrong.
The buttons: R U QRV and Snd Hdr were hidden while reading traffic out on
RTTY, though that is where N1MM starts — R U QRV is the first button
pressed. All Agn is new, and so is the S button on each line, which strikes
a line through a QTC that arrived too broken to read so the rows after it
keep their places; Agn n clears the strike and asks for that line again.
Clicking a line in the digital window fills the next row, which is how the
series is copied on RTTY: the other station sends the header and all ten
lines in one transmission. A click anywhere in a line hands the whole line
to the QTC window, which reads the time, the callsign and the serial number
out of it by shape and flashes the line to show it was taken. A line that is
not a QTC is left to mean what a click always means there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
870 lines
31 KiB
C#
870 lines
31 KiB
C#
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Nonemm.App.Configuration;
|
|
using Nonemm.App.Dialogs;
|
|
using Nonemm.Contests.Rules;
|
|
using Nonemm.App.Theming;
|
|
using Nonemm.Core;
|
|
using Nonemm.Session;
|
|
|
|
namespace Nonemm.App.Windows;
|
|
|
|
/// Taking QTC traffic down, or reading it out. Laid out as N1MM lays it out: a
|
|
/// header, ten lines of time, callsign and serial number, an Agn and a Cfm
|
|
/// button beside each, and Clear, Close and Cancel underneath.
|
|
///
|
|
/// Receiving, the lines are typed as they arrive. Sending, they are filled from
|
|
/// the log and cannot be edited — what is being reported is what was worked.
|
|
///
|
|
/// On CW it puts the traffic on the air through the entry window's keyer, the
|
|
/// way N1MM's QTC window sends through its own entry window. On RTTY it sends
|
|
/// through the digital engine the same way, but a line at a time rather than a
|
|
/// field at a time, and Send All reads the whole series out in one message. On
|
|
/// SSB nothing is sent: that needs a voice keyer, and there is none here.
|
|
public sealed partial class QtcWindow : Window
|
|
{
|
|
private static IBrush Saved => Themes.Brush(Themes.Current.GoodBackground);
|
|
|
|
private static IBrush Unsaved => Themes.Brush(Themes.Current.EmptyBackground);
|
|
|
|
private static IBrush Malformed => Themes.Brush(Themes.Current.BadBackground);
|
|
|
|
private readonly AppSession session;
|
|
private readonly OperatingPosition position;
|
|
private readonly QtcTraffic traffic;
|
|
private readonly Callsign station;
|
|
private readonly Func<string, Task> send;
|
|
private readonly Action refresh;
|
|
private bool isSending;
|
|
|
|
/// How many lines are live. Receiving, the header says: `4/5` is series 4
|
|
/// with five QTCs in it, so five lines are enabled and the rest are not.
|
|
/// It is capped by how many of the station's ten are still free, which is
|
|
/// what N1MM's `ColorizeCWRXFields` does with it.
|
|
private int activeLines;
|
|
|
|
/// The line last read out, which is the one the number keys repeat a field
|
|
/// of.
|
|
private int lastSent;
|
|
private readonly List<Row> rows = [];
|
|
private readonly List<WaeQtc> ready = [];
|
|
|
|
/// `send` puts a message on the air through the entry window this was
|
|
/// opened from, so QTC traffic goes out the same way a function key does.
|
|
public QtcWindow(
|
|
AppSession session,
|
|
OperatingPosition position,
|
|
Callsign station,
|
|
QtcDirection direction,
|
|
Func<string, Task> send,
|
|
Action refresh)
|
|
{
|
|
this.session = session;
|
|
this.position = position;
|
|
this.station = station;
|
|
this.send = send;
|
|
this.refresh = refresh;
|
|
isSending = direction == QtcDirection.Send;
|
|
traffic = new QtcTraffic(position);
|
|
InitializeComponent();
|
|
// on RTTY traffic goes both ways, so the operator says which this is
|
|
DirectionPanel.IsVisible = direction == QtcDirection.Either;
|
|
SendButton.IsChecked = isSending;
|
|
ReceiveButton.IsChecked = !isSending;
|
|
SendButton.IsCheckedChanged += (_, _) => SetDirection(SendButton.IsChecked == true);
|
|
Reset();
|
|
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
|
|
HeaderBox.LostFocus += (_, _) => ApplyHeaderLimit();
|
|
Closed += (_, _) =>
|
|
{
|
|
ShowCall(null);
|
|
session.QtcLines = null;
|
|
};
|
|
// focus set before the window is on screen does not stick
|
|
Opened += (_, _) => FirstBox().Focus();
|
|
}
|
|
|
|
/// Where the cursor goes: RX Ready when the operator wants that step, the
|
|
/// header when it has to be typed, the first line when it is filled in.
|
|
private Control FirstBox() =>
|
|
isSending ? rows[0].Time
|
|
: session.Settings.QtcSkipReady ? HeaderBox
|
|
: ReadyButton;
|
|
|
|
/// N1MM writes the number keys under the lines. They only do anything on
|
|
/// CW, so the line says something else on the other modes.
|
|
private string KeysLine()
|
|
{
|
|
if (!IsCw)
|
|
{
|
|
return isSending
|
|
? IsRtty
|
|
? "Send All reads the whole series out — Snd n sends one QTC again"
|
|
: "Nothing goes out on SSB: read the lines out yourself, then Close to log them."
|
|
: "Type the header, then a line per QTC: time, callsign, serial number.";
|
|
}
|
|
return isSending
|
|
? "1 = time 2 = call 3 = serial 4 = header — sends that field of the line again"
|
|
: "Shift 1 = ask time Shift 2 = ask call Shift 3 = ask serial";
|
|
}
|
|
|
|
/// Which mode the traffic goes out on. The contest says, not what the radio
|
|
/// happens to be on, which is how N1MM decides too.
|
|
private bool IsCw => position.Contest.Modes is [ModeCategory.Cw];
|
|
|
|
private bool IsRtty => position.Contest.Modes.Contains(ModeCategory.Digital);
|
|
|
|
/// Whether this window puts anything on the air. SSB needs a voice keyer
|
|
/// and there is none here, so on SSB the operator speaks and the window
|
|
/// only writes the traffic down.
|
|
private bool Keys => IsCw || IsRtty;
|
|
|
|
private Settings Settings => session.Settings;
|
|
|
|
/// The header of the series as it goes out in a message, which is N1MM's
|
|
/// `QTC 3/10`. The box shows the station callsign after it on RTTY, and
|
|
/// that is for the operator rather than for the air.
|
|
private string SeriesHeader => ready.Count > 0 ? $"QTC {ready[0].SeriesText}" : "";
|
|
|
|
private void SetDirection(bool sending)
|
|
{
|
|
if (sending == isSending)
|
|
{
|
|
return;
|
|
}
|
|
isSending = sending;
|
|
Reset();
|
|
FirstBox().Focus();
|
|
}
|
|
|
|
/// Everything that follows from which way the traffic goes.
|
|
private void Reset()
|
|
{
|
|
// taking traffic down on RTTY, a line clicked in the digital window
|
|
// fills the next row, which is how the series is copied there
|
|
session.QtcLines = !isSending && IsRtty ? TakeLine : null;
|
|
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
|
|
CountsText.Text = traffic.CountsFor(station).Text;
|
|
KeysText.Text = KeysLine();
|
|
// N1MM's labels: the buttons say what they do in the direction the
|
|
// traffic is going, and the ones with nothing to do are hidden
|
|
ReadyButton.IsVisible = Keys || !isSending;
|
|
ReadyButton.Content = !IsCw ? isSending ? "R U QRV" : "RX Ready"
|
|
: isSending ? "R U QRV" : "QRV";
|
|
HeaderAgainButton.Content = isSending ? "Snd Hdr" : "Hdr Agn";
|
|
HeaderAgainButton.IsVisible = !isSending || Keys;
|
|
// N1MM's All Agn, which asks for the whole series again. It is one
|
|
// message, so it is RTTY only
|
|
AllAgainButton.IsVisible = !isSending && IsRtty;
|
|
HeaderCfmButton.IsVisible = !isSending;
|
|
// N1MM's Send All, which reads a whole series out in one message. It
|
|
// needs the engine to hold the text, so it is RTTY only
|
|
SendAllButton.IsVisible = isSending && IsRtty;
|
|
ClearButton.IsVisible = !isSending;
|
|
CloseButton.Content = IsCw ? "Exit" : "Close";
|
|
HeaderBox.IsReadOnly = isSending;
|
|
BuildRows();
|
|
Fill();
|
|
}
|
|
|
|
private void BuildRows()
|
|
{
|
|
Lines.ColumnDefinitions.Clear();
|
|
Lines.RowDefinitions.Clear();
|
|
Lines.Children.Clear();
|
|
rows.Clear();
|
|
foreach (string width in new[] { "60", "120", "60", "Auto", "Auto", "Auto" })
|
|
{
|
|
Lines.ColumnDefinitions.Add(new ColumnDefinition(
|
|
width == "Auto" ? GridLength.Auto : new GridLength(double.Parse(width))));
|
|
}
|
|
for (int at = 0; at < WaeQtc.MostPerSeries; at++)
|
|
{
|
|
Lines.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
|
TextBox time = Box(at, 0);
|
|
TextBox call = Box(at, 1);
|
|
TextBox number = Box(at, 2);
|
|
Button again = new()
|
|
{
|
|
Content = isSending && (IsCw || IsRtty) ? $"Snd{at + 1}" : $"Agn{at + 1}",
|
|
Margin = new Avalonia.Thickness(2, 1, 2, 1),
|
|
};
|
|
Button confirm = new() { Content = $"Cfm{at + 1}", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
|
Button skip = new() { Content = "S", Margin = new Avalonia.Thickness(2, 1, 2, 1) };
|
|
int line = at;
|
|
again.Click += (_, _) => OnLineAgain(line);
|
|
confirm.Click += (_, _) => OnLineConfirm(line);
|
|
skip.Click += (_, _) => OnSkip(line);
|
|
// nothing to confirm while reading our own log out, which is how
|
|
// N1MM hides the column
|
|
confirm.IsVisible = !isSending;
|
|
// N1MM's skip button, for a line that arrived too broken to read.
|
|
// It belongs to the RTTY way of working, where the lines come in
|
|
// one transmission and the rows have to stay in step with them
|
|
skip.IsVisible = !isSending && IsRtty;
|
|
Grid.SetRow(again, at);
|
|
Grid.SetColumn(again, 3);
|
|
Grid.SetRow(confirm, at);
|
|
Grid.SetColumn(confirm, 4);
|
|
Grid.SetRow(skip, at);
|
|
Grid.SetColumn(skip, 5);
|
|
Lines.Children.Add(again);
|
|
Lines.Children.Add(confirm);
|
|
Lines.Children.Add(skip);
|
|
rows.Add(new Row(time, call, number, again, confirm, skip));
|
|
}
|
|
}
|
|
|
|
private TextBox Box(int row, int column)
|
|
{
|
|
TextBox box = new()
|
|
{
|
|
FontFamily = new FontFamily("monospace"),
|
|
Margin = new Avalonia.Thickness(2, 1, 2, 1),
|
|
IsReadOnly = isSending,
|
|
};
|
|
box.TextChanged += (_, _) => Paint();
|
|
if (!isSending && column == 0)
|
|
{
|
|
int at = row;
|
|
box.LostFocus += (_, _) => FillTime(at);
|
|
}
|
|
if (!isSending && column == 1)
|
|
{
|
|
box.TextChanged += (_, _) => ShowCall(box.Text ?? "");
|
|
box.GotFocus += (_, _) => ShowCall(box.Text ?? "");
|
|
box.LostFocus += (_, _) => ShowCall(null);
|
|
}
|
|
Grid.SetRow(box, row);
|
|
Grid.SetColumn(box, column);
|
|
Lines.Children.Add(box);
|
|
return box;
|
|
}
|
|
|
|
/// A time typed short is filled in from the line above as the cursor
|
|
/// leaves the box, which is where N1MM does it.
|
|
private void FillTime(int at)
|
|
{
|
|
string above = at > 0 ? rows[at - 1].Time.Text ?? "" : "";
|
|
string filled = QtcTraffic.FillTime(rows[at].Time.Text ?? "", above);
|
|
if (filled != (rows[at].Time.Text ?? ""))
|
|
{
|
|
rows[at].Time.Text = filled;
|
|
}
|
|
}
|
|
|
|
/// Points the check window at the callsign being typed here rather than
|
|
/// the one in the entry window, and back again when the cursor leaves.
|
|
/// N1MM leaves the check window on the entry window's call.
|
|
private void ShowCall(string? call)
|
|
{
|
|
if (session.Check is null)
|
|
{
|
|
return;
|
|
}
|
|
session.Check.Elsewhere = call;
|
|
refresh();
|
|
}
|
|
|
|
/// Sending, the header and the lines come from the log. Receiving, both are
|
|
/// empty and the operator fills them in.
|
|
private void Fill()
|
|
{
|
|
ready.Clear();
|
|
if (!isSending)
|
|
{
|
|
HeaderBox.Text = "";
|
|
// until a header says otherwise, every line the station has left
|
|
activeLines = Math.Min(traffic.Remaining(station), rows.Count);
|
|
for (int at = 0; at < rows.Count; at++)
|
|
{
|
|
rows[at].SetEnabled(at < activeLines);
|
|
}
|
|
Paint();
|
|
return;
|
|
}
|
|
ready.AddRange(traffic.ToSend(station, session.Settings.QtcLinesPerSeries));
|
|
activeLines = ready.Count;
|
|
HeaderBox.Text = ready.Count == 0
|
|
? ""
|
|
: IsRtty
|
|
? $"{SeriesHeader} - {station.Text}"
|
|
: SeriesHeader;
|
|
for (int at = 0; at < rows.Count; at++)
|
|
{
|
|
bool has = at < ready.Count;
|
|
rows[at].Time.Text = has ? ready[at].TimeUtc : "";
|
|
rows[at].Call.Text = has ? ready[at].Call.Text : "";
|
|
rows[at].Number.Text = has ? $"{ready[at].Number:00}" : "";
|
|
rows[at].SetEnabled(has);
|
|
}
|
|
if (ready.Count == 0)
|
|
{
|
|
StatusText.Text = "nothing left to report";
|
|
}
|
|
Paint();
|
|
}
|
|
|
|
/// A line of received text the operator clicked in the digital window. The
|
|
/// header goes in the header box, and a line goes in the first row that is
|
|
/// still to come, so the rows stay in step with the series even when a
|
|
/// line was skipped. False when the line is not a QTC or there is no room
|
|
/// left, and then the digital window treats the click as it always does.
|
|
private bool TakeLine(string text)
|
|
{
|
|
if ((HeaderBox.Text ?? "").Trim().Length == 0
|
|
&& QtcTraffic.ReadHeader(text) is not null)
|
|
{
|
|
HeaderBox.Text = text.Trim().ToUpperInvariant();
|
|
ApplyHeaderLimit();
|
|
Paint();
|
|
return true;
|
|
}
|
|
if (QtcTraffic.SplitLine(text) is not { } line)
|
|
{
|
|
return false;
|
|
}
|
|
for (int at = 0; at < rows.Count && at < activeLines; at++)
|
|
{
|
|
if (!rows[at].IsEmpty || rows[at].IsStruck)
|
|
{
|
|
continue;
|
|
}
|
|
rows[at].Time.Text = line.Time;
|
|
rows[at].Call.Text = line.Call;
|
|
rows[at].Number.Text = line.Number;
|
|
Paint();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Narrows the live lines to what the header asks for. `4/5` is series 4
|
|
/// with five QTCs in it, so five lines take traffic and the other five are
|
|
/// switched off. A station with fewer than five of its ten left gets that
|
|
/// many, which is the cap N1MM applies in `ColorizeCWRXFields`. Nothing
|
|
/// already typed is cleared.
|
|
private void ApplyHeaderLimit()
|
|
{
|
|
if (isSending)
|
|
{
|
|
return;
|
|
}
|
|
int free = Math.Min(traffic.Remaining(station), rows.Count);
|
|
int wanted = QtcTraffic.ReadHeader(HeaderBox.Text ?? "") is { } header ? header.Count : free;
|
|
activeLines = Math.Clamp(Math.Min(wanted, free), 0, rows.Count);
|
|
for (int at = 0; at < rows.Count; at++)
|
|
{
|
|
rows[at].SetEnabled(at < activeLines);
|
|
}
|
|
Paint();
|
|
}
|
|
|
|
/// Green when the line is ready to save, yellow when it is filled in but
|
|
/// cannot be read, red while it is still empty.
|
|
private void Paint()
|
|
{
|
|
(int Series, int Count)? header = QtcTraffic.ReadHeader(HeaderBox.Text ?? "");
|
|
HeaderBox.Background = header is null
|
|
? (HeaderBox.Text ?? "").Trim().Length == 0 ? Unsaved : Malformed
|
|
: Saved;
|
|
int good = 0;
|
|
for (int at = 0; at < rows.Count; at++)
|
|
{
|
|
Row row = rows[at];
|
|
bool empty = row.IsEmpty;
|
|
WaeQtc? line = empty || header is null ? null : ReadLine(at, header.Value);
|
|
row.Paint(at >= activeLines
|
|
? null
|
|
: line is not null ? Saved : empty ? Unsaved : Malformed);
|
|
if (line is not null)
|
|
{
|
|
good++;
|
|
}
|
|
}
|
|
StatusText.Text = $"{good} of {header?.Count ?? 0}";
|
|
}
|
|
|
|
private WaeQtc? ReadLine(int at, (int Series, int Count) header) =>
|
|
QtcTraffic.Read(
|
|
station,
|
|
isSending,
|
|
header.Series,
|
|
header.Count,
|
|
rows[at].Time.Text ?? "",
|
|
rows[at].Call.Text ?? "",
|
|
rows[at].Number.Text ?? "");
|
|
|
|
/// Tab, enter and space all move to the next box the operator types in,
|
|
/// and shift and tab move back. N1MM handles the three keys itself in
|
|
/// every box rather than leaving them to the tab order, so the walk never
|
|
/// lands on a switched-off line or on a button beside one.
|
|
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
|
{
|
|
switch (e.Key)
|
|
{
|
|
case Key.Escape:
|
|
e.Handled = true;
|
|
Close(false);
|
|
break;
|
|
case Key.Tab:
|
|
e.Handled = true;
|
|
Move(e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? -1 : 1);
|
|
break;
|
|
case >= Key.D1 and <= Key.D4 when IsCw && isSending:
|
|
case >= Key.NumPad1 and <= Key.NumPad4 when IsCw && isSending:
|
|
e.Handled = true;
|
|
SendField(FieldOf(e.Key));
|
|
break;
|
|
case >= Key.D1 and <= Key.D3 when IsCw && e.KeyModifiers.HasFlag(KeyModifiers.Shift):
|
|
case >= Key.NumPad1 and <= Key.NumPad3 when IsCw && e.KeyModifiers.HasFlag(KeyModifiers.Shift):
|
|
e.Handled = true;
|
|
AskFieldAgain(FieldOf(e.Key));
|
|
break;
|
|
case Key.Enter when e.Source is TextBox:
|
|
case Key.Space when e.Source is TextBox:
|
|
e.Handled = true;
|
|
Move(1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// The boxes and buttons the operator works through, in order and without
|
|
/// the ones that are switched off: the header, then each live line, and
|
|
/// Close at the end. Reading traffic out there is nothing to type, so the
|
|
/// walk is over the send buttons instead.
|
|
private List<Control> WalkOrder()
|
|
{
|
|
List<Control> order = [];
|
|
if (!isSending)
|
|
{
|
|
order.Add(HeaderBox);
|
|
}
|
|
for (int at = 0; at < rows.Count && at < activeLines; at++)
|
|
{
|
|
Row row = rows[at];
|
|
if (isSending)
|
|
{
|
|
order.Add(row.Again);
|
|
continue;
|
|
}
|
|
order.Add(row.Time);
|
|
order.Add(row.Call);
|
|
order.Add(row.Number);
|
|
if (!session.Settings.QtcSkipConfirm && row.Confirm.IsVisible)
|
|
{
|
|
order.Add(row.Confirm);
|
|
}
|
|
}
|
|
order.Add(CloseButton);
|
|
return order;
|
|
}
|
|
|
|
/// One step along the walk. Past the last box the cursor lands on Close,
|
|
/// which is where N1MM leaves it; before the first it stays put.
|
|
private void Move(int by)
|
|
{
|
|
List<Control> order = WalkOrder();
|
|
int at = order.FindIndex(b => b.IsFocused);
|
|
if (at < 0)
|
|
{
|
|
order[0].Focus();
|
|
return;
|
|
}
|
|
for (int next = at + by; next >= 0 && next < order.Count; next += by)
|
|
{
|
|
if (order[next].IsEffectivelyEnabled)
|
|
{
|
|
order[next].Focus();
|
|
return;
|
|
}
|
|
}
|
|
if (by > 0)
|
|
{
|
|
CloseButton.Focus();
|
|
}
|
|
}
|
|
|
|
/// Reading traffic out, this asks the other station whether it is ready;
|
|
/// taking it down, it says we are. N1MM keys `QRV?` and `QRV` and plays a
|
|
/// recording on SSB, which there is no keyer for here.
|
|
private void OnReady(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (IsCw)
|
|
{
|
|
_ = send(isSending ? QtcMessages.AreYouReady : QtcMessages.ReadyToReceive);
|
|
}
|
|
else if (IsRtty)
|
|
{
|
|
_ = send(QtcMessages.Rtty(
|
|
isSending ? Settings.QtcRttyAreYouReady : Settings.QtcRttyReady));
|
|
}
|
|
Control next = isSending ? SendAllButton.IsVisible ? SendAllButton : rows[0].Again
|
|
: HeaderBox;
|
|
next.Focus();
|
|
}
|
|
|
|
/// One button, as N1MM has it: reading traffic out it keys the header of
|
|
/// the series, taking it down it asks for the header again.
|
|
private void OnHeaderAgain(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (isSending)
|
|
{
|
|
// the box shows the station callsign after the header on RTTY, and
|
|
// that is for the operator rather than for the air
|
|
_ = send(IsRtty ? QtcMessages.Rtty(SeriesHeader) : HeaderBox.Text ?? "");
|
|
rows[0].Again.Focus();
|
|
return;
|
|
}
|
|
if (IsCw)
|
|
{
|
|
_ = send(QtcMessages.HeaderAgain);
|
|
}
|
|
else if (IsRtty)
|
|
{
|
|
_ = send(QtcMessages.Rtty(QtcMessages.RttyHeaderAgain));
|
|
}
|
|
if (session.Settings.QtcHeaderAgainClears)
|
|
{
|
|
HeaderBox.Text = "";
|
|
}
|
|
HeaderBox.Focus();
|
|
}
|
|
|
|
private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
|
|
|
/// N1MM's All Agn: asks for the header and every line of the series again,
|
|
/// and clears what was taken down so the series can be copied afresh.
|
|
private void OnAllAgain(object? sender, RoutedEventArgs e)
|
|
{
|
|
_ = send(QtcMessages.Rtty(Settings.QtcRttyAllAgain));
|
|
foreach (Row row in rows)
|
|
{
|
|
row.Clear();
|
|
}
|
|
HeaderBox.Focus();
|
|
Paint();
|
|
}
|
|
|
|
/// Ask for a line again: N1MM sends its Agn message and comes back to the
|
|
/// line. Here it clears the line and puts the cursor in it.
|
|
private void OnLineAgain(int at)
|
|
{
|
|
if (isSending)
|
|
{
|
|
if (IsCw || IsRtty)
|
|
{
|
|
SendLine(at);
|
|
return;
|
|
}
|
|
rows[at].Time.Focus();
|
|
return;
|
|
}
|
|
// N1MM asks for the line by number — `AGN QTC: 5 5 5` on RTTY, `AGN
|
|
// QTC 5` on CW — and the RTTY message always clears the line, because
|
|
// the line is copied again from the top
|
|
if (IsRtty)
|
|
{
|
|
_ = send(QtcMessages.Rtty(
|
|
QtcMessages.LineAgain(Settings.QtcRttyLineAgain, at + 1),
|
|
Settings.QtcRttySpacing));
|
|
rows[at].Clear();
|
|
}
|
|
else
|
|
{
|
|
if (IsCw)
|
|
{
|
|
_ = send($"AGN QTC {at + 1}");
|
|
}
|
|
if (Settings.QtcAgainClearsLine)
|
|
{
|
|
rows[at].Clear();
|
|
}
|
|
}
|
|
rows[at].Time.Focus();
|
|
Paint();
|
|
}
|
|
|
|
/// Reads one line out and moves to the next: the time, the callsign and the
|
|
/// serial number, with the operator's spacing between them. When the last
|
|
/// line has gone the cursor lands on Close, which sends the TU message.
|
|
private void SendLine(int at)
|
|
{
|
|
lastSent = at;
|
|
_ = send(IsRtty
|
|
? QtcMessages.SendOne(session.Settings.QtcRttySpacing, RttyLine(at))
|
|
: QtcMessages.Line(
|
|
rows[at].Time.Text ?? "",
|
|
rows[at].Call.Text ?? "",
|
|
rows[at].Number.Text ?? "",
|
|
session.Settings.QtcCwFieldSpacing));
|
|
for (int next = at + 1; next < rows.Count; next++)
|
|
{
|
|
if (rows[next].Again.IsEffectivelyEnabled)
|
|
{
|
|
rows[next].Again.Focus();
|
|
return;
|
|
}
|
|
}
|
|
CloseButton.Focus();
|
|
}
|
|
|
|
private string RttyLine(int at) => QtcMessages.RttyLine(
|
|
rows[at].Time.Text ?? "",
|
|
rows[at].Call.Text ?? "",
|
|
rows[at].Number.Text ?? "");
|
|
|
|
/// N1MM's Send All: the whole series in one message. RTTY runs at 45 baud,
|
|
/// so a series is the best part of a minute of transmission, and sending it
|
|
/// a line at a time means the operator presses a button between each one
|
|
/// while the transmitter is up. The heading, the spacing between the lines
|
|
/// and the ending are the operator's, and the message keys the transmitter
|
|
/// and drops it again itself.
|
|
private void OnSendAll(object? sender, RoutedEventArgs e)
|
|
{
|
|
List<string> lines = [];
|
|
for (int at = 0; at < rows.Count; at++)
|
|
{
|
|
if (!rows[at].IsEmpty)
|
|
{
|
|
lines.Add(RttyLine(at));
|
|
}
|
|
}
|
|
string message = QtcMessages.SendAll(
|
|
session.Settings.QtcRttySendAllHeading,
|
|
session.Settings.QtcRttySendAllEnding,
|
|
session.Settings.QtcRttySpacing,
|
|
SeriesHeader,
|
|
lines);
|
|
if (message.Length == 0)
|
|
{
|
|
StatusText.Text = "nothing to send";
|
|
return;
|
|
}
|
|
lastSent = lines.Count - 1;
|
|
_ = send(message);
|
|
CloseButton.Focus();
|
|
}
|
|
|
|
/// 1, 2, 3 and 4 as N1MM numbers them: the time, the call, the serial
|
|
/// number and the header.
|
|
private static int FieldOf(Key key) =>
|
|
key is >= Key.NumPad1 and <= Key.NumPad4 ? key - Key.NumPad1 : key - Key.D1;
|
|
|
|
/// Reading traffic out, the number keys send one field of the line last
|
|
/// sent again, for a station that missed a piece of it.
|
|
private void SendField(int field)
|
|
{
|
|
if (field == 3)
|
|
{
|
|
_ = send(HeaderBox.Text ?? "");
|
|
return;
|
|
}
|
|
Row row = rows[Math.Max(0, lastSent)];
|
|
_ = send((field switch
|
|
{
|
|
0 => row.Time.Text,
|
|
1 => row.Call.Text,
|
|
_ => row.Number.Text,
|
|
} ?? "").Trim());
|
|
}
|
|
|
|
/// Taking traffic down, shift and a number ask for that field again and
|
|
/// clear it. One message per field, all three in the settings.
|
|
private void AskFieldAgain(int field)
|
|
{
|
|
_ = send(field switch
|
|
{
|
|
0 => session.Settings.QtcCwTimeAgain,
|
|
1 => session.Settings.QtcCwCallAgain,
|
|
_ => session.Settings.QtcCwNumberAgain,
|
|
});
|
|
if (FocusedRow() is { } row)
|
|
{
|
|
TextBox box = field switch
|
|
{
|
|
0 => row.Time,
|
|
1 => row.Call,
|
|
_ => row.Number,
|
|
};
|
|
box.Text = "";
|
|
box.Focus();
|
|
Paint();
|
|
}
|
|
}
|
|
|
|
/// The line the cursor is in, or the first one when it is somewhere else.
|
|
private Row? FocusedRow() =>
|
|
rows.FirstOrDefault(r => r.Time.IsFocused || r.Call.IsFocused || r.Number.IsFocused)
|
|
?? rows.FirstOrDefault();
|
|
|
|
/// N1MM's S button: the line was too broken to read, so it is struck
|
|
/// through and the ones after it keep their places. Agn n clears the
|
|
/// strike and asks for the line again.
|
|
private void OnSkip(int at)
|
|
{
|
|
rows[at].Skip();
|
|
if (at + 1 < rows.Count && at + 1 < activeLines)
|
|
{
|
|
rows[at + 1].Time.Focus();
|
|
}
|
|
Paint();
|
|
}
|
|
|
|
private void OnLineConfirm(int at)
|
|
{
|
|
Control next = at + 1 < rows.Count ? rows[at + 1].Time : CloseButton;
|
|
next.Focus();
|
|
}
|
|
|
|
private void OnClear(object? sender, RoutedEventArgs e)
|
|
{
|
|
foreach (Row row in rows)
|
|
{
|
|
row.Clear();
|
|
}
|
|
Fill();
|
|
}
|
|
|
|
/// Close saves what is filled in, which is what N1MM's Close does. A line
|
|
/// that cannot be read is left out rather than guessed at.
|
|
private void OnClose(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (QtcTraffic.ReadHeader(HeaderBox.Text ?? "") is not { } header)
|
|
{
|
|
StatusText.Text = "the header is not a series";
|
|
HeaderBox.Focus();
|
|
return;
|
|
}
|
|
List<WaeQtc> lines = [];
|
|
for (int at = 0; at < rows.Count; at++)
|
|
{
|
|
if (!rows[at].IsEmpty && ReadLine(at, header) is { } line)
|
|
{
|
|
lines.Add(isSending && at < ready.Count ? ready[at] with { Series = header.Series } : line);
|
|
}
|
|
}
|
|
Save(lines);
|
|
// N1MM sends a message as the window closes: the TU on CW, and on RTTY
|
|
// `R R R ALL OK BK` from the station that took the traffic down or
|
|
// `GL DE <call>` from the one that read it out
|
|
if (IsCw && Settings.QtcCwTu.Trim().Length > 0)
|
|
{
|
|
_ = send(QtcMessages.Tu(Settings.QtcCwTu, HeaderBox.Text ?? ""));
|
|
}
|
|
else if (IsRtty)
|
|
{
|
|
_ = send(QtcMessages.Rtty(
|
|
isSending ? Settings.QtcRttySendSave : Settings.QtcRttySave));
|
|
}
|
|
Close(true);
|
|
}
|
|
|
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
|
|
|
private async void OnSetup(object? sender, RoutedEventArgs e)
|
|
{
|
|
QtcSetupDialog dialog = new(session.Settings);
|
|
if (await dialog.ShowDialog<Settings?>(this) is { } updated)
|
|
{
|
|
session.Save(updated);
|
|
Reset();
|
|
FirstBox().Focus();
|
|
}
|
|
}
|
|
|
|
/// One row per line, a second apart, so they keep their order in the log.
|
|
/// That is what N1MM does with them too.
|
|
private void Save(IReadOnlyList<WaeQtc> lines)
|
|
{
|
|
DateTime at = DateTime.UtcNow;
|
|
at = at.AddTicks(-(at.Ticks % TimeSpan.TicksPerSecond));
|
|
for (int line = 0; line < lines.Count; line++)
|
|
{
|
|
Qso row = new()
|
|
{
|
|
Id = Qso.NewId(),
|
|
TimestampUtc = at.AddSeconds(line),
|
|
Call = station,
|
|
Frequency = position.Frequency,
|
|
Mode = position.Mode,
|
|
RadioNumber = position.RadioNumber,
|
|
ContestName = position.Contest.Name,
|
|
ContestNumber = position.Instance.ContestNumber,
|
|
Operator = position.Session.Operator.Length > 0
|
|
? position.Session.Operator
|
|
: position.Me.Callsign,
|
|
IsRunQso = position.IsRunning,
|
|
};
|
|
position.Session.Add(lines[line].ApplyTo(CountryFields.Apply(row, session.Countries)));
|
|
}
|
|
}
|
|
|
|
private sealed record Row(
|
|
TextBox Time,
|
|
TextBox Call,
|
|
TextBox Number,
|
|
Button Again,
|
|
Button Confirm,
|
|
Button SkipButton)
|
|
{
|
|
/// The mark a skipped line carries in each of its boxes, which is
|
|
/// N1MM's line through the fields.
|
|
private const string Struck = "-";
|
|
|
|
/// A line that was skipped holds nothing worth logging, so it counts
|
|
/// as empty: it is not saved, and the window paints it as a line still
|
|
/// to come.
|
|
public bool IsEmpty =>
|
|
IsStruck
|
|
|| ((Time.Text ?? "").Trim().Length == 0
|
|
&& (Call.Text ?? "").Trim().Length == 0
|
|
&& (Number.Text ?? "").Trim().Length == 0);
|
|
|
|
public bool IsStruck => (Time.Text ?? "").Trim() == Struck;
|
|
|
|
public void Skip()
|
|
{
|
|
Time.Text = Struck;
|
|
Call.Text = Struck;
|
|
Number.Text = Struck;
|
|
}
|
|
|
|
/// A null colour is a line that is switched off, which keeps the
|
|
/// window's own background rather than any of the three.
|
|
public void Paint(IBrush? colour)
|
|
{
|
|
foreach (TextBox box in new[] { Time, Call, Number })
|
|
{
|
|
if (colour is null)
|
|
{
|
|
box.ClearValue(BackgroundProperty);
|
|
}
|
|
else
|
|
{
|
|
box.Background = colour;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
Time.Text = "";
|
|
Call.Text = "";
|
|
Number.Text = "";
|
|
}
|
|
|
|
public void SetEnabled(bool enabled)
|
|
{
|
|
Time.IsEnabled = enabled;
|
|
Call.IsEnabled = enabled;
|
|
Number.IsEnabled = enabled;
|
|
Again.IsEnabled = enabled;
|
|
Confirm.IsEnabled = enabled;
|
|
SkipButton.IsEnabled = enabled;
|
|
}
|
|
}
|
|
}
|