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>
518 lines
19 KiB
C#
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, "", "")]
|
|
: []);
|
|
}
|