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)