From 012b0cdc8b2f946a922811223a54245d96dd8ff1 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sun, 25 Oct 2026 15:43:09 +0000 Subject: [PATCH] Add the Info window, following N1MM's source The window shows the operator, the call-frame spot, country, bearing and sun lines, the WWV line and a message area. It draws both rate graphs against the goals and shows the QSO timer (seven modes), the band timer, the band-change count and the Run/Mult box. Goals use N1MM's `dhh goal` file format and its Import Goals rules, including statistics and CSV files. Logic that does not need the UI is in Nonemm.Contests (Goals, QsoTimer, RateBars) and Nonemm.Session (InfoLines), with tests. Co-Authored-By: Claude Sonnet 5.5 --- docs/unfinished.md | 20 ++ src/Nonemm.App/AppSession.Notices.cs | 68 +++++ src/Nonemm.App/AppSession.cs | 2 + src/Nonemm.App/Configuration/Settings.cs | 2 + .../Configuration/StoredInfoWindow.cs | 38 +++ src/Nonemm.App/Dialogs/GoalsDialog.axaml | 19 ++ src/Nonemm.App/Dialogs/GoalsDialog.axaml.cs | 73 +++++ .../Dialogs/ImportGoalsDialog.axaml | 26 ++ .../Dialogs/ImportGoalsDialog.axaml.cs | 23 ++ src/Nonemm.App/Windows/EntryWindow.Menu.cs | 5 + .../Windows/EntryWindow.Operators.cs | 1 + src/Nonemm.App/Windows/EntryWindow.axaml | 1 + src/Nonemm.App/Windows/EntryWindow.axaml.cs | 5 + src/Nonemm.App/Windows/InfoColours.cs | 11 + src/Nonemm.App/Windows/InfoWindow.Graphs.cs | 158 ++++++++++ src/Nonemm.App/Windows/InfoWindow.Menu.cs | 164 +++++++++++ src/Nonemm.App/Windows/InfoWindow.Timers.cs | 152 ++++++++++ src/Nonemm.App/Windows/InfoWindow.axaml | 114 ++++++++ src/Nonemm.App/Windows/InfoWindow.axaml.cs | 188 ++++++++++++ src/Nonemm.App/Windows/RateGraphView.cs | 102 +++++++ src/Nonemm.Contests/Goals.cs | 178 ++++++++++++ src/Nonemm.Contests/QsoTimer.cs | 179 ++++++++++++ src/Nonemm.Contests/RateBars.cs | 75 +++++ src/Nonemm.Session/InfoLines.cs | 96 ++++++ .../Nonemm.Contests.Tests/InfoWindowTests.cs | 274 ++++++++++++++++++ tests/Nonemm.Session.Tests/InfoLinesTests.cs | 81 ++++++ 26 files changed, 2055 insertions(+) create mode 100644 src/Nonemm.App/AppSession.Notices.cs create mode 100644 src/Nonemm.App/Configuration/StoredInfoWindow.cs create mode 100644 src/Nonemm.App/Dialogs/GoalsDialog.axaml create mode 100644 src/Nonemm.App/Dialogs/GoalsDialog.axaml.cs create mode 100644 src/Nonemm.App/Dialogs/ImportGoalsDialog.axaml create mode 100644 src/Nonemm.App/Dialogs/ImportGoalsDialog.axaml.cs create mode 100644 src/Nonemm.App/Windows/InfoColours.cs create mode 100644 src/Nonemm.App/Windows/InfoWindow.Graphs.cs create mode 100644 src/Nonemm.App/Windows/InfoWindow.Menu.cs create mode 100644 src/Nonemm.App/Windows/InfoWindow.Timers.cs create mode 100644 src/Nonemm.App/Windows/InfoWindow.axaml create mode 100644 src/Nonemm.App/Windows/InfoWindow.axaml.cs create mode 100644 src/Nonemm.App/Windows/RateGraphView.cs create mode 100644 src/Nonemm.Contests/Goals.cs create mode 100644 src/Nonemm.Contests/QsoTimer.cs create mode 100644 src/Nonemm.Contests/RateBars.cs create mode 100644 src/Nonemm.Session/InfoLines.cs create mode 100644 tests/Nonemm.Contests.Tests/InfoWindowTests.cs create mode 100644 tests/Nonemm.Session.Tests/InfoLinesTests.cs diff --git a/docs/unfinished.md b/docs/unfinished.md index 67095c2..1b4947e 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -26,6 +26,26 @@ The Cabrillo output has not been put in front of a contest sponsor's robot. ## Half-built +**The info window: what N1MM has and this does not.** The window follows +`InfoWindow.cs` from the decompiled source: the caption, the call-frame spot, +the country, bearing and sun lines, the WWV line, the message area, both graphs +with goals, the QSO timer in all seven modes, the band timer, the band-change +count and the Run/Mult box. Goals use N1MM's `dhh goal` file format and its +Import Goals rules, including the statistics and CSV files. Left out: + +- the multi-op category mismatch message and the CPU-load advisory for heavy + spot traffic; +- goals kept per contest in the Admin database, and the goal file that a + contest's Associated Files tab loads when the contest starts. Goals are one + list in `settings.json` that stays until it is replaced; +- the highlight on a spot from a preferred spotter; +- N1MM's time-since-last-QSO options 4 to 6, which its code limits by radio + number in a way that does not match their menu labels. This program follows + the labels; +- the hourly graph's slots end on the minute they start, where N1MM's end a + minute early (:19, :39, :59); +- a real check of the RBN link. It is N1MM's URL, and has not been opened. + **The digital window: what N1MM has and this does not.** The window is here — receive pane in both of N1MM's scroll modes, the Letters/Figs and MouseOver readouts, the pause bar, coloured callsigns with N1MM's validity routines, grab diff --git a/src/Nonemm.App/AppSession.Notices.cs b/src/Nonemm.App/AppSession.Notices.cs new file mode 100644 index 0000000..7c76f00 --- /dev/null +++ b/src/Nonemm.App/AppSession.Notices.cs @@ -0,0 +1,68 @@ +using Nonemm.Spotting; + +namespace Nonemm.App; + +/// A line for the info window's message area. +/// `IsAlert` marks one the operator should see, such as a spot of their own call. +public sealed record Notice(DateTime AtUtc, string Text, bool IsAlert = false); + +public sealed partial class AppSession +{ + private const int NoticesKept = 50; + + private readonly List notices = []; + private readonly Lock noticesGuard = new(); + + /// The latest program messages, oldest first: what the keyers reported and + /// the spots of this station's own call. + public IReadOnlyList Notices + { + get + { + lock (noticesGuard) + { + return [.. notices]; + } + } + } + + /// Raised on whatever thread posted the notice. + public event EventHandler? NoticesChanged; + + public void PostNotice(string text, bool isAlert = false) + { + lock (noticesGuard) + { + notices.Add(new Notice(DateTime.UtcNow, text, isAlert)); + if (notices.Count > NoticesKept) + { + notices.RemoveAt(0); + } + } + NoticesChanged?.Invoke(this, EventArgs.Empty); + } + + public void ClearNotices() + { + lock (noticesGuard) + { + notices.Clear(); + } + NoticesChanged?.Invoke(this, EventArgs.Empty); + } + + /// Posts a notice for each spot of our own call in a batch from the cluster. + private void NoticeOwnSpots(IEnumerable batch) + { + string mine = Settings.Station.Callsign.Trim(); + if (mine.Length == 0) + { + return; + } + foreach (Spot spot in batch.Where(s => s.IsStation + && string.Equals(s.Call.Text, mine, StringComparison.OrdinalIgnoreCase))) + { + PostNotice($"{spot.Spotter} spotted {spot.Call.Text} on {spot.Frequency.Kilohertz:0.0} {spot.Comment}".TrimEnd(), isAlert: true); + } + } +} diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index c39b6c1..35253a0 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -81,6 +81,7 @@ public sealed partial class AppSession : IDisposable BandPlan = settings.ToBandPlan(); ApplySpotSettings(); spotFlush = new Timer(_ => FlushSpots(), null, SpotFlushInterval, SpotFlushInterval); + Reported += (_, text) => PostNotice(text); } /// How often the spots that have arrived go onto the bandmap. N1MM's own @@ -1031,6 +1032,7 @@ public sealed partial class AppSession : IDisposable batch = [.. arriving]; arriving.Clear(); } + NoticeOwnSpots(batch); List keeping = []; foreach (Spot spot in batch) { diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 2899ea4..8bd68f1 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -59,6 +59,8 @@ public sealed record Settings /// Which cluster spots reach the bandmap. public StoredSpotFilter SpotFilter { get; init; } = new(); + public StoredInfoWindow Info { get; init; } = new(); + /// How long a CW or digital spot stays on the bandmap. public int CwDigitalSpotTimeoutMinutes { get; init; } = 10; diff --git a/src/Nonemm.App/Configuration/StoredInfoWindow.cs b/src/Nonemm.App/Configuration/StoredInfoWindow.cs new file mode 100644 index 0000000..bc544bb --- /dev/null +++ b/src/Nonemm.App/Configuration/StoredInfoWindow.cs @@ -0,0 +1,38 @@ +using Nonemm.Contests; + +namespace Nonemm.App.Configuration; + +/// What the info window shows. Each flag is one item on its right-click menu. +public sealed record StoredInfoWindow +{ + public bool CallFrameSpot { get; init; } = true; + + public bool CountryInfo { get; init; } = true; + + public bool SunTimes { get; init; } = true; + + public bool Wwv { get; init; } = true; + + public bool Messages { get; init; } = true; + + public bool QsoTimers { get; init; } = true; + + public bool ShowGoals { get; init; } = true; + + /// Count the rates of every computer on the network rather than this one. + public bool NetworkedRate { get; init; } + + /// Count only the contacts on the band the radio is on. + public bool BandRate { get; init; } + + /// Show points on the left graph instead of contacts. + public bool ShowPoints { get; init; } + + /// The length of one bar on the hourly graph: 20, 30 or 60. + public int HourlyMinutes { get; init; } = 30; + + public QsoTimerOption TimerOption { get; init; } = QsoTimerOption.AllStations; + + /// The goals in `Goals.ToText` form. Empty means none are set. + public string Goals { get; init; } = ""; +} diff --git a/src/Nonemm.App/Dialogs/GoalsDialog.axaml b/src/Nonemm.App/Dialogs/GoalsDialog.axaml new file mode 100644 index 0000000..0bdf430 --- /dev/null +++ b/src/Nonemm.App/Dialogs/GoalsDialog.axaml @@ -0,0 +1,19 @@ + + + +