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>
160 lines
6.5 KiB
C#
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();
|
|
}
|