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:
+1
-1
@@ -30,7 +30,7 @@ if [ -f "$archive" ]; then
|
||||
fi
|
||||
|
||||
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
|
||||
rm -f "$out"/*.pdb
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
||||
private AvailableRow? menuRow;
|
||||
private bool menuOnHeader;
|
||||
private string shownKey = "";
|
||||
private string summaryKey = "";
|
||||
private DateTime pausedUntil = DateTime.MinValue;
|
||||
private bool refreshPending;
|
||||
private int ticks;
|
||||
@@ -70,7 +71,10 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
||||
Spots.PointerExited += (_, _) => Resume();
|
||||
Spots.LoadingRow += (_, e) => Paint(e.Row);
|
||||
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.Start();
|
||||
Closed += (_, _) => timer.Stop();
|
||||
@@ -191,64 +195,137 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
||||
|
||||
// ---- 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()
|
||||
{
|
||||
refreshPending = false;
|
||||
ticks = 0;
|
||||
Summary.Children.Clear();
|
||||
Summary.RowDefinitions.Clear();
|
||||
Summary.ColumnDefinitions.Clear();
|
||||
if (running)
|
||||
{
|
||||
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;
|
||||
bool multsOnly = session.Settings.AvailableMultsOnly;
|
||||
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
|
||||
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
|
||||
if (session.Available is null)
|
||||
if (session.Available is not { } available)
|
||||
{
|
||||
StatusText.Text = "no contest is open";
|
||||
Spots.ItemsSource = Array.Empty<AvailableRow>();
|
||||
shownKey = "";
|
||||
summaryKey = "";
|
||||
Summary.Children.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// everything the other threads read is taken here
|
||||
DateTime now = DateTime.UtcNow;
|
||||
session.Bandmap.DropOlderThan(now);
|
||||
IReadOnlyList<AvailableStation> all = session.Available.All();
|
||||
if (Summary.IsVisible)
|
||||
Settings settings = session.Settings;
|
||||
BandPlan plan = session.BandPlan;
|
||||
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";
|
||||
StatusText.Text = all.Count == 0
|
||||
|
||||
if (computed.Summary is { } summary)
|
||||
{
|
||||
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"
|
||||
: $"{mults} mults · {all.Count - mults} Qs · {all.Count} total";
|
||||
: $"{computed.Mults} mults · {computed.Total - computed.Mults} Qs · {computed.Total} total";
|
||||
if (IsPaused)
|
||||
{
|
||||
refreshPending = true;
|
||||
return;
|
||||
}
|
||||
Show(computed.Rows, computed.RowsKey);
|
||||
}
|
||||
|
||||
IReadOnlyList<string> preferred = session.Settings.SpotFilter.PreferredSpotters;
|
||||
Frequency? tuned = session.Position?.Frequency;
|
||||
private Computed Compute(
|
||||
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
|
||||
.Where(s => (!multsOnly || s.IsMultiplier) && IsShown(s))
|
||||
.Where(s => (!settings.AvailableMultsOnly || s.IsMultiplier) && IsShown(s, settings, plan, picked))
|
||||
.Select(s => new AvailableRow(
|
||||
s,
|
||||
ModeName(s.Spot.Frequency),
|
||||
ModeName(plan, s.Spot.Frequency),
|
||||
now,
|
||||
session.Bandmap.Remaining(s.Spot),
|
||||
bandmap.Remaining(s.Spot),
|
||||
preferred.Contains(s.Spot.Spotter, StringComparer.OrdinalIgnoreCase),
|
||||
tuned is { } at && Math.Abs(at.Hertz - s.Spot.Frequency.Hertz) <= 100))
|
||||
.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> modes = session.Settings.AvailableModes;
|
||||
ModeCategory? category = session.BandPlan.ModeAt(station.Spot.Frequency);
|
||||
return (clicked.Count == 0 || clicked.Contains(station.Band))
|
||||
IReadOnlyList<string> bands = settings.AvailableBands;
|
||||
IReadOnlyList<string> modes = settings.AvailableModes;
|
||||
ModeCategory? category = plan.ModeAt(station.Spot.Frequency);
|
||||
return (picked.Count == 0 || picked.Contains(station.Band))
|
||||
&& (bands.Count == 0 || bands.Contains(station.Band.Name, 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
|
||||
/// 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)
|
||||
{
|
||||
return;
|
||||
@@ -280,13 +356,13 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
||||
}
|
||||
string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
|
||||
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.Phone => "SSB",
|
||||
@@ -309,22 +385,44 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
||||
|
||||
// ---- 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
|
||||
? chosen.Select(Bands.Named).OfType<Band>()
|
||||
: Bands.Contest;
|
||||
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> qs = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && !s.IsMultiplier));
|
||||
int mostMults = mults.Values.Max();
|
||||
int mostQs = qs.Values.Max();
|
||||
return new SummaryData(columns, mults, qs, mults.Values.Max(), 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++)
|
||||
{
|
||||
@@ -344,7 +442,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
||||
{
|
||||
Band band = columns[at];
|
||||
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(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));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Threading;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Rig;
|
||||
using Nonemm.Session;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
@@ -76,7 +77,10 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
}
|
||||
};
|
||||
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);
|
||||
session.SolarChanged += solarChanged;
|
||||
Closed += (_, _) => session.SolarChanged -= solarChanged;
|
||||
@@ -127,6 +131,56 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
View.Vfos = Vfos();
|
||||
View.Segments = session.BandPlan.For(band);
|
||||
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
|
||||
@@ -206,11 +260,13 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -219,14 +275,14 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
// N1MM marks a busy frequency as worked, so it is painted like one
|
||||
return Verdict.Dupe;
|
||||
}
|
||||
return session.Position.Log.Judge(new Qso
|
||||
return position.Log.Judge(new Qso
|
||||
{
|
||||
Id = "",
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
Call = spot.Call,
|
||||
Frequency = spot.Frequency,
|
||||
Mode = session.Position.Mode,
|
||||
ContestName = session.Position.Contest.Name,
|
||||
Mode = mode,
|
||||
ContestName = contest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,49 @@ public sealed partial class CheckWindow : RefreshableWindow
|
||||
|
||||
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()
|
||||
{
|
||||
Columns.Children.Clear();
|
||||
if (session.Check is null)
|
||||
if (running)
|
||||
{
|
||||
again = true;
|
||||
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();
|
||||
foreach (CheckColumn _ in columns)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,9 @@ public sealed partial class EntryWindow : Window
|
||||
this.radioNumber = radioNumber;
|
||||
InitializeComponent();
|
||||
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
|
||||
// play has nowhere else to be said
|
||||
session.Reported += (_, what) => Dispatcher.UIThread.Post(() => Status(what));
|
||||
@@ -949,7 +951,7 @@ public sealed partial class EntryWindow : Window
|
||||
RefreshSelf();
|
||||
foreach (Window window in openWindows.Values)
|
||||
{
|
||||
(window as RefreshableWindow)?.Refresh();
|
||||
(window as RefreshableWindow)?.RefreshFromSession();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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];
|
||||
// a line that runs off one edge and back on at the other is the
|
||||
// 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
|
||||
|
||||
@@ -25,23 +25,40 @@ public sealed partial class GraylineWindow : RefreshableWindow
|
||||
clock = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
|
||||
clock.Tick += (_, _) => Refresh();
|
||||
clock.Start();
|
||||
session.Changed += WhenSessionChanged;
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
clock.Stop();
|
||||
session.Changed -= WhenSessionChanged;
|
||||
later?.Cancel();
|
||||
};
|
||||
Refresh();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
Map.Moment = DateTime.UtcNow;
|
||||
Map.Markers = Markers();
|
||||
DateTime now = DateTime.UtcNow;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -95,8 +112,5 @@ public sealed partial class GraylineWindow : RefreshableWindow
|
||||
private CountryLookup? Where(string call) =>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -223,7 +223,12 @@ public sealed partial class LogWindow : RefreshableWindow
|
||||
bool atBottom = ScrolledToBottom();
|
||||
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;
|
||||
for (int at = shownRows.Count; at < qsos.Count; at++)
|
||||
|
||||
@@ -7,6 +7,11 @@ public abstract class RefreshableWindow : Window
|
||||
{
|
||||
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
|
||||
/// changes. A window that shows nothing about them does nothing.
|
||||
public virtual void RefreshForEntry()
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
Table.Children.Clear();
|
||||
Table.ColumnDefinitions.Clear();
|
||||
Table.RowDefinitions.Clear();
|
||||
if (session.Logging is null)
|
||||
{
|
||||
shown = "";
|
||||
Table.Children.Clear();
|
||||
Table.ColumnDefinitions.Clear();
|
||||
Table.RowDefinitions.Clear();
|
||||
TotalText.Text = "no contest is open";
|
||||
return;
|
||||
}
|
||||
@@ -39,32 +44,28 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
||||
.ToList();
|
||||
bool hasKinds = session.Logging.Contest.HasQtcTraffic;
|
||||
|
||||
columnCount = hasKinds ? 5 : 4;
|
||||
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;
|
||||
List<(string[] Cells, bool Header)> rows = [(["Band", "Q/QTC", "QSOs", "Points", "Mults"], true)];
|
||||
foreach ((ScoreKey key, ScoreTally line) in lines)
|
||||
{
|
||||
AddRow(
|
||||
row++,
|
||||
key.Band,
|
||||
key.Kind,
|
||||
line.Qsos.ToString(),
|
||||
line.Points.ToString(),
|
||||
line.TotalMultipliers.ToString());
|
||||
rows.Add((
|
||||
[
|
||||
key.Band,
|
||||
key.Kind,
|
||||
line.Qsos.ToString(),
|
||||
line.Points.ToString(),
|
||||
line.TotalMultipliers.ToString(),
|
||||
],
|
||||
false));
|
||||
}
|
||||
AddRow(
|
||||
row,
|
||||
"Total",
|
||||
hasKinds ? "All" : "",
|
||||
session.Logging.Log.Tally.Qsos.ToString(),
|
||||
session.Logging.Log.Tally.Points.ToString(),
|
||||
session.Logging.Log.Tally.TotalMultipliers.ToString(),
|
||||
header: true);
|
||||
rows.Add((
|
||||
[
|
||||
"Total",
|
||||
hasKinds ? "All" : "",
|
||||
session.Logging.Log.Tally.Qsos.ToString(),
|
||||
session.Logging.Log.Tally.Points.ToString(),
|
||||
session.Logging.Log.Tally.TotalMultipliers.ToString(),
|
||||
],
|
||||
true));
|
||||
|
||||
TimeSpan on = OperatingTime.On(
|
||||
session.Logging.Log.Qsos,
|
||||
@@ -72,10 +73,31 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
||||
TimeSpan off = OperatingTime.Off(
|
||||
session.Logging.Log.Qsos,
|
||||
session.Logging.Contest.MinimumOffTime);
|
||||
TotalText.Text =
|
||||
string total =
|
||||
$"Claimed score {session.Logging.Log.TotalScore:N0}"
|
||||
+ $" on {Hours(on)} off {Hours(off)}"
|
||||
+ 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
|
||||
@@ -92,19 +114,12 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
||||
private static string Hours(TimeSpan time) =>
|
||||
$"{(int)time.TotalHours}:{time.Minutes:00}";
|
||||
|
||||
private void AddRow(
|
||||
int row,
|
||||
string band,
|
||||
string kind,
|
||||
string qsos,
|
||||
string points,
|
||||
string mults,
|
||||
bool header = false)
|
||||
private void AddRow(int row, string[] all, bool header)
|
||||
{
|
||||
Table.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
string[] cells = columnCount == 5
|
||||
? [band, kind, qsos, points, mults]
|
||||
: [band, qsos, points, mults];
|
||||
? all
|
||||
: [all[0], all[2], all[3], all[4]];
|
||||
for (int column = 0; column < cells.Length; column++)
|
||||
{
|
||||
TextBlock text = new()
|
||||
|
||||
@@ -8,7 +8,9 @@ using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
@@ -55,55 +57,118 @@ public sealed partial class StatisticsWindow : RefreshableWindow
|
||||
loading = false;
|
||||
|
||||
ShowPaletteBox();
|
||||
DispatcherTimer every = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||
every.Tick += (_, _) => Refresh();
|
||||
every.Start();
|
||||
Closed += (_, _) => every.Stop();
|
||||
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 =>
|
||||
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
|
||||
|
||||
private StatisticsField Column =>
|
||||
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()
|
||||
{
|
||||
if (session.Logging is null)
|
||||
if (running)
|
||||
{
|
||||
Table.Columns.Clear();
|
||||
Table.ItemsSource = Array.Empty<StatisticsRow>();
|
||||
Chart.Columns = [];
|
||||
Chart.Series = [];
|
||||
Chart.InvalidateVisual();
|
||||
laid = null;
|
||||
Empty("no contest is open");
|
||||
again = true;
|
||||
return;
|
||||
}
|
||||
_ = RunAsync();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
StatisticsPivot pivot = StatisticsPivot.Of(
|
||||
session.Logging.Log.Qsos,
|
||||
Row,
|
||||
Column,
|
||||
session.Logging.Instance.StartDate,
|
||||
session.Settings.StatsIncludeDupes);
|
||||
Title = $"Statistics for {session.Logging.Contest.Name}"
|
||||
+ $" - {session.Logging.Instance.StartDate:yyyy-MM-dd}"
|
||||
+ $" - {Column.Name} by {Row.Name}";
|
||||
if (pivot.IsEmpty)
|
||||
// the log is changed on this thread, so the counting works on a copy
|
||||
IReadOnlyList<Qso> qsos = [.. logging.Log.Qsos];
|
||||
countShown = qsos.Count;
|
||||
StatisticsField row = Row;
|
||||
StatisticsField column = Column;
|
||||
DateTime start = logging.Instance.StartDate;
|
||||
bool dupes = session.Settings.StatsIncludeDupes;
|
||||
(StatisticsPivot pivot, StatisticsTable? table) = await Task.Run(() =>
|
||||
{
|
||||
Table.Columns.Clear();
|
||||
Table.ItemsSource = Array.Empty<StatisticsRow>();
|
||||
Chart.Columns = [];
|
||||
Chart.Series = [];
|
||||
Chart.InvalidateVisual();
|
||||
laid = null;
|
||||
Empty("no contacts are counted");
|
||||
StatisticsPivot counted = StatisticsPivot.Of(qsos, row, column, start, dupes);
|
||||
return (counted, counted.IsEmpty ? null : StatisticsTable.Of(counted));
|
||||
});
|
||||
if (again)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
EmptyText.IsVisible = false;
|
||||
Table.IsVisible = true;
|
||||
FillTable(pivot);
|
||||
FillTable(table);
|
||||
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)
|
||||
{
|
||||
EmptyText.Text = why;
|
||||
@@ -111,9 +176,9 @@ public sealed partial class StatisticsWindow : RefreshableWindow
|
||||
Table.IsVisible = false;
|
||||
}
|
||||
|
||||
private void FillTable(StatisticsPivot pivot)
|
||||
private void FillTable(StatisticsTable table)
|
||||
{
|
||||
laid = StatisticsTable.Of(pivot);
|
||||
laid = table;
|
||||
Table.Columns.Clear();
|
||||
for (int at = 0; at < laid.Headings.Count; at++)
|
||||
{
|
||||
|
||||
@@ -143,26 +143,45 @@ public sealed partial class TelnetWindow : RefreshableWindow
|
||||
private void OnConnectionChanged(object? sender, bool connected) =>
|
||||
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.
|
||||
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.Add(new TextBlock
|
||||
foreach ((string line, IBrush? colour, bool strong) in waiting.Skip(skip))
|
||||
{
|
||||
Text = line,
|
||||
Foreground = colour,
|
||||
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
|
||||
});
|
||||
Traffic.Items.Add(new TextBlock
|
||||
{
|
||||
Text = line,
|
||||
Foreground = colour,
|
||||
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
|
||||
});
|
||||
}
|
||||
waiting.Clear();
|
||||
if (!scrollPending)
|
||||
{
|
||||
scrollPending = true;
|
||||
Dispatcher.UIThread.Post(ScrollToNewest, DispatcherPriority.Background);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ScrollToNewest()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
PttBox.IsCheckedChanged += (_, _) => SaveCommands();
|
||||
OperatorBox.SelectionChanged += (_, _) => Refresh();
|
||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||
FillOperators();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -47,16 +47,32 @@ public sealed class AvailableStations
|
||||
|
||||
/// Every spotted station that is not a dupe, multipliers first and then in
|
||||
/// 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 = [];
|
||||
foreach (Spot spot in bandmap.All())
|
||||
foreach (Spot spot in spots)
|
||||
{
|
||||
if (spot.Band is not { } band || !spot.IsStation)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Verdict verdict = position.Log.Judge(Candidate(spot));
|
||||
Verdict verdict = position.Log.Judge(Candidate(spot, mode, contest, me));
|
||||
if (!verdict.IsDupe)
|
||||
{
|
||||
found.Add(new AvailableStation(
|
||||
@@ -73,7 +89,7 @@ public sealed class AvailableStations
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private Qso Candidate(Spot spot)
|
||||
private Qso Candidate(Spot spot, Mode mode, Contest contest, StationInfo me)
|
||||
{
|
||||
Qso qso = new()
|
||||
{
|
||||
@@ -81,13 +97,13 @@ public sealed class AvailableStations
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
Call = spot.Call,
|
||||
Frequency = spot.Frequency,
|
||||
Mode = position.Mode,
|
||||
ContestName = position.Contest.Name,
|
||||
Mode = mode,
|
||||
ContestName = contest.Name,
|
||||
};
|
||||
return ExchangeSlots.ApplyHistory(
|
||||
CountryFields.Apply(qso, position.Session.Countries),
|
||||
position.Session.History,
|
||||
position.Contest,
|
||||
position.Me);
|
||||
contest,
|
||||
me);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// sections, or the states and provinces. A serial number or a report has
|
||||
/// no list behind it, so the column stays empty.
|
||||
@@ -81,19 +119,26 @@ public sealed class CheckWindowSources
|
||||
{
|
||||
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)
|
||||
{
|
||||
PartialMatchQuality? quality = PartialMatching.Judge(query, call);
|
||||
if (quality is not null)
|
||||
{
|
||||
found.Add(Judged(new PartialMatch(call, quality.Value)));
|
||||
found.Add(new PartialMatch(call, quality.Value));
|
||||
}
|
||||
}
|
||||
return new CheckColumn(
|
||||
source,
|
||||
calls.Count,
|
||||
found.OrderBy(c => c.Quality).ThenBy(c => c.Text, StringComparer.Ordinal).Take(limit).ToList());
|
||||
return [.. found
|
||||
.OrderBy(m => m.Quality)
|
||||
.ThenBy(m => m.Call, StringComparer.Ordinal)
|
||||
.Take(limit)];
|
||||
}
|
||||
|
||||
private CheckCandidate Judged(PartialMatch match) =>
|
||||
|
||||
Reference in New Issue
Block a user