Files
Nonemm/src/Nonemm.App/Windows/LogWindow.axaml.cs
T
erikbrockoandClaude Sonnet 5.5 fe70f9b058 Cut CPU use and move heavy work off the UI thread
Redraws:
- Merge bursts of session and bandmap events into one redraw
  (Coalesced). Closed Bandmap and Available windows now unsubscribe.
- Grayline and Voice Keyer no longer subscribe to the session twice.
- Available summary, Score table and Log rows redraw only when their
  contents change. Telnet lines are added in batches.
- Bandmap view reuses its layout, shaped text and brushes. Grayline
  draws the coastlines as one cached shape.

Threads:
- Available, Bandmap colours, Check and Statistics do their judging
  and counting on the thread pool and draw the result on the UI thread.
- ContestLog takes a lock in Judge and in each method that changes the
  log, so a background Judge cannot read the indexes mid-edit.

Refresh rate:
- Statistics refreshes every 30 seconds and when the QSO count changes.
- Grayline refreshes at most every 15 seconds on session events.

Publish with ReadyToRun to cut startup JIT time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:25:46 +00:00

518 lines
19 KiB
C#

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<LogRow> 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<string> 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<LogRow>().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<MenuItem> { edit, operators, delete } };
}
public override void RefreshForEntry() => ShowEarlier();
public override void Refresh()
{
if (session.Logging is null)
{
Rows.ItemsSource = Array.Empty<LogRow>();
Earlier.ItemsSource = Array.Empty<LogRow>();
return;
}
BuildColumns(session.Logging.Editor);
IReadOnlyList<Qso> 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<LogRow>(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<Qso> 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<DataGridRow>())
{
if (row.DataContext is LogRow item)
{
row.Foreground = item.Colour;
}
}
}
private ScrollBar? VerticalBar =>
Rows.GetVisualDescendants().OfType<ScrollBar>()
.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<DataGridRow>())
{
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<LogRow> 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<LogRow>();
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<Qso> picked = [.. Rows.SelectedItems.OfType<LogRow>().Select(r => r.Qso)];
if (picked.Count == 0)
{
return;
}
string was = picked[0].Operator.Length > 0 ? picked[0].Operator : session.Settings.CurrentOperator;
List<string> known = [.. session.Settings.Operators.Select(o => o.Callsign)];
if (await new ChangeOperatorDialog(picked.Count, was, known).ShowDialog<string?>(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<bool>(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, "", "")]
: []);
}