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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user