Files
Nonemm/src/Nonemm.App/Windows/LogWindow.axaml.cs
ericek111 a31c40fb2d List the earlier contacts with the call being typed under the log
N1MM's log window has two panes. The lower one lists every contact already
logged with the call in the callsign box, on any band and in any mode, so an
apparent dupe can be read off the screen rather than looked for.

EarlierContacts.Matching holds the rule, so it is unit-tested without a
window: three characters before anything is listed, a call that holds what is
typed, `*` for any run of characters and `?` for one, the grid square instead
when nothing is typed, ordered by call, mode, band and time, and fifty at
most. That is N1MM's DupeSQL, except that N1MM passes `?` to SQLite as a plain
character, which finds nothing.

The two grids share their columns, because a value in the pane has to sit
under the column it belongs to. Each column is now as wide as the longest
value it can hold, measured in the font it is drawn in, and the last column
takes what is left over. The values and the header are in different fonts, so
each is measured on its own. This also settles the column widths, which used
to change as rows scrolled into view.

A DataGrid does not pass its own font down to its cells, so the cell font is
set in a style. Without that the cells draw larger than the width measured for
them and every column is a character short.

Looked at under Xvfb with a call typed: the pane lists the two contacts that
hold it, under columns that line up with the log above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
2026-08-30 21:00:04 +00:00

257 lines
8.8 KiB
C#

using System.Globalization;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
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 to
/// change it; the Delete key removes the contact.
public sealed partial class LogWindow : RefreshableWindow
{
/// What a cell takes either side of its text, in device pixels.
private const double CellPadding = 30;
private readonly AppSession session;
private string builtFor = "";
private string message = "";
private string finding = "";
public LogWindow(AppSession session)
{
this.session = session;
InitializeComponent();
Rows.KeyDown += OnKeyDown;
Colour(Rows);
Colour(Earlier);
Refresh();
}
public override void Refresh()
{
if (session.Logging is null)
{
Rows.ItemsSource = Array.Empty<LogRow>();
Earlier.ItemsSource = Array.Empty<LogRow>();
SummaryText.Text = "no contest is open";
return;
}
BuildColumns(session.Logging.Editor);
List<LogRow> rows = session.Logging.Log.Qsos
.Select(q => new LogRow(q, VerdictFor(q), Edit))
.ToList();
Rows.ItemsSource = rows;
if (rows.Count > 0)
{
Rows.ScrollIntoView(rows[^1], null);
}
ShowEarlier();
SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging);
message = "";
}
/// 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), Edit))
.ToList();
}
private static void Colour(DataGrid grid) =>
grid.LoadingRow += (_, e) =>
{
if (e.Row.DataContext is LogRow row)
{
e.Row.Foreground = row.Colour;
}
};
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}";
/// The columns follow the contest exchange, so they are rebuilt when a
/// different contest is opened. Both grids get the same columns: a value in
/// the pane has to sit under the column it belongs to.
private void BuildColumns(QsoEditor editor)
{
string wanted = string.Join('|', editor.Columns.Select(c => c.Label));
if (wanted == builtFor)
{
return;
}
builtFor = wanted;
List<ColumnSpec> spec =
[
.. editor.Columns.Select(c => new ColumnSpec(c.Label, $"[{c.Field}]", true, c.Width)),
new ColumnSpec("Cty", nameof(LogRow.Country), false, 6),
new ColumnSpec("Pts", nameof(LogRow.Points), false, 4),
new ColumnSpec("Mult", nameof(LogRow.Multipliers), false, 5),
];
FillColumns(Rows, spec);
FillColumns(Earlier, spec);
}
/// N1MM gives each column the width of the longest value it can hold, and
/// the last column what is left over. A fixed width also keeps a column from
/// changing size as rows scroll into view.
private static void FillColumns(DataGrid grid, IReadOnlyList<ColumnSpec> spec)
{
grid.Columns.Clear();
double Width(ColumnSpec column) => WidthFor(column, grid);
for (int at = 0; at < spec.Count; at++)
{
ColumnSpec column = spec[at];
grid.Columns.Add(new DataGridTextColumn
{
Header = column.Header,
IsReadOnly = !column.IsEditable,
Width = at == spec.Count - 1
? new DataGridLength(1, DataGridLengthUnitType.Star)
: new DataGridLength(Width(column)),
MinWidth = Width(column),
Binding = column.IsEditable
? new Binding(column.Path) { Mode = BindingMode.TwoWay }
: new Binding(column.Path),
});
}
}
/// Wide enough for both the longest value and the header. They are in
/// different fonts — the values in the grid's monospace, the header in the
/// theme's own font — so each is measured in the font it is drawn in.
private static double WidthFor(ColumnSpec column, DataGrid grid) =>
Math.Max(
TextWidth(new string('0', column.Characters + 1), grid.FontFamily, grid.FontSize),
TextWidth(column.Header + "0", FontFamily.Default, grid.FontSize)) + CellPadding;
private static double TextWidth(string text, FontFamily family, double size) =>
new FormattedText(
text,
CultureInfo.InvariantCulture,
FlowDirection.LeftToRight,
new Typeface(family),
size,
null).Width;
private sealed record ColumnSpec(string Header, string Path, bool IsEditable, int Characters);
/// 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 IReadOnlyList<LogRow> rows)
{
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[at].Qso.Call.Text.Equals(wanted, StringComparison.OrdinalIgnoreCase))
{
continue;
}
Rows.SelectedIndex = at;
Rows.ScrollIntoView(rows[at], null);
return rows[at].Qso;
}
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)
{
if (session.Logging is null)
{
return false;
}
QsoEdit edit = session.Logging.Edit(qso.Id, field, text);
if (edit.IsAccepted)
{
return true;
}
SummaryText.Text = edit.Error;
return false;
}
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;
OnEditContact(sender, new RoutedEventArgs());
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);
}
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);
message = $"{row.Qso.Call.Text} deleted";
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, "", "")]
: []);
}