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 columnOf = []; private IReadOnlyList 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().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 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 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 columns) { session.Save(session.Settings with { LogColumns = columns }); builtFor = ""; Refresh(); } private IReadOnlyList 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 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 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.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 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; }