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
+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()