Walk the QTC window the way N1MM walks it

Four changes to the QTC window, three of them N1MM's behaviour and one an
addition.

Tab, enter and space all move to the next box the operator types in, and shift
and tab move back. The walk covers those boxes and nothing else. N1MM handles
the three keys in every box itself — `QTCLinesA_KeyDown` and its two siblings,
with `PreviewKeyDown` claiming Tab — rather than leaving them to the tab order,
so the cursor never lands on a switched-off line or on a button beside one.
Tab was going to Avalonia's default walk, which covered every control in the
window.

The header now says how many lines take traffic. `QTCHeader_LostFocus` reads
the count after the last `/` and hands it to `ColorizeCWRXFields`, which enables
that many lines and switches off the rest, capped by how many of the station's
ten are still free. All ten were live whatever the header said. Nothing already
typed is cleared, which N1MM does under `DontErase`.

A time typed short is filled in from the line above as the cursor leaves the
box: `52` under `1148` reads as `1152`. `QTCLinesA_LostFocus` takes as many
characters off the front of the line above as the typed part is missing, and
drops a colon whatever the length. `QtcTraffic.FillTime` is that rule.

The addition: while the cursor is in a QTC callsign box the check window
answers for that call rather than the entry window's, so a call taken down in
traffic is checked the same way one being worked is. `CheckWindowSources`
takes the call from `Elsewhere` when the QTC window has set it.

