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 send; private bool isSending; /// The line last read out, which is the one the number keys repeat a field /// of. private int lastSent; private readonly List rows = []; private readonly List 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 send) { this.session = session; this.position = position; this.station = station; this.send = send; 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: 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" : $"{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."; } 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); /// 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() { Title = isSending ? $"Send QTC · {station.Text}" : $"Receive QTC · {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 = IsCw || !isSending; ReadyButton.Content = !IsCw ? "RX Ready" : isSending ? "R U QRV" : "QRV"; HeaderAgainButton.Content = isSending && IsCw ? "Snd Hdr" : "Hdr Agn"; HeaderAgainButton.IsVisible = !isSending || IsCw; 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" }) { 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) }; int line = at; again.Click += (_, _) => OnLineAgain(line); confirm.Click += (_, _) => OnLineConfirm(line); // nothing to confirm while reading our own log out, which is how // N1MM hides the column confirm.IsVisible = !isSending; 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, session.Settings.QtcLinesPerSeries)); 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(); } /// 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.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: e.Handled = true; MoveOn(); break; case Key.Space when e.Source is TextBox: e.Handled = true; MoveOn(); break; } } private void MoveOn() { List order = [HeaderBox]; foreach (Row row in rows) { order.Add(row.Time); order.Add(row.Call); order.Add(row.Number); if (!session.Settings.QtcSkipConfirm && row.Confirm.IsVisible) { order.Add(row.Confirm); } } 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(); } /// 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); } Control next = isSending ? rows[0].Again : rows[0].Time; 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) { _ = send(HeaderBox.Text ?? ""); rows[0].Again.Focus(); return; } if (IsCw) { _ = send(QtcMessages.HeaderAgain); } if (session.Settings.QtcHeaderAgainClears) { 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) { if (IsCw || IsRtty) { SendLine(at); return; } rows[at].Time.Focus(); return; } if (session.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 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(); 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 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); if (IsCw && session.Settings.QtcCwTu.Trim().Length > 0) { _ = send(QtcMessages.Tu(session.Settings.QtcCwTu, HeaderBox.Text ?? "")); } 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(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 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; } } }