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 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(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(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 files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Import ADIF", AllowMultiple = false, }); if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path) { return; } IReadOnlyList 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(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(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(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(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(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(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(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(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(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 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(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> 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(Func 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 Folder(string path) => await StorageProvider.TryGetFolderFromPathAsync(path); }