Checked by driving the window: header `4/5` left five lines live and read
"1 of 5", tab walked header to time to call to serial to the next line's time,
shift and tab walked back, `52` on the second line became `1152`, and the check
window listed W1AW while `W1A` was typed in a QTC line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
This commit is contained in:
2026-09-09 21:17:03 +00:00
co-authored by Claude Opus 5
parent d4d0375f1f
commit 7ca2c5cd52
7 changed files with 203 additions and 24 deletions
+20
View File
@@ -731,6 +731,26 @@ being worked, and never more than the ten any station may have — and cannot be
edited. On CW and SSB the direction is fixed by the rules; on RTTY traffic goes
both ways and the window has a switch.
Tab, enter and space all move to the next box the operator types in, and shift
and tab move back. The walk covers those boxes and nothing else: N1MM handles
the three keys itself in every box rather than leaving them to the tab order,
so the cursor never lands on a switched-off line or on a button beside one.
The header says how many lines take traffic. `4/5` is series 4 with five QTCs
in it, so five lines are live and the other five are switched off; a station
with fewer than five of its ten left gets that many instead. Nothing already
typed is cleared when the header changes.
A time typed short is filled in from the line above as the cursor leaves the
box: `52` under `1148` reads as `1152`. As many characters are taken off the
front of the line above as the typed part is missing, which is N1MM's rule, and
a colon is dropped whatever the length.
One thing here that N1MM does not do: while the cursor is in a QTC callsign
box, the check window answers for that call rather than for the one in the
entry window, so a call taken down in traffic is checked the same way one being
worked is.
Right-click the window for **Setup**: how many QTCs a series carries, whether
the RX Ready and Cfm steps are stopped at, whether Hdr Agn and Agn clear what
they ask for again, and the CW messages below. The defaults are N1MM's — both
+2 -1
View File
@@ -1093,7 +1093,8 @@ public sealed partial class EntryWindow : Window
Status("nothing left to report to " + station.Text);
return;
}
QtcWindow window = new(session, Logging, station, direction, text => SendTextAsync(text));
QtcWindow window = new(
session, Logging, station, direction, text => SendTextAsync(text), Refresh);
bool saved = await window.ShowDialog<bool>(this);
Logging.Wipe();
SyncBoxes();
+1 -1
View File
@@ -26,7 +26,7 @@
<Grid Name="Lines" Margin="0,6,0,0" />
<TextBlock Name="KeysText" FontSize="11" Opacity="0.7" Margin="0,6,0,0" />
<TextBlock FontSize="11" Opacity="0.7" TextWrapping="Wrap" MaxWidth="420"
Text="Enter, Tab move forward — space moves within a QTC line. Green = saved, red = not filled, yellow = format error." />
Text="Tab, enter and space move to the next box, shift and tab back. Green = saved, red = not filled, yellow = format error." />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,6,0,0">
<TextBlock Name="StatusText" VerticalAlignment="Center" FontSize="11" Opacity="0.7" />
<Button Name="ClearButton" Content="Clear" Click="OnClear" />
+132 -20
View File
@@ -36,8 +36,15 @@ public sealed partial class QtcWindow : Window
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;
@@ -51,12 +58,14 @@ public sealed partial class QtcWindow : Window
OperatingPosition position,
Callsign station,
QtcDirection direction,
Func<string, Task> send)
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();
@@ -67,6 +76,8 @@ public sealed partial class QtcWindow : Window
SendButton.IsCheckedChanged += (_, _) => SetDirection(SendButton.IsChecked == true);
Reset();
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
HeaderBox.LostFocus += (_, _) => ApplyHeaderLimit();
Closed += (_, _) => ShowCall(null);
// focus set before the window is on screen does not stick
Opened += (_, _) => FirstBox().Focus();
}
@@ -187,12 +198,48 @@ public sealed partial class QtcWindow : Window
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()
@@ -201,14 +248,17 @@ public sealed partial class QtcWindow : Window
if (!isSending)
{
HeaderBox.Text = "";
foreach (Row row in rows)
// 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++)
{
row.SetEnabled(true);
rows[at].SetEnabled(at < activeLines);
}
Paint();
return;
}
ready.AddRange(traffic.ToSend(station, session.Settings.QtcLinesPerSeries));
activeLines = ready.Count;
HeaderBox.Text = ready.Count == 0
? ""
: IsRtty
@@ -229,6 +279,27 @@ public sealed partial class QtcWindow : Window
Paint();
}
/// 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()
@@ -243,8 +314,9 @@ public sealed partial class QtcWindow : Window
Row row = rows[at];
bool empty = row.IsEmpty;
WaeQtc? line = empty || header is null ? null : ReadLine(at, header.Value);
IBrush colour = line is not null ? Saved : empty ? Unsaved : Malformed;
row.Paint(colour);
row.Paint(at >= activeLines
? null
: line is not null ? Saved : empty ? Unsaved : Malformed);
if (line is not null)
{
good++;
@@ -263,8 +335,10 @@ public sealed partial class QtcWindow : Window
rows[at].Call.Text ?? "",
rows[at].Number.Text ?? "");
/// Enter and Tab move forward through the boxes; space moves within a line,
/// which is how an operator walks a QTC while listening.
/// 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)
@@ -273,6 +347,10 @@ public sealed partial class QtcWindow : Window
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;
@@ -284,21 +362,32 @@ public sealed partial class QtcWindow : Window
AskFieldAgain(FieldOf(e.Key));
break;
case Key.Enter when e.Source is TextBox:
e.Handled = true;
MoveOn();
break;
case Key.Space when e.Source is TextBox:
e.Handled = true;
MoveOn();
Move(1);
break;
}
}
private void MoveOn()
/// 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 = [HeaderBox];
foreach (Row row in rows)
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);
@@ -307,12 +396,22 @@ public sealed partial class QtcWindow : Window
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 + 1; next < order.Count; next++)
for (int next = at + by; next >= 0 && next < order.Count; next += by)
{
if (order[next].IsEffectivelyEnabled)
{
@@ -320,7 +419,10 @@ public sealed partial class QtcWindow : Window
return;
}
}
CloseButton.Focus();
if (by > 0)
{
CloseButton.Focus();
}
}
/// Reading traffic out, this asks the other station whether it is ready;
@@ -587,11 +689,21 @@ public sealed partial class QtcWindow : Window
&& (Call.Text ?? "").Trim().Length == 0
&& (Number.Text ?? "").Trim().Length == 0;
public void Paint(IBrush colour)
/// 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)
{
Time.Background = colour;
Call.Background = colour;
Number.Background = colour;
foreach (TextBox box in new[] { Time, Call, Number })
{
if (colour is null)
{
box.ClearValue(BackgroundProperty);
}
else
{
box.Background = colour;
}
}
}
public void Clear()
+9 -2
View File
@@ -19,12 +19,18 @@ public sealed class CheckWindowSources
this.bandmap = bandmap;
}
/// A callsign being typed somewhere other than the entry window. The QTC
/// window sets it while the cursor is in one of its callsign boxes, so a
/// call taken down in traffic is checked the same way one being worked is.
/// Null the rest of the time, and then the entry window's call is read.
public string? Elsewhere { get; set; }
/// The columns are read off what the operator has typed: the callsign box
/// feeds the call columns, and whichever exchange box has the cursor feeds
/// the exchange column.
public IReadOnlyList<CheckColumn> Columns(int limit = 24)
{
string query = session.Entry.Call.Trim().ToUpperInvariant();
string query = (Elsewhere ?? session.Entry.Call).Trim().ToUpperInvariant();
return
[
Column(CheckSource.Log, LoggedCalls(), query, limit),
@@ -46,7 +52,8 @@ public sealed class CheckWindowSources
private CheckColumn ExchangeColumn(int limit)
{
int focus = session.Entry.Focus;
if (focus == 0)
// a call typed outside the entry window brings no exchange box with it
if (Elsewhere is not null || focus == 0)
{
return new CheckColumn(CheckSource.Exchange, 0, []);
}
+16
View File
@@ -177,6 +177,22 @@ public sealed class QtcTraffic
: null;
}
/// A time typed short is filled in from the line above, which is what N1MM
/// does when the cursor leaves the box: `52` under `1148` reads as `1152`.
/// As many characters are taken off the front of the line above as the
/// typed part is missing. A colon is dropped whatever the length.
public static string FillTime(string typed, string above)
{
string text = typed.Trim().Replace(":", "");
string previous = above.Trim().Replace(":", "");
if (text.Length is 0 or >= 4)
{
return text;
}
int missing = 4 - text.Length;
return (previous.Length >= missing ? previous[..missing] : previous) + text;
}
private static bool IsClockTime(string text) =>
int.Parse(text[..2]) < 24 && int.Parse(text[2..]) < 60;
@@ -207,4 +207,27 @@ public class QtcTrafficTests
[InlineData("abc/10")]
public void AHeaderThatIsNotOneReadsAsNothing(string text) =>
Assert.Null(QtcTraffic.ReadHeader(text));
/// A time typed short takes the front off the line above, as many
/// characters as it is missing.
[Theory]
[InlineData("52", "1148", "1152")]
[InlineData("2", "1148", "1142")]
[InlineData("152", "1148", "1152")]
[InlineData("1207", "1148", "1207")]
[InlineData("12:07", "1148", "1207")]
public void ATimeTypedShortIsFilledInFromTheLineAbove(
string typed,
string above,
string filled) =>
Assert.Equal(filled, QtcTraffic.FillTime(typed, above));
/// The first line has nothing above it, so what was typed stands and is
/// judged as it is.
[Theory]
[InlineData("52", "52")]
[InlineData("", "")]
[InlineData(" 1148 ", "1148")]
public void ATimeOnTheFirstLineIsLeftAlone(string typed, string filled) =>
Assert.Equal(filled, QtcTraffic.FillTime(typed, ""));
}