Stack the calls that answer a CQ, the way N1MM does

A CQ is answered by more than one station. N1MM lets the operator put the
others on a stack and work them one after another; this is that, with N1MM's
keys and macros.

Ctrl+Alt+G and {STACKANOTHER} put the call being typed on the stack and clear
the boxes. Alt+G brings the top call back. Alt+D drops it. {SOCALLSTACK} both
stacks and unstacks in one key: it swaps the typed call for the top one, stacks
it when the stack is empty, and takes the top call when the boxes are.
{LOGTHENPOP} and {LOGTHENNEXT} log the contact and bring the next caller in.
{CLRSTACK} empties it.

The rules are N1MM's, and CallStack and the entry actions hold them without a
window, so they are unit-tested: stacking only while running, nothing shorter
than two characters, no dupe, no frequency typed into the callsign box, a call
already on the stack moved rather than repeated, and a multiplier to the front.
{SOCALLSTACK} takes the stacked call off before it puts the typed one on, so a
multiplier just typed cannot come straight back.

Two places differ. N1MM refuses your own call only while the stack is empty;
it is never a station to work, so it is refused either way here. And the order
is multipliers first in every mode: N1MM's digital window offers first-in and
last-in as well, and there is no digital window here.

The stack window opens by itself with the first call and closes with the last,
or Window > Call Stack opens it. It does not take the keyboard when it opens,
because the operator is typing the next call. The next call out is ringed and
each is coloured by what working it would bring, as in every other window.

OperatingPosition.JudgeCall now answers what a call alone is worth, which the
stack window and the check window both need; it was private to the check
window before.

Driven under Xvfb: four calls stacked with Ctrl+Alt+G and listed in the window,
Alt+G bringing the top one into the boxes, Alt+D dropping the next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-30 22:23:30 +00:00
co-authored by Claude Opus 5
parent bb35848e11
commit ec8cdb88d7
16 changed files with 757 additions and 26 deletions
+99
View File
@@ -92,6 +92,105 @@ public sealed class OperatingPosition
/// The frequency typed into the callsign box, if that is what it holds.
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
/// What working this call on the band and mode we are on would bring,
/// judged from the call alone. A call that has not been worked has sent no
/// exchange, so a contest scored on what the other station sends can be
/// judged no further than this.
public Verdict? JudgeCall(string call)
{
string wanted = call.Trim();
return wanted.Length == 0
? null
: Log.Judge(new Qso
{
Id = "",
TimestampUtc = DateTime.UtcNow,
Call = Callsign.Parse(wanted),
Frequency = Frequency,
Mode = Mode,
ContestName = Contest.Name,
});
}
/// The calls answering our CQ that have not been worked yet.
public CallStack Stack { get; } = new();
/// N1MM's {STACKANOTHER} and Ctrl+Alt+G: the call being typed goes on the
/// stack and the entry is cleared, so the next caller can be copied. Only
/// while running, and a dupe is not stacked. False when nothing was
/// stacked.
public bool StackAnother()
{
if (!IsRunning || !IsWorthStacking(Entry.Call.Trim()))
{
return false;
}
Verdict? worth = Verdict();
if (worth is null || worth.IsDupe)
{
return false;
}
Stack.Add(Entry.Call.Trim(), worth.IsNewMultiplier);
Wipe();
return true;
}
/// N1MM's Alt+G: the call on top of the stack replaces what is typed.
/// False when the stack is empty, and then the entry is left alone.
public bool GrabStacked()
{
if (Stack.IsEmpty)
{
return false;
}
string call = Stack.Next();
Wipe();
Entry.Call = call;
Entry.FocusOn(0);
Session.NotifyChanged();
return true;
}
/// N1MM's {SOCALLSTACK}: one key both stacks and unstacks. With a call
/// typed and the stack holding one, the two are swapped — the stacked call
/// comes off first, so the typed call cannot come straight back. With the
/// box empty it takes the next call off; with the stack empty it puts the
/// typed call on. Only while running, and a dupe or a frequency is left
/// alone. False when there was nothing to do, which is what N1MM reports
/// back to ESM.
public bool SwapWithStack()
{
if (!IsRunning)
{
return false;
}
string call = Entry.Call.Trim();
if (call.Length == 0)
{
return GrabStacked();
}
if (!IsWorthStacking(call) || Verdict() is not { IsDupe: false } worth)
{
return false;
}
bool isMultiplier = worth.IsNewMultiplier;
string next = Stack.Next();
Stack.Add(call, isMultiplier);
Wipe();
Entry.Call = next;
Entry.FocusOn(0);
Session.NotifyChanged();
return next.Length > 0;
}
/// A frequency typed into the callsign box is a band change, not a call,
/// and our own callsign is never stacked. N1MM refuses its own call only
/// while the stack is empty.
private bool IsWorthStacking(string call) =>
call.Length >= CallStack.ShortestCall
&& !call.All(c => char.IsAsciiDigit(c) || c == '.')
&& !string.Equals(call, Me.Callsign, StringComparison.OrdinalIgnoreCase);
/// Logs what is typed and clears the entry. Throws when the entry is not
/// complete, so a caller that has not checked cannot lose an exchange.
public Qso LogContact()