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
+1 -1
View File
@@ -30,7 +30,7 @@ if [ -f "$archive" ]; then
fi fi
for project in Nonemm.App Nonemm.Recorder.App; do for project in Nonemm.App Nonemm.Recorder.App; do
./build.sh publish "src/$project" -c Release -r "$rid" --self-contained -o "$out" || exit 1 ./build.sh publish "src/$project" -c Release -r "$rid" --self-contained -p:PublishReadyToRun=true -o "$out" || exit 1
done done
rm -f "$out"/*.pdb rm -f "$out"/*.pdb
+134 -36
View File
@@ -44,6 +44,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
private AvailableRow? menuRow; private AvailableRow? menuRow;
private bool menuOnHeader; private bool menuOnHeader;
private string shownKey = ""; private string shownKey = "";
private string summaryKey = "";
private DateTime pausedUntil = DateTime.MinValue; private DateTime pausedUntil = DateTime.MinValue;
private bool refreshPending; private bool refreshPending;
private int ticks; private int ticks;
@@ -70,7 +71,10 @@ public sealed partial class AvailableWindow : RefreshableWindow
Spots.PointerExited += (_, _) => Resume(); Spots.PointerExited += (_, _) => Resume();
Spots.LoadingRow += (_, e) => Paint(e.Row); Spots.LoadingRow += (_, e) => Paint(e.Row);
Spots.ContextMenu = BuildMenu(); 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.Tick += (_, _) => OnTick();
timer.Start(); timer.Start();
Closed += (_, _) => timer.Stop(); Closed += (_, _) => timer.Stop();
@@ -191,64 +195,137 @@ public sealed partial class AvailableWindow : RefreshableWindow
// ---- refresh // ---- 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() public override void Refresh()
{ {
refreshPending = false; refreshPending = false;
ticks = 0; ticks = 0;
Summary.Children.Clear(); if (running)
Summary.RowDefinitions.Clear(); {
Summary.ColumnDefinitions.Clear(); 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; Summary.IsVisible = session.Settings.AvailableShowsSummary;
bool multsOnly = session.Settings.AvailableMultsOnly; bool multsOnly = session.Settings.AvailableMultsOnly;
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs"; ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth; ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
if (session.Available is null) if (session.Available is not { } available)
{ {
StatusText.Text = "no contest is open"; StatusText.Text = "no contest is open";
Spots.ItemsSource = Array.Empty<AvailableRow>(); Spots.ItemsSource = Array.Empty<AvailableRow>();
shownKey = ""; shownKey = "";
summaryKey = "";
Summary.Children.Clear();
return; return;
} }
// everything the other threads read is taken here
DateTime now = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
session.Bandmap.DropOlderThan(now); Settings settings = session.Settings;
IReadOnlyList<AvailableStation> all = session.Available.All(); BandPlan plan = session.BandPlan;
if (Summary.IsVisible) 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"; if (computed.Summary is { } summary)
StatusText.Text = all.Count == 0 {
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" ? "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) if (IsPaused)
{ {
refreshPending = true; refreshPending = true;
return; return;
} }
Show(computed.Rows, computed.RowsKey);
}
IReadOnlyList<string> preferred = session.Settings.SpotFilter.PreferredSpotters; private Computed Compute(
Frequency? tuned = session.Position?.Frequency; 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 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( .Select(s => new AvailableRow(
s, s,
ModeName(s.Spot.Frequency), ModeName(plan, s.Spot.Frequency),
now, now,
session.Bandmap.Remaining(s.Spot), bandmap.Remaining(s.Spot),
preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase), preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase),
tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100)) tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100))
.ToList(); .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> bands = settings.AvailableBands;
IReadOnlyList<string> modes = session.Settings.AvailableModes; IReadOnlyList<string> modes = settings.AvailableModes;
ModeCategory? category = session.BandPlan.ModeAt(station.Spot.Frequency); ModeCategory? category = plan.ModeAt(station.Spot.Frequency);
return (clicked.Count == 0 || clicked.Contains(station.Band)) return (picked.Count == 0 || picked.Contains(station.Band))
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase)) && (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase))
&& (modes.Count == 0 || category is null || modes.Contains(ModeKey(category.Value), 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 /// A refresh that changes nothing leaves the list alone, so the scroll
/// position and the picked line stay where the operator left them. /// 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) if (key == shownKey && Spots.ItemsSource is not null)
{ {
return; return;
@@ -280,13 +356,13 @@ public sealed partial class AvailableWindow : RefreshableWindow
} }
string? picked = (Spots.SelectedItem as AvailableRow)?.Call; string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
Spots.ItemsSource = view; 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.Cw => "CW",
ModeCategory.Phone => "SSB", ModeCategory.Phone => "SSB",
@@ -309,22 +385,44 @@ public sealed partial class AvailableWindow : RefreshableWindow
// ---- summary table // ---- 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 IEnumerable<Band> basis = chosen.Count > 0
? chosen.Select(Bands.Named).OfType<Band>() ? chosen.Select(Bands.Named).OfType<Band>()
: Bands.Contest; : Bands.Contest;
return [.. basis.Concat(all.Select(s => s.Band)).Distinct().OrderBy(b => b.Low.Hertz)]; 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> 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)); Dictionary<Band, int> qs = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && !s.IsMultiplier));
int mostMults = mults.Values.Max(); return new SummaryData(columns, mults, qs, mults.Values.Max(), qs.Values.Max());
int mostQs = 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++) for (int row = 0; row < 4; row++)
{ {
@@ -344,7 +442,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
{ {
Band band = columns[at]; Band band = columns[at];
int column = at + 1; 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(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(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)); 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); DrawSegments(context);
DrawScale(context, ink); DrawScale(context, ink);
DrawVfos(context); DrawVfos(context);
placed = [.. BandmapLayout.Place(Spots, scale, LabelHeight) PlaceLabels();
.Select(l => l with { MarkY = l.MarkY + Inset, LabelY = l.LabelY + Inset })];
foreach (BandmapLabel label in placed) foreach (BandmapLabel label in placed)
{ {
DrawLabel(context, label, ink); 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 /// N1MM paints the CW, digital and phone parts of the band behind the
/// scale, the digital block over the other two. /// scale, the digital block over the other two.
/// Anything past a band edge is painted as out of band, which is the only /// 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) protected override void OnPointerMoved(PointerEventArgs e)
{ {
base.OnPointerMoved(e); base.OnPointerMoved(e);
Spot? under = LabelAt(e.GetPosition(this))?.Spot;
if (Equals(under, tipFor))
{
return;
}
tipFor = under;
ToolTip.SetShowDelay(this, 0); ToolTip.SetShowDelay(this, 0);
ToolTip.SetTip( ToolTip.SetTip(this, under is null ? null : SpotDescription.Of(under));
this,
LabelAt(e.GetPosition(this)) is { } label ? SpotDescription.Of(label.Spot) : null);
} }
/// 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) protected override void OnPointerPressed(PointerPressedEventArgs e)
{ {
base.OnPointerPressed(e); base.OnPointerPressed(e);
@@ -227,23 +256,56 @@ public sealed class BandmapView : Control
FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset)); FrequencyPicked?.Invoke(this, scale.At(at.Y - Inset));
} }
private FormattedText Text(string text, IBrush brush, double size, bool bold = false, bool italic = false) => /// Shaping text is the dearest part of a redraw, and the same few hundred
new( /// 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, text,
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
FlowDirection.LeftToRight, FlowDirection.LeftToRight,
bold ? boldTypeface : italic ? italicTypeface : typeface, bold ? boldTypeface : italic ? italicTypeface : typeface,
size, size,
brush); brush);
texts[(text, brush, size, bold, italic)] = made;
return made;
}
/// The form's text colour, which the theme sets in both light and dark. /// The form's text colour, which the theme sets in both light and dark.
private IBrush Foreground() => private IBrush Foreground() =>
this.FindResource("FormForeground") as IBrush ?? Brushes.Gray; this.FindResource("FormForeground") as IBrush ?? Brushes.Gray;
private static IBrush Opaque(IBrush ink, double opacity) => private readonly Dictionary<(IBrush Ink, double Opacity), IBrush> faded = [];
ink is ISolidColorBrush solid
? new SolidColorBrush(solid.Color, opacity) /// Kept, so the same brush comes back each time and the text drawn with it
: ink; /// 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 private static IBrush BrushFor(VfoRole role) => role switch
{ {
+63 -7
View File
@@ -5,6 +5,7 @@ using Avalonia.Threading;
using Nonemm.Contests; using Nonemm.Contests;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Rig; using Nonemm.Rig;
using Nonemm.Session;
using Nonemm.Spotting; using Nonemm.Spotting;
namespace Nonemm.App.Windows; namespace Nonemm.App.Windows;
@@ -76,7 +77,10 @@ public sealed partial class BandmapWindow : RefreshableWindow
} }
}; };
View.FrequencyPicked += (_, frequency) => tune(frequency, ""); 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); EventHandler solarChanged = (_, _) => Dispatcher.UIThread.Post(ShowSolar);
session.SolarChanged += solarChanged; session.SolarChanged += solarChanged;
Closed += (_, _) => session.SolarChanged -= solarChanged; Closed += (_, _) => session.SolarChanged -= solarChanged;
@@ -127,6 +131,56 @@ public sealed partial class BandmapWindow : RefreshableWindow
View.Vfos = Vfos(); View.Vfos = Vfos();
View.Segments = session.BandPlan.For(band); View.Segments = session.BandPlan.For(band);
View.InvalidateVisual(); 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 /// 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) => 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; 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 // N1MM marks a busy frequency as worked, so it is painted like one
return Verdict.Dupe; return Verdict.Dupe;
} }
return session.Position.Log.Judge(new Qso return position.Log.Judge(new Qso
{ {
Id = "", Id = "",
TimestampUtc = DateTime.UtcNow, TimestampUtc = DateTime.UtcNow,
Call = spot.Call, Call = spot.Call,
Frequency = spot.Frequency, Frequency = spot.Frequency,
Mode = session.Position.Mode, Mode = mode,
ContestName = session.Position.Contest.Name, ContestName = contest,
}); });
} }
} }
+38 -3
View File
@@ -29,14 +29,49 @@ public sealed partial class CheckWindow : RefreshableWindow
public override void RefreshForEntry() => Refresh(); 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() public override void Refresh()
{ {
Columns.Children.Clear(); if (running)
if (session.Check is null)
{ {
again = true;
return; 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(); Columns.ColumnDefinitions.Clear();
foreach (CheckColumn _ in columns) 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; this.radioNumber = radioNumber;
InitializeComponent(); InitializeComponent();
BuildFunctionKeys(); 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 // a keyer speaks on its own thread, and a recording that would not
// play has nowhere else to be said // play has nowhere else to be said
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what)); session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
@@ -949,7 +951,7 @@ public sealed partial class EntryWindow : Window
RefreshSelf(); RefreshSelf();
foreach (Window window in openWindows.Values) 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) 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) 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]; Point to = line[at];
// a line that runs off one edge and back on at the other is the // a line that runs off one edge and back on at the other is the
// date line being crossed, and is left undrawn // 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 /// 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 = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
clock.Tick += (_, _) => Refresh(); clock.Tick += (_, _) => Refresh();
clock.Start(); clock.Start();
session.Changed += WhenSessionChanged;
Closed += (_, _) => Closed += (_, _) =>
{ {
clock.Stop(); clock.Stop();
session.Changed -= WhenSessionChanged; later?.Cancel();
}; };
Refresh(); Refresh();
} }
private OperatingPosition? Radio => session.Position; 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() public override void Refresh()
{ {
Map.Moment = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
Map.Markers = Markers(); 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(); Map.InvalidateVisual();
} }
@@ -95,8 +112,5 @@ public sealed partial class GraylineWindow : RefreshableWindow
private CountryLookup? Where(string call) => private CountryLookup? Where(string call) =>
call.Trim().Length == 0 ? null : session.Countries?.Find(call.Trim()); 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); 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(); bool atBottom = ScrolledToBottom();
for (int at = 0; at < shownRows.Count; at++) 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; bool grown = qsos.Count > shownRows.Count;
for (int at = shownRows.Count; at < qsos.Count; at++) for (int at = shownRows.Count; at < qsos.Count; at++)
@@ -7,6 +7,11 @@ public abstract class RefreshableWindow : Window
{ {
public abstract void Refresh(); 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 /// Called instead of `Refresh` when the entry window's call or exchange
/// changes. A window that shows nothing about them does nothing. /// changes. A window that shows nothing about them does nothing.
public virtual void RefreshForEntry() 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() public override void Refresh()
{ {
Table.Children.Clear();
Table.ColumnDefinitions.Clear();
Table.RowDefinitions.Clear();
if (session.Logging is null) if (session.Logging is null)
{ {
shown = "";
Table.Children.Clear();
Table.ColumnDefinitions.Clear();
Table.RowDefinitions.Clear();
TotalText.Text = "no contest is open"; TotalText.Text = "no contest is open";
return; return;
} }
@@ -39,32 +44,28 @@ public sealed partial class ScoreWindow : RefreshableWindow
.ToList(); .ToList();
bool hasKinds = session.Logging.Contest.HasQtcTraffic; bool hasKinds = session.Logging.Contest.HasQtcTraffic;
columnCount = hasKinds ? 5 : 4; List<(string[] Cells, bool Header)> rows = [(["Band", "Q/QTC", "QSOs", "Points", "Mults"], true)];
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;
foreach ((ScoreKey key, ScoreTally line) in lines) foreach ((ScoreKey key, ScoreTally line) in lines)
{ {
AddRow( rows.Add((
row++, [
key.Band, key.Band,
key.Kind, key.Kind,
line.Qsos.ToString(), line.Qsos.ToString(),
line.Points.ToString(), line.Points.ToString(),
line.TotalMultipliers.ToString()); line.TotalMultipliers.ToString(),
],
false));
} }
AddRow( rows.Add((
row, [
"Total", "Total",
hasKinds ? "All" : "", hasKinds ? "All" : "",
session.Logging.Log.Tally.Qsos.ToString(), session.Logging.Log.Tally.Qsos.ToString(),
session.Logging.Log.Tally.Points.ToString(), session.Logging.Log.Tally.Points.ToString(),
session.Logging.Log.Tally.TotalMultipliers.ToString(), session.Logging.Log.Tally.TotalMultipliers.ToString(),
header: true); ],
true));
TimeSpan on = OperatingTime.On( TimeSpan on = OperatingTime.On(
session.Logging.Log.Qsos, session.Logging.Log.Qsos,
@@ -72,10 +73,31 @@ public sealed partial class ScoreWindow : RefreshableWindow
TimeSpan off = OperatingTime.Off( TimeSpan off = OperatingTime.Off(
session.Logging.Log.Qsos, session.Logging.Log.Qsos,
session.Logging.Contest.MinimumOffTime); session.Logging.Contest.MinimumOffTime);
TotalText.Text = string total =
$"Claimed score {session.Logging.Log.TotalScore:N0}" $"Claimed score {session.Logging.Log.TotalScore:N0}"
+ $" on {Hours(on)} off {Hours(off)}" + $" on {Hours(on)} off {Hours(off)}"
+ BandChanges(); + 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 /// 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) => private static string Hours(TimeSpan time) =>
$"{(int)time.TotalHours}:{time.Minutes:00}"; $"{(int)time.TotalHours}:{time.Minutes:00}";
private void AddRow( private void AddRow(int row, string[] all, bool header)
int row,
string band,
string kind,
string qsos,
string points,
string mults,
bool header = false)
{ {
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
string[] cells = columnCount == 5 string[] cells = columnCount == 5
? [band, kind, qsos, points, mults] ? all
: [band, qsos, points, mults]; : [all[0], all[2], all[3], all[4]];
for (int column = 0; column < cells.Length; column++) for (int column = 0; column < cells.Length; column++)
{ {
TextBlock text = new() TextBlock text = new()
@@ -8,7 +8,9 @@ using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Nonemm.Core;
using Nonemm.Session; using Nonemm.Session;
namespace Nonemm.App.Windows; namespace Nonemm.App.Windows;
@@ -55,55 +57,118 @@ public sealed partial class StatisticsWindow : RefreshableWindow
loading = false; loading = false;
ShowPaletteBox(); ShowPaletteBox();
DispatcherTimer every = new() { Interval = TimeSpan.FromSeconds(30) };
every.Tick += (_, _) => Refresh();
every.Start();
Closed += (_, _) => every.Stop();
Refresh(); 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 => private StatisticsField Row =>
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow); StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
private StatisticsField Column => private StatisticsField Column =>
StatisticsFields.Find(StatisticsFields.Columns, session.Settings.StatsColumn); 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() public override void Refresh()
{ {
if (session.Logging is null) if (running)
{ {
Table.Columns.Clear(); again = true;
Table.ItemsSource = Array.Empty<StatisticsRow>(); return;
Chart.Columns = []; }
Chart.Series = []; _ = RunAsync();
Chart.InvalidateVisual(); }
laid = null;
Empty("no contest is open"); 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; return;
} }
StatisticsPivot pivot = StatisticsPivot.Of( // the log is changed on this thread, so the counting works on a copy
session.Logging.Log.Qsos, IReadOnlyList<Qso> qsos = [.. logging.Log.Qsos];
Row, countShown = qsos.Count;
Column, StatisticsField row = Row;
session.Logging.Instance.StartDate, StatisticsField column = Column;
session.Settings.StatsIncludeDupes); DateTime start = logging.Instance.StartDate;
Title = $"Statistics for {session.Logging.Contest.Name}" bool dupes = session.Settings.StatsIncludeDupes;
+ $" - {session.Logging.Instance.StartDate:yyyy-MM-dd}" (StatisticsPivot pivot, StatisticsTable? table) = await Task.Run(() =>
+ $" - {Column.Name} by {Row.Name}";
if (pivot.IsEmpty)
{ {
Table.Columns.Clear(); StatisticsPivot counted = StatisticsPivot.Of(qsos, row, column, start, dupes);
Table.ItemsSource = Array.Empty<StatisticsRow>(); return (counted, counted.IsEmpty ? null : StatisticsTable.Of(counted));
Chart.Columns = []; });
Chart.Series = []; if (again)
Chart.InvalidateVisual(); {
laid = null; return;
Empty("no contacts are counted"); }
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; return;
} }
EmptyText.IsVisible = false; EmptyText.IsVisible = false;
Table.IsVisible = true; Table.IsVisible = true;
FillTable(pivot); FillTable(table);
FillChart(pivot); 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) private void Empty(string why)
{ {
EmptyText.Text = why; EmptyText.Text = why;
@@ -111,9 +176,9 @@ public sealed partial class StatisticsWindow : RefreshableWindow
Table.IsVisible = false; Table.IsVisible = false;
} }
private void FillTable(StatisticsPivot pivot) private void FillTable(StatisticsTable table)
{ {
laid = StatisticsTable.Of(pivot); laid = table;
Table.Columns.Clear(); Table.Columns.Clear();
for (int at = 0; at < laid.Headings.Count; at++) 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) => private void OnConnectionChanged(object? sender, bool connected) =>
Dispatcher.UIThread.Post(Refresh); 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. /// 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(() => 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.RemoveAt(0);
} }
Traffic.Items.Add(new TextBlock foreach ((string line, IBrush? colour, bool strong) in waiting.Skip(skip))
{ {
Text = line, Traffic.Items.Add(new TextBlock
Foreground = colour, {
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal, Text = line,
}); Foreground = colour,
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
});
}
waiting.Clear();
if (!scrollPending) if (!scrollPending)
{ {
scrollPending = true; scrollPending = true;
Dispatcher.UIThread.Post(ScrollToNewest, DispatcherPriority.Background); Dispatcher.UIThread.Post(ScrollToNewest, DispatcherPriority.Background);
} }
}); }
private void ScrollToNewest() 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(); RecordBox.LostFocus += (_, _) => SaveCommands();
PttBox.IsCheckedChanged += (_, _) => SaveCommands(); PttBox.IsCheckedChanged += (_, _) => SaveCommands();
OperatorBox.SelectionChanged += (_, _) => Refresh(); OperatorBox.SelectionChanged += (_, _) => Refresh();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
FillOperators(); FillOperators();
Refresh(); Refresh();
} }
+62 -21
View File
@@ -22,6 +22,12 @@ public sealed class ContestLog
private readonly Dictionary<ScoreKey, ScoreTally> rows = []; private readonly Dictionary<ScoreKey, ScoreTally> rows = [];
private ScoreTally tally = new(); 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( public ContestLog(
Contest contest, Contest contest,
StationInfo me, StationInfo me,
@@ -67,6 +73,14 @@ public sealed class ContestLog
/// What logging this contact right now would do, without logging it. /// What logging this contact right now would do, without logging it.
public Verdict Judge(Qso candidate) public Verdict Judge(Qso candidate)
{
lock (guard)
{
return JudgeLocked(candidate);
}
}
private Verdict JudgeLocked(Qso candidate)
{ {
if (!candidate.IsClaimed) if (!candidate.IsClaimed)
{ {
@@ -103,19 +117,25 @@ public sealed class ContestLog
/// Adds the contact and returns it with points and multiplier flags filled in. /// Adds the contact and returns it with points and multiplier flags filled in.
public Qso Add(Qso qso) public Qso Add(Qso qso)
{ {
Verdict verdict = Judge(qso); lock (guard)
Qso scored = ApplyVerdict(qso, verdict); {
qsos.Add(scored); Verdict verdict = Judge(qso);
Index(scored, verdict); Qso scored = ApplyVerdict(qso, verdict);
return scored; qsos.Add(scored);
Index(scored, verdict);
return scored;
}
} }
/// Loads contacts read back from storage. Points and multiplier flags in /// Loads contacts read back from storage. Points and multiplier flags in
/// the stored rows are ignored and worked out again from the rules. /// the stored rows are ignored and worked out again from the rules.
public void Restore(IEnumerable<Qso> stored) public void Restore(IEnumerable<Qso> stored)
{ {
qsos.AddRange(stored); lock (guard)
Rebuild(); {
qsos.AddRange(stored);
Rebuild();
}
} }
/// Adds a contact that may be older than ones already logged, which is what /// 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. /// again: the earlier of two contacts is the one that claims a multiplier.
public void Insert(Qso qso) public void Insert(Qso qso)
{ {
qsos.Add(qso); lock (guard)
Rebuild(); {
qsos.Add(qso);
Rebuild();
}
} }
/// `Insert` for several contacts, scoring the log once instead of once per /// `Insert` for several contacts, scoring the log once instead of once per
/// contact. A resync sends a few hundred in a burst. /// contact. A resync sends a few hundred in a burst.
public void InsertRange(IEnumerable<Qso> arrived) public void InsertRange(IEnumerable<Qso> arrived)
{ {
qsos.AddRange(arrived); lock (guard)
Rebuild(); {
qsos.AddRange(arrived);
Rebuild();
}
} }
public void Remove(string id) public void Remove(string id)
{ {
qsos.RemoveAll(q => q.Id == id); lock (guard)
Rebuild(); {
qsos.RemoveAll(q => q.Id == id);
Rebuild();
}
} }
/// `Remove` for several contacts, scoring the log once. /// `Remove` for several contacts, scoring the log once.
public void RemoveRange(IReadOnlyCollection<string> ids) public void RemoveRange(IReadOnlyCollection<string> ids)
{ {
HashSet<string> gone = [.. ids]; HashSet<string> gone = [.. ids];
qsos.RemoveAll(q => gone.Contains(q.Id)); lock (guard)
Rebuild(); {
qsos.RemoveAll(q => gone.Contains(q.Id));
Rebuild();
}
} }
public void Replace(Qso qso) public void Replace(Qso qso)
{ {
int at = qsos.FindIndex(q => q.Id == qso.Id); lock (guard)
if (at < 0)
{ {
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 /// 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 /// 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 /// are written from this, and a caller that is about to write them asks for
/// a pass first. /// 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 /// Rescores the whole log. A removed or edited contact can hand its
/// multiplier to a later one, which only a full pass gets right. /// multiplier to a later one, which only a full pass gets right.
+24 -8
View File
@@ -47,16 +47,32 @@ public sealed class AvailableStations
/// Every spotted station that is not a dupe, multipliers first and then in /// Every spotted station that is not a dupe, multipliers first and then in
/// frequency order. /// frequency order.
public IReadOnlyList<AvailableStation> All() public IReadOnlyList<AvailableStation> All() =>
Build(bandmap.All(), position.Mode, position.Contest, position.Me);
/// `All` on a thread-pool thread, for the window that would otherwise
/// judge every spot between two key presses. What the position holds is
/// read here, on the caller's thread, and the log locks itself while it
/// judges.
public Task<IReadOnlyList<AvailableStation>> AllAsync()
{
IReadOnlyList<Spot> spots = bandmap.All();
Mode mode = position.Mode;
Contest contest = position.Contest;
StationInfo me = position.Me;
return Task.Run(() => Build(spots, mode, contest, me));
}
private IReadOnlyList<AvailableStation> Build(IReadOnlyList<Spot> spots, Mode mode, Contest contest, StationInfo me)
{ {
List<AvailableStation> found = []; List<AvailableStation> found = [];
foreach (Spot spot in bandmap.All()) foreach (Spot spot in spots)
{ {
if (spot.Band is not { } band || !spot.IsStation) if (spot.Band is not { } band || !spot.IsStation)
{ {
continue; continue;
} }
Verdict verdict = position.Log.Judge(Candidate(spot)); Verdict verdict = position.Log.Judge(Candidate(spot, mode, contest, me));
if (!verdict.IsDupe) if (!verdict.IsDupe)
{ {
found.Add(new AvailableStation( found.Add(new AvailableStation(
@@ -73,7 +89,7 @@ public sealed class AvailableStations
.ToList(); .ToList();
} }
private Qso Candidate(Spot spot) private Qso Candidate(Spot spot, Mode mode, Contest contest, StationInfo me)
{ {
Qso qso = new() Qso qso = new()
{ {
@@ -81,13 +97,13 @@ public sealed class AvailableStations
TimestampUtc = DateTime.UtcNow, TimestampUtc = DateTime.UtcNow,
Call = spot.Call, Call = spot.Call,
Frequency = spot.Frequency, Frequency = spot.Frequency,
Mode = position.Mode, Mode = mode,
ContestName = position.Contest.Name, ContestName = contest.Name,
}; };
return ExchangeSlots.ApplyHistory( return ExchangeSlots.ApplyHistory(
CountryFields.Apply(qso, position.Session.Countries), CountryFields.Apply(qso, position.Session.Countries),
position.Session.History, position.Session.History,
position.Contest, contest,
position.Me); me);
} }
} }
+51 -6
View File
@@ -46,6 +46,44 @@ public sealed class CheckWindowSources
]; ];
} }
/// `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 /// 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 /// sections, or the states and provinces. A serial number or a report has
/// no list behind it, so the column stays empty. /// no list behind it, so the column stays empty.
@@ -81,19 +119,26 @@ public sealed class CheckWindowSources
{ {
return new CheckColumn(source, calls.Count, []); return new CheckColumn(source, calls.Count, []);
} }
List<CheckCandidate> found = []; 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) foreach (string call in calls)
{ {
PartialMatchQuality? quality = PartialMatching.Judge(query, call); PartialMatchQuality? quality = PartialMatching.Judge(query, call);
if (quality is not null) if (quality is not null)
{ {
found.Add(Judged(new PartialMatch(call, quality.Value))); found.Add(new PartialMatch(call, quality.Value));
} }
} }
return new CheckColumn( return [.. found
source, .OrderBy(m => m.Quality)
calls.Count, .ThenBy(m => m.Call, StringComparer.Ordinal)
found.OrderBy(c => c.Quality).ThenBy(c => c.Text, StringComparer.Ordinal).Take(limit).ToList()); .Take(limit)];
} }
private CheckCandidate Judged(PartialMatch match) => private CheckCandidate Judged(PartialMatch match) =>