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
+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
{