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