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(); Earlier.ItemsSource = Array.Empty(); SummaryText.Text = "no contest is open"; return; } BuildColumns(session.Logging.Editor); List 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(); 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 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 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 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(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, "", "")] : []); }