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
parent d347538fbc
commit 1c118f9ce4
27 changed files with 694 additions and 158 deletions

View File

@@ -28,9 +28,10 @@ public sealed partial class EntryWindow
/// 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 async Task EditMessages()
private Task EditMessages() => EditMessages(Logging?.Mode.Category ?? ModeCategory.Cw);
private async Task EditMessages(ModeCategory mode)
{
ModeCategory mode = Logging?.Mode.Category ?? ModeCategory.Cw;
string stored = mode == ModeCategory.Phone
? session.Settings.PhoneMessageFile
: session.Settings.CwMessageFile;

View File

@@ -3,6 +3,7 @@ using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Formats.Adif;
@@ -189,6 +190,129 @@ public sealed partial class EntryWindow
await new EditContactDialog(Logging.Session, Logging.Log.Qsos[^1].Id).ShowDialog(this);
}
/// N1MM's Ctrl+N. The note goes on the contact in the boxes when one is
/// being typed, and on the last logged contact otherwise.
private async void OnAddNote(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
return;
}
if (Logging.Entry.Call.Trim().Length > 0)
{
if (await new NoteDialog("Current Contact", Logging.Comment).ShowDialog<string?>(this) is { } typed)
{
Logging.Comment = typed;
SyncBoxes();
Status($"note on {Logging.Entry.Call.Trim()}: {typed}");
}
return;
}
if (Logging.Log.Qsos.Count == 0)
{
Status("there is nothing in the log yet");
return;
}
Qso last = Logging.Log.Qsos[^1];
string title = $"{last.Call.Text} @ {last.TimestampUtc:yyyy-MM-dd HH:mm:ss}";
if (await new NoteDialog(title, last.Comment).ShowDialog<string?>(this) is { } note)
{
Logging.Session.Update(last with { Comment = note });
Status($"note on {last.Call.Text}: {note}");
}
}
/// N1MM's Edit Current Contact: the same form the log uses, over what is
/// typed but not logged yet. What comes back goes into the boxes.
private async void OnEditCurrentContact(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
return;
}
if (Logging.Entry.Call.Trim().Length == 0)
{
Status("no callsign to edit");
return;
}
EditContactDialog dialog = new(Logging.Session, Logging.InProgress());
if (await dialog.ShowDialog<Qso?>(this) is { } edited)
{
Logging.LoadFrom(edited);
SyncBoxes();
Refresh();
}
}
private void OnQuickEditBack(object? sender, RoutedEventArgs e) => QuickEdit(forward: false);
private void OnQuickEditForward(object? sender, RoutedEventArgs e) => QuickEdit(forward: true);
/// Loads an earlier contact into the boxes. Enter writes the changes back,
/// Esc leaves the boxes as they were.
private void QuickEdit(bool forward)
{
if (Logging is null)
{
return;
}
if (!Logging.QuickEdit(forward))
{
Status(forward ? "not in quick edit" : "there is nothing earlier in the log");
return;
}
SyncBoxes();
Refresh();
boxes[0].Focus();
Status(Logging.Editing is null
? "back to the contact being typed"
: $"quick edit: {Logging.Editing.Call.Text} — Enter saves, Esc leaves");
}
/// N1MM's Ctrl+U: the received serial number goes up by one, for the
/// station that says it sent the next number. With no serial box, a
/// numeric exchange box is bumped instead, which is what N1MM does for the
/// contests that count in the exchange.
private void OnBumpNumber(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
return;
}
for (int at = 0; at < Logging.Entry.Exchange.Count; at++)
{
ExchangeField field = Logging.Entry.Exchange[at];
if ((field.Kind != ExchangeFieldKind.Number && field.Slot != ExchangeSlot.Exchange1) ||
!int.TryParse(Logging.Entry[at + 1].Trim(), out int number) || number <= 0)
{
continue;
}
Logging.Entry[at + 1] = (number + 1).ToString();
SyncBoxes();
Refresh();
return;
}
Status("no number to increase");
}
/// N1MM's Ctrl+F: shows the call being typed in the log window, and again
/// for the next contact with the same call.
private void OnFind(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
return;
}
string call = Logging.Entry.Call.Trim();
if (call.Length == 0)
{
Status("no callsign to find");
return;
}
Qso? found = Show(() => new LogWindow(session)).FindNextCall(call);
Status(found is null ? $"{call} not found" : $"{call} at {found.TimestampUtc:HH:mm:ss}");
}
/// Puts the call being typed on the cluster, or the last one logged when
/// nothing is typed. That is what N1MM's Spot It button does.
private async void OnSpotIt(object? sender, RoutedEventArgs e)
@@ -341,6 +465,19 @@ public sealed partial class EntryWindow
BuildFunctionKeys();
}
private void OnEditCwMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Cw);
private void OnEditPhoneMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Phone);
private async void OnQtcSetup(object? sender, RoutedEventArgs e)
{
QtcSetupDialog dialog = new(session.Settings);
if (await dialog.ShowDialog<Settings?>(this) is { } updated)
{
session.Save(updated);
}
}
/// Call history files are published per contest, so the operator points at
/// one rather than the program looking in a fixed place.
private async void OnCallHistoryFile(object? sender, RoutedEventArgs e)

