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
+20 -12
View File
@@ -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<Qso, QsoField, string, bool> edit;
public LogRow(Qso qso, Verdict? verdict, Func<Qso, QsoField, string, bool> 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<QsoField>(name));
set => edit(Qso, Enum.Parse<QsoField>(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<QsoField>(name));
public IBrush Colour => Verdicts.Colour(Verdict);
}
+5 -3
View File
@@ -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),
});
}
+22 -4
View File
@@ -13,14 +13,32 @@
<Setter Property="FontFamily" Value="monospace" />
<Setter Property="FontSize" Value="12" />
</Style>
<!-- the editing box is left a little shorter than the cell, otherwise
it grows the row and the whole grid shifts as editing starts -->
<!-- the theme gives a cell 32 pixels of height, which fits about a
dozen rows on screen. The row is cut down to the height of its
text and one pixel either side -->
<Style Selector="DataGridRow">
<Setter Property="MinHeight" Value="0" />
</Style>
<Style Selector="DataGridCell">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0,1" />
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="MinHeight" Value="0" />
</Style>
<!-- the editing box takes the same height as the text it covers,
otherwise it grows the row and the whole grid shifts as editing
starts. Its border is what would make it two pixels taller, so it
is dropped: the white background already marks the cell out. The
padding is what the cell gives its text, so the text does not move
sideways as the box opens -->
<Style Selector="DataGridCell TextBox">
<Setter Property="FontFamily" Value="monospace" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="0,1" />
<Setter Property="Padding" Value="4,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="12,0" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
</Grid.Styles>
+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)