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
+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();
}