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:
@@ -208,6 +208,10 @@ public sealed record Settings
|
||||
|
||||
public string QtcRttySendAllEnding { get; init; } = QtcMessages.DefaultSendAllEnding;
|
||||
|
||||
/// The log window's columns, as `QsoField` names left to right. Empty means
|
||||
/// the set the contest opens with.
|
||||
public IReadOnlyList<string> LogColumns { get; init; } = [];
|
||||
|
||||
/// What the statistics window counts down the rows and across the columns.
|
||||
/// N1MM opens on the contacts per hand per hour, so this program does too.
|
||||
public string StatsRow { get; init; } = "Hour";
|
||||
|
||||
@@ -5,10 +5,10 @@ using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// One line of the log window. The grid reaches the editable fields through the
|
||||
/// indexer, so the columns can follow the contest exchange instead of being
|
||||
/// fixed in the XAML. The colour comes from the same scorer as the entry window
|
||||
/// and the bandmap.
|
||||
/// One line of the log window. The grid reaches the fields through the indexer,
|
||||
/// so the columns can follow the contest exchange and the operator's choice
|
||||
/// instead of being fixed in the XAML. The colour comes from the same scorer as
|
||||
/// the entry window and the bandmap.
|
||||
public sealed class LogRow
|
||||
{
|
||||
private readonly Func<Qso, QsoField, string, bool> edit;
|
||||
@@ -32,14 +32,5 @@ public sealed class LogRow
|
||||
set => edit(Qso, Enum.Parse<QsoField>(name), value);
|
||||
}
|
||||
|
||||
public string Country => Qso.CountryPrefix;
|
||||
|
||||
public int Points => Qso.Points;
|
||||
|
||||
public string Multipliers => string.Concat(
|
||||
Qso.IsMultiplier1 ? "1" : "",
|
||||
Qso.IsMultiplier2 ? "2" : "",
|
||||
Qso.IsMultiplier3 ? "3" : "");
|
||||
|
||||
public IBrush Colour => Verdicts.Colour(Verdict);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.VisualTree;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// Which columns the log shows, in which order, and which one it sorts on. The
|
||||
/// contest gives the set the log opens with; a right click on a header adds or
|
||||
/// removes one and a header can be dragged to move its column, both of which
|
||||
/// are kept in the settings file. A click on a header sorts on that column and
|
||||
/// lasts as long as the window is open.
|
||||
public sealed partial class LogWindow
|
||||
{
|
||||
/// What a cell takes either side of its text, in device pixels.
|
||||
private const double CellPadding = 30;
|
||||
|
||||
private readonly Dictionary<DataGridColumn, QsoColumn> columnOf = [];
|
||||
private IReadOnlyList<QsoColumn> shown = [];
|
||||
private Control? hiddenDragIndicator;
|
||||
private string builtFor = "";
|
||||
|
||||
/// Avalonia arranges the drag indicator once before it has worked out where
|
||||
/// the pointer is, and that first frame draws the header about a column's
|
||||
/// width to the left of where it belongs. The indicator is hidden until the
|
||||
/// next pointer move, which is the first frame that carries the offset.
|
||||
private void HideDragIndicator(object? sender, DataGridColumnReorderingEventArgs e)
|
||||
{
|
||||
hiddenDragIndicator = e.DragIndicator;
|
||||
if (hiddenDragIndicator is not null)
|
||||
{
|
||||
hiddenDragIndicator.Opacity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowDragIndicator(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (hiddenDragIndicator is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
hiddenDragIndicator.Opacity = 1;
|
||||
hiddenDragIndicator = null;
|
||||
}
|
||||
|
||||
private static bool IsColumnHeader(object? source) =>
|
||||
source is Visual visual && visual.GetSelfAndVisualAncestors().OfType<DataGridColumnHeader>().Any();
|
||||
|
||||
/// One item per column the contest or this program can show, ticked when it
|
||||
/// is on screen. The last item puts the contest's own set back.
|
||||
private ContextMenu ColumnMenu()
|
||||
{
|
||||
List<Control> items = [];
|
||||
foreach (QsoColumn column in LogColumns.Choices(ContestColumns()))
|
||||
{
|
||||
MenuItem item = new()
|
||||
{
|
||||
Header = column.Label,
|
||||
ToggleType = MenuItemToggleType.CheckBox,
|
||||
IsChecked = shown.Any(c => c.Field == column.Field),
|
||||
};
|
||||
item.Click += (_, _) => Toggle(column);
|
||||
items.Add(item);
|
||||
}
|
||||
MenuItem reset = new() { Header = "Default columns" };
|
||||
reset.Click += (_, _) => Store([]);
|
||||
items.Add(new Separator());
|
||||
items.Add(reset);
|
||||
return new ContextMenu { ItemsSource = items };
|
||||
}
|
||||
|
||||
/// Removing the last column would leave a grid nothing can be selected in,
|
||||
/// so the last one stays.
|
||||
private void Toggle(QsoColumn column)
|
||||
{
|
||||
List<QsoColumn> next = [.. shown];
|
||||
if (next.RemoveAll(c => c.Field == column.Field) == 0)
|
||||
{
|
||||
next.Add(column);
|
||||
}
|
||||
else if (next.Count == 0)
|
||||
{
|
||||
message = "the log needs at least one column";
|
||||
Refresh();
|
||||
return;
|
||||
}
|
||||
Store(LogColumns.Store(next));
|
||||
}
|
||||
|
||||
private void SaveColumnOrder() =>
|
||||
Store(LogColumns.Store(Rows.Columns
|
||||
.OrderBy(c => c.DisplayIndex)
|
||||
.Select(c => columnOf[c])));
|
||||
|
||||
private void Store(IReadOnlyList<string> columns)
|
||||
{
|
||||
session.Save(session.Settings with { LogColumns = columns });
|
||||
builtFor = "";
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private IReadOnlyList<QsoColumn> ContestColumns() =>
|
||||
session.Logging?.Editor.Columns ?? [];
|
||||
|
||||
/// The columns follow the contest exchange and the operator's choice, so
|
||||
/// they are rebuilt when either changes. 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)
|
||||
{
|
||||
IReadOnlyList<QsoColumn> chosen = LogColumns.Chosen(editor.Columns, session.Settings.LogColumns);
|
||||
string wanted = string.Join('|', chosen.Select(c => $"{c.Field}:{c.Label}"));
|
||||
if (wanted == builtFor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
builtFor = wanted;
|
||||
shown = chosen;
|
||||
FillColumns(Rows, chosen);
|
||||
FillColumns(Earlier, chosen);
|
||||
columnOf.Clear();
|
||||
for (int at = 0; at < chosen.Count; at++)
|
||||
{
|
||||
columnOf[Rows.Columns[at]] = chosen[at];
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<QsoColumn> columns)
|
||||
{
|
||||
grid.Columns.Clear();
|
||||
double Width(QsoColumn column) => WidthFor(column, grid);
|
||||
for (int at = 0; at < columns.Count; at++)
|
||||
{
|
||||
QsoColumn column = columns[at];
|
||||
grid.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = column.Label,
|
||||
IsReadOnly = !column.IsEditable,
|
||||
Width = at == columns.Count - 1
|
||||
? new DataGridLength(1, DataGridLengthUnitType.Star)
|
||||
: new DataGridLength(Width(column)),
|
||||
MinWidth = Width(column),
|
||||
Binding = column.IsEditable
|
||||
? new Binding($"[{column.Field}]") { Mode = BindingMode.TwoWay }
|
||||
: new Binding($"[{column.Field}]"),
|
||||
CustomSortComparer = By(column.Field),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid sorts on the field itself rather than on the text of the cell,
|
||||
/// which is what `LogColumns.SortKey` gives.
|
||||
private static IComparer By(QsoField field) =>
|
||||
Comparer<LogRow>.Create((left, right) =>
|
||||
LogColumns.SortKey(left.Qso, field).CompareTo(LogColumns.SortKey(right.Qso, field)));
|
||||
|
||||
/// A new view for each refresh, carrying over the sort the operator clicked.
|
||||
/// Without one the log shows the order the contacts were worked in.
|
||||
private DataGridCollectionView Sorted(IEnumerable<LogRow> rows)
|
||||
{
|
||||
DataGridCollectionView view = new(rows);
|
||||
if (Rows.ItemsSource is DataGridCollectionView old)
|
||||
{
|
||||
foreach (DataGridSortDescription sort in old.SortDescriptions)
|
||||
{
|
||||
view.SortDescriptions.Add(sort);
|
||||
}
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
/// 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(QsoColumn column, DataGrid grid) =>
|
||||
Math.Max(
|
||||
TextWidth(new string('0', column.Width + 1), grid.FontFamily, grid.FontSize),
|
||||
TextWidth(column.Label + "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;
|
||||
}
|
||||
@@ -13,16 +13,19 @@
|
||||
<Setter Property="FontFamily" Value="monospace" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<!-- the editing box is left a little shorter than the cell, otherwise
|
||||
it grows the row and the whole grid shifts as editing starts -->
|
||||
<Style Selector="DataGridCell TextBox">
|
||||
<Setter Property="FontFamily" Value="monospace" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="0,1" />
|
||||
<Setter Property="Padding" Value="4,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</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>
|
||||
<DataGrid Name="Rows" Grid.Row="0" GridLinesVisibility="Horizontal" CanUserSortColumns="True"
|
||||
CanUserReorderColumns="True" FontSize="12" FontFamily="monospace" />
|
||||
<GridSplitter Grid.Row="1" Height="3" ResizeDirection="Rows" />
|
||||
<DataGrid Name="Earlier" Grid.Row="2" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
||||
CanUserSortColumns="False" HeadersVisibility="None"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user