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
198 lines
7.3 KiB
C#
198 lines
7.3 KiB
C#
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;
|
|
}
|