Let the operator pick, order and sort the log columns

The log showed the contest's columns and nothing else, with the country,
points and multiplier columns fixed in the XAML. LogColumns now holds every
field the log can show and the settings file keeps the names, left to right.

A right click on a header adds or removes a column and offers the contest's
own set back, a header can be dragged to move its column, and a click sorts
on it. Sorting uses the field, not the text of the cell, so 9,000 kHz comes
before 14,000 and the serial numbers do not run 1, 10, 2. Both grids get the
same columns, so a value in the pane below sits under the column it belongs
to. Removing the last column is refused, otherwise nothing could be selected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZkFxHde1iifAQemFTHxf
This commit is contained in:
2026-09-10 11:38:30 +00:00
co-authored by Claude Opus 5
parent d5788da240
commit 33c7631e17
7 changed files with 478 additions and 101 deletions
+43 -79
View File
@@ -1,9 +1,7 @@
using System.Globalization;
using Avalonia.Collections;
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;
@@ -12,14 +10,12 @@ using Nonemm.Session;
namespace Nonemm.App.Windows;
/// The contacts of the contest in progress, newest last. Double-click a cell to
/// change it; the Delete key removes the contact.
/// change it; the Delete key removes the contact. Clicking a column header
/// sorts on it, right-clicking picks which columns are shown and dragging one
/// moves its column; see `LogWindow.Columns.cs`.
public sealed partial class LogWindow : RefreshableWindow
{
/// What a cell takes either side of its text, in device pixels.
private const double CellPadding = 30;
private readonly AppSession session;
private string builtFor = "";
private string message = "";
private string finding = "";
@@ -28,11 +24,44 @@ public sealed partial class LogWindow : RefreshableWindow
this.session = session;
InitializeComponent();
Rows.KeyDown += OnKeyDown;
Rows.ColumnReordering += HideDragIndicator;
Rows.AddHandler(PointerMovedEvent, ShowDragIndicator, RoutingStrategies.Tunnel);
Rows.ColumnReordered += (_, _) => SaveColumnOrder();
Rows.PreparingCellForEdit += SelectCellText;
Rows.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Bubble);
Colour(Rows);
Colour(Earlier);
Refresh();
}
/// A cell that has just opened for editing has all of its text selected, so
/// typing replaces the value rather than adding to it.
private static void SelectCellText(object? sender, DataGridPreparingCellForEditEventArgs e)
{
if (e.EditingElement is TextBox box)
{
box.SelectAll();
}
}
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
e.Handled = true;
ContextMenu menu = IsColumnHeader(e.Source) ? ColumnMenu() : RowMenu();
menu.PlacementTarget = Rows;
menu.Placement = PlacementMode.Pointer;
menu.Open(Rows);
}
private ContextMenu RowMenu()
{
MenuItem edit = new() { Header = "Edit contact…", InputGesture = new KeyGesture(Key.Enter) };
edit.Click += OnEditContact;
MenuItem delete = new() { Header = "Delete contact", InputGesture = new KeyGesture(Key.Delete) };
delete.Click += OnDelete;
return new ContextMenu { ItemsSource = new List<MenuItem> { edit, delete } };
}
public override void Refresh()
{
if (session.Logging is null)
@@ -46,7 +75,7 @@ public sealed partial class LogWindow : RefreshableWindow
List<LogRow> rows = session.Logging.Log.Qsos
.Select(q => new LogRow(q, VerdictFor(q), Edit))
.ToList();
Rows.ItemsSource = rows;
Rows.ItemsSource = Sorted(rows);
if (rows.Count > 0)
{
Rows.ScrollIntoView(rows[^1], null);
@@ -88,79 +117,13 @@ public sealed partial class LogWindow : RefreshableWindow
$"{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. 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));
if (wanted == builtFor)
{
return;
}
builtFor = wanted;
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);
}
/// 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++)
{
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),
});
}
}
/// 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
/// the call is not in the log.
public Qso? FindNextCall(string call)
{
string wanted = call.Trim();
if (wanted.Length == 0 || Rows.ItemsSource is not IReadOnlyList<LogRow> rows)
if (wanted.Length == 0 || Rows.ItemsSource is not DataGridCollectionView rows || rows.Count == 0)
{
return null;
}
@@ -169,13 +132,14 @@ public sealed partial class LogWindow : RefreshableWindow
for (int step = 0; step < rows.Count; step++)
{
int at = (from + step) % rows.Count;
if (!rows[at].Qso.Call.Text.Equals(wanted, StringComparison.OrdinalIgnoreCase))
if (rows.GetItemAt(at) is not LogRow row
|| !row.Qso.Call.Text.Equals(wanted, StringComparison.OrdinalIgnoreCase))
{
continue;
}
Rows.SelectedIndex = at;
Rows.ScrollIntoView(rows[at], null);
return rows[at].Qso;
Rows.ScrollIntoView(row, null);
return row.Qso;
}
return null;
}