From a31c40fb2dd354afed1953d021f7a54bae1ab8b7 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sun, 30 Aug 2026 21:00:04 +0000 Subject: [PATCH] List the earlier contacts with the call being typed under the log N1MM's log window has two panes. The lower one lists every contact already logged with the call in the callsign box, on any band and in any mode, so an apparent dupe can be read off the screen rather than looked for. EarlierContacts.Matching holds the rule, so it is unit-tested without a window: three characters before anything is listed, a call that holds what is typed, `*` for any run of characters and `?` for one, the grid square instead when nothing is typed, ordered by call, mode, band and time, and fifty at most. That is N1MM's DupeSQL, except that N1MM passes `?` to SQLite as a plain character, which finds nothing. The two grids share their columns, because a value in the pane has to sit under the column it belongs to. Each column is now as wide as the longest value it can hold, measured in the font it is drawn in, and the last column takes what is left over. The values and the header are in different fonts, so each is measured on its own. This also settles the column widths, which used to change as rows scrolled into view. A DataGrid does not pass its own font down to its cells, so the cell font is set in a style. Without that the cells draw larger than the width measured for them and every column is a character short. Looked at under Xvfb with a call typed: the pane lists the two contacts that hold it, under columns that line up with the log above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD --- README.md | 12 +- docs/unfinished.md | 3 - src/Nonemm.App/Windows/LogWindow.axaml | 32 +++-- src/Nonemm.App/Windows/LogWindow.axaml.cs | 130 ++++++++++++------ src/Nonemm.Session/EarlierContacts.cs | 100 ++++++++++++++ .../EarlierContactsTests.cs | 118 ++++++++++++++++ 6 files changed, 341 insertions(+), 54 deletions(-) create mode 100644 src/Nonemm.Session/EarlierContacts.cs create mode 100644 tests/Nonemm.Session.Tests/EarlierContactsTests.cs diff --git a/README.md b/README.md index 64e30c0..3f01947 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,17 @@ otherwise, which puts the station in the middle of its country. Sunrise and sunset are worked out from that position for today; inside the polar circles the line says the sun does not rise or set. -### Editing the log +### The log window + +The pane under the log lists what has already been worked with the call being +typed: every earlier contact whose call holds it, on any band and in any mode, +so an apparent dupe can be read off the screen. `*` stands for any run of +characters and `?` for one. With nothing typed and a grid square copied, it +lists the contacts from that grid square instead. N1MM has the same pane, and +stops at fifty contacts as this does. + +Both grids get the same columns, each as wide as the longest value it can hold, +with the last column taking what is left over. Double-click a cell in the log window to change it. The columns follow the contest exchange, so CQ WW shows a Zone column and Sweepstakes shows Nr, Prec, diff --git a/docs/unfinished.md b/docs/unfinished.md index 4cbee10..f013cc1 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -114,9 +114,6 @@ beyond country, prefix, zone, section, exchange, grid and continent. ## Known rough edges -**The log window's column widths** are the grid's own automatic sizing with a -character-count floor. A column can change width as rows scroll into view. - **Contacts arriving over the station network** are applied from the socket thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it. That is correct but heavy, and it discards what is typed in an entry window on diff --git a/src/Nonemm.App/Windows/LogWindow.axaml b/src/Nonemm.App/Windows/LogWindow.axaml index 471e40d..aee0fbc 100644 --- a/src/Nonemm.App/Windows/LogWindow.axaml +++ b/src/Nonemm.App/Windows/LogWindow.axaml @@ -5,14 +5,28 @@ Title="Log" Width="1000" Height="420"> - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/Nonemm.App/Windows/LogWindow.axaml.cs b/src/Nonemm.App/Windows/LogWindow.axaml.cs index 697c386..17541c3 100644 --- a/src/Nonemm.App/Windows/LogWindow.axaml.cs +++ b/src/Nonemm.App/Windows/LogWindow.axaml.cs @@ -1,8 +1,11 @@ +using System.Globalization; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Media; using Nonemm.App.Dialogs; +using Nonemm.Contests; using Nonemm.Core; using Nonemm.Session; @@ -12,11 +15,8 @@ namespace Nonemm.App.Windows; /// change it; the Delete key removes the contact. public sealed partial class LogWindow : RefreshableWindow { - /// Roughly the width of one character of the grid font, in device pixels. - private const double CharacterWidth = 7.5; - - /// What a cell takes either side of its text. - private const double CellPadding = 20; + /// What a cell takes either side of its text, in device pixels. + private const double CellPadding = 30; private readonly AppSession session; private string builtFor = ""; @@ -28,13 +28,8 @@ public sealed partial class LogWindow : RefreshableWindow this.session = session; InitializeComponent(); Rows.KeyDown += OnKeyDown; - Rows.LoadingRow += (_, e) => - { - if (e.Row.DataContext is LogRow row) - { - e.Row.Foreground = row.Colour; - } - }; + Colour(Rows); + Colour(Earlier); Refresh(); } @@ -43,6 +38,7 @@ public sealed partial class LogWindow : RefreshableWindow if (session.Logging is null) { Rows.ItemsSource = Array.Empty(); + Earlier.ItemsSource = Array.Empty(); SummaryText.Text = "no contest is open"; return; } @@ -55,16 +51,46 @@ public sealed partial class LogWindow : RefreshableWindow { Rows.ScrollIntoView(rows[^1], null); } + ShowEarlier(); SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging); message = ""; } + /// The pane under the log: what has already been worked with the call being + /// typed, so an apparent dupe can be read off the screen. + private void ShowEarlier() + { + OperatingPosition? position = session.Position; + if (position is null || session.Logging is null) + { + Earlier.ItemsSource = Array.Empty(); + return; + } + Earlier.ItemsSource = EarlierContacts + .Matching( + session.Logging.Log.Qsos, + position.Entry.Call, + position.Entry.ValueOf(ExchangeSlot.GridSquare)) + .Select(q => new LogRow(q, VerdictFor(q), Edit)) + .ToList(); + } + + private static void Colour(DataGrid grid) => + grid.LoadingRow += (_, e) => + { + if (e.Row.DataContext is LogRow row) + { + e.Row.Foreground = row.Colour; + } + }; + private static string Summary(ContestSession logging) => $"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " + $"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}"; /// The columns follow the contest exchange, so they are rebuilt when a - /// different contest is opened. + /// different contest is opened. Both grids get the same columns: a value in + /// the pane has to sit under the column it belongs to. private void BuildColumns(QsoEditor editor) { string wanted = string.Join('|', editor.Columns.Select(c => c.Label)); @@ -73,38 +99,60 @@ public sealed partial class LogWindow : RefreshableWindow return; } builtFor = wanted; - Rows.Columns.Clear(); - foreach (QsoColumn column in editor.Columns) - { - Rows.Columns.Add(new DataGridTextColumn - { - Header = column.Label, - Width = DataGridLength.Auto, - MinWidth = MinimumFor(column.Width), - Binding = new Binding($"[{column.Field}]") { Mode = BindingMode.TwoWay }, - }); - } - Rows.Columns.Add(ReadOnlyColumn("Cty", nameof(LogRow.Country), 6)); - Rows.Columns.Add(ReadOnlyColumn("Pts", nameof(LogRow.Points), 4)); - Rows.Columns.Add(ReadOnlyColumn("Mult", nameof(LogRow.Multipliers), 5)); + List spec = + [ + .. editor.Columns.Select(c => new ColumnSpec(c.Label, $"[{c.Field}]", true, c.Width)), + new ColumnSpec("Cty", nameof(LogRow.Country), false, 6), + new ColumnSpec("Pts", nameof(LogRow.Points), false, 4), + new ColumnSpec("Mult", nameof(LogRow.Multipliers), false, 5), + ]; + FillColumns(Rows, spec); + FillColumns(Earlier, spec); } - private DataGridTextColumn ReadOnlyColumn(string header, string property, int characters) => - new() + /// N1MM gives each column the width of the longest value it can hold, and + /// the last column what is left over. A fixed width also keeps a column from + /// changing size as rows scroll into view. + private static void FillColumns(DataGrid grid, IReadOnlyList spec) + { + grid.Columns.Clear(); + double Width(ColumnSpec column) => WidthFor(column, grid); + for (int at = 0; at < spec.Count; at++) { - Header = header, - IsReadOnly = true, - Width = DataGridLength.Auto, - MinWidth = MinimumFor(characters), - Binding = new Binding(property), - }; + ColumnSpec column = spec[at]; + grid.Columns.Add(new DataGridTextColumn + { + Header = column.Header, + IsReadOnly = !column.IsEditable, + Width = at == spec.Count - 1 + ? new DataGridLength(1, DataGridLengthUnitType.Star) + : new DataGridLength(Width(column)), + MinWidth = Width(column), + Binding = column.IsEditable + ? new Binding(column.Path) { Mode = BindingMode.TwoWay } + : new Binding(column.Path), + }); + } + } - /// The grid sizes a column to the wider of its header and the values it can - /// see. The header is in the theme's own font, not the grid's monospace, so - /// counting characters gets it wrong; the count is only a floor, so a - /// column of short values does not collapse. - private static double MinimumFor(int characters) => - (characters * CharacterWidth) + CellPadding; + /// Wide enough for both the longest value and the header. They are in + /// different fonts — the values in the grid's monospace, the header in the + /// theme's own font — so each is measured in the font it is drawn in. + private static double WidthFor(ColumnSpec column, DataGrid grid) => + Math.Max( + TextWidth(new string('0', column.Characters + 1), grid.FontFamily, grid.FontSize), + TextWidth(column.Header + "0", FontFamily.Default, grid.FontSize)) + CellPadding; + + private static double TextWidth(string text, FontFamily family, double size) => + new FormattedText( + text, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + new Typeface(family), + size, + null).Width; + + private sealed record ColumnSpec(string Header, string Path, bool IsEditable, int Characters); /// 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 diff --git a/src/Nonemm.Session/EarlierContacts.cs b/src/Nonemm.Session/EarlierContacts.cs new file mode 100644 index 0000000..68520c0 --- /dev/null +++ b/src/Nonemm.Session/EarlierContacts.cs @@ -0,0 +1,100 @@ +using Nonemm.Core; + +namespace Nonemm.Session; + +/// The contacts already logged with the callsign being typed. N1MM lists them +/// under the log, so an apparent dupe can be read off the screen: which band, +/// which mode, and how long ago. +public static class EarlierContacts +{ + /// N1MM's limit on the list. + public const int Limit = 50; + + /// Two characters match too much of any log to be worth listing. + public const int ShortestQuery = 3; + + /// With a call typed, the contacts whose call holds it. With nothing typed, + /// the contacts whose grid square holds the one typed for the station being + /// worked; with neither, nothing. + /// + /// `*` stands for any run of characters and `?` for one, as in the check + /// window. N1MM passes `?` to SQLite, which reads it as a plain character; + /// that finds nothing, because no callsign holds a question mark. + public static IReadOnlyList Matching( + IReadOnlyList logged, + string typed, + string gridSquare = "", + int limit = Limit) + { + string query = typed.Trim().ToUpperInvariant(); + string grid = gridSquare.Trim(); + IEnumerable found; + if (query.Length == 0) + { + if (grid.Length == 0) + { + return []; + } + found = logged.Where(q => q.GridSquare.Contains(grid, StringComparison.OrdinalIgnoreCase)); + } + else if (query.Length < ShortestQuery) + { + return []; + } + else if (query.Contains('*') || query.Contains('?')) + { + found = logged.Where(q => MatchesPattern(query, q.Call.Text)); + } + else + { + found = logged.Where(q => q.Call.Text.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + return + [ + .. found + .OrderBy(q => q.Call.Text, StringComparer.OrdinalIgnoreCase) + .ThenBy(q => q.Mode.Name, StringComparer.Ordinal) + .ThenBy(q => q.Frequency.Hertz) + .ThenBy(q => q.TimestampUtc) + .Take(limit), + ]; + } + + /// A wildcard pattern covers the whole call, as N1MM's `like` does. + private static bool MatchesPattern(string pattern, string call) + { + int at = 0; + int character = 0; + int lastStar = -1; + int afterStar = 0; + while (character < call.Length) + { + if (at < pattern.Length && + (pattern[at] == '?' || pattern[at] == char.ToUpperInvariant(call[character]))) + { + at++; + character++; + } + else if (at < pattern.Length && pattern[at] == '*') + { + lastStar = at++; + afterStar = character; + } + else if (lastStar >= 0) + { + // the run the last `*` stood for was too short: give it one more + at = lastStar + 1; + character = ++afterStar; + } + else + { + return false; + } + } + while (at < pattern.Length && pattern[at] == '*') + { + at++; + } + return at == pattern.Length; + } +} diff --git a/tests/Nonemm.Session.Tests/EarlierContactsTests.cs b/tests/Nonemm.Session.Tests/EarlierContactsTests.cs new file mode 100644 index 0000000..107727d --- /dev/null +++ b/tests/Nonemm.Session.Tests/EarlierContactsTests.cs @@ -0,0 +1,118 @@ +using Nonemm.Core; + +namespace Nonemm.Session.Tests; + +public class EarlierContactsTests +{ + private static Qso Contact( + string call, + double kilohertz = 14_030, + Mode? mode = null, + int minute = 0, + string grid = "") + { + return new Qso + { + Id = Qso.NewId(), + TimestampUtc = new DateTime(2026, 1, 1, 0, minute, 0, DateTimeKind.Utc), + Call = Callsign.Parse(call), + Frequency = Frequency.FromKilohertz(kilohertz), + Mode = mode ?? Modes.Cw, + ContestName = "CQWW", + GridSquare = grid, + }; + } + + [Fact] + public void FindsEveryContactWhoseCallHoldsWhatIsTyped() + { + List log = [Contact("DL1ABC"), Contact("OM3KFF"), Contact("W1ABCD")]; + + IReadOnlyList found = EarlierContacts.Matching(log, "ABC"); + + Assert.Equal(["DL1ABC", "W1ABCD"], found.Select(q => q.Call.Text)); + } + + [Fact] + public void SaysNothingUntilThreeCharactersAreTyped() + { + List log = [Contact("DL1ABC")]; + + Assert.Empty(EarlierContacts.Matching(log, "DL")); + Assert.Single(EarlierContacts.Matching(log, "DL1")); + } + + [Fact] + public void AStarStandsForAnyRunOfCharacters() + { + List log = [Contact("DL1ABC"), Contact("DL2XYZ"), Contact("OM3DL1")]; + + IReadOnlyList found = EarlierContacts.Matching(log, "DL*C"); + + Assert.Equal(["DL1ABC"], found.Select(q => q.Call.Text)); + } + + [Fact] + public void AQuestionMarkStandsForOneCharacter() + { + List log = [Contact("DL1ABC"), Contact("DL11ABC")]; + + IReadOnlyList found = EarlierContacts.Matching(log, "DL?ABC"); + + Assert.Equal(["DL1ABC"], found.Select(q => q.Call.Text)); + } + + [Fact] + public void AWildcardPatternCoversTheWholeCall() + { + List log = [Contact("DL1ABC"), Contact("OM3DL1")]; + + Assert.Equal(["DL1ABC"], EarlierContacts.Matching(log, "DL1*").Select(q => q.Call.Text)); + } + + [Fact] + public void WithNothingTypedItFollowsTheGridSquare() + { + List log = + [ + Contact("DL1ABC", grid: "JN88MD"), + Contact("OM3KFF", grid: "JO70AA"), + ]; + + IReadOnlyList found = EarlierContacts.Matching(log, "", "JN88"); + + Assert.Equal(["DL1ABC"], found.Select(q => q.Call.Text)); + } + + [Fact] + public void WithNeitherACallNorAGridItSaysNothing() + { + Assert.Empty(EarlierContacts.Matching([Contact("DL1ABC", grid: "JN88MD")], "", "")); + } + + [Fact] + public void OrdersByCallThenModeThenFrequencyThenTime() + { + List log = + [ + Contact("OM3KFF", kilohertz: 14_200, mode: Modes.Usb, minute: 4), + Contact("DL1ABC", kilohertz: 21_030, minute: 3), + Contact("DL1ABC", kilohertz: 14_030, mode: Modes.Usb, minute: 2), + Contact("DL1ABC", kilohertz: 14_030, minute: 1), + ]; + + IReadOnlyList found = EarlierContacts.Matching(log, "??????"); + + Assert.Equal( + [(1, "DL1ABC"), (3, "DL1ABC"), (2, "DL1ABC"), (4, "OM3KFF")], + found.Select(q => (q.TimestampUtc.Minute, q.Call.Text))); + } + + [Fact] + public void KeepsAtMostTheLimit() + { + List log = [.. Enumerable.Range(0, 60).Select(n => Contact($"DL1AB{n:00}"))]; + + Assert.Equal(EarlierContacts.Limit, EarlierContacts.Matching(log, "DL1").Count); + } +}