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:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user