Files
Nonemm/src/Nonemm.App/Windows/LogWindow.Columns.cs
T
erikbrockoandClaude Opus 5 56c7bcef21 Fit more of the log on screen and edit a cell without fighting it
The theme gave each cell 32 pixels of height, which fits about seven
contacts in the window. A row is now as tall as its text and one pixel
either side, so about fifteen fit.

Editing a cell:

- the columns bind one way, and the value is written back when the edit
  is taken, on Enter or a click elsewhere. A two-way binding wrote on
  every key press, which took the edit as the first character was typed
  and closed the box.
- Enter on the current cell opens the box. The handler runs before the
  grid's own, which reads Enter as "move down a line".
- Left, Right, Home and End stay in the box. The grid read a key the box
  left alone as "go to the next cell", and the box leaves a key alone
  when the caret cannot move; End also landed at the start of the text.
- the box takes the height and the left inset of the text it covers, so
  nothing moves as it opens.

An edit no longer rebuilds the list. The lines on screen are given the
contact again, so the view stays where the operator put it instead of
jumping to the top and back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
2026-09-10 16:55:59 +00:00

200 lines
7.5 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),
// one way in both directions of the grid: `LogWindow.TakeCellEdit`
// writes an edited cell back. A two-way binding would write on
// every key press, and the DataGrid turns a binding without a
// mode into a two-way one
Binding = new Binding($"[{column.Field}]") { Mode = BindingMode.OneWay },
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;
}