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,24 +1,36 @@
using Avalonia.Media;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// One line of the log window. It carries the colour so the log is coloured by
/// the same scorer as the entry window and the bandmap.
public sealed record LogRow(Qso Qso, Verdict? Verdict)
/// One line of the log window. The grid reaches the editable fields through the
/// indexer, so the columns can follow the contest exchange 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 string Time => Qso.TimestampUtc.ToString("MM-dd HH:mm");
private readonly Func<Qso, QsoField, string, bool> edit;
public string Call => Qso.Call.Text;
public LogRow(Qso qso, Verdict? verdict, Func<Qso, QsoField, string, bool> edit)
{
Qso = qso;
Verdict = verdict;
this.edit = edit;
}
public string Frequency => Qso.Frequency.Kilohertz.ToString("0.0");
public Qso Qso { get; }
public string Mode => Qso.Mode.Name;
public Verdict? Verdict { get; }
public string Sent => $"{Qso.SentReport} {SentExchange()}".Trim();
public string Received => $"{Qso.ReceivedReport} {ReceivedExchange()}".Trim();
/// `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]
{
get => QsoEditor.Read(Qso, Enum.Parse<QsoField>(name));
set => edit(Qso, Enum.Parse<QsoField>(name), value);
}
public string Country => Qso.CountryPrefix;
@@ -29,31 +41,5 @@ public sealed record LogRow(Qso Qso, Verdict? Verdict)
Qso.IsMultiplier2 ? "2" : "",
Qso.IsMultiplier3 ? "3" : "");
public string Operator => Qso.Operator;
public IBrush Colour => Verdicts.Colour(Verdict);
private string SentExchange() =>
Qso.SentNumber > 0 ? Qso.SentNumber.ToString() : "";
private string ReceivedExchange()
{
List<string> parts = [];
if (Qso.ReceivedNumber > 0)
{
parts.Add(Qso.ReceivedNumber.ToString());
}
if (Qso.Zone > 0)
{
parts.Add(Qso.Zone.ToString());
}
foreach (string value in new[] { Qso.Section, Qso.Exchange1, Qso.Name, Qso.GridSquare, Qso.MiscText })
{
if (value.Length > 0)
{
parts.Add(value);
}
}
return string.Join(' ', parts);
}
}

View File

@@ -5,22 +5,13 @@
Title="Log" Width="1000" Height="420">
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
x:DataType="local:LogRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Time" Binding="{Binding Time}" Width="125" />
<DataGridTextColumn Header="Freq" Binding="{Binding Frequency}" Width="95" />
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" Width="60" />
<DataGridTextColumn Header="Call" Binding="{Binding Call}" Width="120"
Foreground="{Binding Colour}" />
<DataGridTextColumn Header="Sent" Binding="{Binding Sent}" Width="100" />
<DataGridTextColumn Header="Received" Binding="{Binding Received}" Width="160" />
<DataGridTextColumn Header="Cty" Binding="{Binding Country}" Width="70" />
<DataGridTextColumn Header="Pts" Binding="{Binding Points}" Width="60" />
<DataGridTextColumn Header="Mult" Binding="{Binding Multipliers}" Width="70" />
<DataGridTextColumn Header="Op" Binding="{Binding Operator}" Width="*" />
</DataGrid.Columns>
<DataGrid Name="Rows" GridLinesVisibility="Horizontal" CanUserSortColumns="False"
FontSize="12" FontFamily="monospace">
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
</DockPanel>
</local:RefreshableWindow>

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