From 56c7bcef21e688a674f0782d69132410820c67d9 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 10 Sep 2026 16:55:59 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ --- src/Nonemm.App/Windows/LogRow.cs | 32 ++- src/Nonemm.App/Windows/LogWindow.Columns.cs | 8 +- src/Nonemm.App/Windows/LogWindow.axaml | 26 ++- src/Nonemm.App/Windows/LogWindow.axaml.cs | 229 ++++++++++++++++++-- 4 files changed, 253 insertions(+), 42 deletions(-) diff --git a/src/Nonemm.App/Windows/LogRow.cs b/src/Nonemm.App/Windows/LogRow.cs index 92461a6..1979887 100644 --- a/src/Nonemm.App/Windows/LogRow.cs +++ b/src/Nonemm.App/Windows/LogRow.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using Avalonia.Media; using Nonemm.Contests; using Nonemm.Core; @@ -9,28 +10,35 @@ namespace Nonemm.App.Windows; /// 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 +public sealed class LogRow : INotifyPropertyChanged { - private readonly Func edit; - - public LogRow(Qso qso, Verdict? verdict, Func edit) + public LogRow(Qso qso, Verdict? verdict) { Qso = qso; Verdict = verdict; - this.edit = edit; } - public Qso Qso { get; } + public event PropertyChangedEventHandler? PropertyChanged; - public Verdict? Verdict { get; } + public Qso Qso { get; private set; } - /// `name` is a `QsoField` name. A refused edit leaves the contact alone, so - /// the grid reads the old value back and the cell reverts. - public string this[string name] + public Verdict? Verdict { get; private set; } + + /// Takes the contact again after it was edited. The grid redraws the line + /// it is already showing, so the view stays where it was. "Item" is the + /// name the indexer below has in the metadata, and the name the binding + /// listens for; any other name is ignored. + public void Show(Qso qso, Verdict? verdict) { - get => QsoEditor.Read(Qso, Enum.Parse(name)); - set => edit(Qso, Enum.Parse(name), value); + Qso = qso; + Verdict = verdict; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item")); } + /// `name` is a `QsoField` name. Read only: an edited cell is written back by + /// `LogWindow.TakeCellEdit` when the operator takes the edit, not as the + /// text is typed. + public string this[string name] => QsoEditor.Read(Qso, Enum.Parse(name)); + public IBrush Colour => Verdicts.Colour(Verdict); } diff --git a/src/Nonemm.App/Windows/LogWindow.Columns.cs b/src/Nonemm.App/Windows/LogWindow.Columns.cs index a4c8c32..610ab5e 100644 --- a/src/Nonemm.App/Windows/LogWindow.Columns.cs +++ b/src/Nonemm.App/Windows/LogWindow.Columns.cs @@ -149,9 +149,11 @@ public sealed partial class LogWindow ? 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}]"), + // 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), }); } diff --git a/src/Nonemm.App/Windows/LogWindow.axaml b/src/Nonemm.App/Windows/LogWindow.axaml index 7e6cd99..2a09de0 100644 --- a/src/Nonemm.App/Windows/LogWindow.axaml +++ b/src/Nonemm.App/Windows/LogWindow.axaml @@ -13,14 +13,32 @@ - + + + + + diff --git a/src/Nonemm.App/Windows/LogWindow.axaml.cs b/src/Nonemm.App/Windows/LogWindow.axaml.cs index a2e924a..91682c0 100644 --- a/src/Nonemm.App/Windows/LogWindow.axaml.cs +++ b/src/Nonemm.App/Windows/LogWindow.axaml.cs @@ -1,7 +1,10 @@ +using Avalonia; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; using Nonemm.App.Dialogs; using Nonemm.Contests; using Nonemm.Core; @@ -9,25 +12,32 @@ 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. Clicking a column header +/// The contacts of the contest in progress, newest last. Double-click a cell or +/// press Enter on it to 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 { private readonly AppSession session; private string message = ""; + /// The lines the grid is showing. They are kept and given the contact again + /// when only the values changed, so that an edit does not rebuild the list + /// and move the view. + private List shownRows = []; private string finding = ""; public LogWindow(AppSession session) { this.session = session; InitializeComponent(); - Rows.KeyDown += OnKeyDown; + // before the grid's own handling: the grid takes Enter to mean "move to + // the next line", which would close the editing box as soon as it opens + Rows.AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel); Rows.ColumnReordering += HideDragIndicator; Rows.AddHandler(PointerMovedEvent, ShowDragIndicator, RoutingStrategies.Tunnel); Rows.ColumnReordered += (_, _) => SaveColumnOrder(); Rows.PreparingCellForEdit += SelectCellText; + Rows.CellEditEnding += TakeCellEdit; Rows.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Bubble); Colour(Rows); Colour(Earlier); @@ -38,10 +48,42 @@ public sealed partial class LogWindow : RefreshableWindow /// typing replaces the value rather than adding to it. private static void SelectCellText(object? sender, DataGridPreparingCellForEditEventArgs e) { - if (e.EditingElement is TextBox box) + if (e.EditingElement is not TextBox box) { - box.SelectAll(); + return; } + box.SelectAll(); + // the grid hands the box back for the next cell, so the handler is + // taken off before it is put on again + box.RemoveHandler(KeyDownEvent, KeepCaretKeys); + box.AddHandler( + KeyDownEvent, + KeepCaretKeys, + RoutingStrategies.Direct | RoutingStrategies.Bubble, + handledEventsToo: true); + } + + /// Left, Right, Home and End stay in the editing box. The box marks a key + /// as handled only when it moves the caret, and the grid reads a key the + /// box left alone as "go to the next cell". Opening the box leaves the + /// caret at the end of the text, so one press of Right closed the box. + private static void KeepCaretKeys(object? sender, KeyEventArgs e) + { + if (e.Key is not (Key.Left or Key.Right or Key.Home or Key.End)) + { + return; + } + // the box drops a selection by putting the caret at the start of it, + // and does so after End has moved the caret to the end of the text, so + // End on a box whose text is all selected lands at the start + if (e.Key == Key.End && !e.KeyModifiers.HasFlag(KeyModifiers.Shift) && sender is TextBox box) + { + int end = (box.Text ?? "").Length; + box.CaretIndex = end; + box.SelectionStart = end; + box.SelectionEnd = end; + } + e.Handled = true; } private void OnContextRequested(object? sender, ContextRequestedEventArgs e) @@ -55,11 +97,13 @@ public sealed partial class LogWindow : RefreshableWindow private ContextMenu RowMenu() { - MenuItem edit = new() { Header = "Edit contact…", InputGesture = new KeyGesture(Key.Enter) }; + MenuItem edit = new() { Header = "Edit contact…" }; edit.Click += OnEditContact; + MenuItem operators = new() { Header = "Change Operator…" }; + operators.Click += OnChangeOperator; MenuItem delete = new() { Header = "Delete contact", InputGesture = new KeyGesture(Key.Delete) }; delete.Click += OnDelete; - return new ContextMenu { ItemsSource = new List { edit, delete } }; + return new ContextMenu { ItemsSource = new List { edit, operators, delete } }; } public override void Refresh() @@ -72,19 +116,108 @@ public sealed partial class LogWindow : RefreshableWindow return; } BuildColumns(session.Logging.Editor); - List rows = session.Logging.Log.Qsos - .Select(q => new LogRow(q, VerdictFor(q), Edit)) - .ToList(); - Rows.ItemsSource = Sorted(rows); - if (rows.Count > 0) + IReadOnlyList qsos = session.Logging.Log.Qsos; + if (SameContacts(qsos)) { - Rows.ScrollIntoView(rows[^1], null); + for (int at = 0; at < qsos.Count; at++) + { + shownRows[at].Show(qsos[at], VerdictFor(qsos[at])); + } + Recolour(Rows); + } + else + { + bool grown = qsos.Count > shownRows.Count; + Qso? bottom = BottomOfView(); + shownRows = qsos.Select(q => new LogRow(q, VerdictFor(q))).ToList(); + Rows.ItemsSource = Sorted(shownRows); + if (grown && shownRows.Count > 0) + { + Rows.ScrollIntoView(shownRows[^1], null); + } + else + { + ShowAgain(bottom, shownRows); + } } ShowEarlier(); SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging); message = ""; } + /// True when the log holds the same contacts, in the same order, as the + /// grid is showing. An edit leaves the list itself alone; logging or + /// deleting a contact does not. + private bool SameContacts(IReadOnlyList qsos) + { + if (qsos.Count != shownRows.Count) + { + return false; + } + for (int at = 0; at < qsos.Count; at++) + { + if (qsos[at].Id != shownRows[at].Qso.Id) + { + return false; + } + } + return true; + } + + /// The colour of a line is set as the grid draws it, so a line that is + /// already on screen keeps the colour of the verdict it had before the + /// edit until it is coloured again. + private static void Recolour(DataGrid grid) + { + foreach (DataGridRow row in grid.GetVisualDescendants().OfType()) + { + if (row.DataContext is LogRow item) + { + row.Foreground = item.Colour; + } + } + } + + /// The contact drawn at the foot of the view, or null when the log is empty + /// or has not been drawn yet. Setting `ItemsSource` puts the view back at + /// the top, so this is what the view is wound back to afterwards. + private Qso? BottomOfView() + { + Qso? found = null; + double lowest = double.MinValue; + foreach (DataGridRow row in Rows.GetVisualDescendants().OfType()) + { + Point? foot = row.TranslatePoint(new Point(0, row.Bounds.Height), Rows); + if (row.DataContext is not LogRow item + || foot is null + || foot.Value.Y > Rows.Bounds.Height + 1 + || foot.Value.Y <= lowest) + { + continue; + } + lowest = foot.Value.Y; + found = item.Qso; + } + return found; + } + + /// Scrolling a row into view from the top of the list leaves it at the foot + /// of the view, which is where it was before the list was rebuilt. The grid + /// puts the view back at the top itself as it takes the new list, so this + /// waits for that to be over. + private void ShowAgain(Qso? bottom, IReadOnlyList rows) + { + if (bottom is null) + { + return; + } + LogRow? row = rows.FirstOrDefault(r => r.Qso.Id == bottom.Id); + if (row is not null) + { + Dispatcher.UIThread.Post(() => Rows.ScrollIntoView(row, null), DispatcherPriority.Loaded); + } + } + /// The pane under the log: what has already been worked with the call being /// typed, so an apparent dupe can be read off the screen. private void ShowEarlier() @@ -100,7 +233,7 @@ public sealed partial class LogWindow : RefreshableWindow session.Logging.Log.Qsos, position.Entry.Call, position.Entry.ValueOf(ExchangeSlot.GridSquare)) - .Select(q => new LogRow(q, VerdictFor(q), Edit)) + .Select(q => new LogRow(q, VerdictFor(q))) .ToList(); } @@ -144,21 +277,38 @@ public sealed partial class LogWindow : RefreshableWindow return null; } - /// True when the edit was taken. A refused edit puts the reason in the - /// summary line and the cell falls back to what it held before. - private bool Edit(Qso qso, QsoField field, string text) + /// The cell is written back when the operator takes the edit — Enter, or a + /// click on another cell — and not before. The columns bind one way, so + /// typing changes the box alone: Escape drops what was typed, and the value + /// does not go into the log one character at a time. + private void TakeCellEdit(object? sender, DataGridCellEditEndingEventArgs e) + { + if (e.EditAction != DataGridEditAction.Commit + || e.EditingElement is not TextBox box + || e.Row.DataContext is not LogRow row + || !columnOf.TryGetValue(e.Column, out QsoColumn? column)) + { + return; + } + string text = box.Text ?? ""; + // the edit rebuilds the rows, which the grid cannot take while it is + // still closing the editing box + Dispatcher.UIThread.Post(() => Edit(row.Qso, column.Field, text)); + } + + /// A refused edit puts the reason in the summary line and the cell falls + /// back to what it held before. + private void Edit(Qso qso, QsoField field, string text) { if (session.Logging is null) { - return false; + return; } QsoEdit edit = session.Logging.Edit(qso.Id, field, text); - if (edit.IsAccepted) + if (!edit.IsAccepted) { - return true; + SummaryText.Text = edit.Error; } - SummaryText.Text = edit.Error; - return false; } private void OnKeyDown(object? sender, KeyEventArgs e) @@ -175,7 +325,7 @@ public sealed partial class LogWindow : RefreshableWindow break; case Key.Enter: e.Handled = true; - OnEditContact(sender, new RoutedEventArgs()); + Rows.BeginEdit(); break; } } @@ -189,6 +339,39 @@ public sealed partial class LogWindow : RefreshableWindow await new EditContactDialog(session.Logging, row.Qso.Id).ShowDialog(this); } + /// Writes one callsign over the operator of every contact picked in the + /// grid. A multi-operator station logs a whole shift under whoever was at + /// the radio, so a shift logged under the wrong callsign is put right in + /// one go rather than a contact at a time. + private async void OnChangeOperator(object? sender, RoutedEventArgs e) + { + if (session.Logging is null) + { + return; + } + List picked = [.. Rows.SelectedItems.OfType().Select(r => r.Qso)]; + if (picked.Count == 0) + { + message = "pick the contacts to change first"; + Refresh(); + return; + } + string was = picked[0].Operator.Length > 0 ? picked[0].Operator : session.Settings.CurrentOperator; + List known = [.. session.Settings.Operators.Select(o => o.Callsign)]; + if (await new ChangeOperatorDialog(picked.Count, was, known).ShowDialog(this) is not { } callsign) + { + return; + } + foreach (Qso qso in picked) + { + session.Logging.Edit(qso.Id, QsoField.Operator, callsign); + } + message = picked.Count == 1 + ? $"1 contact logged by {callsign}" + : $"{picked.Count} contacts logged by {callsign}"; + Refresh(); + } + private async void OnDelete(object? sender, RoutedEventArgs e) { if (session.Logging is null || Rows.SelectedItem is not LogRow row)