Lay the menu bar out the way N1MM lays it out, and fill in the Edit menu

The menu tree comes from N1MM's EntryWindow.Designer.cs: File, Edit, View,
Tools, Config, Window, with the items under the names N1MM gives them. The
window list moved out of View into Window; the call history file moved from
Config into File > Import. Ctrl+W, Ctrl+L and Ctrl+M now work, so the keys
printed in the menu are real. Two commands that had no menu item before:
the CW and SSB function key definitions, and the QTC setup area.

The rest of the Edit menu:

- Ctrl+N asks for a note and puts it in the comment of the contact in the
  boxes, or of the last logged contact when nothing is typed.
- Edit Current Contact opens the Edit Contact form over what is typed but
  not logged. Update hands the values back to the entry boxes.
- Ctrl+Q and Ctrl+A load a logged contact into the boxes and paint them
  pale yellow. Enter writes the change back, Esc puts back what was being
  typed, and stepping forward past the newest contact leaves quick edit.
- Ctrl+U raises the received serial, or a numeric exchange box when the
  contest has no serial.
- Ctrl+F shows the call being typed in the log window, then the next
  contact with that call.

RadioPosition is now OperatingPosition: it is the operator at one radio,
not a place on a band.

Looked at under Xvfb on a 3775-contact log: quick edit back and forward,
Esc, the note dialog, find, and Edit Current Contact putting a zone back
into the entry boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 12:18:37 +00:00
co-authored by Claude Opus 5
parent d347538fbc
commit 1c118f9ce4
27 changed files with 694 additions and 158 deletions
+2 -2
View File
@@ -29,10 +29,10 @@ public sealed record AvailableStation(Spot Spot, Band Band, Verdict Verdict)
/// in, so in a mixed-mode contest the answer follows the operator.
public sealed class AvailableStations
{
private readonly RadioPosition position;
private readonly OperatingPosition position;
private readonly Bandmap bandmap;
public AvailableStations(RadioPosition position, Bandmap bandmap)
public AvailableStations(OperatingPosition position, Bandmap bandmap)
{
this.position = position;
this.bandmap = bandmap;
+2 -2
View File
@@ -8,11 +8,11 @@ namespace Nonemm.Session;
/// Answers what the callsign being typed could be, a column per source.
public sealed class CheckWindowSources
{
private readonly RadioPosition session;
private readonly OperatingPosition session;
private readonly CallDatabase database;
private readonly Bandmap bandmap;
public CheckWindowSources(RadioPosition session, CallDatabase database, Bandmap bandmap)
public CheckWindowSources(OperatingPosition session, CallDatabase database, Bandmap bandmap)
{
this.session = session;
this.database = database;
+1 -1
View File
@@ -8,7 +8,7 @@ namespace Nonemm.Session;
/// The running contest: the log, the score and what happens when a contact is
/// logged. One of these per contest, however many radios the station has.
/// What the operator is typing lives in `RadioPosition`, one per radio.
/// What the operator is typing lives in `OperatingPosition`, one per radio.
///
/// No UI framework is referenced here, so the behaviour an operator judges the
/// logger by is tested with plain unit tests.
+20
View File
@@ -26,6 +26,26 @@ public static class ExchangeSlots
_ => qso,
};
/// What a logged contact holds for one exchange box. The inverse of
/// `Apply`, used to load a contact back into the entry boxes.
public static string ValueOf(Qso qso, ExchangeSlot slot) => slot switch
{
ExchangeSlot.ReceivedReport => qso.ReceivedReport,
ExchangeSlot.SerialNumber => Digits(qso.ReceivedNumber),
ExchangeSlot.Zone => Digits(qso.Zone),
ExchangeSlot.Section => qso.Section,
ExchangeSlot.Check => Digits(qso.Check),
ExchangeSlot.Precedence => qso.Precedence,
ExchangeSlot.Exchange1 => qso.Exchange1,
ExchangeSlot.MiscText => qso.MiscText,
ExchangeSlot.Name => qso.Name,
ExchangeSlot.Qth => qso.Qth,
ExchangeSlot.GridSquare => qso.GridSquare,
ExchangeSlot.Power => qso.Power,
ExchangeSlot.Comment => qso.Comment,
_ => "",
};
/// What the call history file says this station sends in one exchange box,
/// or an empty string when the file holds nothing for it.
public static string ValueFrom(CallHistoryEntry known, ExchangeField field) => field.Slot switch
+15 -15
View File
@@ -23,7 +23,7 @@ public static class MessageExpander
/// `other` is the radio the operator is not on, for the macros that pass a
/// station to the other band. Null when the station has one radio, and then
/// those macros stand for nothing.
public static string Expand(string template, RadioPosition session, RadioPosition? other = null)
public static string Expand(string template, OperatingPosition session, OperatingPosition? other = null)
{
StringBuilder text = new();
int at = 0;
@@ -56,7 +56,7 @@ public static class MessageExpander
}
/// The macros that need no braces: `*`, `!` and `#`.
public static string TextMacro(char macro, RadioPosition session) => macro switch
public static string TextMacro(char macro, OperatingPosition session) => macro switch
{
'*' => session.Me.Callsign,
'!' => TheirCall(session),
@@ -66,14 +66,14 @@ public static class MessageExpander
/// The number for this contact, or the one just logged when nothing is
/// typed in the callsign box, so a repeat after logging sends the same
/// number again.
private static string SerialNumber(RadioPosition session) =>
private static string SerialNumber(OperatingPosition session) =>
session.Entry.Call.Trim().Length == 0 && LastLogged(session) is { } last
? last.SentNumber.ToString(CultureInfo.InvariantCulture)
: session.SentNumber.ToString(CultureInfo.InvariantCulture);
/// What the macro stands for, or null when it is not a text macro — an
/// action macro, or a name this program does not know.
public static string? TextMacro(string name, RadioPosition session, RadioPosition? other) =>
public static string? TextMacro(string name, OperatingPosition session, OperatingPosition? other) =>
name.ToUpperInvariant() switch
{
"MYCALL" => session.Me.Callsign,
@@ -110,19 +110,19 @@ public static class MessageExpander
/// The call being worked, or the last one logged when the box is empty,
/// which is what N1MM sends for both `{CALL}` and `!`.
private static string TheirCall(RadioPosition session) =>
private static string TheirCall(OperatingPosition session) =>
session.Entry.Call.Trim() is { Length: > 0 } typed ? typed : LastLogged(session)?.Call.Text ?? "";
private static Qso? LastLogged(RadioPosition session) =>
private static Qso? LastLogged(OperatingPosition session) =>
session.Log.Qsos.Count > 0 ? session.Log.Qsos[^1] : null;
private static string Name(RadioPosition session) =>
private static string Name(OperatingPosition session) =>
Theirs(session, ExchangeSlot.Name, known => known.Name);
/// What the operator has typed in that exchange box, or what the call
/// history file says when the contest has no such box or it is empty.
private static string Theirs(
RadioPosition session,
OperatingPosition session,
ExchangeSlot slot,
Func<CallHistoryEntry, string> fromHistory)
{
@@ -136,7 +136,7 @@ public static class MessageExpander
}
/// Kilohertz to one decimal, and on CW the decimal point is sent as R.
private static string FrequencyText(RadioPosition session, Frequency frequency, bool round)
private static string FrequencyText(OperatingPosition session, Frequency frequency, bool round)
{
if (round)
{
@@ -146,20 +146,20 @@ public static class MessageExpander
}
/// The band in megahertz, as N1MM sends it: 14, or 3R5 on CW.
private static string Megahertz(RadioPosition session, Frequency frequency) =>
private static string Megahertz(OperatingPosition session, Frequency frequency) =>
Bands.ForFrequency(frequency) is { } band
? CwDecimal(session, band.MegahertzLabel.ToString("0.###", CultureInfo.InvariantCulture))
: "";
private static Frequency Radio(RadioPosition session, RadioPosition? other, int number) =>
private static Frequency Radio(OperatingPosition session, OperatingPosition? other, int number) =>
session.RadioNumber == number ? session.Frequency
: other?.RadioNumber == number ? other.Frequency
: Frequency.Zero;
private static string CwDecimal(RadioPosition session, string text) =>
private static string CwDecimal(OperatingPosition session, string text) =>
session.Mode.Category == ModeCategory.Cw ? text.Replace('.', 'R') : text;
private static string Bearing(RadioPosition session, bool reverse)
private static string Bearing(OperatingPosition session, bool reverse)
{
if (!Grids(session, out GridSquare mine, out GridSquare theirs))
{
@@ -169,12 +169,12 @@ public static class MessageExpander
return Math.Round(bearing).ToString("0", CultureInfo.InvariantCulture);
}
private static string Distance(RadioPosition session) =>
private static string Distance(OperatingPosition session) =>
Grids(session, out GridSquare mine, out GridSquare theirs)
? Math.Round(mine.DistanceTo(theirs)).ToString("0", CultureInfo.InvariantCulture)
: "";
private static bool Grids(RadioPosition session, out GridSquare mine, out GridSquare theirs)
private static bool Grids(OperatingPosition session, out GridSquare mine, out GridSquare theirs)
{
theirs = default;
return GridSquare.TryParse(session.Me.GridSquare, out mine)
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed record MessagePlan(
string Text,
IReadOnlyList<MessageAction> After)
{
public static MessagePlan Read(string template, RadioPosition session, RadioPosition? other = null)
public static MessagePlan Read(string template, OperatingPosition session, OperatingPosition? other = null)
{
List<MessageAction> before = [];
List<MessageAction> after = [];
@@ -9,9 +9,9 @@ namespace Nonemm.Session;
/// One radio and what the operator is typing on it. A single-radio station has
/// one of these; an SO2R station has two, sharing one `ContestSession` and so
/// one log, one score and one run of serial numbers.
public sealed class RadioPosition
public sealed class OperatingPosition
{
public RadioPosition(ContestSession session, int radioNumber = 1)
public OperatingPosition(ContestSession session, int radioNumber = 1)
{
Session = session;
RadioNumber = radioNumber;
@@ -170,8 +170,110 @@ public sealed class RadioPosition
}
}
/// The logged contact the boxes are re-editing, or null while a new one is
/// being typed. N1MM calls this quick edit: Ctrl+Q steps back through the
/// log, Ctrl+A forward, Enter writes the changes back and Esc leaves.
public Qso? Editing { get; private set; }
private (string Call, string[] Values, string Name, string Comment)? held;
/// Steps one contact back or forward and loads it into the boxes. False
/// when the log has nothing that way; stepping forward past the newest
/// contact leaves quick edit instead.
public bool QuickEdit(bool forward)
{
IReadOnlyList<Qso> qsos = Log.Qsos;
if (qsos.Count == 0)
{
return false;
}
int at = Editing is null ? qsos.Count : IndexOf(Editing.Id);
int wanted = forward ? at + 1 : at - 1;
if (wanted < 0 || at < 0)
{
return false;
}
if (wanted >= qsos.Count)
{
LeaveQuickEdit();
return true;
}
held ??= (Entry.Call, [.. Enumerable.Range(1, Entry.FieldCount - 1).Select(f => Entry[f])], OtherName, Comment);
Editing = qsos[wanted];
LoadFrom(Editing);
Session.NotifyChanged();
return true;
}
/// Puts the boxes back to the contact that was being typed.
public void LeaveQuickEdit()
{
if (Editing is null)
{
return;
}
Editing = null;
if (held is { } typed)
{
Entry.Call = typed.Call;
for (int field = 1; field < Entry.FieldCount; field++)
{
Entry[field] = typed.Values[field - 1];
}
OtherName = typed.Name;
Comment = typed.Comment;
held = null;
}
Session.NotifyChanged();
}
/// Writes the boxes back to the contact being quick edited and leaves quick
/// edit. Null when no contact is being edited.
public Qso? SaveQuickEdit()
{
if (Editing is null)
{
return null;
}
Qso saved = Noted(ApplyExchange(CountryFields.Apply(
Editing with { Call = Callsign.Parse(Entry.Call.Trim()) }, Session.Countries)));
Session.Update(saved);
LeaveQuickEdit();
return saved;
}
/// The contact as it would be logged now, for the dialog that edits the one
/// in progress.
public Qso InProgress() => Noted(BuildQso(Entry.Call.Trim()));
/// Fills the boxes from a contact, as quick edit does.
public void LoadFrom(Qso qso)
{
Entry.Call = qso.Call.Text;
for (int at = 0; at < Entry.Exchange.Count; at++)
{
Entry[at + 1] = ExchangeSlots.ValueOf(qso, Entry.Exchange[at].Slot);
}
OtherName = Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name) ? "" : qso.Name;
Comment = qso.Comment;
}
private int IndexOf(string id)
{
for (int at = 0; at < Log.Qsos.Count; at++)
{
if (Log.Qsos[at].Id == id)
{
return at;
}
}
return -1;
}
public void Wipe()
{
Editing = null;
held = null;
Entry.Clear();
OtherName = "";
Comment = "";
+2 -2
View File
@@ -24,9 +24,9 @@ public enum QtcDirection
/// only rule left is that the two stations are on different continents.
public sealed class QtcTraffic
{
private readonly RadioPosition position;
private readonly OperatingPosition position;
public QtcTraffic(RadioPosition position) => this.position = position;
public QtcTraffic(OperatingPosition position) => this.position = position;
public bool IsWaeContest => position.Contest is Wae;
+3 -3
View File
@@ -23,7 +23,7 @@ public sealed record StationPath(
/// Null when there is nothing to work out a path from: no station in the
/// callsign box, no country file, or no position for either end.
public static StationPath? For(RadioPosition session, DateTime nowUtc)
public static StationPath? For(OperatingPosition session, DateTime nowUtc)
{
if (Mine(session) is not { } here || Theirs(session) is not { } there)
{
@@ -37,10 +37,10 @@ public sealed record StationPath(
sun?.Set);
}
private static (double Latitude, double Longitude)? Mine(RadioPosition session) =>
private static (double Latitude, double Longitude)? Mine(OperatingPosition session) =>
Place(session.Me.GridSquare) ?? Place(session.Session.Countries?.Find(session.Me.Callsign));
private static (double Latitude, double Longitude)? Theirs(RadioPosition session)
private static (double Latitude, double Longitude)? Theirs(OperatingPosition session)
{
if (session.Entry.Call.Trim().Length == 0)
{