Cut CPU use and move heavy work off the UI thread

Redraws:
- Merge bursts of session and bandmap events into one redraw
  (Coalesced). Closed Bandmap and Available windows now unsubscribe.
- Grayline and Voice Keyer no longer subscribe to the session twice.
- Available summary, Score table and Log rows redraw only when their
  contents change. Telnet lines are added in batches.
- Bandmap view reuses its layout, shaped text and brushes. Grayline
  draws the coastlines as one cached shape.

Threads:
- Available, Bandmap colours, Check and Statistics do their judging
  and counting on the thread pool and draw the result on the UI thread.
- ContestLog takes a lock in Judge and in each method that changes the
  log, so a background Judge cannot read the indexes mid-edit.

Refresh rate:
- Statistics refreshes every 30 seconds and when the QSO count changes.
- Grayline refreshes at most every 15 seconds on session events.

Publish with ReadyToRun to cut startup JIT time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 12:25:46 +00:00
co-authored by Claude Sonnet 5.5
parent 0a23815f38
commit fe70f9b058
19 changed files with 752 additions and 184 deletions
+134 -36
View File
@@ -44,6 +44,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
private AvailableRow? menuRow;
private bool menuOnHeader;
private string shownKey = "";
private string summaryKey = "";
private DateTime pausedUntil = DateTime.MinValue;
private bool refreshPending;
private int ticks;
@@ -70,7 +71,10 @@ public sealed partial class AvailableWindow : RefreshableWindow
Spots.PointerExited += (_, _) => Resume();
Spots.LoadingRow += (_, e) => Paint(e.Row);
Spots.ContextMenu = BuildMenu();
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
Coalesced changed = new(Refresh);
EventHandler spotsChanged = (_, _) => changed.Request();
session.Bandmap.Changed += spotsChanged;
Closed += (_, _) => session.Bandmap.Changed -= spotsChanged;
timer.Tick += (_, _) => OnTick();
timer.Start();
Closed += (_, _) => timer.Stop();
@@ -191,64 +195,137 @@ public sealed partial class AvailableWindow : RefreshableWindow
// ---- refresh
private bool running;
private bool again;
/// Judging every spot and building the lines is done on other threads, so
/// a refresh never holds up a key press. A request that comes in while one
/// is running is answered by one more run afterwards, and the run it
/// replaces is not drawn.
public override void Refresh()
{
refreshPending = false;
ticks = 0;
Summary.Children.Clear();
Summary.RowDefinitions.Clear();
Summary.ColumnDefinitions.Clear();
if (running)
{
again = true;
return;
}
_ = RunAsync();
}
private async Task RunAsync()
{
running = true;
try
{
do
{
again = false;
await Update();
}
while (again);
}
finally
{
running = false;
}
}
/// What the other threads work out, so the UI thread only has to draw it.
private sealed record SummaryData(
IReadOnlyList<Band> Columns,
Dictionary<Band, int> Mults,
Dictionary<Band, int> Qs,
int MostMults,
int MostQs);
private sealed record Computed(int Mults, int Total, SummaryData? Summary, List<AvailableRow> Rows, string RowsKey);
private async Task Update()
{
Summary.IsVisible = session.Settings.AvailableShowsSummary;
bool multsOnly = session.Settings.AvailableMultsOnly;
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
if (session.Available is null)
if (session.Available is not { } available)
{
StatusText.Text = "no contest is open";
Spots.ItemsSource = Array.Empty<AvailableRow>();
shownKey = "";
summaryKey = "";
Summary.Children.Clear();
return;
}
// everything the other threads read is taken here
DateTime now = DateTime.UtcNow;
session.Bandmap.DropOlderThan(now);
IReadOnlyList<AvailableStation> all = session.Available.All();
if (Summary.IsVisible)
Settings settings = session.Settings;
BandPlan plan = session.BandPlan;
Bandmap bandmap = session.Bandmap;
HashSet<Band> picked = [.. clicked];
Frequency? tuned = session.Position?.Frequency;
bool summaryShown = Summary.IsVisible;
bandmap.DropOlderThan(now);
IReadOnlyList<AvailableStation> all = await available.AllAsync();
Computed computed = await Task.Run(() => Compute(all, now, settings, plan, bandmap, picked, tuned, summaryShown));
if (again)
{
BuildSummary(all);
return;
}
int mults = all.Count(s => s.IsMultiplier);
Title = $"Available Mults and Qs — {all.Count - mults} Qs · {mults} Mults · {all.Count} total";
StatusText.Text = all.Count == 0
if (computed.Summary is { } summary)
{
BuildSummary(summary, settings, picked);
}
Title = $"Available Mults and Qs — {computed.Total - computed.Mults} Qs · {computed.Mults} Mults · {computed.Total} total";
StatusText.Text = computed.Total == 0
? "No workable spots: check the contest, the bands and the cluster filters"
: $"{mults} mults · {all.Count - mults} Qs · {all.Count} total";
: $"{computed.Mults} mults · {computed.Total - computed.Mults} Qs · {computed.Total} total";
if (IsPaused)
{
refreshPending = true;
return;
}
Show(computed.Rows, computed.RowsKey);
}
IReadOnlyList<string> preferred = session.Settings.SpotFilter.PreferredSpotters;
Frequency? tuned = session.Position?.Frequency;
private Computed Compute(
IReadOnlyList<AvailableStation> all,
DateTime now,
Settings settings,
BandPlan plan,
Bandmap bandmap,
HashSet<Band> picked,
Frequency? tuned,
bool summaryShown)
{
int mults = all.Count(s => s.IsMultiplier);
IReadOnlyList<string> preferred = settings.SpotFilter.PreferredSpotters;
List<AvailableRow> rows = all
.Where(s => (!multsOnly || s.IsMultiplier) && IsShown(s))
.Where(s => (!settings.AvailableMultsOnly || s.IsMultiplier) && IsShown(s, settings, plan, picked))
.Select(s => new AvailableRow(
s,
ModeName(s.Spot.Frequency),
ModeName(plan, s.Spot.Frequency),
now,
session.Bandmap.Remaining(s.Spot),
bandmap.Remaining(s.Spot),
preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase),
tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100))
.ToList();
Show(rows);
return new Computed(
mults,
all.Count,
summaryShown ? SummaryOf(all, settings) : null,
rows,
string.Join(';', rows.Select(r => r.Key)));
}
private bool IsShown(AvailableStation station)
private static bool IsShown(AvailableStation station, Settings settings, BandPlan plan, HashSet<Band> picked)
{
IReadOnlyList<string> bands = session.Settings.AvailableBands;
IReadOnlyList<string> modes = session.Settings.AvailableModes;
ModeCategory? category = session.BandPlan.ModeAt(station.Spot.Frequency);
return (clicked.Count == 0 || clicked.Contains(station.Band))
IReadOnlyList<string> bands = settings.AvailableBands;
IReadOnlyList<string> modes = settings.AvailableModes;
ModeCategory? category = plan.ModeAt(station.Spot.Frequency);
return (picked.Count == 0 || picked.Contains(station.Band))
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase))
&& (modes.Count == 0 || category is null || modes.Contains(ModeKey(category.Value), StringComparer.OrdinalIgnoreCase));
}
@@ -262,9 +339,8 @@ public sealed partial class AvailableWindow : RefreshableWindow
/// A refresh that changes nothing leaves the list alone, so the scroll
/// position and the picked line stay where the operator left them.
private void Show(List<AvailableRow> rows)
private void Show(List<AvailableRow> rows, string key)
{
string key = string.Join(';', rows.Select(r => r.Key));
if (key == shownKey && Spots.ItemsSource is not null)
{
return;
@@ -280,13 +356,13 @@ public sealed partial class AvailableWindow : RefreshableWindow
}
string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
Spots.ItemsSource = view;
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } again)
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } same)
{
Spots.SelectedItem = again;
Spots.SelectedItem = same;
}
}
private string ModeName(Frequency frequency) => session.BandPlan.ModeAt(frequency) switch
private static string ModeName(BandPlan plan, Frequency frequency) => plan.ModeAt(frequency) switch
{
ModeCategory.Cw => "CW",
ModeCategory.Phone => "SSB",
@@ -309,22 +385,44 @@ public sealed partial class AvailableWindow : RefreshableWindow
// ---- summary table
private IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all)
private IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all) =>
SummaryBands(all, session.Settings);
private static IReadOnlyList<Band> SummaryBands(IReadOnlyList<AvailableStation> all, Settings settings)
{
IReadOnlyList<string> chosen = session.Settings.AvailableSummaryBands;
IReadOnlyList<string> chosen = settings.AvailableSummaryBands;
IEnumerable<Band> basis = chosen.Count > 0
? chosen.Select(Bands.Named).OfType<Band>()
: Bands.Contest;
return [.. basis.Concat(all.Select(s => s.Band)).Distinct().OrderBy(b => b.Low.Hertz)];
}
private void BuildSummary(IReadOnlyList<AvailableStation> all)
private static SummaryData SummaryOf(IReadOnlyList<AvailableStation> all, Settings settings)
{
IReadOnlyList<Band> columns = SummaryBands(all);
IReadOnlyList<Band> columns = SummaryBands(all, settings);
Dictionary<Band, int> mults = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && s.IsMultiplier));
Dictionary<Band, int> qs = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && !s.IsMultiplier));
int mostMults = mults.Values.Max();
int mostQs = qs.Values.Max();
return new SummaryData(columns, mults, qs, mults.Values.Max(), qs.Values.Max());
}
private void BuildSummary(SummaryData data, Settings settings, HashSet<Band> picked)
{
(IReadOnlyList<Band> columns, Dictionary<Band, int> mults, Dictionary<Band, int> qs, int mostMults, int mostQs) = data;
// the table is built again only when a number, a highlight or the
// font size is different from what it shows
string key = string.Join(
';',
columns.Select(b => $"{b.Name}:{mults[b]}:{qs[b]}:{picked.Contains(b)}"))
+ $"|{mostMults}|{mostQs}|{settings.AvailableFontSize}";
if (key == summaryKey)
{
return;
}
summaryKey = key;
Summary.Children.Clear();
Summary.RowDefinitions.Clear();
Summary.ColumnDefinitions.Clear();
for (int row = 0; row < 4; row++)
{
@@ -344,7 +442,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
{
Band band = columns[at];
int column = at + 1;
Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), clicked.Contains(band));
Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), picked.Contains(band));
Label(1, column, Count(mults[band]), e => ShowBands([band], true, e), mults[band] > 0 && mults[band] == mostMults);
Label(2, column, Count(qs[band]), e => ShowBands([band], false, e), qs[band] > 0 && qs[band] == mostQs);
Label(3, column, Count(mults[band] + qs[band]), e => ShowBands([band], null, e));
+73 -11
View File
@@ -99,14 +99,35 @@ public sealed class BandmapView : Control
DrawSegments(context);
DrawScale(context, ink);
DrawVfos(context);
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
PlaceLabels();
foreach (BandmapLabel label in placed)
{
DrawLabel(context, label, ink);
}
}
private IReadOnlyList<Spot> placedSpots = [];
private BandmapScale? placedScale;
private double placedHeight;
/// Lays the callsigns out again only when the spots, the scale or the text
/// size are not what the last layout used. Redraws for any other reason, a
/// moving receiver bar for one, reuse it.
private void PlaceLabels()
{
if (placedScale == scale
&& placedHeight == LabelHeight
&& (ReferenceEquals(placedSpots, Spots) || placedSpots.SequenceEqual(Spots)))
{
return;
}
placedSpots = Spots;
placedScale = scale;
placedHeight = LabelHeight;
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight)
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
}
/// N1MM paints the CW, digital and phone parts of the band behind the
/// scale, the digital block over the other two.
/// Anything past a band edge is painted as out of band, which is the only
@@ -209,12 +230,20 @@ public sealed class BandmapView : Control
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
Spot? under = LabelAt(e.GetPosition(this))?.Spot;
if (Equals(under, tipFor))
{
return;
}
tipFor = under;
ToolTip.SetShowDelay(this, 0);
ToolTip.SetTip(
this,
LabelAt(e.GetPosition(this)) is { } label ? SpotDescription.Of(label.Spot) : null);
ToolTip.SetTip(this, under is null ? null : SpotDescription.Of(under));
}
/// The spot the tool tip describes, so moving the pointer along a callsign
/// does not build the text again.
private Spot? tipFor;
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
@@ -227,23 +256,56 @@ public sealed class BandmapView : Control
FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset));
}
private FormattedText Text(string text, IBrush brush, double size, bool bold = false, bool italic = false) =>
new(
/// Shaping text is the dearest part of a redraw, and the same few hundred
/// callsigns and frequency numbers are drawn every time, so each is kept.
private readonly Dictionary<(string Text, IBrush Brush, double Size, bool Bold, bool Italic), FormattedText> texts = [];
private FormattedText Text(string text, IBrush brush, double size, bool bold = false, bool italic = false)
{
if (texts.TryGetValue((text, brush, size, bold, italic), out FormattedText? kept))
{
return kept;
}
if (texts.Count > 2000)
{
texts.Clear();
}
FormattedText made = new(
text,
CultureInfo.InvariantCulture,
FlowDirection.LeftToRight,
bold ? boldTypeface : italic ? italicTypeface : typeface,
size,
brush);
texts[(text, brush, size, bold, italic)] = made;
return made;
}
/// The form's text colour, which the theme sets in both light and dark.
private IBrush Foreground() =>
this.FindResource("FormForeground") as IBrush ?? Brushes.Gray;
private static IBrush Opaque(IBrush ink, double opacity) =>
ink is ISolidColorBrush solid
? new SolidColorBrush(solid.Color, opacity)
: ink;
private readonly Dictionary<(IBrush Ink, double Opacity), IBrush> faded = [];
/// Kept, so the same brush comes back each time and the text drawn with it
/// is found in `texts`.
private IBrush Opaque(IBrush ink, double opacity)
{
if (ink is not ISolidColorBrush solid)
{
return ink;
}
if (!faded.TryGetValue((ink, opacity), out IBrush? brush))
{
if (faded.Count > 50)
{
faded.Clear();
}
brush = new SolidColorBrush(solid.Color, opacity);
faded[(ink, opacity)] = brush;
}
return brush;
}
private static IBrush BrushFor(VfoRole role) => role switch
{
+63 -7
View File
@@ -5,6 +5,7 @@ using Avalonia.Threading;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Rig;
using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App.Windows;
@@ -76,7 +77,10 @@ public sealed partial class BandmapWindow : RefreshableWindow
}
};
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
Coalesced changed = new(Refresh);
EventHandler spotsChanged = (_, _) => changed.Request();
session.Bandmap.Changed += spotsChanged;
Closed += (_, _) => session.Bandmap.Changed -= spotsChanged;
EventHandler solarChanged = (_, _) => Dispatcher.UIThread.Post(ShowSolar);
session.SolarChanged += solarChanged;
Closed += (_, _) => session.SolarChanged -= solarChanged;
@@ -127,6 +131,56 @@ public sealed partial class BandmapWindow : RefreshableWindow
View.Vfos = Vfos();
View.Segments = session.BandPlan.For(band);
View.InvalidateVisual();
_ = JudgeAsync();
}
/// What each callsign is worth, as of the last pass. The colours are looked
/// up when the view draws, so the log is never searched on the UI thread.
private Dictionary<Spot, Verdict?> verdicts = new(ReferenceEqualityComparer.Instance);
private bool judging;
private bool judgeAgain;
/// Judges every spot on another thread and draws again when that is done.
/// A request made while a pass is running is answered by one more pass.
private async Task JudgeAsync()
{
if (judging)
{
judgeAgain = true;
return;
}
judging = true;
try
{
do
{
judgeAgain = false;
OperatingPosition? position = session.Position;
Mode? mode = position?.Mode;
string contest = position?.Contest.Name ?? "";
IReadOnlyList<Spot> asked = View.Spots;
Dictionary<Spot, Verdict?> judged = await Task.Run(() =>
{
Dictionary<Spot, Verdict?> found = new(ReferenceEqualityComparer.Instance);
foreach (Spot spot in asked)
{
found[spot] = VerdictFor(position, mode, contest, spot);
}
return found;
});
if (judgeAgain)
{
continue;
}
verdicts = judged;
View.InvalidateVisual();
}
while (judgeAgain);
}
finally
{
judging = false;
}
}
/// The bandmap's spots with the pseudospot for the CQ frequency, when the
@@ -206,11 +260,13 @@ public sealed partial class BandmapWindow : RefreshableWindow
};
private IBrush Colour(Spot spot) =>
spot.IsCqFrequency ? CqBrush : Verdicts.Colour(VerdictFor(spot));
spot.IsCqFrequency
? CqBrush
: Verdicts.Colour(verdicts.GetValueOrDefault(spot));
private Verdict? VerdictFor(Spot spot)
private static Verdict? VerdictFor(OperatingPosition? position, Mode? mode, string contest, Spot spot)
{
if (session.Position is null)
if (position is null || mode is null)
{
return null;
}
@@ -219,14 +275,14 @@ public sealed partial class BandmapWindow : RefreshableWindow
// N1MM marks a busy frequency as worked, so it is painted like one
return Verdict.Dupe;
}
return session.Position.Log.Judge(new Qso
return position.Log.Judge(new Qso
{
Id = "",
TimestampUtc = DateTime.UtcNow,
Call = spot.Call,
Frequency = spot.Frequency,
Mode = session.Position.Mode,
ContestName = session.Position.Contest.Name,
Mode = mode,
ContestName = contest,
});
}
}
+38 -3
View File
@@ -29,14 +29,49 @@ public sealed partial class CheckWindow : RefreshableWindow
public override void RefreshForEntry() => Refresh();
private bool running;
private bool again;
/// The search runs on another thread, so a key press never waits for it.
/// A request that comes in while one is running is answered by one more
/// run afterwards, which sees the latest call.
public override void Refresh()
{
Columns.Children.Clear();
if (session.Check is null)
if (running)
{
again = true;
return;
}
IReadOnlyList<CheckColumn> columns = session.Check.Columns();
_ = RunAsync();
}
private async Task RunAsync()
{
running = true;
try
{
do
{
again = false;
await ShowColumns();
}
while (again);
}
finally
{
running = false;
}
}
private async Task ShowColumns()
{
if (session.Check is null)
{
Columns.Children.Clear();
return;
}
IReadOnlyList<CheckColumn> columns = await session.Check.ColumnsAsync();
Columns.Children.Clear();
Columns.ColumnDefinitions.Clear();
foreach (CheckColumn _ in columns)
{
+28
View File
@@ -0,0 +1,28 @@
using Avalonia.Threading;
namespace Nonemm.App.Windows;
/// Runs an action on the UI thread once for any number of requests made before
/// it runs. A radio turning its dial or a busy cluster raises an event many
/// times a second, and each window only needs to redraw once the burst has been
/// handled.
public sealed class Coalesced(Action action)
{
private int pending;
/// Safe to call from any thread.
public void Request()
{
if (Interlocked.Exchange(ref pending, 1) == 1)
{
return;
}
Dispatcher.UIThread.Post(
() =>
{
Volatile.Write(ref pending, 0);
action();
},
DispatcherPriority.Background);
}
}
+4 -2
View File
@@ -67,7 +67,9 @@ public sealed partial class EntryWindow : Window
this.radioNumber = radioNumber;
InitializeComponent();
BuildFunctionKeys();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
// a radio being tuned raises this many times a second
Coalesced changed = new(Refresh);
session.Changed += (_, _) => changed.Request();
// a keyer speaks on its own thread, and a recording that would not
// play has nowhere else to be said
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
@@ -949,7 +951,7 @@ public sealed partial class EntryWindow : Window
RefreshSelf();
foreach (Window window in openWindows.Values)
{
(window as RefreshableWindow)?.Refresh();
(window as RefreshableWindow)?.RefreshFromSession();
}
}
+37 -6
View File
@@ -49,24 +49,55 @@ public sealed class GraylineView : Control
}
}
private static readonly Pen CoastPen = new(Land, 1);
private Geometry? coastlines;
private Size coastlinesFor;
/// The coastlines are thousands of short segments, so they are built into
/// one shape that is kept until the window changes size.
private void DrawCoastlines(DrawingContext context)
{
Pen pen = new(Land, 1);
if (coastlines is null || coastlinesFor != Bounds.Size)
{
coastlines = BuildCoastlines();
coastlinesFor = Bounds.Size;
}
context.DrawGeometry(null, CoastPen, coastlines);
}
private StreamGeometry BuildCoastlines()
{
StreamGeometry shape = new();
using StreamGeometryContext path = shape.Open();
foreach (IReadOnlyList<Point> line in WorldMap.Coastlines)
{
for (int at = 1; at < line.Count; at++)
bool open = false;
for (int at = 0; at < line.Count; at++)
{
Point from = line[at - 1];
Point to = line[at];
// a line that runs off one edge and back on at the other is the
// date line being crossed, and is left undrawn
if (Math.Abs(to.X - from.X) > 180)
if (open && Math.Abs(to.X - line[at - 1].X) > 180)
{
continue;
path.EndFigure(false);
open = false;
}
context.DrawLine(pen, At(from.Y, from.X), At(to.Y, to.X));
if (open)
{
path.LineTo(At(to.Y, to.X));
}
else
{
path.BeginFigure(At(to.Y, to.X), isFilled: false);
open = true;
}
}
if (open)
{
path.EndFigure(false);
}
}
return shape;
}
/// The night is one shape, its edge the terminator: the latitude where the
+22 -8
View File
@@ -25,23 +25,40 @@ public sealed partial class GraylineWindow : RefreshableWindow
clock = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
clock.Tick += (_, _) => Refresh();
clock.Start();
session.Changed += WhenSessionChanged;
Closed += (_, _) =>
{
clock.Stop();
session.Changed -= WhenSessionChanged;
later?.Cancel();
};
Refresh();
}
private OperatingPosition? Radio => session.Position;
public override void RefreshForEntry() => Refresh();
private Throttled? later;
/// The map is not watched closely: a station typed or spotted shows up
/// within fifteen seconds, and the grey line moves too slowly to see.
public override void RefreshForEntry() => RefreshFromSession();
public override void RefreshFromSession()
{
later ??= new Throttled(TimeSpan.FromSeconds(15), Refresh);
later.Request();
}
public override void Refresh()
{
Map.Moment = DateTime.UtcNow;
Map.Markers = Markers();
DateTime now = DateTime.UtcNow;
IReadOnlyList<MapMarker> markers = Markers();
// the terminator moves a quarter of a degree a minute, so a redraw
// for nothing but the clock can wait
if (now - Map.Moment < TimeSpan.FromSeconds(30) && Map.Markers.SequenceEqual(markers))
{
return;
}
Map.Moment = now;
Map.Markers = markers;
Map.InvalidateVisual();
}
@@ -95,8 +112,5 @@ public sealed partial class GraylineWindow : RefreshableWindow
private CountryLookup? Where(string call) =>
call.Trim().Length == 0 ? null : session.Countries?.Find(call.Trim());
private void WhenSessionChanged(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(Refresh);
private sealed record Station(string Call, double Latitude, double Longitude, IBrush Colour);
}
+6 -1
View File
@@ -223,7 +223,12 @@ public sealed partial class LogWindow : RefreshableWindow
bool atBottom = ScrolledToBottom();
for (int at = 0; at < shownRows.Count; at++)
{
shownRows[at].Show(qsos[at], VerdictFor(qsos[at]));
// a contact that was not edited is the same object, and the
// row has nothing to redraw
if (!ReferenceEquals(shownRows[at].Qso, qsos[at]))
{
shownRows[at].Show(qsos[at], VerdictFor(qsos[at]));
}
}
bool grown = qsos.Count > shownRows.Count;
for (int at = shownRows.Count; at < qsos.Count; at++)
@@ -7,6 +7,11 @@ public abstract class RefreshableWindow : Window
{
public abstract void Refresh();
/// Called instead of `Refresh` when the session changes. A window that
/// does not need to follow every change redraws later here, while `Refresh`
/// stays immediate for what the operator does in the window itself.
public virtual void RefreshFromSession() => Refresh();
/// Called instead of `Refresh` when the entry window's call or exchange
/// changes. A window that shows nothing about them does nothing.
public virtual void RefreshForEntry()
+52 -37
View File
@@ -19,13 +19,18 @@ public sealed partial class ScoreWindow : RefreshableWindow
}
/// What the table and the total line showed last, so a refresh that
/// changes nothing leaves the controls alone.
private string shown = "";
public override void Refresh()
{
Table.Children.Clear();
Table.ColumnDefinitions.Clear();
Table.RowDefinitions.Clear();
if (session.Logging is null)
{
shown = "";
Table.Children.Clear();
Table.ColumnDefinitions.Clear();
Table.RowDefinitions.Clear();
TotalText.Text = "no contest is open";
return;
}
@@ -39,32 +44,28 @@ public sealed partial class ScoreWindow : RefreshableWindow
.ToList();
bool hasKinds = session.Logging.Contest.HasQtcTraffic;
columnCount = hasKinds ? 5 : 4;
for (int column = 0; column < columnCount; column++)
{
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
}
AddRow(0, "Band", "Q/QTC", "QSOs", "Points", "Mults", header: true);
int row = 1;
List<(string[] Cells, bool Header)> rows = [(["Band", "Q/QTC", "QSOs", "Points", "Mults"], true)];
foreach ((ScoreKey key, ScoreTally line) in lines)
{
AddRow(
row++,
key.Band,
key.Kind,
line.Qsos.ToString(),
line.Points.ToString(),
line.TotalMultipliers.ToString());
rows.Add((
[
key.Band,
key.Kind,
line.Qsos.ToString(),
line.Points.ToString(),
line.TotalMultipliers.ToString(),
],
false));
}
AddRow(
row,
"Total",
hasKinds ? "All" : "",
session.Logging.Log.Tally.Qsos.ToString(),
session.Logging.Log.Tally.Points.ToString(),
session.Logging.Log.Tally.TotalMultipliers.ToString(),
header: true);
rows.Add((
[
"Total",
hasKinds ? "All" : "",
session.Logging.Log.Tally.Qsos.ToString(),
session.Logging.Log.Tally.Points.ToString(),
session.Logging.Log.Tally.TotalMultipliers.ToString(),
],
true));
TimeSpan on = OperatingTime.On(
session.Logging.Log.Qsos,
@@ -72,10 +73,31 @@ public sealed partial class ScoreWindow : RefreshableWindow
TimeSpan off = OperatingTime.Off(
session.Logging.Log.Qsos,
session.Logging.Contest.MinimumOffTime);
TotalText.Text =
string total =
$"Claimed score {session.Logging.Log.TotalScore:N0}"
+ $" on {Hours(on)} off {Hours(off)}"
+ BandChanges();
string signature = hasKinds + "\n" + string.Join('\n', rows.Select(r => string.Join('\t', r.Cells)));
TotalText.Text = total;
if (signature == shown)
{
return;
}
shown = signature;
Table.Children.Clear();
Table.ColumnDefinitions.Clear();
Table.RowDefinitions.Clear();
columnCount = hasKinds ? 5 : 4;
for (int column = 0; column < columnCount; column++)
{
Table.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
}
for (int row = 0; row < rows.Count; row++)
{
AddRow(row, rows[row].Cells, rows[row].Header);
}
}
/// The band changes made and the allowance, for a contest that limits
@@ -92,19 +114,12 @@ public sealed partial class ScoreWindow : RefreshableWindow
private static string Hours(TimeSpan time) =>
$"{(int)time.TotalHours}:{time.Minutes:00}";
private void AddRow(
int row,
string band,
string kind,
string qsos,
string points,
string mults,
bool header = false)
private void AddRow(int row, string[] all, bool header)
{
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
string[] cells = columnCount == 5
? [band, kind, qsos, points, mults]
: [band, qsos, points, mults];
? all
: [all[0], all[2], all[3], all[4]];
for (int column = 0; column < cells.Length; column++)
{
TextBlock text = new()
@@ -8,7 +8,9 @@ using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Nonemm.App.Configuration;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
@@ -55,55 +57,118 @@ public sealed partial class StatisticsWindow : RefreshableWindow
loading = false;
ShowPaletteBox();
DispatcherTimer every = new() { Interval = TimeSpan.FromSeconds(30) };
every.Tick += (_, _) => Refresh();
every.Start();
Closed += (_, _) => every.Stop();
Refresh();
}
/// How many contacts the figures were counted from.
private int countShown = -1;
/// The numbers are not watched as they change. They are counted again
/// every thirty seconds and when a contact has been logged or deleted; the
/// refresh button and the field boxes are immediate.
public override void RefreshFromSession()
{
if (session.Logging is { } logging && logging.Log.Qsos.Count != countShown)
{
Refresh();
}
}
private StatisticsField Row =>
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
private StatisticsField Column =>
StatisticsFields.Find(StatisticsFields.Columns, session.Settings.StatsColumn);
private bool running;
private bool again;
/// Counting the log is done on another thread, so a refresh never holds up
/// the entry window. A request made while one is running is answered by one
/// more run afterwards, and the run it replaces is not drawn.
public override void Refresh()
{
if (session.Logging is null)
if (running)
{
Table.Columns.Clear();
Table.ItemsSource = Array.Empty<StatisticsRow>();
Chart.Columns = [];
Chart.Series = [];
Chart.InvalidateVisual();
laid = null;
Empty("no contest is open");
again = true;
return;
}
_ = RunAsync();
}
private async Task RunAsync()
{
running = true;
try
{
do
{
again = false;
await Update();
}
while (again);
}
finally
{
running = false;
}
}
private async Task Update()
{
if (session.Logging is not { } logging)
{
countShown = -1;
ShowNothing("no contest is open");
return;
}
StatisticsPivot pivot = StatisticsPivot.Of(
session.Logging.Log.Qsos,
Row,
Column,
session.Logging.Instance.StartDate,
session.Settings.StatsIncludeDupes);
Title = $"Statistics for {session.Logging.Contest.Name}"
+ $" - {session.Logging.Instance.StartDate:yyyy-MM-dd}"
+ $" - {Column.Name} by {Row.Name}";
if (pivot.IsEmpty)
// the log is changed on this thread, so the counting works on a copy
IReadOnlyList<Qso> qsos = [.. logging.Log.Qsos];
countShown = qsos.Count;
StatisticsField row = Row;
StatisticsField column = Column;
DateTime start = logging.Instance.StartDate;
bool dupes = session.Settings.StatsIncludeDupes;
(StatisticsPivot pivot, StatisticsTable? table) = await Task.Run(() =>
{
Table.Columns.Clear();
Table.ItemsSource = Array.Empty<StatisticsRow>();
Chart.Columns = [];
Chart.Series = [];
Chart.InvalidateVisual();
laid = null;
Empty("no contacts are counted");
StatisticsPivot counted = StatisticsPivot.Of(qsos, row, column, start, dupes);
return (counted, counted.IsEmpty ? null : StatisticsTable.Of(counted));
});
if (again)
{
return;
}
Title = $"Statistics for {logging.Contest.Name}"
+ $" - {start:yyyy-MM-dd}"
+ $" - {column.Name} by {row.Name}";
if (table is null)
{
ShowNothing("no contacts are counted");
return;
}
EmptyText.IsVisible = false;
Table.IsVisible = true;
FillTable(pivot);
FillTable(table);
FillChart(pivot);
}
private void ShowNothing(string why)
{
Table.Columns.Clear();
Table.ItemsSource = Array.Empty<StatisticsRow>();
Chart.Columns = [];
Chart.Series = [];
Chart.InvalidateVisual();
laid = null;
Empty(why);
}
private void Empty(string why)
{
EmptyText.Text = why;
@@ -111,9 +176,9 @@ public sealed partial class StatisticsWindow : RefreshableWindow
Table.IsVisible = false;
}
private void FillTable(StatisticsPivot pivot)
private void FillTable(StatisticsTable table)
{
laid = StatisticsTable.Of(pivot);
laid = table;
Table.Columns.Clear();
for (int at = 0; at < laid.Headings.Count; at++)
{
+27 -8
View File
@@ -143,26 +143,45 @@ public sealed partial class TelnetWindow : RefreshableWindow
private void OnConnectionChanged(object? sender, bool connected) =>
Dispatcher.UIThread.Post(Refresh);
/// `strong` marks the lines from a preferred spotter. The view stays on the
/// `strong` marks the lines from a preferred spotter. Lines wait here and go
/// into the list together, because a busy node sends dozens a second and
/// each one added alone lays the list out again. The view stays on the
/// newest line; a burst of lines is scrolled once, after the last of them.
private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() =>
{
while (Traffic.Items.Count >= LinesKept)
waiting.Add((line, colour, strong));
flushWaiting ??= new Coalesced(AddWaiting);
flushWaiting.Request();
});
private readonly List<(string Line, IBrush? Colour, bool Strong)> waiting = [];
private Coalesced? flushWaiting;
private void AddWaiting()
{
// lines that would be pushed out again at once are not added
int skip = Math.Max(0, waiting.Count - LinesKept);
int excess = Math.Max(0, Traffic.Items.Count + waiting.Count - skip - LinesKept);
for (int removed = 0; removed < excess && Traffic.Items.Count > 0; removed++)
{
Traffic.Items.RemoveAt(0);
}
Traffic.Items.Add(new TextBlock
foreach ((string line, IBrush? colour, bool strong) in waiting.Skip(skip))
{
Text = line,
Foreground = colour,
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
});
Traffic.Items.Add(new TextBlock
{
Text = line,
Foreground = colour,
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
});
}
waiting.Clear();
if (!scrollPending)
{
scrollPending = true;
Dispatcher.UIThread.Post(ScrollToNewest, DispatcherPriority.Background);
}
});
}
private void ScrollToNewest()
{
+32
View File
@@ -0,0 +1,32 @@
using Avalonia.Threading;
namespace Nonemm.App.Windows;
/// Runs an action once, `delay` after the first request, however many requests
/// come in meanwhile. It is for windows that do not have to follow every
/// change: the picture is at most `delay` old. Call it on the UI thread.
public sealed class Throttled
{
private readonly DispatcherTimer timer;
public Throttled(TimeSpan delay, Action action)
{
timer = new DispatcherTimer { Interval = delay };
timer.Tick += (_, _) =>
{
timer.Stop();
action();
};
}
public void Request()
{
if (!timer.IsEnabled)
{
timer.Start();
}
}
/// Drops a request that is waiting, for a window that has closed.
public void Cancel() => timer.Stop();
}
@@ -32,7 +32,6 @@ public sealed partial class VoiceKeyerWindow : RefreshableWindow
RecordBox.LostFocus += (_, _) => SaveCommands();
PttBox.IsCheckedChanged += (_, _) => SaveCommands();
OperatorBox.SelectionChanged += (_, _) => Refresh();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
FillOperators();
Refresh();
}