Take WAE QTC traffic down
Ctrl+Z opens the QTC window for the station being worked, which is the key N1MM puts it on, and the window is laid out the way N1MM lays it out: the header, ten lines of time, callsign and serial number, an Agn and a Cfm button beside each, Clear, Close and Cancel underneath, and the same colours — green for a line that will be saved, red for one still empty, yellow for one that cannot be read. Enter and Tab move forward and space moves within a line, so a line can be walked while listening to it. Sending, the lines are filled from the log and cannot be edited: the contacts not reported to anybody yet, oldest first, never the station being worked, and never past the ten any station may have. The rule for what has been reported is N1MM's — a contact counts as reported when a QTC row carries its call and the serial we sent it — so the pair is kept in the row the way N1MM keeps it. Which way traffic goes is not the operator's choice on CW and SSB: Europe receives and the rest of the world sends. On RTTY it goes both ways, and there the window carries a switch. That is the one piece of the workflow that is ours rather than N1MM's, which toggles a QTC mode outside the window instead. Nothing here transmits. N1MM sends the lines on CW and RTTY and plays recorded voice messages on SSB; we have no QTC message templates, no voice keyer and no digital window, so Agn, Cfm and RX Ready move the cursor and the operator sends by hand. Running it caught what the tests could not: focus set in the constructor does not stick, so the first thing typed into a freshly opened window was dropped and every field after it took the value of the one before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -254,6 +254,10 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
MoveFocus(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();
|
||||
@@ -456,6 +460,50 @@ public sealed partial class EntryWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private async Task OpenQtcWindow()
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QtcTraffic traffic = new(Logging);
|
||||
if (!traffic.IsWaeContest)
|
||||
{
|
||||
Status("QTC traffic is a WAE thing");
|
||||
return;
|
||||
}
|
||||
if (Logging.Entry.Call.Trim().Length > 0 && Logging.Entry.IsComplete)
|
||||
{
|
||||
OnEnter();
|
||||
}
|
||||
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 (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);
|
||||
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()
|
||||
|
||||
29
src/Nonemm.App/Windows/QtcWindow.axaml
Normal file
29
src/Nonemm.App/Windows/QtcWindow.axaml
Normal file
@@ -0,0 +1,29 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Windows.QtcWindow"
|
||||
Title="QTC" SizeToContent="WidthAndHeight" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<StackPanel Margin="10" Spacing="4">
|
||||
<StackPanel Name="DirectionPanel" Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<RadioButton Name="ReceiveButton" Content="Receive" GroupName="direction" IsChecked="True" />
|
||||
<RadioButton Name="SendButton" Content="Send" GroupName="direction" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="QTC Header :" FontSize="11" Opacity="0.7" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBox Name="HeaderBox" Width="110" />
|
||||
<Button Name="ReadyButton" Content="RX Ready" Click="OnReady" />
|
||||
<Button Name="HeaderAgainButton" Content="Hdr Agn" Click="OnHeaderAgain" />
|
||||
<Button Name="HeaderCfmButton" Content="Cfm" Click="OnHeaderConfirm" />
|
||||
</StackPanel>
|
||||
<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." />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,6,0,0">
|
||||
<TextBlock Name="StatusText" VerticalAlignment="Center" FontSize="11" Opacity="0.7" />
|
||||
<Button Content="Clear" Click="OnClear" />
|
||||
<Button Name="CloseButton" Content="Close" Click="OnClose" IsDefault="True" />
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
372
src/Nonemm.App/Windows/QtcWindow.axaml.cs
Normal file
372
src/Nonemm.App/Windows/QtcWindow.axaml.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Contests.Rules;
|
||||
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.
|
||||
/// Nothing here transmits: on CW the operator sends from the entry window, on
|
||||
/// SSB by voice, on RTTY from the digital window there is not yet.
|
||||
public sealed partial class QtcWindow : Window
|
||||
{
|
||||
private static readonly IBrush Saved = new SolidColorBrush(Color.FromRgb(0xD4, 0xF5, 0xD4));
|
||||
private static readonly IBrush Unsaved = new SolidColorBrush(Color.FromRgb(0xFF, 0xE4, 0xE1));
|
||||
private static readonly IBrush Malformed = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xC4));
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly RadioPosition position;
|
||||
private readonly QtcTraffic traffic;
|
||||
private readonly Callsign station;
|
||||
private bool isSending;
|
||||
private readonly List<Row> rows = [];
|
||||
private readonly List<WaeQtc> ready = [];
|
||||
|
||||
public QtcWindow(
|
||||
AppSession session,
|
||||
RadioPosition position,
|
||||
Callsign station,
|
||||
QtcDirection direction)
|
||||
{
|
||||
this.session = session;
|
||||
this.position = position;
|
||||
this.station = station;
|
||||
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);
|
||||
// focus set before the window is on screen does not stick
|
||||
Opened += (_, _) => FirstBox().Focus();
|
||||
}
|
||||
|
||||
/// Where the cursor goes: the header when it has to be typed, the first
|
||||
/// line when it is already filled in.
|
||||
private TextBox FirstBox() => isSending ? rows[0].Time : HeaderBox;
|
||||
|
||||
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()
|
||||
{
|
||||
Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {station.Text}";
|
||||
KeysText.Text = isSending
|
||||
? $"{traffic.Remaining(station)} of the ten QTCs for {station.Text} are still free"
|
||||
: "Type the header, then a line per QTC: time, callsign, serial number.";
|
||||
ReadyButton.IsVisible = !isSending;
|
||||
HeaderAgainButton.IsVisible = !isSending;
|
||||
HeaderCfmButton.IsVisible = !isSending;
|
||||
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" })
|
||||
{
|
||||
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 = $"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) };
|
||||
int line = at;
|
||||
again.Click += (_, _) => OnLineAgain(line);
|
||||
confirm.Click += (_, _) => OnLineConfirm(line);
|
||||
Grid.SetRow(again, at);
|
||||
Grid.SetColumn(again, 3);
|
||||
Grid.SetRow(confirm, at);
|
||||
Grid.SetColumn(confirm, 4);
|
||||
Lines.Children.Add(again);
|
||||
Lines.Children.Add(confirm);
|
||||
rows.Add(new Row(time, call, number, again, confirm));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
Grid.SetRow(box, row);
|
||||
Grid.SetColumn(box, column);
|
||||
Lines.Children.Add(box);
|
||||
return box;
|
||||
}
|
||||
|
||||
/// 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 = "";
|
||||
foreach (Row row in rows)
|
||||
{
|
||||
row.SetEnabled(true);
|
||||
}
|
||||
Paint();
|
||||
return;
|
||||
}
|
||||
ready.AddRange(traffic.ToSend(station));
|
||||
// the contest is what makes it RTTY, not whatever the radio is on now
|
||||
bool rtty = position.Contest.Modes.Contains(ModeCategory.Digital);
|
||||
HeaderBox.Text = ready.Count == 0
|
||||
? ""
|
||||
: rtty
|
||||
? $"QTC {ready[0].SeriesText} - {station.Text}"
|
||||
: $"QTC {ready[0].SeriesText}";
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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);
|
||||
IBrush colour = line is not null ? Saved : empty ? Unsaved : Malformed;
|
||||
row.Paint(colour);
|
||||
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 ?? "");
|
||||
|
||||
/// Enter and Tab move forward through the boxes; space moves within a line,
|
||||
/// which is how an operator walks a QTC while listening.
|
||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
e.Handled = true;
|
||||
Close(false);
|
||||
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();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveOn()
|
||||
{
|
||||
List<TextBox> order = [HeaderBox, .. rows.SelectMany(r => new[] { r.Time, r.Call, r.Number })];
|
||||
int at = order.FindIndex(b => b.IsFocused);
|
||||
if (at < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int next = at + 1; next < order.Count; next++)
|
||||
{
|
||||
if (order[next].IsEffectivelyEnabled)
|
||||
{
|
||||
order[next].Focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
CloseButton.Focus();
|
||||
}
|
||||
|
||||
/// N1MM's RX Ready tells the other station to go ahead; with no transmit
|
||||
/// path here it only puts the cursor where the first line will land.
|
||||
private void OnReady(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
||||
|
||||
private void OnHeaderAgain(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
HeaderBox.Text = "";
|
||||
HeaderBox.Focus();
|
||||
}
|
||||
|
||||
private void OnHeaderConfirm(object? sender, RoutedEventArgs e) => rows[0].Time.Focus();
|
||||
|
||||
/// 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)
|
||||
{
|
||||
rows[at].Time.Focus();
|
||||
return;
|
||||
}
|
||||
rows[at].Clear();
|
||||
rows[at].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);
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
|
||||
/// 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)
|
||||
{
|
||||
public bool IsEmpty =>
|
||||
(Time.Text ?? "").Trim().Length == 0
|
||||
&& (Call.Text ?? "").Trim().Length == 0
|
||||
&& (Number.Text ?? "").Trim().Length == 0;
|
||||
|
||||
public void Paint(IBrush colour)
|
||||
{
|
||||
Time.Background = colour;
|
||||
Call.Background = colour;
|
||||
Number.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user