Files
Nonemm/src/Nonemm.App/Windows/EntryWindow.Menu.cs
ericek111 ec8cdb88d7 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
2026-08-30 22:23:30 +00:00

634 lines
22 KiB
C#

using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.App.Theming;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Formats.Adif;
using Nonemm.Formats.Cabrillo;
using Nonemm.Session;
using Nonemm.Spotting;
using Nonemm.Storage;
namespace Nonemm.App.Windows;
/// The entry window's menu. Each item does one thing and says what happened in
/// the status line.
public sealed partial class EntryWindow
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
private async void OnNewDatabase(object? sender, RoutedEventArgs e)
{
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "New log database",
SuggestedFileName = "ham.s3db",
DefaultExtension = "s3db",
SuggestedStartLocation = await Folder(session.Paths.Databases),
});
OpenDatabaseAt(file);
}
private async void OnOpenDatabase(object? sender, RoutedEventArgs e)
{
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open log database",
AllowMultiple = false,
SuggestedStartLocation = await Folder(session.Paths.Databases),
});
OpenDatabaseAt(files.FirstOrDefault());
}
private void OpenDatabaseAt(IStorageFile? file)
{
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
try
{
session.OpenDatabase(path);
Status($"log database {Path.GetFileName(path)}");
}
catch (Exception error) when (error is IOException or InvalidOperationException)
{
Status($"could not open {path}: {error.Message}");
}
}
private async void OnNewContest(object? sender, RoutedEventArgs e)
{
if (!HasDatabase())
{
return;
}
ContestSetupDialog dialog = new(session);
ContestInstance? chosen = await dialog.ShowDialog<ContestInstance?>(this);
if (chosen is null)
{
return;
}
session.StartContest(chosen);
Status($"{chosen.ContestName} started");
}
private async void OnOpenContest(object? sender, RoutedEventArgs e)
{
if (!HasDatabase())
{
return;
}
ContestPickerDialog dialog = new(session.Store.Contests());
int? chosen = await dialog.ShowDialog<int?>(this);
if (chosen is not null)
{
session.OpenContest(chosen.Value);
}
}
private async void OnExportCabrillo(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Cabrillo",
SuggestedFileName = $"{session.Settings.Station.Callsign}.log",
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
CabrilloWriter writer = new(Logging.Contest, Logging.Me);
CabrilloHeader header = new()
{
Contest = Logging.Contest.CabrilloName,
Callsign = Logging.Me.Callsign,
OperatorCategory = Logging.Instance.OperatorCategory,
AssistedCategory = Logging.Instance.AssistedCategory,
BandCategory = Logging.Instance.BandCategory,
ModeCategory = Logging.Instance.ModeCategory,
PowerCategory = Logging.Instance.PowerCategory,
StationCategory = Logging.Instance.StationCategory,
TransmitterCategory = Logging.Instance.TransmitterCategory,
OverlayCategory = Logging.Instance.OverlayCategory,
TimeCategory = Logging.Instance.TimeCategory,
ClaimedScore = Logging.Log.TotalScore,
Club = Logging.Me.Club,
Name = Logging.Me.Name,
Operators = Logging.Instance.Operators,
Soapbox = Logging.Instance.Soapbox,
};
await File.WriteAllTextAsync(path, writer.Write(header, Logging.Log.Qsos));
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
}
private async void OnExportAdif(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export ADIF",
SuggestedFileName = $"{session.Settings.Station.Callsign}.adi",
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
await File.WriteAllTextAsync(path, new AdifWriter(Logging.Me).Write(Logging.Log.Qsos));
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
}
private async void OnImportAdif(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Import ADIF",
AllowMultiple = false,
});
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
{
return;
}
IReadOnlyList<Qso> read = AdifReader.Read(
await File.ReadAllTextAsync(path),
Logging.Contest.Name,
Logging.Instance.ContestNumber);
foreach (Qso qso in read)
{
session.Store.Add(qso);
}
session.OpenContest(Logging.Instance.ContestNumber);
Status($"{read.Count} contacts read from {Path.GetFileName(path)}");
}
private void OnExit(object? sender, RoutedEventArgs e) => Close();
private async void OnEditLastContact(object? sender, RoutedEventArgs e)
{
if (Logging is null || Logging.Log.Qsos.Count == 0)
{
Status("there is nothing in the log yet");
return;
}
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)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
if (session.Cluster is not { IsConnected: true } cluster)
{
Status("not connected to a cluster node");
return;
}
string typed = Logging.Entry.Call.Trim();
Callsign call;
Frequency where;
if (typed.Length > 0)
{
call = Callsign.Parse(typed);
where = Logging.Frequency;
}
else if (Logging.Log.Qsos.Count > 0)
{
call = Logging.Log.Qsos[^1].Call;
where = Logging.Log.Qsos[^1].Frequency;
}
else
{
Status("nothing to spot");
return;
}
await cluster.SendSpotAsync(where, call, MessageExpander.Expand(session.Settings.SpotComment, Logging, session.Other(Logging)));
session.Bandmap.Add(new Spot(call, where, DateTime.UtcNow, SpotSource.Operator));
Status($"{call.Text} spotted on {where.Kilohertz:0.0}");
}
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));
private void OnShowAvailable(object? sender, RoutedEventArgs e) =>
Show(() => new AvailableWindow(session, Tune));
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
private async void OnStationSettings(object? sender, RoutedEventArgs e)
{
StationDialog dialog = new(session.Settings.Station);
StoredStation? updated = await dialog.ShowDialog<StoredStation?>(this);
if (updated is not null)
{
session.Save(session.Settings with { Station = FromCall(updated) });
if (Logging is not null)
{
session.OpenContest(Logging.Instance.ContestNumber);
}
}
}
/// N1MM's station form has no continent or country box: it looks both up
/// from the callsign in the country file.
private StoredStation FromCall(StoredStation station)
{
CountryLookup? country = session.Countries?.Find(station.Callsign);
return country is null ? station : station with
{
Continent = country.Continent,
CountryPrefix = country.Entity.PrimaryPrefix,
};
}
private async void OnRadioSettings(object? sender, RoutedEventArgs e)
{
RadioDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is null)
{
return;
}
session.Save(updated);
session.ConnectRadios();
try
{
session.ApplySo2rSettings();
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException)
{
Status($"could not open the SO2R box: {error.Message}");
return;
}
Status(session.Radios.Count switch
{
0 => "no radio",
1 => $"radio: rigctld at {updated.Radios[0].Host}:{updated.Radios[0].Port}",
_ => $"{session.Radios.Count} radios · Ctrl+Tab moves between them",
});
}
/// N1MM keeps the cluster settings on the telnet window rather than in a
/// dialog of their own, and so do we.
private void OnClusterSettings(object? sender, RoutedEventArgs e) =>
Show(() => new TelnetWindow(session, Tune)).ShowClusters();
/// N1MM puts ESM on the entry window's Config menu, and remembers it
/// between runs.
private void OnToggleEsm(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with { EsmEnabled = !session.Settings.EsmEnabled });
Status(session.Settings.EsmEnabled
? "ESM on — Enter sends the message the contact has got to"
: "ESM off");
Refresh();
}
private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
{
NetworkDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is not null)
{
session.Save(updated);
session.ApplyNetworkSettings();
Status(updated.NetworkEnabled ? "networked with the other stations" : "networking off");
}
}
private async void OnKeyerSettings(object? sender, RoutedEventArgs e)
{
KeyerDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is null)
{
return;
}
session.Save(updated);
try
{
session.ApplyKeyerSettings();
Status(updated.KeyerKind == "none" ? "no keyer" : $"keyer: {updated.KeyerKind}");
}
catch (Exception error) when (error is InvalidOperationException or IOException or UnauthorizedAccessException)
{
Status($"could not open the keyer: {error.Message}");
}
BuildFunctionKeys();
}
private void OnEditCwMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Cw);
private void OnEditPhoneMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Phone);
private async void OnThemeSettings(object? sender, RoutedEventArgs e)
{
ThemeDialog dialog = new(session.Settings.Theme);
if (await dialog.ShowDialog<string?>(this) is not { } picked)
{
return;
}
session.Save(session.Settings with { Theme = picked });
Themes.Use(picked);
Status($"{picked} colours");
}
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)
{
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Call history file",
AllowMultiple = false,
SuggestedStartLocation = await Folder(session.Paths.SupportFiles),
});
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
{
return;
}
session.Save(session.Settings with { CallHistoryFile = path });
session.ReloadSupportFiles();
Status(session.History.Count > 0
? $"{session.History.Count} calls read from {Path.GetFileName(path)}"
: $"{Path.GetFileName(path)} holds no calls we could read");
}
/// The bandmap's CW, digital and phone boundaries.
private async void OnSubBandSettings(object? sender, RoutedEventArgs e)
{
SubBandsDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is null)
{
return;
}
session.Save(updated);
Status(updated.SubBands.Count switch
{
0 => "sub bands back to the defaults",
1 => "1 band changed from the defaults",
int changed => $"{changed} bands changed from the defaults",
});
}
private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
await Download(
downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),
"country file");
private async void OnDownloadCallDatabase(object? sender, RoutedEventArgs e) =>
await Download(
downloader => downloader.DownloadCallDatabaseAsync(session.Paths.CallDatabaseFile),
"callsign database");
private void OnReloadSupportFiles(object? sender, RoutedEventArgs e)
{
session.ReloadSupportFiles();
Status($"country file {(session.Countries is null ? "missing" : "loaded")}, " +
$"{session.Calls.Count} callsigns, {session.History.Count} in the call history");
}
private async Task Download(Func<SupportFileDownloader, Task<string>> fetch, string what)
{
Status($"fetching the {what}…");
try
{
string result = await fetch(new SupportFileDownloader(Http));
session.ReloadSupportFiles();
Status($"{what}: {result}");
}
catch (Exception error) when (error is InvalidOperationException or IOException)
{
Status(error.Message);
}
}
private void Tune(Frequency frequency, string call)
{
if (Logging is null)
{
return;
}
Logging.Tune(frequency);
_ = session.Radio?.TuneAsync(frequency);
Logging.Entry.Call = call;
SyncBoxes();
boxes[0].Focus();
Refresh();
}
private bool HasDatabase()
{
if (session.Settings.DatabasePath.Length > 0)
{
return true;
}
Status("open a log database first: File ▸ New Database");
return false;
}
/// The one window of that kind, opened if it was not open already.
/// `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<T>(Func<T> create, bool activate = true) where T : Window
{
if (openWindows.TryGetValue(typeof(T), out Window? existing))
{
if (activate)
{
existing.Activate();
}
return (T)existing;
}
T window = create();
openWindows[typeof(T)] = window;
window.Closed += (_, _) => openWindows.Remove(typeof(T));
window.Show(this);
return window;
}
private async Task<IStorageFolder?> Folder(string path) =>
await StorageProvider.TryGetFolderFromPathAsync(path);
}