Files
Nonemm/src/Nonemm.Session/CheckWindowSources.cs
T
erikbrockoandClaude Sonnet 5.5 fe70f9b058 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>
2026-10-25 12:25:46 +00:00

160 lines
6.5 KiB
C#

using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Spotting;
namespace Nonemm.Session;
/// Answers what the callsign being typed could be, a column per source.
public sealed class CheckWindowSources
{
private readonly OperatingPosition session;
private readonly CallDatabase database;
private readonly Bandmap bandmap;
public CheckWindowSources(OperatingPosition session, CallDatabase database, Bandmap bandmap)
{
this.session = session;
this.database = database;
this.bandmap = bandmap;
}
/// A callsign being typed somewhere other than the entry window. The QTC
/// window sets it while the cursor is in one of its callsign boxes, so a
/// call taken down in traffic is checked the same way one being worked is.
/// Null the rest of the time, and then the entry window's call is read.
public string? Elsewhere { get; set; }
/// The columns are read off what the operator has typed: the callsign box
/// feeds the call columns, and whichever exchange box has the cursor feeds
/// the exchange column.
public IReadOnlyList<CheckColumn> Columns(int limit = 24)
{
string query = (Elsewhere ?? session.Entry.Call).Trim().ToUpperInvariant();
return
[
Column(CheckSource.Log, LoggedCalls(), query, limit),
new CheckColumn(
CheckSource.Database,
database.Count,
query.Length < PartialMatching.ShortestUsefulQuery
? []
: database.Matches(query, limit).Select(Judged).ToList()),
Column(CheckSource.History, HistoryCalls(), query, limit),
Column(CheckSource.Bandmap, SpottedCalls(), query, limit),
ExchangeColumn(limit),
];
}
/// `Columns`, with the searching done on another thread. The master
/// database holds tens of thousands of calls, and searching it between two
/// key presses is what makes typing stutter. The log, the history and the
/// bandmap are read here on the caller's thread, because the log is
/// changed there; the caller's thread also judges the few calls that are
/// shown, because that reads the log too.
public async Task<IReadOnlyList<CheckColumn>> ColumnsAsync(int limit = 24)
{
string query = (Elsewhere ?? session.Entry.Call).Trim().ToUpperInvariant();
IReadOnlyList<string> logged = LoggedCalls();
IReadOnlyList<string> history = HistoryCalls();
IReadOnlyList<string> spotted = SpottedCalls();
CheckColumn exchange = ExchangeColumn(limit);
bool searching = query.Length >= PartialMatching.ShortestUsefulQuery;
List<PartialMatch> none = [];
(List<PartialMatch> Log, List<PartialMatch> Master, List<PartialMatch> History, List<PartialMatch> Bandmap) found =
await Task.Run(() =>
{
if (!searching)
{
return (none, none, none, none);
}
return (
Best(logged, query, limit),
database.Matches(query, limit).Select(m => m).ToList(),
Best(history, query, limit),
Best(spotted, query, limit));
});
return
[
new CheckColumn(CheckSource.Log, logged.Count, [.. found.Log.Select(Judged)]),
new CheckColumn(CheckSource.Database, database.Count, [.. found.Master.Select(Judged)]),
new CheckColumn(CheckSource.History, history.Count, [.. found.History.Select(Judged)]),
new CheckColumn(CheckSource.Bandmap, spotted.Count, [.. found.Bandmap.Select(Judged)]),
exchange,
];
}
/// What the exchange box with the cursor in it could hold: the ARRL
/// sections, or the states and provinces. A serial number or a report has
/// no list behind it, so the column stays empty.
private CheckColumn ExchangeColumn(int limit)
{
int focus = session.Entry.Focus;
// a call typed outside the entry window brings no exchange box with it
if (Elsewhere is not null || focus == 0)
{
return new CheckColumn(CheckSource.Exchange, 0, []);
}
ExchangeField field = session.Entry.Exchange[focus - 1];
IReadOnlyList<string> values = ExchangeValues.For(field.Kind);
string query = session.Entry[focus].Trim().ToUpperInvariant();
if (query.Length == 0)
{
return new CheckColumn(CheckSource.Exchange, values.Count, []);
}
List<CheckCandidate> found = [.. values
.Where(v => v.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.Take(limit)
.Select(v => new CheckCandidate(v, PartialMatchQuality.Exact, null))];
return new CheckColumn(CheckSource.Exchange, values.Count, found);
}
private CheckColumn Column(
CheckSource source,
IReadOnlyList<string> calls,
string query,
int limit)
{
if (query.Length < PartialMatching.ShortestUsefulQuery)
{
return new CheckColumn(source, calls.Count, []);
}
return new CheckColumn(source, calls.Count, [.. Best(calls, query, limit).Select(Judged)]);
}
/// The calls that match the query, the closest first. It reads nothing but
/// its arguments, so it can run on any thread.
private static List<PartialMatch> Best(IReadOnlyList<string> calls, string query, int limit)
{
List<PartialMatch> found = [];
foreach (string call in calls)
{
PartialMatchQuality? quality = PartialMatching.Judge(query, call);
if (quality is not null)
{
found.Add(new PartialMatch(call, quality.Value));
}
}
return [.. found
.OrderBy(m => m.Quality)
.ThenBy(m => m.Call, StringComparer.Ordinal)
.Take(limit)];
}
private CheckCandidate Judged(PartialMatch match) =>
new(match.Call, match.Quality, session.JudgeCall(match.Call));
private IReadOnlyList<string> LoggedCalls() =>
session.Log.Qsos.Select(q => q.Call.Text).Distinct(StringComparer.Ordinal).ToList();
private IReadOnlyList<string> HistoryCalls() => [.. session.Session.History.Calls];
private IReadOnlyList<string> SpottedCalls() =>
bandmap.All()
.Where(s => s.IsStation)
.Select(s => s.Call.Text)
.Distinct(StringComparer.Ordinal)
.ToList();
}