diff --git a/publish.sh b/publish.sh index e581d08..daa2523 100755 --- a/publish.sh +++ b/publish.sh @@ -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 diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs index 16ad1d7..bd8f374 100644 --- a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs @@ -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 Columns, + Dictionary Mults, + Dictionary Qs, + int MostMults, + int MostQs); + + private sealed record Computed(int Mults, int Total, SummaryData? Summary, List 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(); shownKey = ""; + summaryKey = ""; + Summary.Children.Clear(); return; } + // everything the other threads read is taken here DateTime now = DateTime.UtcNow; - session.Bandmap.DropOlderThan(now); - IReadOnlyList all = session.Available.All(); - if (Summary.IsVisible) + Settings settings = session.Settings; + BandPlan plan = session.BandPlan; + Bandmap bandmap = session.Bandmap; + HashSet picked = [.. clicked]; + Frequency? tuned = session.Position?.Frequency; + bool summaryShown = Summary.IsVisible; + bandmap.DropOlderThan(now); + IReadOnlyList 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 preferred = session.Settings.SpotFilter.PreferredSpotters; - Frequency? tuned = session.Position?.Frequency; + private Computed Compute( + IReadOnlyList all, + DateTime now, + Settings settings, + BandPlan plan, + Bandmap bandmap, + HashSet picked, + Frequency? tuned, + bool summaryShown) + { + int mults = all.Count(s => s.IsMultiplier); + IReadOnlyList preferred = settings.SpotFilter.PreferredSpotters; List 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 picked) { - IReadOnlyList bands = session.Settings.AvailableBands; - IReadOnlyList modes = session.Settings.AvailableModes; - ModeCategory? category = session.BandPlan.ModeAt(station.Spot.Frequency); - return (clicked.Count == 0 || clicked.Contains(station.Band)) + IReadOnlyList bands = settings.AvailableBands; + IReadOnlyList 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 rows) + private void Show(List 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 SummaryBands(IReadOnlyList all) + private IReadOnlyList SummaryBands(IReadOnlyList all) => + SummaryBands(all, session.Settings); + + private static IReadOnlyList SummaryBands(IReadOnlyList all, Settings settings) { - IReadOnlyList chosen = session.Settings.AvailableSummaryBands; + IReadOnlyList chosen = settings.AvailableSummaryBands; IEnumerable basis = chosen.Count > 0 ? chosen.Select(Bands.Named).OfType() : Bands.Contest; return [.. basis.Concat(all.Select(s => s.Band)).Distinct().OrderBy(b => b.Low.Hertz)]; } - private void BuildSummary(IReadOnlyList all) + private static SummaryData SummaryOf(IReadOnlyList all, Settings settings) { - IReadOnlyList columns = SummaryBands(all); + IReadOnlyList columns = SummaryBands(all, settings); Dictionary mults = columns.ToDictionary(b => b, b => all.Count(s => s.Band == b && s.IsMultiplier)); Dictionary 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 picked) + { + (IReadOnlyList columns, Dictionary mults, Dictionary 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)); diff --git a/src/Nonemm.App/Windows/BandmapView.cs b/src/Nonemm.App/Windows/BandmapView.cs index 74cffc9..b3f305f 100644 --- a/src/Nonemm.App/Windows/BandmapView.cs +++ b/src/Nonemm.App/Windows/BandmapView.cs @@ -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 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 { diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs index 0123748..94bdbb8 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs @@ -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 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 asked = View.Spots; + Dictionary judged = await Task.Run(() => + { + Dictionary 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, }); } } diff --git a/src/Nonemm.App/Windows/CheckWindow.axaml.cs b/src/Nonemm.App/Windows/CheckWindow.axaml.cs index de8a092..47685d4 100644 --- a/src/Nonemm.App/Windows/CheckWindow.axaml.cs +++ b/src/Nonemm.App/Windows/CheckWindow.axaml.cs @@ -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 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 columns = await session.Check.ColumnsAsync(); + Columns.Children.Clear(); Columns.ColumnDefinitions.Clear(); foreach (CheckColumn _ in columns) { diff --git a/src/Nonemm.App/Windows/Coalesced.cs b/src/Nonemm.App/Windows/Coalesced.cs new file mode 100644 index 0000000..9dff8a7 --- /dev/null +++ b/src/Nonemm.App/Windows/Coalesced.cs @@ -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); + } +} diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index d07feee..54729a5 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -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(); } } diff --git a/src/Nonemm.App/Windows/GraylineView.cs b/src/Nonemm.App/Windows/GraylineView.cs index 90cc74e..c571e87 100644 --- a/src/Nonemm.App/Windows/GraylineView.cs +++ b/src/Nonemm.App/Windows/GraylineView.cs @@ -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 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 diff --git a/src/Nonemm.App/Windows/GraylineWindow.axaml.cs b/src/Nonemm.App/Windows/GraylineWindow.axaml.cs index 0d3259f..b384bb5 100644 --- a/src/Nonemm.App/Windows/GraylineWindow.axaml.cs +++ b/src/Nonemm.App/Windows/GraylineWindow.axaml.cs @@ -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 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); } diff --git a/src/Nonemm.App/Windows/LogWindow.axaml.cs b/src/Nonemm.App/Windows/LogWindow.axaml.cs index a5e3aca..62e9774 100644 --- a/src/Nonemm.App/Windows/LogWindow.axaml.cs +++ b/src/Nonemm.App/Windows/LogWindow.axaml.cs @@ -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++) diff --git a/src/Nonemm.App/Windows/RefreshableWindow.cs b/src/Nonemm.App/Windows/RefreshableWindow.cs index 07df951..e3dadc1 100644 --- a/src/Nonemm.App/Windows/RefreshableWindow.cs +++ b/src/Nonemm.App/Windows/RefreshableWindow.cs @@ -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() diff --git a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs index 0e0bd71..70bed7c 100644 --- a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs +++ b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs @@ -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() diff --git a/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs b/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs index b38095a..4fe23fb 100644 --- a/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs +++ b/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs @@ -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(); - 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 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(); - 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(); + 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++) { diff --git a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs index a234b30..3c1196c 100644 --- a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs +++ b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs @@ -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() { diff --git a/src/Nonemm.App/Windows/Throttled.cs b/src/Nonemm.App/Windows/Throttled.cs new file mode 100644 index 0000000..df561c0 --- /dev/null +++ b/src/Nonemm.App/Windows/Throttled.cs @@ -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(); +} diff --git a/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs index 55df1bd..cc6419d 100644 --- a/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs +++ b/src/Nonemm.App/Windows/VoiceKeyerWindow.axaml.cs @@ -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(); } diff --git a/src/Nonemm.Contests/ContestLog.cs b/src/Nonemm.Contests/ContestLog.cs index 5b890f7..741f2c2 100644 --- a/src/Nonemm.Contests/ContestLog.cs +++ b/src/Nonemm.Contests/ContestLog.cs @@ -22,6 +22,12 @@ public sealed class ContestLog private readonly Dictionary 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 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 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 ids) { HashSet 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. diff --git a/src/Nonemm.Session/AvailableStations.cs b/src/Nonemm.Session/AvailableStations.cs index 6e84cbb..a5faa48 100644 --- a/src/Nonemm.Session/AvailableStations.cs +++ b/src/Nonemm.Session/AvailableStations.cs @@ -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 All() + public IReadOnlyList 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> AllAsync() + { + IReadOnlyList 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 Build(IReadOnlyList spots, Mode mode, Contest contest, StationInfo me) { List 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); } } diff --git a/src/Nonemm.Session/CheckWindowSources.cs b/src/Nonemm.Session/CheckWindowSources.cs index b9d0ec1..306c586 100644 --- a/src/Nonemm.Session/CheckWindowSources.cs +++ b/src/Nonemm.Session/CheckWindowSources.cs @@ -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> ColumnsAsync(int limit = 24) + { + string query = (Elsewhere ?? session.Entry.Call).Trim().ToUpperInvariant(); + IReadOnlyList logged = LoggedCalls(); + IReadOnlyList history = HistoryCalls(); + IReadOnlyList spotted = SpottedCalls(); + CheckColumn exchange = ExchangeColumn(limit); + bool searching = query.Length >= PartialMatching.ShortestUsefulQuery; + List none = []; + (List Log, List Master, List History, List 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 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 Best(IReadOnlyList calls, string query, int limit) + { + List 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) =>