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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-30 21:00:04 +00:00
parent da04764756
commit a31c40fb2d
6 changed files with 341 additions and 54 deletions

View File

@@ -5,14 +5,28 @@
Title="Log" Width="1000" Height="420">
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
<DataGrid Name="Rows" GridLinesVisibility="Horizontal" CanUserSortColumns="False"
FontSize="12" FontFamily="monospace">
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="Edit contact…" Click="OnEditContact" InputGesture="Enter" />
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
<Grid RowDefinitions="2*,Auto,*">
<Grid.Styles>
<!-- the DataGrid's own font settings do not reach the cells, and the
columns are sized from the width of the text in this font -->
<Style Selector="DataGridCell TextBlock">
<Setter Property="FontFamily" Value="monospace" />
<Setter Property="FontSize" Value="12" />
</Style>
</Grid.Styles>
<DataGrid Name="Rows" Grid.Row="0" GridLinesVisibility="Horizontal" CanUserSortColumns="False"
FontSize="12" FontFamily="monospace">
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="Edit contact…" Click="OnEditContact" InputGesture="Enter" />
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
<GridSplitter Grid.Row="1" Height="3" ResizeDirection="Rows" />
<DataGrid Name="Earlier" Grid.Row="2" IsReadOnly="True" GridLinesVisibility="Horizontal"
CanUserSortColumns="False" HeadersVisibility="None"
FontSize="12" FontFamily="monospace" />
</Grid>
</DockPanel>
</local:RefreshableWindow>

View File

@@ -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<LogRow>();
Earlier.ItemsSource = Array.Empty<LogRow>();
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<LogRow>();
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<ColumnSpec> 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<ColumnSpec> 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

View File

@@ -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<Qso> Matching(
IReadOnlyList<Qso> logged,
string typed,
string gridSquare = "",
int limit = Limit)
{
string query = typed.Trim().ToUpperInvariant();
string grid = gridSquare.Trim();
IEnumerable<Qso> 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;
}
}