diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index b74f397..df4498c 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -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 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"; diff --git a/src/Nonemm.App/Windows/LogRow.cs b/src/Nonemm.App/Windows/LogRow.cs index f8c0c65..92461a6 100644 --- a/src/Nonemm.App/Windows/LogRow.cs +++ b/src/Nonemm.App/Windows/LogRow.cs @@ -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 edit; @@ -32,14 +32,5 @@ public sealed class LogRow set => edit(Qso, Enum.Parse(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); } diff --git a/src/Nonemm.App/Windows/LogWindow.Columns.cs b/src/Nonemm.App/Windows/LogWindow.Columns.cs new file mode 100644 index 0000000..a4c8c32 --- /dev/null +++ b/src/Nonemm.App/Windows/LogWindow.Columns.cs @@ -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 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; +} diff --git a/src/Nonemm.App/Windows/LogWindow.axaml b/src/Nonemm.App/Windows/LogWindow.axaml index aee0fbc..7e6cd99 100644 --- a/src/Nonemm.App/Windows/LogWindow.axaml +++ b/src/Nonemm.App/Windows/LogWindow.axaml @@ -13,16 +13,19 @@ + + - - - - - - - - + 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 { edit, delete } }; + } + public override void Refresh() { if (session.Logging is null) @@ -46,7 +75,7 @@ public sealed partial class LogWindow : RefreshableWindow List 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 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 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 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; } diff --git a/src/Nonemm.Session/LogColumns.cs b/src/Nonemm.Session/LogColumns.cs new file mode 100644 index 0000000..13a7ff2 --- /dev/null +++ b/src/Nonemm.Session/LogColumns.cs @@ -0,0 +1,95 @@ +using Nonemm.Core; + +namespace Nonemm.Session; + +/// The columns the log window can show, and how a set the operator picked is +/// stored. The contest gives the default set and the labels for its own +/// exchange fields; every other field can be added to it from the header menu. +public static class LogColumns +{ + /// Every field the log can show, in the order the header menu lists the + /// ones the contest did not ask for. Labels and widths are the fallback + /// for a field the contest has no column for. + public static readonly IReadOnlyList All = + [ + new QsoColumn("Time", QsoField.Time, 19), + new QsoColumn("Freq", QsoField.Frequency, 8), + new QsoColumn("QSX", QsoField.QsxFrequency, 8), + new QsoColumn("Mode", QsoField.Mode, 5), + new QsoColumn("Call", QsoField.Call, 12), + new QsoColumn("Snt", QsoField.SentReport, 4), + new QsoColumn("SntNr", QsoField.SentNumber, 5), + new QsoColumn("Rcv", QsoField.ReceivedReport, 4), + new QsoColumn("Nr", QsoField.ReceivedNumber, 5), + new QsoColumn("Zone", QsoField.Zone, 4), + new QsoColumn("Sect", QsoField.Section, 5), + new QsoColumn("Prec", QsoField.Precedence, 4), + new QsoColumn("Ck", QsoField.Check, 3), + new QsoColumn("Exch", QsoField.Exchange1, 6), + new QsoColumn("Misc", QsoField.MiscText, 6), + new QsoColumn("Name", QsoField.Name, 10), + new QsoColumn("QTH", QsoField.Qth, 10), + new QsoColumn("Grid", QsoField.GridSquare, 6), + new QsoColumn("Pwr", QsoField.Power, 5), + new QsoColumn("Comment", QsoField.Comment, 16), + new QsoColumn("Op", QsoField.Operator, 8), + new QsoColumn("Cty", QsoField.CountryPrefix, 6), + new QsoColumn("WPX", QsoField.WpxPrefix, 6), + new QsoColumn("Radio", QsoField.RadioNumber, 5), + new QsoColumn("Run", QsoField.RunPosition, 3), + new QsoColumn("Pts", QsoField.Points, 4, IsEditable: false), + new QsoColumn("Mult", QsoField.Multipliers, 5, IsEditable: false), + ]; + + /// What the header menu offers: the contest's own columns first, in the + /// order the contest gives them, then the rest. + public static IReadOnlyList Choices(IReadOnlyList contest) => + [ + .. contest, + .. All.Where(c => !contest.Any(other => other.Field == c.Field)), + ]; + + /// The stored field names turned back into columns. A name that is not a + /// field, or is repeated, is dropped. An empty or unusable list falls back + /// to the contest's own columns, which is what a fresh log shows. + public static IReadOnlyList Chosen( + IReadOnlyList contest, + IEnumerable stored) + { + IReadOnlyList offered = Choices(contest); + List chosen = []; + foreach (string name in stored) + { + if (!Enum.TryParse(name, out QsoField field) + || chosen.Any(c => c.Field == field) + || offered.FirstOrDefault(c => c.Field == field) is not { } column) + { + continue; + } + chosen.Add(column); + } + return chosen.Count > 0 ? chosen : contest; + } + + /// What a column sorts on. Numbers and times sort as numbers and times, not + /// as the text the log shows, so 9,000 kHz comes before 14,000 kHz and the + /// serial numbers do not run 1, 10, 2. + public static IComparable SortKey(Qso qso, QsoField field) => field switch + { + QsoField.Time => qso.TimestampUtc, + QsoField.Frequency => qso.Frequency.Hertz, + QsoField.QsxFrequency => qso.QsxFrequency.Hertz, + QsoField.SentNumber => qso.SentNumber, + QsoField.ReceivedNumber => qso.ReceivedNumber, + QsoField.Zone => qso.Zone, + QsoField.Check => qso.Check, + QsoField.RadioNumber => qso.RadioNumber, + QsoField.RunPosition => qso.RunPosition, + QsoField.Points => qso.Points, + _ => QsoEditor.Read(qso, field), + }; + + /// What goes in the settings file: the field names, left to right. + public static IReadOnlyList Store(IEnumerable columns) => + [.. columns.Select(c => c.Field.ToString())]; +} diff --git a/tests/Nonemm.Session.Tests/LogColumnsTests.cs b/tests/Nonemm.Session.Tests/LogColumnsTests.cs new file mode 100644 index 0000000..be714ac --- /dev/null +++ b/tests/Nonemm.Session.Tests/LogColumnsTests.cs @@ -0,0 +1,123 @@ +using Nonemm.Contests.Rules; +using Nonemm.Core; + +namespace Nonemm.Session.Tests; + +public class LogColumnsTests +{ + private static readonly StationInfo Me = new() + { + Callsign = "DL1ABC", + CqZone = 14, + ItuZone = 28, + Continent = "EU", + CountryPrefix = "DL", + }; + + private static IReadOnlyList Contest() => + new QsoEditor(new CqWorldWide(ModeCategory.Cw), Me, null).Columns; + + [Fact] + public void AnEmptyChoiceKeepsTheColumnsTheContestOpensWith() => + Assert.Equal(Contest(), LogColumns.Chosen(Contest(), [])); + + [Fact] + public void TheChosenColumnsComeBackInTheStoredOrder() + { + IReadOnlyList chosen = LogColumns.Chosen(Contest(), ["Call", "Time", "Comment"]); + + Assert.Equal([QsoField.Call, QsoField.Time, QsoField.Comment], chosen.Select(c => c.Field)); + } + + /// The contest names its own exchange columns, so a field it asked for + /// keeps that label rather than the fallback one. + [Fact] + public void AContestColumnKeepsTheLabelTheContestGaveIt() + { + QsoColumn zone = LogColumns.Chosen(Contest(), ["Zone"]).Single(); + + Assert.Equal(Contest().Single(c => c.Field == QsoField.Zone).Label, zone.Label); + } + + [Fact] + public void ANameThatIsNotAFieldIsDropped() => + Assert.Equal( + [QsoField.Call], + LogColumns.Chosen(Contest(), ["Call", "Nonsense"]).Select(c => c.Field)); + + [Fact] + public void ARepeatedNameOnlyGivesOneColumn() => + Assert.Single(LogColumns.Chosen(Contest(), ["Call", "Call"])); + + /// Every name dropped leaves nothing to show, so the contest's own set is + /// used rather than an empty grid. + [Fact] + public void AListOfNothingUsableFallsBackToTheContest() => + Assert.Equal(Contest(), LogColumns.Chosen(Contest(), ["Nonsense"])); + + [Fact] + public void TheMenuOffersTheContestColumnsFirstAndThenTheRest() + { + IReadOnlyList choices = LogColumns.Choices(Contest()); + + Assert.Equal(Contest(), choices.Take(Contest().Count)); + Assert.Equal(choices.Select(c => c.Field).Distinct(), choices.Select(c => c.Field)); + Assert.Contains(QsoField.Comment, choices.Select(c => c.Field)); + } + + [Fact] + public void EveryFieldTheLogCanShowReadsBack() => + Assert.All(LogColumns.All, c => Assert.NotNull(QsoEditor.Read(Contact(), c.Field))); + + private static Qso Contact() => new() + { + Id = "1", + TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc), + Call = Callsign.Parse("JA1XYZ"), + Frequency = Frequency.FromKilohertz(14_025), + Mode = Modes.Cw, + ContestName = "CQWW", + Points = 3, + }; + + /// Sorting on the text of the cell would put 14,000 kHz before 9,000 and + /// serial number 10 before 2. + [Fact] + public void NumbersSortAsNumbersNotAsTheTextTheLogShows() + { + Qso low = Contact() with { Frequency = Frequency.FromKilohertz(9_000), ReceivedNumber = 2 }; + Qso high = Contact() with { Frequency = Frequency.FromKilohertz(14_000), ReceivedNumber = 10 }; + + Assert.True(Key(low, QsoField.Frequency).CompareTo(Key(high, QsoField.Frequency)) < 0); + Assert.True(Key(low, QsoField.ReceivedNumber).CompareTo(Key(high, QsoField.ReceivedNumber)) < 0); + } + + [Fact] + public void TheTimeSortsAsATime() + { + Qso first = Contact(); + Qso second = Contact() with { TimestampUtc = first.TimestampUtc.AddHours(1) }; + + Assert.True(Key(first, QsoField.Time).CompareTo(Key(second, QsoField.Time)) < 0); + } + + [Fact] + public void ATextFieldSortsOnWhatTheLogShows() => + Assert.Equal( + QsoEditor.Read(Contact(), QsoField.Call), + Key(Contact(), QsoField.Call)); + + [Fact] + public void EveryColumnHasASortKey() => + Assert.All(LogColumns.All, c => Assert.NotNull(Key(Contact(), c.Field))); + + private static IComparable Key(Qso qso, QsoField field) => LogColumns.SortKey(qso, field); + + [Fact] + public void StoreAndChosenRoundTrip() + { + IReadOnlyList chosen = LogColumns.Chosen(Contest(), ["Comment", "Call"]); + + Assert.Equal(chosen, LogColumns.Chosen(Contest(), LogColumns.Store(chosen))); + } +}