View File

@@ -64,45 +64,72 @@
<DockPanel>
<Menu Name="MainMenu" DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
<MenuItem Header="New Log in Database…" Click="OnNewContest" />
<MenuItem Header="Open Log in Database…" Click="OnOpenContest" />
<Separator />
<MenuItem Header="New Database…" Click="OnNewDatabase" />
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />
<Separator />
<MenuItem Header="New _Contest…" Click="OnNewContest" />
<MenuItem Header="Open Con_test…" Click="OnOpenContest" />
<MenuItem Header="Generate Cabrillo File" Click="OnExportCabrillo" />
<MenuItem Header="Import">
<MenuItem Header="Import ADIF from file…" Click="OnImportAdif" />
<MenuItem Header="Import Call History…" Click="OnCallHistoryFile" />
</MenuItem>
<MenuItem Header="Export">
<MenuItem Header="Export ADIF to file…" Click="OnExportAdif" />
</MenuItem>
<Separator />
<MenuItem Header="Export _Cabrillo…" Click="OnExportCabrillo" />
<MenuItem Header="Export _ADIF…" Click="OnExportAdif" />
<MenuItem Header="_Import ADIF…" Click="OnImportAdif" />
<Separator />
<MenuItem Header="E_xit" Click="OnExit" />
<MenuItem Header="E_xit" Click="OnExit" InputGesture="Alt+F4" />
</MenuItem>
<MenuItem Header="_Edit">
<MenuItem Header="Edit _Last Contact…" Click="OnEditLastContact" InputGesture="Ctrl+Y" />
<MenuItem Header="S_pot It" Click="OnSpotIt" InputGesture="Alt+P" />
<MenuItem Header="Wipe Out Entry Fields" Click="OnWipe" InputGesture="Ctrl+W" />
<Separator />
<MenuItem Header="Edit Last Contact" Click="OnEditLastContact" InputGesture="Ctrl+Y" />
<MenuItem Header="Add a Note to Last/Current Contact" Click="OnAddNote" InputGesture="Ctrl+N" />
<MenuItem Header="Edit Current Contact" Click="OnEditCurrentContact" />
<MenuItem Header="Quick Edit Previous Contacts (Back)" Click="OnQuickEditBack" InputGesture="Ctrl+Q" />
<MenuItem Header="Quick Edit Previous Contacts (Forward)" Click="OnQuickEditForward" InputGesture="Ctrl+A" />
<MenuItem Header="Increase Received NR by 1" Click="OnBumpNumber" InputGesture="Ctrl+U" />
<Separator />
<MenuItem Header="Find/Find Again" Click="OnFind" InputGesture="Ctrl+F" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="_Log" Click="OnShowLog" />
<MenuItem Header="_Check" Click="OnShowCheck" />
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
<MenuItem Header="_Available Mults and Qs" Click="OnShowAvailable" />
<MenuItem Header="_Score Summary" Click="OnShowScore" />
<MenuItem Header="_Telnet" Click="OnShowTelnet" />
<MenuItem Header="Show QRZ" Click="OnLookUpCall" />
</MenuItem>
<MenuItem Header="Tools">
<MenuItem Header="Download and Install Latest Check Partial file (master.scp) (Internet)"
Click="OnDownloadCallDatabase" />
<MenuItem Header="Download and install latest country file (wl_cty.dat) (Internet)"
Click="OnDownloadCountryFile" />
<Separator />
<MenuItem Header="Reload Support Files" Click="OnReloadSupportFiles" />
</MenuItem>
<MenuItem Header="_Config">
<MenuItem Header="_Station…" Click="OnStationSettings" />
<MenuItem Header="_Radio…" Click="OnRadioSettings" />
<MenuItem Header="_Cluster…" Click="OnClusterSettings" />
<MenuItem Header="_Network…" Click="OnNetworkSettings" />
<MenuItem Header="_Keyer and messages…" Click="OnKeyerSettings" />
<MenuItem Name="EsmItem" Header="_ESM — Enter sends message" ToggleType="CheckBox"
Click="OnToggleEsm" />
<MenuItem Header="Configure Ports, Mode Control, Winkey, etc…" Click="OnKeyerSettings" />
<MenuItem Header="Configure Radios…" Click="OnRadioSettings" />
<MenuItem Header="Configure Cluster…" Click="OnClusterSettings" />
<MenuItem Header="Change Your Station Data…" Click="OnStationSettings" />
<Separator />
<MenuItem Header="Call _History File…" Click="OnCallHistoryFile" />
<MenuItem Header="S_ub bands…" Click="OnSubBandSettings" />
<MenuItem Name="EsmItem" Header="Enter Sends Message (current mode)" ToggleType="CheckBox"
Click="OnToggleEsm" InputGesture="Ctrl+M" />
<Separator />
<MenuItem Header="Download Country _File" Click="OnDownloadCountryFile" />
<MenuItem Header="Download Check _Partial File" Click="OnDownloadCallDatabase" />
<MenuItem Header="Reload Support Files" Click="OnReloadSupportFiles" />
<MenuItem Header="Change CW/SSB/Digital Function Key Definitions">
<MenuItem Header="Change _CW Function Key Definitions" Click="OnEditCwMessages" />
<MenuItem Header="Change _SSB Function Key Definitions" Click="OnEditPhoneMessages" />
</MenuItem>
<MenuItem Header="Change Band Plan…" Click="OnSubBandSettings" />
<MenuItem Header="Edit Networked-Computer Names…" Click="OnNetworkSettings" />
<MenuItem Header="WAE">
<MenuItem Header="Open QTC Window setup area" Click="OnQtcSetup" />
</MenuItem>
</MenuItem>
<MenuItem Header="Wi_ndow">
<MenuItem Header="Available Mult's and Q's" Click="OnShowAvailable" />
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
<MenuItem Header="Check" Click="OnShowCheck" />
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
<MenuItem Header="Score Summary" Click="OnShowScore" />
<MenuItem Header="Telnet" Click="OnShowTelnet" />
</MenuItem>
</Menu>

