using Nonemm.App.Configuration; using System.Collections.ObjectModel; using Avalonia; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; using Nonemm.App.Dialogs; using Nonemm.Contests; using Nonemm.Core; using Nonemm.Session; namespace Nonemm.App.Windows; /// 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; /// 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 ObservableCollection shownRows = []; /// The contacts the operator picked, by id. The grid is told to show them /// as picked only while this window is in front: a marked line in a window /// behind says nothing about where the keyboard is. private List picked = []; /// True while this window is setting the selection itself, so that its own /// changes are not read back as the operator's. private bool picking; /// The contact quick edit is on, so that the picked line follows Ctrl+Q /// and Ctrl+A rather than being put back on every refresh. private string quickEditId = ""; private string finding = ""; public LogWindow(AppSession session) { this.session = session; InitializeComponent(); // 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); Rows.SelectionChanged += RememberPicked; Activated += (_, _) => { Rows.Classes.Remove("away"); ShowPicked(); }; Deactivated += (_, _) => { Rows.Classes.Add("away"); HidePicked(); }; Colour(Rows); Colour(Earlier); ApplyFontSize(); _ = new FontZoom(this, new Settings().LogFontSize, () => session.Settings.LogFontSize, size => { session.Save(session.Settings with { LogFontSize = size }); ApplyFontSize(); builtFor = ""; Refresh(); }); Refresh(); } /// The cells take their size from the `LogFontSize` resource, and the /// grids need it too because the column widths are measured from it. private void ApplyFontSize() { double size = session.Settings.LogFontSize; Resources["LogFontSize"] = size; Rows.FontSize = size; Earlier.FontSize = size; } /// What the operator picked. The grid raises this for the picks this window /// makes as well, and those are already known. private void RememberPicked(object? sender, SelectionChangedEventArgs e) { if (picking) { return; } picked = [.. Rows.SelectedItems.OfType().Select(r => r.Qso.Id)]; } /// Marks the picked lines again. Only a window in front marks them, so the /// callers that are not the window coming to the front ask whether it is in /// front first. private void ShowPicked() { picking = true; Rows.SelectedItems.Clear(); foreach (LogRow row in shownRows.Where(r => picked.Contains(r.Qso.Id))) { Rows.SelectedItems.Add(row); } picking = false; } /// Takes the marks off while another window has the keyboard. The contacts /// stay picked and are marked again when the window comes back. private void HidePicked() { picking = true; Rows.SelectedItems.Clear(); picking = false; } /// Quick edit steps through the log from the entry window with Ctrl+Q and /// Ctrl+A. The contact in the entry boxes is the picked line here, and it /// is scrolled to whether or not this window is in front. private void FollowQuickEdit() { string id = session.Position?.Editing?.Id ?? ""; if (id == quickEditId || id.Length == 0) { quickEditId = id; return; } quickEditId = id; picked = [id]; if (IsActive) { ShowPicked(); } if (shownRows.FirstOrDefault(r => r.Qso.Id == id) is { } row) { Rows.ScrollIntoView(row, null); } } /// 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 not TextBox box) { 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) { 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…" }; 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, operators, delete } }; } public override void RefreshForEntry() => ShowEarlier(); public override void Refresh() { if (session.Logging is null) { Rows.ItemsSource = Array.Empty(); Earlier.ItemsSource = Array.Empty(); return; } BuildColumns(session.Logging.Editor); IReadOnlyList qsos = session.Logging.Log.Qsos; if (Rows.ItemsSource is DataGridCollectionView shown && shown.SourceCollection == shownRows && StartsWithShown(qsos)) { // an edit, or contacts added at the end: the grid keeps its list, so // the view stays where it is and an open editing box stays open bool atBottom = ScrolledToBottom(); for (int at = 0; at < shownRows.Count; at++) { // a contact that was not edited is the same object, and the // row has nothing to redraw if (!ReferenceEquals(shownRows[at].Qso, qsos[at])) { shownRows[at].Show(qsos[at], VerdictFor(qsos[at])); } } bool grown = qsos.Count > shownRows.Count; for (int at = shownRows.Count; at < qsos.Count; at++) { // the grid puts the row where the sort says shownRows.Add(new LogRow(qsos[at], VerdictFor(qsos[at]))); } Recolour(Rows); // the view follows a new contact only when it was at the foot of // the list; at the top the new row appears without a scroll if (grown && atBottom && Rows.ItemsSource is DataGridCollectionView view && view.Count > 0) { object last = view.GetItemAt(view.Count - 1); Dispatcher.UIThread.Post(() => Rows.ScrollIntoView(last, null), DispatcherPriority.Loaded); } } else { // a contact was deleted or moved: the list is built again, which // puts the view at the top, so it is wound back Qso? bottom = BottomOfView(); shownRows = new ObservableCollection(qsos.Select(q => new LogRow(q, VerdictFor(q)))); Rows.ItemsSource = Sorted(shownRows); ShowAgain(bottom, shownRows); // the new list comes with nothing picked if (IsActive) { ShowPicked(); } } FollowQuickEdit(); ShowEarlier(); } /// True when the grid's lines are the first contacts of the log, in the /// same order. An edit leaves the list itself alone and logging a contact /// adds one at the end; deleting a contact does neither. private bool StartsWithShown(IReadOnlyList qsos) { if (qsos.Count < shownRows.Count) { return false; } for (int at = 0; at < shownRows.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; } } } private ScrollBar? VerticalBar => Rows.GetVisualDescendants().OfType() .FirstOrDefault(b => b.Orientation == Avalonia.Layout.Orientation.Vertical); /// True when the view is at the foot of the list, or when the list fits. private bool ScrolledToBottom() => VerticalBar is not { } bar || bar.Value >= bar.Maximum - 0.5; /// 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() { OperatingPosition? position = session.Position; if (position is null || session.Logging is null) { Earlier.ItemsSource = Array.Empty(); return; } Earlier.ItemsSource = EarlierContacts .Matching( session.Logging.Log.Qsos, position.Entry.Call, position.Entry.ValueOf(ExchangeSlot.GridSquare)) .Select(q => new LogRow(q, VerdictFor(q))) .ToList(); } private static void Colour(DataGrid grid) => grid.LoadingRow += (_, e) => { if (e.Row.DataContext is LogRow row) { e.Row.Foreground = row.Colour; } }; /// 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 DataGridCollectionView rows || rows.Count == 0) { return null; } int from = wanted.Equals(finding, StringComparison.OrdinalIgnoreCase) ? Rows.SelectedIndex + 1 : 0; finding = wanted; for (int step = 0; step < rows.Count; step++) { int at = (from + step) % rows.Count; if (rows.GetItemAt(at) is not LogRow row || !row.Qso.Call.Text.Equals(wanted, StringComparison.OrdinalIgnoreCase)) { continue; } Rows.SelectedIndex = at; Rows.ScrollIntoView(row, null); return row.Qso; } return null; } /// 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 leaves the cell as it was and shows the reason. private async void Edit(Qso qso, QsoField field, string text) { if (session.Logging is null) { return; } QsoEdit edit = session.Logging.Edit(qso.Id, field, text); if (!edit.IsAccepted) { await new AlertDialog("Edit refused", edit.Error).ShowDialog(this); } } private void OnKeyDown(object? sender, KeyEventArgs e) { if (e.Source is TextBox) { return; } switch (e.Key) { case Key.Delete: e.Handled = true; OnDelete(sender, new RoutedEventArgs()); break; case Key.Enter: e.Handled = true; Rows.BeginEdit(); break; } } private async void OnEditContact(object? sender, RoutedEventArgs e) { if (session.Logging is null || Rows.SelectedItem is not LogRow row) { return; } 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) { 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); } Refresh(); } private async void OnDelete(object? sender, RoutedEventArgs e) { if (session.Logging is null || Rows.SelectedItem is not LogRow row) { return; } ConfirmDialog dialog = new( "Delete contact", $"Delete {row.Qso.Call.Text} at {row.Qso.TimestampUtc:yyyy-MM-dd HH:mm:ss}? " + "The other stations are told to delete it too.", "Delete"); if (await dialog.ShowDialog(this)) { session.Logging.Delete(row.Qso.Id); Refresh(); } } /// A logged contact is coloured by what it turned out to be worth, not by /// judging it again as if it were new. private static Contests.Verdict VerdictFor(Qso qso) => new( IsDupe: false, Points: qso.Points, NewMultipliers: qso.IsMultiplier1 || qso.IsMultiplier2 || qso.IsMultiplier3 ? [new Contests.Multiplier(1, "", "")] : []); }