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
+62 -21
View File
@@ -22,6 +22,12 @@ public sealed class ContestLog
private readonly Dictionary<ScoreKey, ScoreTally> rows = [];
private ScoreTally tally = new();
/// `Judge` is called from a background thread by the windows that judge
/// every spot, while the UI thread adds and edits contacts. Both take this
/// lock around the indexes. The lock can be entered again by the thread
/// that holds it, because a rebuild judges each contact.
private readonly Lock guard = new();
public ContestLog(
Contest contest,
StationInfo me,
@@ -67,6 +73,14 @@ public sealed class ContestLog
/// What logging this contact right now would do, without logging it.
public Verdict Judge(Qso candidate)
{
lock (guard)
{
return JudgeLocked(candidate);
}
}
private Verdict JudgeLocked(Qso candidate)
{
if (!candidate.IsClaimed)
{
@@ -103,19 +117,25 @@ public sealed class ContestLog
/// Adds the contact and returns it with points and multiplier flags filled in.
public Qso Add(Qso qso)
{
Verdict verdict = Judge(qso);
Qso scored = ApplyVerdict(qso, verdict);
qsos.Add(scored);
Index(scored, verdict);
return scored;
lock (guard)
{
Verdict verdict = Judge(qso);
Qso scored = ApplyVerdict(qso, verdict);
qsos.Add(scored);
Index(scored, verdict);
return scored;
}
}
/// Loads contacts read back from storage. Points and multiplier flags in
/// the stored rows are ignored and worked out again from the rules.
public void Restore(IEnumerable<Qso> stored)
{
qsos.AddRange(stored);
Rebuild();
lock (guard)
{
qsos.AddRange(stored);
Rebuild();
}
}
/// Adds a contact that may be older than ones already logged, which is what
@@ -123,41 +143,56 @@ public sealed class ContestLog
/// again: the earlier of two contacts is the one that claims a multiplier.
public void Insert(Qso qso)
{
qsos.Add(qso);
Rebuild();
lock (guard)
{
qsos.Add(qso);
Rebuild();
}
}
/// `Insert` for several contacts, scoring the log once instead of once per
/// contact. A resync sends a few hundred in a burst.
public void InsertRange(IEnumerable<Qso> arrived)
{
qsos.AddRange(arrived);
Rebuild();
lock (guard)
{
qsos.AddRange(arrived);
Rebuild();
}
}
public void Remove(string id)
{
qsos.RemoveAll(q => q.Id == id);
Rebuild();
lock (guard)
{
qsos.RemoveAll(q => q.Id == id);
Rebuild();
}
}
/// `Remove` for several contacts, scoring the log once.
public void RemoveRange(IReadOnlyCollection<string> ids)
{
HashSet<string> gone = [.. ids];
qsos.RemoveAll(q => gone.Contains(q.Id));
Rebuild();
lock (guard)
{
qsos.RemoveAll(q => gone.Contains(q.Id));
Rebuild();
}
}
public void Replace(Qso qso)
{
int at = qsos.FindIndex(q => q.Id == qso.Id);
if (at < 0)
lock (guard)
{
throw new InvalidOperationException($"no contact with id {qso.Id} in the log");
int at = qsos.FindIndex(q => q.Id == qso.Id);
if (at < 0)
{
throw new InvalidOperationException($"no contact with id {qso.Id} in the log");
}
qsos[at] = qso;
Rebuild();
}
qsos[at] = qso;
Rebuild();
}
/// A station worked before, under the contest's dupe rule. A contest with
@@ -284,7 +319,13 @@ public sealed class ContestLog
/// rescores what it touches — but the stored points and multiplier flags
/// are written from this, and a caller that is about to write them asks for
/// a pass first.
public void Rescore() => Rebuild();
public void Rescore()
{
lock (guard)
{
Rebuild();
}
}
/// Rescores the whole log. A removed or edited contact can hand its
/// multiplier to a later one, which only a full pass gets right.