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
416 lines
14 KiB
C#
416 lines
14 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Nonemm.App.Theming;
|
|
using Nonemm.Session;
|
|
|
|
namespace Nonemm.App.Windows;
|
|
|
|
/// The calls heard and what the pointer does to them: the grab list beside the
|
|
/// transmit pane, and the clicking and hovering in the receive pane.
|
|
public sealed partial class DigitalWindow
|
|
{
|
|
/// The call the pointer is resting on, which hover mode takes and the
|
|
/// modifier keys act on.
|
|
private string underPointer = "";
|
|
|
|
/// A callsign the modem decoded goes in the grab list, unless it is ours,
|
|
/// the one already being typed, or the filter turns it away.
|
|
private void Heard(string call)
|
|
{
|
|
if (Radio is not { } position
|
|
|| !GrabList.Wanted(call, position.Me.Callsign, position.Entry.Call)
|
|
|| !PassesFilter(position, call))
|
|
{
|
|
return;
|
|
}
|
|
grab.NewestFirst = Settings.DigitalGrabNewestFirst;
|
|
if (grab.Add(call))
|
|
{
|
|
ShowGrabList();
|
|
}
|
|
}
|
|
|
|
private bool PassesFilter(OperatingPosition position, string call) =>
|
|
Settings.DigitalGrabFilter.ToLowerInvariant() switch
|
|
{
|
|
"notdupes" => position.JudgeCall(call)?.IsDupe != true,
|
|
"knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0,
|
|
_ => true,
|
|
};
|
|
|
|
private void ShowGrabList()
|
|
{
|
|
GrabCalls.Children.Clear();
|
|
if (Radio is not { } position)
|
|
{
|
|
return;
|
|
}
|
|
foreach (string call in grab.Calls)
|
|
{
|
|
GrabCalls.Children.Add(GrabRow(position, call));
|
|
}
|
|
}
|
|
|
|
private Control GrabRow(OperatingPosition position, string call)
|
|
{
|
|
TextBlock text = new()
|
|
{
|
|
Text = call,
|
|
FontFamily = new FontFamily("monospace"),
|
|
FontSize = Settings.DigitalFontSize,
|
|
Foreground = Verdicts.Colour(position.JudgeCall(call)),
|
|
};
|
|
Border row = new()
|
|
{
|
|
Padding = new Thickness(4, 1),
|
|
Cursor = new Cursor(StandardCursorType.Hand),
|
|
// a border with no background of its own is not clickable away from
|
|
// the letters, and the whole row is the target
|
|
Background = Brushes.Transparent,
|
|
Tag = call,
|
|
Child = text,
|
|
};
|
|
row.PointerPressed += (_, e) => GrabFromList(call, e);
|
|
return row;
|
|
}
|
|
|
|
/// Left click grabs the call; the right click menu is on the list itself.
|
|
private void GrabFromList(string call, PointerPressedEventArgs e)
|
|
{
|
|
if (!e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
|
{
|
|
Grab(call);
|
|
}
|
|
}
|
|
|
|
/// N1MM's grab list menu: clear the whole list, clear the call under the
|
|
/// pointer, or change the order calls come off it in.
|
|
private void OnGrabListPressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
if (!e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
|
{
|
|
return;
|
|
}
|
|
string call = (e.Source as Control)?.FindAncestorOfType<Border>()?.Tag as string ?? "";
|
|
MenuFlyout menu = new();
|
|
menu.Items.Add(Item("Clear list", () =>
|
|
{
|
|
grab.Clear();
|
|
ShowGrabList();
|
|
}));
|
|
menu.Items.Add(Item($"Clear selected call{(call.Length > 0 ? $" ({call})" : "")}", () =>
|
|
{
|
|
grab.Remove(call);
|
|
ShowGrabList();
|
|
}, call.Length > 0));
|
|
menu.Items.Add(new Separator());
|
|
menu.Items.Add(Item("Last in, first out", () => GrabOrder(newestFirst: true)));
|
|
menu.Items.Add(Item("First in, first out", () => GrabOrder(newestFirst: false)));
|
|
menu.ShowAt(GrabCalls, showAtPointer: true);
|
|
}
|
|
|
|
private static MenuItem Item(string header, Action act, bool enabled = true)
|
|
{
|
|
MenuItem item = new() { Header = header, IsEnabled = enabled };
|
|
item.Click += (_, _) => act();
|
|
return item;
|
|
}
|
|
|
|
/// Puts a call in the entry window. With call stacking on and a call
|
|
/// already being typed, the one being typed goes on the stack first, so
|
|
/// neither is lost — N1MM's digital call stacking.
|
|
private void Grab(string call, bool space = true)
|
|
{
|
|
if (Radio is not { } position)
|
|
{
|
|
return;
|
|
}
|
|
position.Stack.Order = StackingOrder();
|
|
if (position.Stack.Order != StackOrder.Disabled
|
|
&& position.Entry.Call.Trim().Length > 0)
|
|
{
|
|
position.StackAnother();
|
|
}
|
|
grab.Remove(call);
|
|
ShowGrabList();
|
|
entry.GrabCall(call, space && Settings.DigitalGrabSendsSpace);
|
|
entry.Refresh();
|
|
}
|
|
|
|
/// N1MM's Grab button: the next call off the list.
|
|
private void OnGrabNext(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (grab.Count > 0)
|
|
{
|
|
Grab(grab.Calls[0]);
|
|
}
|
|
}
|
|
|
|
/// What the pointer rests on: the Letters/Figs and MouseOver boxes follow
|
|
/// it, and with hover mode on a callsign goes to the entry window without a
|
|
/// click at all.
|
|
private void OnReceivePointerMoved(object? sender, PointerEventArgs e)
|
|
{
|
|
if (e.Source is not TextBlock block || block.Text is not { } text || text.Trim().Length == 0)
|
|
{
|
|
Unhighlight();
|
|
return;
|
|
}
|
|
if (ReferenceEquals(block, hovered))
|
|
{
|
|
return;
|
|
}
|
|
Unhighlight();
|
|
hovered = block;
|
|
block.TextDecorations = TextDecorations.Underline;
|
|
underPointer = DigitalReceiver.Scrub(text);
|
|
MouseOverText.Text = underPointer;
|
|
// the box is Baudot's, so it says nothing in a mode that is not RTTY,
|
|
// which is what N1MM writes in it
|
|
FigsText.Text = (Radio?.Mode ?? Nonemm.Core.Modes.Rtty) == Nonemm.Core.Modes.Rtty
|
|
? Baudot.LettersFigs(underPointer)
|
|
: "NOT USED";
|
|
WhenPointerRestsOnCall(underPointer);
|
|
}
|
|
|
|
/// N1MM's hover mode and its callsign pause routines, both worked off the
|
|
/// call the pointer is resting on: the call goes into an empty callsign box
|
|
/// on its own, and the pane stops so the text under the pointer stays where
|
|
/// it is.
|
|
private void WhenPointerRestsOnCall(string text)
|
|
{
|
|
if (Radio is not { } position || CallToTake(position, text) is not { Length: > 0 } call)
|
|
{
|
|
return;
|
|
}
|
|
if (Settings.DigitalPauseOnCallsign && position.Entry.Call.Trim().Length == 0)
|
|
{
|
|
paused = true;
|
|
ShowPauseBar();
|
|
}
|
|
if (Settings.DigitalHoverMode && position.Entry.Call.Trim().Length == 0)
|
|
{
|
|
Grab(call, Settings.DigitalClickSendsSpace);
|
|
}
|
|
}
|
|
|
|
private void OnReceivePointerExited(object? sender, PointerEventArgs e)
|
|
{
|
|
Unhighlight();
|
|
Resume();
|
|
}
|
|
|
|
private void Unhighlight()
|
|
{
|
|
if (hovered is not null)
|
|
{
|
|
hovered.TextDecorations = null;
|
|
hovered = null;
|
|
}
|
|
underPointer = "";
|
|
MouseOverText.Text = "";
|
|
FigsText.Text = "";
|
|
}
|
|
|
|
/// One left click copies the word under the pointer, the way N1MM does: a
|
|
/// callsign goes in the callsign box, anything else in the exchange box the
|
|
/// contest keeps for it. Ctrl puts it in the box the cursor is in whatever
|
|
/// it holds, shift converts it to the other Baudot case first, alt puts a
|
|
/// call on the call stack, and a double click replaces a call already in
|
|
/// the box. A right click presses Enter in the entry window when that
|
|
/// option is on, and opens the menu when it is not.
|
|
private void OnReceivePointerPressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
|
{
|
|
RightClick(e);
|
|
return;
|
|
}
|
|
if (TakenAsQtc(e))
|
|
{
|
|
return;
|
|
}
|
|
string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
|
|
if (clicked.Length == 0)
|
|
{
|
|
paused = true;
|
|
ShowPauseBar();
|
|
return;
|
|
}
|
|
Resume();
|
|
KeyModifiers keys = e.KeyModifiers;
|
|
if (keys.HasFlag(KeyModifiers.Shift))
|
|
{
|
|
clicked = Baudot.LettersFigs(clicked);
|
|
}
|
|
if (keys.HasFlag(KeyModifiers.Control))
|
|
{
|
|
entry.GrabIntoFocused(DigitalElement.Trim(clicked));
|
|
return;
|
|
}
|
|
Copy(clicked, keys.HasFlag(KeyModifiers.Alt), e.ClickCount > 1);
|
|
}
|
|
|
|
/// While the QTC window is taking traffic down on RTTY, a click anywhere in
|
|
/// a line hands the whole line to it rather than picking the word out: the
|
|
/// other station sends the header and the whole series in one
|
|
/// transmission, and the operator clicks each line as it prints. The line
|
|
/// flashes to show which one was taken, which is what N1MM does with it.
|
|
/// False when there is no QTC window, or the line is not a QTC, and then
|
|
/// the click means what it always means.
|
|
private bool TakenAsQtc(PointerPressedEventArgs e)
|
|
{
|
|
if (session.QtcLines is not { } take || LineUnder(e.Source as Visual) is not { } border)
|
|
{
|
|
return false;
|
|
}
|
|
string text = DigitalReceiver.Scrub(string.Concat(
|
|
Body(border).Children.OfType<TextBlock>().Select(b => b.Text ?? "")));
|
|
if (text.Trim().Length == 0 || !take(text))
|
|
{
|
|
return false;
|
|
}
|
|
Flash(border);
|
|
return true;
|
|
}
|
|
|
|
/// The line a clicked control belongs to: the border a printed line lives
|
|
/// in, walking up from whatever was under the pointer.
|
|
private static Border? LineUnder(Visual? clicked)
|
|
{
|
|
for (Visual? at = clicked; at is not null; at = at.GetVisualParent())
|
|
{
|
|
if (at is Border border && border.Child is WrapPanel)
|
|
{
|
|
return border;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// A moment of colour behind the line that was taken.
|
|
private static void Flash(Border border)
|
|
{
|
|
IBrush was = border.Background ?? Brushes.Transparent;
|
|
border.Background = Themes.Brush(Themes.Current.QuickEdit);
|
|
DispatcherTimer.RunOnce(() => border.Background = was, TimeSpan.FromMilliseconds(400));
|
|
}
|
|
|
|
/// Where a clicked word goes. N1MM's order: an empty callsign box takes a
|
|
/// call, a callsign box that already holds one takes another only onto the
|
|
/// call stack or on a double click, and everything else is an exchange
|
|
/// element.
|
|
private void Copy(string clicked, bool toStack, bool replaces)
|
|
{
|
|
if (Radio is not { } position)
|
|
{
|
|
return;
|
|
}
|
|
string call = CallToTake(position, clicked);
|
|
bool waiting = position.Entry.Call.Trim().Length == 0;
|
|
if (call.Length > 0 && toStack)
|
|
{
|
|
position.Stack.Order = StackingOrder();
|
|
position.Stack.Add(call, position.JudgeCall(call)?.IsNewMultiplier == true);
|
|
grab.Remove(call);
|
|
ShowGrabList();
|
|
entry.Refresh();
|
|
return;
|
|
}
|
|
if (call.Length > 0 && (waiting || replaces || StackingOrder() != StackOrder.Disabled))
|
|
{
|
|
Grab(call, Settings.DigitalClickSendsSpace);
|
|
return;
|
|
}
|
|
if (!waiting)
|
|
{
|
|
entry.GrabExchange(DigitalElement.Trim(clicked));
|
|
}
|
|
}
|
|
|
|
/// The callsign in a clicked or hovered word, or empty when it is not one
|
|
/// this station would work. Our own call is never taken.
|
|
private string CallToTake(OperatingPosition position, string clicked)
|
|
{
|
|
string call = DigitalReceiver.TrimToCall(clicked);
|
|
bool isCall = call.Length is >= 3 and < 13
|
|
&& !call.All(char.IsAsciiDigit)
|
|
&& DigitalReceiver.IsCallsign(call)
|
|
&& !string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase);
|
|
return isCall ? call : "";
|
|
}
|
|
|
|
private void RightClick(PointerPressedEventArgs e)
|
|
{
|
|
if (Settings.DigitalRightClickSendsEnter)
|
|
{
|
|
entry.PressEnter();
|
|
if (!Settings.DigitalRightClickKeepsCall && Radio is { } position)
|
|
{
|
|
position.Wipe();
|
|
entry.Refresh();
|
|
}
|
|
return;
|
|
}
|
|
MenuFlyout menu = new();
|
|
menu.Items.Add(Item("Clear RX", () => OnClearReceive(this, new RoutedEventArgs())));
|
|
menu.Items.Add(Item("Output to text file", () =>
|
|
Store(Settings with { DigitalOutputToTextFile = !Settings.DigitalOutputToTextFile })));
|
|
menu.Items.Add(Item(paused ? "Let the pane run" : "Hold the pane still", () =>
|
|
{
|
|
if (paused)
|
|
{
|
|
Resume();
|
|
}
|
|
else
|
|
{
|
|
paused = true;
|
|
ShowPauseBar();
|
|
}
|
|
}));
|
|
menu.ShowAt(ReceiveLines, showAtPointer: true);
|
|
}
|
|
|
|
/// N1MM's pause bar: the strip beside the receive pane, green while text is
|
|
/// printing and yellow while it is held.
|
|
private void OnPauseBarPressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
if (paused)
|
|
{
|
|
Resume();
|
|
return;
|
|
}
|
|
paused = true;
|
|
ShowPauseBar();
|
|
}
|
|
|
|
private void ShowPauseBar()
|
|
{
|
|
PauseBar.Background = paused ? Brushes.Gold : new SolidColorBrush(Color.FromRgb(0x2E, 0x8B, 0x57));
|
|
PauseText.Text = paused ? "the pane is held still" : "";
|
|
}
|
|
|
|
/// Lets the pane scroll again and prints what arrived while it was stopped.
|
|
private void Resume()
|
|
{
|
|
if (!paused)
|
|
{
|
|
return;
|
|
}
|
|
paused = false;
|
|
ShowPauseBar();
|
|
if (held.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
string waiting = held.ToString();
|
|
held.Clear();
|
|
Print(waiting);
|
|
}
|
|
}
|