From 6e24e00ec804fbe17fc43d1445fd729d1f6dab40 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 4 Sep 2026 07:59:22 +0000 Subject: [PATCH] Copy the statistics table and graph to the clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU --- README.md | 2 +- docs/unfinished.md | 17 ++- src/Nonemm.App/Windows/StatisticsWindow.axaml | 2 + .../Windows/StatisticsWindow.axaml.cs | 130 ++++++++---------- src/Nonemm.Session/StatisticsTable.cs | 119 ++++++++++++++++ .../StatisticsTableTests.cs | 78 +++++++++++ 6 files changed, 271 insertions(+), 77 deletions(-) create mode 100644 src/Nonemm.Session/StatisticsTable.cs create mode 100644 tests/Nonemm.Session.Tests/StatisticsTableTests.cs diff --git a/README.md b/README.md index 94cb968..fa48f14 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/unfinished.md b/docs/unfinished.md index 5d63d50..bbd5480 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -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 diff --git a/src/Nonemm.App/Windows/StatisticsWindow.axaml b/src/Nonemm.App/Windows/StatisticsWindow.axaml index cc3a68a..e3a6e68 100644 --- a/src/Nonemm.App/Windows/StatisticsWindow.axaml +++ b/src/Nonemm.App/Windows/StatisticsWindow.axaml @@ -7,6 +7,8 @@ + + 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 Lines(StatisticsPivot pivot) - { - List lines = []; - int labels = pivot.Row.Headings.Count; - for (int down = 0; down < pivot.Rows.Count; down++) - { - List 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 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 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 lines) - { - foreach (StatisticsRow line in lines) - { - text.AppendLine(string.Join(between, line.Cells)); - } - } - return text.ToString(); - } } diff --git a/src/Nonemm.Session/StatisticsTable.cs b/src/Nonemm.Session/StatisticsTable.cs new file mode 100644 index 0000000..370f5dd --- /dev/null +++ b/src/Nonemm.Session/StatisticsTable.cs @@ -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 headings, + IReadOnlyList> rows, + int labelColumns) + { + Headings = headings; + Rows = rows; + LabelColumns = labelColumns; + } + + public static StatisticsTable Of(StatisticsPivot pivot) + { + List headings = + [ + .. pivot.Row.Headings, + .. pivot.Columns.Select(Heading), + "Tot", + "Accum", + ]; + int labels = pivot.Row.Headings.Count; + List> rows = []; + for (int down = 0; down < pivot.Rows.Count; down++) + { + List 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 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 Headings { get; } + + /// Every line, the `Total` line last. + public IReadOnlyList> Rows { get; } + + /// How many columns at the left hold the row's own value rather than a + /// count. Two for Hour and Mode & 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 row in Rows) + { + Line(text, row, widths); + } + return text.ToString(); + } + + private void Line(StringBuilder text, IReadOnlyList 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 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(); +} diff --git a/tests/Nonemm.Session.Tests/StatisticsTableTests.cs b/tests/Nonemm.Session.Tests/StatisticsTableTests.cs new file mode 100644 index 0000000..a42021e --- /dev/null +++ b/tests/Nonemm.Session.Tests/StatisticsTableTests.cs @@ -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 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)); + } +}