Edit and delete contacts in the log

Double-click a cell in the log window to change it. The columns follow the
contest exchange instead of being fixed in the XAML, so CQ WW gets a Zone
column and Sweepstakes gets Nr, Prec, Ck and Sec.

QsoEditor holds the column list and applies one field edit. Validation comes
from the ExchangeFieldKind the contest declared: a CQ zone is 1 to 40, an ITU
zone 1 to 90, a section is looked up in the ARRL list, a grid must parse.
A refused edit returns the reason and leaves the log alone, so the cell
reverts. Changing the callsign runs the country lookup again.

Delete, or the right-click menu, removes the selected contact after a
confirmation. Either way the log is rescored, so a multiplier the removed
contact held passes to the next contact that claims it.

Both go out to the other stations in N1MM's own messages: contactreplace
carries oldcall and oldtimestamp, contactdelete names the contact. An incoming
edit or delete is matched by contact id first, falling back to call plus
timestamp because N1MM does not know our ids.

Country, continent and the two prefixes were filled in twice, once when
logging and once when editing. They now come from CountryFields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:32:32 +00:00
parent 328a40b27c
commit eb31fa5ae1
23 changed files with 953 additions and 112 deletions

View File

@@ -1,20 +1,39 @@
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// The contacts of the contest in progress, newest last.
/// 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
{
/// Roughly the width of one character of the grid font, in device pixels.
private const double CharacterWidth = 8;
private readonly AppSession session;
private string builtFor = "";
private string message = "";
public LogWindow(AppSession session)
{
this.session = session;
InitializeComponent();
Rows.KeyDown += OnKeyDown;
Rows.LoadingRow += (_, e) =>
{
if (e.Row.DataContext is LogRow row)
{
e.Row.Foreground = row.Colour;
}
};
Refresh();
}
public override void Refresh()
{
if (session.Logging is null)
@@ -23,17 +42,100 @@ public sealed partial class LogWindow : RefreshableWindow
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)))
.Select(q => new LogRow(q, VerdictFor(q), Edit))
.ToList();
Rows.ItemsSource = rows;
if (rows.Count > 0)
{
Rows.ScrollIntoView(rows[^1], null);
}
SummaryText.Text =
$"{session.Logging.Log.Tally.Qsos} contacts · {session.Logging.Log.Tally.Points} points · " +
$"{session.Logging.Log.Tally.TotalMultipliers} multipliers · score {session.Logging.Log.TotalScore:N0}";
SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging);
message = "";
}
private static string Summary(LoggingSession 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.
private void BuildColumns(QsoEditor editor)
{
string wanted = string.Join('|', editor.Columns.Select(c => c.Label));
if (wanted == builtFor)
{
return;
}
builtFor = wanted;
Rows.Columns.Clear();
foreach (QsoColumn column in editor.Columns)
{
Rows.Columns.Add(new DataGridTextColumn
{
Header = column.Label,
Width = new DataGridLength(column.Width * CharacterWidth),
Binding = new Binding($"[{column.Field}]") { Mode = BindingMode.TwoWay },
});
}
Rows.Columns.Add(ReadOnlyColumn("Cty", nameof(LogRow.Country), 6));
Rows.Columns.Add(ReadOnlyColumn("Pts", nameof(LogRow.Points), 4));
Rows.Columns.Add(ReadOnlyColumn("Mult", nameof(LogRow.Multipliers), 5));
}
private static DataGridTextColumn ReadOnlyColumn(string header, string property, int characters) =>
new()
{
Header = header,
IsReadOnly = true,
Width = new DataGridLength(characters * CharacterWidth),
Binding = new Binding(property),
};
/// 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.Key == Key.Delete && e.Source is not TextBox)
{
OnDelete(sender, new RoutedEventArgs());
e.Handled = true;
}
}
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