View File

@@ -24,6 +24,9 @@ public sealed partial class EntryWindow : Window
private static readonly IBrush Dark = new SolidColorBrush(Color.FromArgb(0x33, 0x80, 0x80, 0x80));
/// The pale yellow N1MM paints the entry boxes while quick editing.
private static readonly IBrush QuickEditBackground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xC0));
private bool sending;
private TextBox? nameBox;
private TextBox? commentBox;
@@ -61,7 +64,7 @@ public sealed partial class EntryWindow : Window
/// Resolved every time rather than held, because opening a contest builds
/// new positions.
private RadioPosition? Logging =>
private OperatingPosition? Logging =>
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
/// Reopens the database and contest the operator was last in.
@@ -289,6 +292,14 @@ public sealed partial class EntryWindow : Window
e.Handled = true;
OnEnter();
break;
case Key.Escape when Logging.Editing is not null:
e.Handled = true;
Logging.LeaveQuickEdit();
SyncBoxes();
Refresh();
boxes[0].Focus();
Status("quick edit left");
break;
case Key.Escape:
e.Handled = true;
sending = false;
@@ -325,6 +336,38 @@ public sealed partial class EntryWindow : Window
e.Handled = true;
ToggleAlternatingCq();
break;
case Key.N when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnAddNote(this, new RoutedEventArgs());
break;
case Key.Q when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnQuickEditBack(this, new RoutedEventArgs());
break;
case Key.A when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnQuickEditForward(this, new RoutedEventArgs());
break;
case Key.U when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnBumpNumber(this, new RoutedEventArgs());
break;
case Key.F when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnFind(this, new RoutedEventArgs());
break;
case Key.W when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnWipe(this, new RoutedEventArgs());
break;
case Key.L when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnShowLog(this, new RoutedEventArgs());
break;
case Key.M when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnToggleEsm(this, new RoutedEventArgs());
break;
case Key.Y when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnEditLastContact(this, new RoutedEventArgs());
@@ -350,6 +393,11 @@ public sealed partial class EntryWindow : Window
{
return;
}
if (Logging.Editing is not null)
{
SaveQuickEdit();
return;
}
Frequency? qsy = Logging.PendingQsy();
if (qsy is not null)
{
@@ -375,6 +423,18 @@ public sealed partial class EntryWindow : Window
/// Logs what is in the boxes, which is what Enter and N1MM's `{LOG}` macro
/// both do.
private void SaveQuickEdit()
{
if (Logging?.SaveQuickEdit() is not { } saved)
{
return;
}
Status($"{saved.Call.Text} updated");
SyncBoxes();
Refresh();
boxes[0].Focus();
}
private void LogContact()
{
if (Logging is null)
@@ -585,6 +645,21 @@ public sealed partial class EntryWindow : Window
VerdictText.Text = "";
return;
}
foreach (TextBox box in boxes)
{
if (Logging.Editing is null)
{
// clearing rather than assigning null: a null brush would paint
// no text at all
box.ClearValue(BackgroundProperty);
box.ClearValue(ForegroundProperty);
}
else
{
box.Background = QuickEditBackground;
box.Foreground = Brushes.Black;
}
}
FrequencyText.Text = Logging.TransmitFrequency.Hertz > 0
? $"{Logging.Frequency.Kilohertz:0.00} ▸ {Logging.TransmitFrequency.Kilohertz:0.0}"
: Logging.Frequency.Kilohertz.ToString("0.00");

View File

@@ -21,6 +21,7 @@ public sealed partial class LogWindow : RefreshableWindow
private readonly AppSession session;
private string builtFor = "";
private string message = "";
private string finding = "";
public LogWindow(AppSession session)
{
@@ -105,6 +106,32 @@ public sealed partial class LogWindow : RefreshableWindow
private static double MinimumFor(int characters) =>
(characters * CharacterWidth) + CellPadding;
/// N1MM's Ctrl+F: shows the next contact with this call and selects it,
/// starting again from the top once the last one has been shown. Null when
/// the call is not in the log.
public Qso? FindNextCall(string call)
{
string wanted = call.Trim();
if (wanted.Length == 0 || Rows.ItemsSource is not IReadOnlyList<LogRow> rows)
{
return null;
}
int from = wanted.Equals(finding, StringComparison.OrdinalIgnoreCase) ? Rows.SelectedIndex + 1 : 0;
finding = wanted;
for (int step = 0; step < rows.Count; step++)
{
int at = (from + step) % rows.Count;
if (!rows[at].Qso.Call.Text.Equals(wanted, StringComparison.OrdinalIgnoreCase))
{
continue;
}
Rows.SelectedIndex = at;
Rows.ScrollIntoView(rows[at], null);
return rows[at].Qso;
}
return null;
}
/// True when the edit was taken. A refused edit puts the reason in the
/// summary line and the cell falls back to what it held before.
private bool Edit(Qso qso, QsoField field, string text)

View File

@@ -25,7 +25,7 @@ public sealed partial class QtcWindow : Window
private static readonly IBrush Malformed = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xC4));
private readonly AppSession session;
private readonly RadioPosition position;
private readonly OperatingPosition position;
private readonly QtcTraffic traffic;
private readonly Callsign station;
private bool isSending;
@@ -34,7 +34,7 @@ public sealed partial class QtcWindow : Window
public QtcWindow(
AppSession session,
RadioPosition position,
OperatingPosition position,
Callsign station,
QtcDirection direction)
{