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