Show scroll bars at full width, drop the log status bar

- Turn off scroll bar auto-hide for ScrollViewer and DataGrid.
- Make the splitter between the two log grids 5 px, in the grid line color.
- Remove the log window status bar. The stats are in the entry window.
- Show a refused log edit in an alert dialog.
- Keep the log grid's rows in an ObservableCollection, so a new contact
  does not rebuild the list and move the view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:28:01 +00:00
co-authored by Claude Sonnet 5.5
parent f784a73115
commit 0b2f8e2820
6 changed files with 80 additions and 41 deletions
+41 -37
View File
@@ -1,6 +1,8 @@
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;
@@ -19,11 +21,10 @@ namespace Nonemm.App.Windows;
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 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.
@@ -190,33 +191,42 @@ public sealed partial class LogWindow : RefreshableWindow
{
Rows.ItemsSource = Array.Empty<LogRow>();
Earlier.ItemsSource = Array.Empty<LogRow>();
SummaryText.Text = "no contest is open";
return;
}
BuildColumns(session.Logging.Editor);
IReadOnlyList<Qso> qsos = session.Logging.Log.Qsos;
if (SameContacts(qsos))
if (Rows.ItemsSource is DataGridCollectionView shown && shown.SourceCollection == shownRows && StartsWithShown(qsos))
{
for (int at = 0; at < qsos.Count; at++)
// 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++)
{
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
{
bool grown = qsos.Count > shownRows.Count;
// 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 = qsos.Select(q => new LogRow(q, VerdictFor(q))).ToList();
shownRows = new ObservableCollection<LogRow>(qsos.Select(q => new LogRow(q, VerdictFor(q))));
Rows.ItemsSource = Sorted(shownRows);
if (grown && shownRows.Count > 0)
{
Rows.ScrollIntoView(shownRows[^1], null);
}
else
{
ShowAgain(bottom, shownRows);
}
ShowAgain(bottom, shownRows);
// the new list comes with nothing picked
if (IsActive)
{
@@ -225,20 +235,18 @@ public sealed partial class LogWindow : RefreshableWindow
}
FollowQuickEdit();
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)
/// 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)
if (qsos.Count < shownRows.Count)
{
return false;
}
for (int at = 0; at < qsos.Count; at++)
for (int at = 0; at < shownRows.Count; at++)
{
if (qsos[at].Id != shownRows[at].Qso.Id)
{
@@ -262,6 +270,13 @@ public sealed partial class LogWindow : RefreshableWindow
}
}
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.
@@ -330,10 +345,6 @@ public sealed partial class LogWindow : RefreshableWindow
}
};
private static string Summary(ContestSession logging) =>
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
/// 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.
@@ -380,9 +391,8 @@ public sealed partial class LogWindow : RefreshableWindow
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)
/// 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)
{
@@ -391,7 +401,7 @@ public sealed partial class LogWindow : RefreshableWindow
QsoEdit edit = session.Logging.Edit(qso.Id, field, text);
if (!edit.IsAccepted)
{
SummaryText.Text = edit.Error;
await new AlertDialog("Edit refused", edit.Error).ShowDialog(this);
}
}
@@ -436,8 +446,6 @@ public sealed partial class LogWindow : RefreshableWindow
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;
@@ -450,9 +458,6 @@ public sealed partial class LogWindow : RefreshableWindow
{
session.Logging.Edit(qso.Id, QsoField.Operator, callsign);
}
message = picked.Count == 1
? $"1 contact logged by {callsign}"
: $"{picked.Count} contacts logged by {callsign}";
Refresh();
}
@@ -470,7 +475,6 @@ public sealed partial class LogWindow : RefreshableWindow
if (await dialog.ShowDialog<bool>(this))
{
session.Logging.Delete(row.Qso.Id);
message = $"{row.Qso.Call.Text} deleted";
Refresh();
}
}