Copy the statistics table and graph to the clipboard

Copy All takes whichever tab is open, as N1MM's does: the table off the table
tab, a picture of the graph off the graph tab. Copy Table as Text and Copy
Graph as Image do either one from either tab.

`StatisticsTable` lays the pivot out as the window prints it — the headings,
the rows, the Total line — and pads it into columns for the text form, so the
grid, the clipboard and Print to File all say the same thing.

X11 asks for a picture when the other program pastes rather than when it is
copied, so the bitmap given to the clipboard is held until the clipboard is
given a different one. Disposing it at the end of the copy killed the program
on the next paste; the table pasted into a second Avalonia program reads back
lined up, and the picture reads back as a 916x438 bitmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
2026-09-04 07:59:22 +00:00
co-authored by Claude Opus 5
parent acd22a9b5e
commit 6e24e00ec8
6 changed files with 271 additions and 77 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
| While transmitting | a digital message is held in the transmit pane and fed to the engine a character at a time, so what has not gone out can still be rewritten; what has gone out is red, and the engine idles when it catches up with the cursor |
| Windows | entry, log, check, bandmap, available mults and Qs, score summary, statistics, grey line, telnet, digital interface |
| Statistics | the log counted under any two of its fields, as N1MM's table with its `Tot` and `Accum` columns or as a stacked column graph |
| Statistics | the log counted under any two of its fields, as N1MM's table with its `Tot` and `Accum` columns or as a stacked column graph; either one copies to the clipboard, as spaced text or as a picture |
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
+11 -6
View File
@@ -668,12 +668,17 @@ What still differs, and why:
the log under any two of its fields, in N1MM's own two lists — 29 fields down
the rows, 20 of them across the columns — with N1MM's `Tot` and `Accum` columns,
its `Total` line, its "Include Dupes & X-QSOs" box and its stacked column graph.
Left out: printing to a printer, which needs a print dialog this program has
nowhere else, and N1MM's font size spinner, because the theme dialog sets the
fonts here. Copy All copies the table as text on both tabs; N1MM copies the
graph as an image on the graph tab, which the clipboard on Linux would need a
per-toolkit path for, so the graph is written to a PNG through File ▸ Save Graph
instead.
Copy All takes whichever tab is open, as N1MM's does: the table as spaced text
off the table tab, a picture of the graph off the graph tab. File ▸ Copy Table
as Text and File ▸ Copy Graph as Image do either from either tab, and File ▸
Save Graph writes the picture to a PNG. Left out: printing to a printer, which
needs a print dialog this program has nowhere else, and N1MM's font size
spinner, because the theme dialog sets the fonts here.
X11 hands over a picture only when the other program pastes, not when it is
copied, so `StatisticsWindow` holds the bitmap it gave the clipboard until it
gives it a different one. Disposing it at the end of the copy killed the
program on the next paste.
The palette names are N1MM's twelve, which are the .NET chart control's. The
colours are not: they are chosen to match the name, because the control's own
@@ -7,6 +7,8 @@
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="Copy All" Click="OnCopyAll" InputGesture="Ctrl+C" />
<MenuItem Header="Copy Table as Text" Click="OnCopyTable" />
<MenuItem Header="Copy Graph as Image" Click="OnCopyGraph" />
<MenuItem Name="PrintToFileItem" Header="Print to File…" Click="OnPrintToFile" />
<MenuItem Name="SaveGraphItem" Header="Save Graph…" Click="OnSaveGraph" />
<MenuItem Name="ShowTitleItem" Header="Show Title" ToggleType="CheckBox"
@@ -26,6 +26,13 @@ public sealed partial class StatisticsWindow : RefreshableWindow
/// does not count as the operator choosing it.
private bool loading = true;
/// The table as it is printed, kept so the copy and print actions do not
/// read the values back out of the grid.
private StatisticsTable? laid;
/// The picture the clipboard is showing, if any.
private RenderTargetBitmap? copied;
public StatisticsWindow(AppSession session)
{
this.session = session;
@@ -66,6 +73,7 @@ public sealed partial class StatisticsWindow : RefreshableWindow
Chart.Columns = [];
Chart.Series = [];
Chart.InvalidateVisual();
laid = null;
Empty("no contest is open");
return;
}
@@ -86,6 +94,7 @@ public sealed partial class StatisticsWindow : RefreshableWindow
Chart.Columns = [];
Chart.Series = [];
Chart.InvalidateVisual();
laid = null;
Empty("no contacts are counted");
return;
}
@@ -104,63 +113,22 @@ public sealed partial class StatisticsWindow : RefreshableWindow
private void FillTable(StatisticsPivot pivot)
{
List<string> headers =
[
.. pivot.Row.Headings,
.. pivot.Columns.Select(c => c.Label.Length > 0 ? c.Label : "(none)"),
"Tot",
"Accum",
];
laid = StatisticsTable.Of(pivot);
Table.Columns.Clear();
for (int at = 0; at < headers.Count; at++)
for (int at = 0; at < laid.Headings.Count; at++)
{
Table.Columns.Add(new DataGridTextColumn
{
Header = headers[at],
Header = laid.Headings[at],
Width = DataGridLength.Auto,
Binding = new Binding($"[{at}]"),
});
}
Table.ItemsSource = Lines(pivot);
Table.ItemsSource = laid.Rows
.Select((cells, at) => new StatisticsRow(cells, isTotal: at == laid.Rows.Count - 1))
.ToList();
}
private static List<StatisticsRow> Lines(StatisticsPivot pivot)
{
List<StatisticsRow> lines = [];
int labels = pivot.Row.Headings.Count;
for (int down = 0; down < pivot.Rows.Count; down++)
{
List<string> cells = [pivot.Rows[down].First];
if (labels == 2)
{
cells.Add(pivot.Rows[down].Second);
}
for (int across = 0; across < pivot.Columns.Count; across++)
{
cells.Add(Number(pivot.Count(down, across)));
}
cells.Add(Number(pivot.RowTotals[down]));
cells.Add(Number(pivot.Accumulated[down]));
lines.Add(new StatisticsRow(cells));
}
List<string> total = ["Total"];
if (labels == 2)
{
total.Add("");
}
total.AddRange(pivot.ColumnTotals.Select(Number));
total.Add(Number(pivot.Total));
total.Add(Number(pivot.Total));
lines.Add(new StatisticsRow(total, isTotal: true));
return lines;
}
/// N1MM leaves a cell with nothing in it blank rather than printing a zero,
/// so the shape of the operating shows through the table.
private static string Number(int count) =>
count == 0 ? "" : count.ToString(CultureInfo.InvariantCulture);
private void FillChart(StatisticsPivot pivot)
{
Chart.Series = [.. pivot.Columns.Select(c => c.Label)];
@@ -249,12 +217,43 @@ public sealed partial class StatisticsWindow : RefreshableWindow
Refresh();
}
/// N1MM's Copy All takes whichever tab is open: the table off the table
/// tab, a picture of the graph off the graph tab.
private async void OnCopyAll(object? sender, RoutedEventArgs e)
{
if (Clipboard is { } clipboard)
if (Tabs.SelectedIndex == 1)
{
await clipboard.SetTextAsync(AsText(", "));
await CopyGraph();
return;
}
await CopyTable();
}
private async void OnCopyTable(object? sender, RoutedEventArgs e) => await CopyTable();
private async void OnCopyGraph(object? sender, RoutedEventArgs e) => await CopyGraph();
private async Task CopyTable()
{
if (Clipboard is { } clipboard && laid is not null)
{
await clipboard.SetTextAsync(laid.ToText());
}
}
private async Task CopyGraph()
{
if (Clipboard is not { } clipboard)
{
return;
}
RenderTargetBitmap picture = Picture();
await clipboard.SetBitmapAsync(picture);
// X11 does not take a copy: it asks this program for the bytes when
// another one pastes, so the picture has to stay alive until the
// clipboard holds a different one
copied?.Dispose();
copied = picture;
}
private async void OnPrintToFile(object? sender, RoutedEventArgs e)
@@ -265,9 +264,9 @@ public sealed partial class StatisticsWindow : RefreshableWindow
SuggestedFileName = "statistics.txt",
SuggestedStartLocation = await Folder(session.Paths.ExportFiles),
});
if (file?.TryGetLocalPath() is { } path)
if (file?.TryGetLocalPath() is { } path && laid is not null)
{
await File.WriteAllTextAsync(path, AsText("\t"));
await File.WriteAllTextAsync(path, laid.ToText());
}
}
@@ -283,30 +282,21 @@ public sealed partial class StatisticsWindow : RefreshableWindow
{
return;
}
using RenderTargetBitmap picture = Picture();
picture.Save(path, new PngBitmapEncoderOptions());
}
/// The graph as a picture, at the size it is on the screen.
private RenderTargetBitmap Picture()
{
PixelSize size = Chart.Bounds.Width >= 1 && Chart.Bounds.Height >= 1
? new PixelSize((int)Chart.Bounds.Width, (int)Chart.Bounds.Height)
: GraphSize;
using RenderTargetBitmap bitmap = new(size);
bitmap.Render(Chart);
bitmap.Save(path, new PngBitmapEncoderOptions());
RenderTargetBitmap picture = new(size);
picture.Render(Chart);
return picture;
}
private async Task<IStorageFolder?> Folder(string path) =>
Directory.Exists(path) ? await StorageProvider.TryGetFolderFromPathAsync(path) : null;
/// The table as text, one line per row. N1MM's Copy All separates the
/// values with commas and its Print to File lines the columns up.
private string AsText(string between)
{
StringBuilder text = new();
text.AppendLine(string.Join(between, Table.Columns.Select(c => c.Header)));
if (Table.ItemsSource is IEnumerable<StatisticsRow> lines)
{
foreach (StatisticsRow line in lines)
{
text.AppendLine(string.Join(between, line.Cells));
}
}
return text.ToString();
}
}
+119
View File
@@ -0,0 +1,119 @@
using System.Text;
namespace Nonemm.Session;
/// The pivot laid out the way the statistics window prints it: N1MM's headings,
/// one line per row with the label columns first and the `Tot` and `Accum`
/// columns last, and the `Total` line at the bottom.
public sealed class StatisticsTable
{
/// What goes between two columns in the text form.
private const string Between = " ";
private StatisticsTable(
IReadOnlyList<string> headings,
IReadOnlyList<IReadOnlyList<string>> rows,
int labelColumns)
{
Headings = headings;
Rows = rows;
LabelColumns = labelColumns;
}
public static StatisticsTable Of(StatisticsPivot pivot)
{
List<string> headings =
[
.. pivot.Row.Headings,
.. pivot.Columns.Select(Heading),
"Tot",
"Accum",
];
int labels = pivot.Row.Headings.Count;
List<IReadOnlyList<string>> rows = [];
for (int down = 0; down < pivot.Rows.Count; down++)
{
List<string> cells = [pivot.Rows[down].First];
if (labels == 2)
{
cells.Add(pivot.Rows[down].Second);
}
for (int across = 0; across < pivot.Columns.Count; across++)
{
cells.Add(Count(pivot.Count(down, across)));
}
cells.Add(Count(pivot.RowTotals[down]));
cells.Add(Count(pivot.Accumulated[down]));
rows.Add(cells);
}
List<string> total = ["Total"];
if (labels == 2)
{
total.Add("");
}
total.AddRange(pivot.ColumnTotals.Select(Count));
total.Add(Count(pivot.Total));
total.Add(Count(pivot.Total));
rows.Add(total);
return new StatisticsTable(headings, rows, labels);
}
public IReadOnlyList<string> Headings { get; }
/// Every line, the `Total` line last.
public IReadOnlyList<IReadOnlyList<string>> Rows { get; }
/// How many columns at the left hold the row's own value rather than a
/// count. Two for Hour and Mode &amp; Band, one for every other field.
public int LabelColumns { get; }
/// The table with spaces between the columns, so it lines up under a
/// monospace font. The label columns are left-aligned and the counts are
/// right-aligned, as they are on the screen.
public string ToText()
{
int[] widths = new int[Headings.Count];
for (int at = 0; at < Headings.Count; at++)
{
widths[at] = Math.Max(Headings[at].Length, Rows.Max(r => At(r, at).Length));
}
StringBuilder text = new();
Line(text, Headings, widths);
foreach (IReadOnlyList<string> row in Rows)
{
Line(text, row, widths);
}
return text.ToString();
}
private void Line(StringBuilder text, IReadOnlyList<string> cells, int[] widths)
{
for (int at = 0; at < widths.Length; at++)
{
string cell = At(cells, at);
text.Append(at < LabelColumns ? cell.PadRight(widths[at]) : cell.PadLeft(widths[at]));
if (at < widths.Length - 1)
{
text.Append(Between);
}
}
// a row of blanks at the right would otherwise leave the line ragged
while (text.Length > 0 && text[^1] == ' ')
{
text.Length--;
}
text.Append('\n');
}
private static string At(IReadOnlyList<string> cells, int at) => at < cells.Count ? cells[at] : "";
/// A column with no value of its own is headed the way the window heads it,
/// so the text and the screen say the same thing.
private static string Heading(PivotKey column) =>
column.Label.Length > 0 ? column.Label : "(none)";
/// N1MM leaves a cell with nothing in it blank rather than printing a zero,
/// so the shape of the operating shows through the table.
private static string Count(int count) => count == 0 ? "" : count.ToString();
}
@@ -0,0 +1,78 @@
using Nonemm.Core;
namespace Nonemm.Session.Tests;
public class StatisticsTableTests
{
private static readonly DateTime Start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static Qso Contact(double kilohertz, Mode? mode = null, int minute = 0) => new()
{
Id = Qso.NewId(),
TimestampUtc = Start.AddMinutes(minute),
Call = Callsign.Parse("DL1ABC"),
Frequency = Frequency.FromKilohertz(kilohertz),
Mode = mode ?? Modes.Cw,
ContestName = "CQWW",
};
private static StatisticsTable Of(IReadOnlyList<Qso> log, string row = "Band", string column = "Mode") =>
StatisticsTable.Of(StatisticsPivot.Of(
log,
StatisticsFields.Find(StatisticsFields.Rows, row),
StatisticsFields.Find(StatisticsFields.Columns, column),
Start));
[Fact]
public void HeadsTheCountsWithTheColumnValuesAndEndsWithTotAndAccum()
{
StatisticsTable table = Of([Contact(14_030), Contact(7_030, Modes.Usb)]);
Assert.Equal(["Band", "CW", "USB", "Tot", "Accum"], table.Headings);
Assert.Equal(1, table.LabelColumns);
}
[Fact]
public void PutsTheTotalLineLast()
{
StatisticsTable table = Of([Contact(14_030), Contact(14_040), Contact(7_030)]);
Assert.Equal(["40M", "1", "1", "1"], table.Rows[0]);
Assert.Equal(["20M", "2", "2", "3"], table.Rows[1]);
Assert.Equal(["Total", "3", "3", "3"], table.Rows[^1]);
}
[Fact]
public void LinesTheColumnsUpUnderTheirHeadings()
{
string text = Of([Contact(14_030), Contact(3_530, Modes.Usb), Contact(3_540, Modes.Usb)]).ToText();
Assert.Equal(
"""
Band CW USB Tot Accum
80M 2 2 2
20M 1 1 3
Total 1 2 3 3
""",
text.TrimEnd('\n'));
}
[Fact]
public void KeepsTheTwoLabelColumnsOfAFieldThatHasThem()
{
StatisticsTable table = Of([Contact(14_030, minute: 5), Contact(14_040, minute: 70)], row: "Hour");
Assert.Equal(2, table.LabelColumns);
Assert.Equal(["Day", "Hr", "CW", "Tot", "Accum"], table.Headings);
Assert.Equal(["2026-01-01", "00", "1", "1", "1"], table.Rows[0]);
Assert.Equal(["Total", "", "2", "2", "2"], table.Rows[^1]);
}
[Fact]
public void LeavesNoSpacesHangingOffTheEndOfALine()
{
string text = Of([Contact(14_030)]).ToText();
Assert.All(text.Split('\n'), line => Assert.Equal(line.TrimEnd(), line));
}
}