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); + } +}