Files
Nonemm/src/Nonemm.App/Windows/EntryWindow.Macros.cs
T
erikbrockoandClaude Opus 5 871a28c10f Take a clicked word into the box the cursor is in
GrabIntoFocused puts text in whichever box has the cursor, whatever kind of
value that box normally holds. It is what N1MM's digital window does on
Ctrl+click, for the exchange element the contest has no box for.

The entry window also says when a CQ has gone out, which the digital window
needs to clear its grab list on: a run that starts again brings new callers.

GrabList.N1mmDepth is the ten calls N1MM's grab window holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
2026-09-01 00:03:08 +00:00

452 lines
15 KiB
C#

using Avalonia.Threading;
using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Keying;
using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App.Windows;
/// Running a function key message: the action macros in it, the text that goes
/// on the air, and the actions N1MM's `{END}` holds back until the message has
/// gone out.
public sealed partial class EntryWindow
{
/// A keyer that says nothing after this long is taken as finished, so the
/// actions after `{END}` still run.
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
/// What sends this radio's messages: the digital engine on a digital mode,
/// and the CW or voice keyer otherwise. A message therefore goes out the
/// way the mode needs without the caller choosing.
private MessageSender? Sender =>
Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine
? engine
: session.Keyer;
/// What to say when a message has nowhere to go. The mode decides which
/// keyer a message uses, so the mode is part of the answer: an operator on
/// a data mode who sees "no keyer for USB" knows to move the radio or the
/// band panel rather than to look at the keyer settings.
private string NoKeyer() =>
Logging is { } logging
? $"no keyer for {logging.Mode.Name} — Config ▸ Keyer"
: "no keyer — Config ▸ Keyer";
/// The twelve keys for this radio, which are a different twelve while
/// running and while searching, as N1MM's file holds them.
private IReadOnlyList<FunctionKey> Keys() =>
Messages
.For(
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
session.Settings.CwMessageFile,
session.Settings.PhoneMessageFile)
.Keys(Logging?.IsRunning ?? false);
/// Right-clicking a function key button opens the messages for the mode the
/// radio is in, which is where import and export live too.
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
private async Task EditMessages(ModeCategory mode)
{
string stored = mode == ModeCategory.Phone
? session.Settings.PhoneMessageFile
: session.Settings.CwMessageFile;
if (await new MessagesDialog(mode, stored, folder: session.Paths.FunctionKeyMessages).ShowDialog<string?>(this) is not { } edited)
{
return;
}
session.Save(mode == ModeCategory.Phone
? session.Settings with { PhoneMessageFile = edited }
: session.Settings with { CwMessageFile = edited });
BuildFunctionKeys();
Refresh();
}
private void SendMessage(int index) => _ = SendKeyAsync(index);
/// Sends one function key's message. The task finishes when the text has
/// been handed to the keyer, not when it has gone out on the air, so a
/// second key can be queued behind it. `prefix` carries the corrected call
/// that ESM puts in front of the message that ends a contact.
private async Task SendKeyAsync(int index, string prefix = "")
{
if (Logging is null)
{
return;
}
string template = Keys()[index].Message;
if (template.Length == 0)
{
return;
}
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
foreach (MessageAction action in plan.Before)
{
Run(action);
}
Refresh();
if (plan.Text.Length == 0)
{
// a key that only acts: {WIPE}, {RUN}, {TELNET sh/dx}
return;
}
if (Sender is null)
{
Status(NoKeyer());
return;
}
if (index == Esm.Exchange)
{
// the BARTG contest sends the time, and logs the one it sent
Logging.StampSentTime();
}
// only a CQ carries alternating CQ on to the other radio
if (index == Esm.CallCq)
{
session.Alternating?.CarryOn();
CqSent?.Invoke(this, EventArgs.Empty);
}
else
{
session.Alternating?.Hold();
}
string text = prefix + plan.Text;
// the box has to point at this radio before the key does
await session.PointTransmitAtAsync(radioNumber);
Status($"sending {text}");
await SendThenAsync(Sender, text, plan.After);
}
/// Sends a message that did not come from a function key. The QTC window
/// hands its traffic over this way, as N1MM's does through its own entry
/// window, so it goes out through the same keyer with the same macros
/// expanded.
/// `through` is the keyer the caller insists on: the digital window keys
/// its own engine whatever mode the entry window is on, which is what
/// N1MM's digital window does. Everyone else passes null and gets the keyer
/// the mode calls for. False when nothing went out, with the reason already
/// in the status line.
internal async Task<bool> SendTextAsync(string template, MessageSender? through = null)
{
if (Logging is null || template.Trim().Length == 0)
{
return false;
}
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
foreach (MessageAction action in plan.Before)
{
Run(action);
}
if (plan.Text.Length == 0)
{
// a button that only acts: {WIPE}, {LOG}, {RUN}
return true;
}
if ((through ?? Sender) is not { } keyer)
{
Status(NoKeyer());
return false;
}
await session.PointTransmitAtAsync(radioNumber);
Status($"sending {plan.Text}");
await SendThenAsync(keyer, plan.Text, plan.After);
return true;
}
/// Hands the text to the keyer, and leaves what follows `{END}` to run on
/// its own once the keyer says the message has gone out.
private async Task SendThenAsync(MessageSender keyer, string text, IReadOnlyList<MessageAction> after)
{
try
{
sending = true;
Refresh();
await keyer.SendAsync(text);
}
catch (InvalidOperationException e)
{
sending = false;
Status(e.Message);
return;
}
_ = RunWhenSentAsync(keyer, after);
}
/// Waits for the keyer, turns the transmit light off, and runs whatever
/// stood after `{END}`.
private async Task RunWhenSentAsync(MessageSender keyer, IReadOnlyList<MessageAction> after)
{
await WhenSentAsync(keyer);
Dispatcher.UIThread.Post(() =>
{
sending = false;
foreach (MessageAction action in after)
{
Run(action);
}
Refresh();
});
}
/// Waits for the keyer to say the message has gone out. A keyer that does
/// not report completion is taken at its word as soon as the text is sent,
/// because the alternative is never running what follows `{END}`.
private static async Task WhenSentAsync(MessageSender keyer)
{
if (!keyer.ReportsCompletion)
{
return;
}
TaskCompletionSource sent = new();
void Finished(object? sender, EventArgs e) => sent.TrySetResult();
keyer.Finished += Finished;
try
{
await sent.Task.WaitAsync(SendingPatience);
}
catch (TimeoutException)
{
}
finally
{
keyer.Finished -= Finished;
}
}
private void Run(MessageAction action)
{
if (Logging is null)
{
return;
}
switch (action.Command)
{
case MessageCommand.Wipe:
Logging.Wipe();
SyncBoxes();
boxes[0].Focus();
break;
case MessageCommand.Log:
LogContact();
break;
case MessageCommand.Run:
Logging.IsRunning = true;
break;
case MessageCommand.SearchAndPounce:
Logging.IsRunning = false;
break;
case MessageCommand.Space:
MoveFocus(forward: true);
break;
case MessageCommand.SpotMe:
SpotMe();
break;
case MessageCommand.ExchangeSent:
exchangeSent = true;
break;
// the manual's table has the two page macros the other way round;
// the keys they are named after move the frequency this way
case MessageCommand.FrequencyUp:
Step(session.Settings.FrequencyStepHertz);
break;
case MessageCommand.FrequencyDown:
Step(-session.Settings.FrequencyStepHertz);
break;
case MessageCommand.PageUp:
Step(session.Settings.PageStepHertz);
break;
case MessageCommand.PageDown:
Step(-session.Settings.PageStepHertz);
break;
case MessageCommand.StopSending:
sending = false;
_ = Sender?.AbortAsync();
break;
case MessageCommand.Telnet:
SendToCluster(action.Argument);
break;
case MessageCommand.JumpToOtherRadio:
session.SwapRadio();
break;
case MessageCommand.JumpToOtherRadioWithTransmit:
session.SwapRadio();
_ = session.PointTransmitAtAsync(session.ActiveRadioNumber);
break;
case MessageCommand.SendOnOtherRadio:
SendOnOtherRadio(action.Argument);
break;
case MessageCommand.Otrsp:
_ = session.Box?.SendCommandAsync(action.Argument);
break;
case MessageCommand.StackAnother:
StackAnother();
break;
case MessageCommand.SwapWithStack:
SwapWithStack();
break;
case MessageCommand.LogThenPop:
LogThenPop();
break;
case MessageCommand.ClearStack:
Logging.Stack.Clear();
ShowCallStack();
break;
// the digital engine keys itself while it has text to send, so
// {TX} and {RX} only matter when a macro wants the transmitter
// held open around what it sends
case MessageCommand.StartTransmit:
_ = session.Digital?.SetPttAsync(true);
break;
case MessageCommand.ReturnToReceive:
_ = session.Digital?.ReturnToReceiveAsync();
break;
}
}
/// N1MM's {STACKANOTHER} and Ctrl+Alt+G.
private void StackAnother()
{
if (Logging is null || !Logging.StackAnother())
{
return;
}
SyncBoxes();
boxes[0].Focus();
ShowCallStack();
Status($"{Logging.Stack.Count} stacked");
}
/// N1MM's Alt+G.
private void GrabStacked()
{
if (Logging is null || !Logging.GrabStacked())
{
return;
}
SyncBoxes();
boxes[0].Focus();
ShowCallStack();
}
/// N1MM's Alt+D: the call on top of the stack is dropped without being
/// worked.
private void DropStacked()
{
if (Logging is null || Logging.Stack.IsEmpty)
{
return;
}
Status($"{Logging.Stack.Next()} dropped");
ShowCallStack();
}
/// N1MM's {SOCALLSTACK}.
private void SwapWithStack()
{
if (Logging is null)
{
return;
}
Logging.SwapWithStack();
SyncBoxes();
boxes[0].Focus();
ShowCallStack();
}
/// N1MM's {LOGTHENPOP} and {LOGTHENNEXT}: one key logs the contact and
/// brings the next caller into the boxes, so a run does not stop between
/// callers.
private void LogThenPop()
{
if (Logging is null || !Logging.IsRunning)
{
return;
}
LogContact();
GrabStacked();
}
private void Step(int hertz)
{
if (Logging is null)
{
return;
}
Frequency moved = Frequency.FromHertz(Logging.Frequency.Hertz + hertz);
Logging.Tune(moved);
_ = session.Radio?.TuneAsync(moved);
Refresh();
}
/// N1MM only self-spots while running, and so does this: a station that is
/// not calling CQ has nothing to tell the cluster.
private void SpotMe()
{
if (Logging is null || session.Cluster is not { IsConnected: true } cluster)
{
Status("not connected to a cluster node");
return;
}
if (!Logging.IsRunning)
{
Status("self-spotting is for running only");
return;
}
Callsign me = Callsign.Parse(Logging.Me.Callsign);
string comment = MessageExpander.Expand(
session.Settings.SpotComment, Logging, session.Other(Logging));
_ = cluster.SendSpotAsync(Logging.Frequency, me, comment);
session.Bandmap.Add(new Spot(me, Logging.Frequency, DateTime.UtcNow, SpotSource.Operator));
Status($"spotted {me.Text} on {Logging.Frequency.Kilohertz:0.0}");
}
private void SendToCluster(string command)
{
if (session.Cluster is not { } cluster)
{
Status("not connected to a cluster node");
return;
}
_ = cluster.SendAsync(command);
Status($"cluster: {command}");
}
/// `{CTRLFn}` sends the other radio's message n while the keyboard stays
/// here, which is how an SO2R station CQs on the other radio.
private void SendOnOtherRadio(string key)
{
if (Logging is null
|| session.Other(Logging) is not { } other
|| Sender is null
|| !int.TryParse(key, out int number))
{
return;
}
string template = Messages
.For(other.Mode.Category, session.Settings.CwMessageFile, session.Settings.PhoneMessageFile)
.Keys(other.IsRunning)[number - 1]
.Message;
if (template.Length == 0)
{
return;
}
string text = MessageExpander.Expand(template, other, Logging);
_ = SendOnOtherRadioAsync(other.RadioNumber, text);
Status($"radio {other.RadioNumber}: {text}");
}
private async Task SendOnOtherRadioAsync(int radio, string text)
{
await session.PointTransmitAtAsync(radio);
try
{
await Sender!.SendAsync(text);
}
catch (InvalidOperationException e)
{
Status(e.Message);
}
}
}