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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
2026-09-10 16:55:59 +00:00
co-authored by Claude Opus 5
parent f2d9e331b4
commit 56c7bcef21
4 changed files with 253 additions and 42 deletions
+206 -23
View File
@@ -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<LogRow> 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<MenuItem> { edit, delete } };
return new ContextMenu { ItemsSource = new List<MenuItem> { edit, operators, delete } };
}
public override void Refresh()
@@ -72,19 +116,108 @@ public sealed partial class LogWindow : RefreshableWindow
return;
}
BuildColumns(session.Logging.Editor);
List<LogRow> rows = session.Logging.Log.Qsos
.Select(q => new LogRow(q, VerdictFor(q), Edit))
.ToList();
Rows.ItemsSource = Sorted(rows);
if (rows.Count > 0)
IReadOnlyList<Qso> 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<Qso> 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<DataGridRow>())
{
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<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()
@@ -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<Qso> picked = [.. Rows.SelectedItems.OfType<LogRow>().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<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);
}
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)