diff --git a/README.md b/README.md
index ae74595..1511977 100644
--- a/README.md
+++ b/README.md
@@ -132,7 +132,8 @@ macros that are filled in:
The action macros run rather than standing for text: `{WIPE}`, `{LOG}`,
`{RUN}`, `{S&P}`, `{SPACE}`, `{SPOTME}`, `{STOPTX}`, `{FREQUP}`, `{FREQDN}`,
-`{PGUP}`, `{PGDN}`, `{JUMPRX}`, `{JUMPRXTX}`, `{CTRLF1}` to `{CTRLF12}` to send
+`{PGUP}`, `{PGDN}`, `{JUMPRX}`, `{JUMPRXTX}`, `{STACKANOTHER}`, `{SOCALLSTACK}`,
+`{LOGTHENPOP}`, `{LOGTHENNEXT}`, `{CLRSTACK}`, `{CTRLF1}` to `{CTRLF12}` to send
on the other radio, `{TELNET sh/dx}` to put a command on the cluster, and
`{OTRSP TX2}` to send one to the SO2R box. An action runs before the message
goes out, unless it stands after `{END}`, and then it runs once the message has
@@ -145,8 +146,8 @@ How far `{FREQUP}` and the rest move the radio is `FrequencyStepHertz` and
for the same two numbers in its Configurer.
The rest of N1MM's action macros — the CAT families, audio, rotators, stereo,
-call stacking, the digital TNC macros — are read and passed over, so a message
-that holds one still sends the right characters.
+the digital TNC macros — are read and passed over, so a message that holds one
+still sends the right characters.
### ESM — Enter sends the message
@@ -204,6 +205,41 @@ Ctrl+? switches between them, and F1 puts you into run mode, as in N1MM.
Two lights sit beside them: the left one is green while a radio is answering,
the right one red while something is going out on the air.
+### Call stacking
+
+A CQ is answered by more than one station at a time. Rather than ask the others
+to call again, put them on the call stack and work them one after another. This
+is N1MM's single-operator call stacking, with N1MM's keys.
+
+| Key | Macro | What it does |
+|---|---|---|
+| Ctrl+Alt+G | `{STACKANOTHER}` | puts the call being typed on the stack and clears the boxes, so the next caller can be copied |
+| Alt+G | | the call on top of the stack replaces what is typed |
+| Alt+D | | drops the call on top without working it |
+| | `{SOCALLSTACK}` | one key that both stacks and unstacks: it swaps the call being typed for the one on top, stacks it when the stack is empty, and takes the top call when the boxes are |
+| | `{LOGTHENPOP}`, `{LOGTHENNEXT}` | logs the contact and brings the next caller into the boxes in one key |
+| | `{CLRSTACK}` | empties the stack |
+
+Stacking only works while running: a station you are chasing is worked now, not
+later. A dupe is not stacked, nor is your own call, nor a frequency typed into
+the callsign box. `{SOCALLSTACK}` takes the stacked call off before it puts the
+typed one on, so a multiplier you have just typed cannot come straight back into
+the boxes.
+
+A call that would bring a multiplier goes to the front of the stack and the rest
+to the back, which is the order N1MM uses on CW and phone. N1MM's digital window
+also offers plain first-in-first-out and last-in-first-out; there is no digital
+window here, so every mode gets that one order.
+
+The stack window opens by itself when the first call goes on and closes when the
+last one comes off, or **Window ▸ Call Stack** opens it. The next call out is
+ringed, and each is coloured by what working it would bring, as everywhere else.
+Double-click a call to bring it to the front, right-click to drop it.
+
+What is not here: N1MM's ESM "next call key" setting, which picks the function
+key ESM sends when a contact is logged with calls still stacked, and passing
+stacked calls to the other stations of a multi-operator entry.
+
### The buttons and the title bar
The twelve function keys sit in two rows of six, with the row of actions under
@@ -214,9 +250,10 @@ them. Each row fills the width, and the first button of the action row takes the
The action row is N1MM's: Esc: Stop, Wipe, Log It, Edit, Spot It and QRZ, each
doing what its key does. QRZ opens the callsign's page in a browser.
-N1MM's other two buttons are not there. Mark puts a `Busy@` marker on your own
-bandmap at the frequency you are on, which needs a bandmap entry that is not a
-station. Store is call stacking, which is not here either.
+N1MM's other two buttons are not there. Both put something on your own bandmap
+rather than on the cluster: Mark leaves a `Busy@` marker at the frequency you
+are on, which needs a bandmap entry that is not a station, and Store puts the
+call being typed there so you can come back to it.
The title bar says what N1MM's says: the frequency, the mode, whether the
frequency came from a radio or was typed, and which radio the window belongs to
diff --git a/docs/unfinished.md b/docs/unfinished.md
index f013cc1..e5dd250 100644
--- a/docs/unfinished.md
+++ b/docs/unfinished.md
@@ -75,7 +75,7 @@ when a download brings back something unreadable.
**Not every action macro is acted on.** The text macros and the action macros
that this program can carry out are listed in the README. What is read and
passed over: the `{CAT…}` and radio-hex families, the audio and rotator macros,
-`{STEREOON}` and `{STEREOOFF}`, the call-stacking macros, `{CONDJUMP}`,
+`{STEREOON}` and `{STEREOOFF}`, `{CONDJUMP}`,
`{QSYCQ}`, `{FORCELOG}`, `{SwapContests}`, the digital TNC macros (`{ENTER}`,
`{ESC}`, `{CTRL-A}`…), and the wav-directory macros, which wait on voice keying.
A message holding any of them still sends the right characters.
diff --git a/src/Nonemm.App/Windows/CallStackWindow.axaml b/src/Nonemm.App/Windows/CallStackWindow.axaml
new file mode 100644
index 0000000..a9a9fe6
--- /dev/null
+++ b/src/Nonemm.App/Windows/CallStackWindow.axaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/CallStackWindow.axaml.cs b/src/Nonemm.App/Windows/CallStackWindow.axaml.cs
new file mode 100644
index 0000000..9d9b9a5
--- /dev/null
+++ b/src/Nonemm.App/Windows/CallStackWindow.axaml.cs
@@ -0,0 +1,87 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Nonemm.Session;
+
+namespace Nonemm.App.Windows;
+
+/// The calls waiting to be worked, next one at the top and ringed. Each is
+/// coloured by what working it would bring, the way every other window colours
+/// a station. Double-click one to bring it to the top, right-click to drop it.
+public sealed partial class CallStackWindow : RefreshableWindow
+{
+ private readonly AppSession session;
+ private readonly int radioNumber;
+
+ public CallStackWindow(AppSession session, int radioNumber = 1)
+ {
+ this.session = session;
+ this.radioNumber = radioNumber;
+ InitializeComponent();
+ Refresh();
+ }
+
+ private OperatingPosition? Radio =>
+ session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
+
+ public override void Refresh()
+ {
+ Calls.Children.Clear();
+ if (Radio is not { } position)
+ {
+ return;
+ }
+ ClearButton.IsEnabled = !position.Stack.IsEmpty;
+ foreach (string call in position.Stack.Calls)
+ {
+ Calls.Children.Add(Row(position, call, isNext: call == position.Stack.Top));
+ }
+ }
+
+ private Control Row(OperatingPosition position, string call, bool isNext)
+ {
+ TextBlock text = new()
+ {
+ Text = call,
+ FontFamily = new FontFamily("monospace"),
+ FontSize = 14,
+ Foreground = Verdicts.Colour(position.JudgeCall(call)),
+ };
+ Border row = new()
+ {
+ CornerRadius = new CornerRadius(9),
+ BorderThickness = new Thickness(1),
+ BorderBrush = isNext ? text.Foreground : Brushes.Transparent,
+ Padding = new Thickness(6, 1),
+ Margin = new Thickness(0, 0, 0, 2),
+ Cursor = new Cursor(StandardCursorType.Hand),
+ Child = text,
+ };
+ row.PointerPressed += (_, e) =>
+ {
+ if (e.GetCurrentPoint(row).Properties.IsRightButtonPressed)
+ {
+ position.Stack.Remove(call);
+ position.Session.NotifyChanged();
+ }
+ };
+ row.DoubleTapped += (_, _) =>
+ {
+ position.Stack.MoveToTop(call);
+ position.Session.NotifyChanged();
+ };
+ return row;
+ }
+
+ private void OnClear(object? sender, RoutedEventArgs e)
+ {
+ if (Radio is not { } position)
+ {
+ return;
+ }
+ position.Stack.Clear();
+ position.Session.NotifyChanged();
+ }
+}
diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs
index 469dc83..9b2bac4 100644
--- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs
@@ -211,9 +211,85 @@ public sealed partial class EntryWindow
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;
}
}
+ /// 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)
diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
index 27d0845..48fc895 100644
--- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
@@ -353,6 +353,28 @@ public sealed partial class EntryWindow
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
+ private void OnShowCallStack(object? sender, RoutedEventArgs e) =>
+ Show(() => new CallStackWindow(session, radioNumber));
+
+ /// N1MM opens the stack window when the first call goes on it and closes it
+ /// when the last one comes off.
+ private void ShowCallStack()
+ {
+ if (Logging is null)
+ {
+ return;
+ }
+ if (Logging.Stack.IsEmpty)
+ {
+ if (openWindows.TryGetValue(typeof(CallStackWindow), out Window? open))
+ {
+ open.Close();
+ }
+ return;
+ }
+ Show(() => new CallStackWindow(session, radioNumber), activate: false).Refresh();
+ }
+
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session));
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
@@ -587,11 +609,16 @@ public sealed partial class EntryWindow
}
/// The one window of that kind, opened if it was not open already.
- private T Show(Func create) where T : Window
+ /// `activate` is false for a window that opens on its own while the
+ /// operator is typing, so the keyboard stays in the entry boxes.
+ private T Show(Func create, bool activate = true) where T : Window
{
if (openWindows.TryGetValue(typeof(T), out Window? existing))
{
- existing.Activate();
+ if (activate)
+ {
+ existing.Activate();
+ }
return (T)existing;
}
T window = create();
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml
index 213be11..ed2ebb1 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml
@@ -115,6 +115,7 @@