diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs index 6d28d51..d047eca 100644 --- a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs @@ -84,7 +84,7 @@ public sealed partial class AvailableWindow : RefreshableWindow Spots.ContextMenu = BuildMenu(); // replacing the grid's rows is the dearest thing this window does, and // spots arrive and the radio moves many times a second - limited = new RateLimited(TimeSpan.FromSeconds(2), Refresh); + limited = new RateLimited(TimeSpan.FromSeconds(2), Refresh, holdWhileTyping: true); EventHandler spotsChanged = (_, _) => limited.Request(); session.Bandmap.Changed += spotsChanged; Closed += (_, _) => @@ -131,7 +131,7 @@ public sealed partial class AvailableWindow : RefreshableWindow private void OnTick() { ShowNotice(); - if (IsPaused) + if (IsPaused || OperatorActivity.IsTyping) { return; } diff --git a/src/Nonemm.App/Windows/BandmapView.cs b/src/Nonemm.App/Windows/BandmapView.cs index 8d00812..ca5b113 100644 --- a/src/Nonemm.App/Windows/BandmapView.cs +++ b/src/Nonemm.App/Windows/BandmapView.cs @@ -102,6 +102,12 @@ public sealed class BandmapView : Control PlaceLabels(); foreach (BandmapLabel label in placed) { + // the labels are in order down the scale, so the rest are below + // the edge as well + if (label.LabelY > Bounds.Height) + { + break; + } DrawLabel(context, label, ink); } } diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml.cs b/src/Nonemm.App/Windows/EntryWindow.axaml.cs index 54729a5..2ad3faf 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml.cs +++ b/src/Nonemm.App/Windows/EntryWindow.axaml.cs @@ -433,6 +433,7 @@ public sealed partial class EntryWindow : Window private void OnWindowKeyDown(object? sender, KeyEventArgs e) { + OperatorActivity.KeyPressed(); if (Logging is null) { return; diff --git a/src/Nonemm.App/Windows/GraylineWindow.axaml.cs b/src/Nonemm.App/Windows/GraylineWindow.axaml.cs index b384bb5..976bd82 100644 --- a/src/Nonemm.App/Windows/GraylineWindow.axaml.cs +++ b/src/Nonemm.App/Windows/GraylineWindow.axaml.cs @@ -43,7 +43,7 @@ public sealed partial class GraylineWindow : RefreshableWindow public override void RefreshFromSession() { - later ??= new Throttled(TimeSpan.FromSeconds(15), Refresh); + later ??= new Throttled(TimeSpan.FromSeconds(15), Refresh, holdWhileTyping: true); later.Request(); } diff --git a/src/Nonemm.App/Windows/OperatorActivity.cs b/src/Nonemm.App/Windows/OperatorActivity.cs new file mode 100644 index 0000000..bad98cd --- /dev/null +++ b/src/Nonemm.App/Windows/OperatorActivity.cs @@ -0,0 +1,29 @@ +using System.Diagnostics; + +namespace Nonemm.App.Windows; + +/// When the operator last pressed a key. Windows that redraw a whole list +/// take a long time to do it, and a key pressed meanwhile waits for them, so +/// they hold their redraw until the typing stops. +public static class OperatorActivity +{ + /// How long without a key press counts as stopped typing. A call and its + /// exchange go in with a gap shorter than this. + private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(800); + + private static long lastKey = long.MinValue; + + /// How long a held-back redraw waits before it looks again. + public static readonly TimeSpan Recheck = TimeSpan.FromMilliseconds(300); + + public static void KeyPressed() => Volatile.Write(ref lastKey, Stopwatch.GetTimestamp()); + + public static bool IsTyping + { + get + { + long at = Volatile.Read(ref lastKey); + return at != long.MinValue && Stopwatch.GetElapsedTime(at) < Quiet; + } + } +} diff --git a/src/Nonemm.App/Windows/RateLimited.cs b/src/Nonemm.App/Windows/RateLimited.cs index cd49a27..c142c92 100644 --- a/src/Nonemm.App/Windows/RateLimited.cs +++ b/src/Nonemm.App/Windows/RateLimited.cs @@ -6,20 +6,23 @@ namespace Nonemm.App.Windows; /// Runs an action on the UI thread at most once in `every`. The first request /// after a quiet spell runs at once; the ones that follow within `every` are /// answered by one run when it is up. It is for windows that redraw a whole -/// list, where following every change costs more than it shows. +/// list, where following every change costs more than it shows. With +/// `holdWhileTyping` the run also waits until the operator has stopped typing. public sealed class RateLimited { private readonly TimeSpan every; private readonly Action action; + private readonly bool holdWhileTyping; private readonly DispatcherTimer timer = new(); private readonly Coalesced onUiThread; private long lastRun = long.MinValue; private bool cancelled; - public RateLimited(TimeSpan every, Action action) + public RateLimited(TimeSpan every, Action action, bool holdWhileTyping = false) { this.every = every; this.action = action; + this.holdWhileTyping = holdWhileTyping; onUiThread = new Coalesced(Ask); timer.Tick += (_, _) => { @@ -56,6 +59,12 @@ public sealed class RateLimited private void Run() { + if (holdWhileTyping && OperatorActivity.IsTyping) + { + timer.Interval = OperatorActivity.Recheck; + timer.Start(); + return; + } lastRun = Stopwatch.GetTimestamp(); action(); } diff --git a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs index 1a99a50..151a0c7 100644 --- a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs +++ b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs @@ -17,7 +17,7 @@ public sealed partial class ScoreWindow : RefreshableWindow { this.session = session; InitializeComponent(); - limited = new RateLimited(TimeSpan.FromSeconds(1), Refresh); + limited = new RateLimited(TimeSpan.FromSeconds(1), Refresh, holdWhileTyping: true); Closed += (_, _) => limited.Cancel(); Refresh(); } diff --git a/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs b/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs index 6ff671f..9f6f54a 100644 --- a/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs +++ b/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs @@ -58,7 +58,7 @@ public sealed partial class StatisticsWindow : RefreshableWindow ShowPaletteBox(); DispatcherTimer every = new() { Interval = TimeSpan.FromSeconds(30) }; - every.Tick += (_, _) => Refresh(); + every.Tick += (_, _) => RefreshWhenQuiet(); every.Start(); Closed += (_, _) => every.Stop(); Refresh(); @@ -74,10 +74,35 @@ public sealed partial class StatisticsWindow : RefreshableWindow { if (session.Logging is { } logging && logging.Log.Qsos.Count != countShown) { - Refresh(); + RefreshWhenQuiet(); } } + /// The figures are not wanted in the middle of typing a call: filling the + /// table holds up the key presses. This waits for a gap. + private void RefreshWhenQuiet() + { + if (!IsVisible || waiting) + { + return; + } + if (OperatorActivity.IsTyping) + { + waiting = true; + DispatcherTimer.RunOnce( + () => + { + waiting = false; + RefreshWhenQuiet(); + }, + OperatorActivity.Recheck); + return; + } + Refresh(); + } + + private bool waiting; + private StatisticsField Row => StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow); diff --git a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs index 6e52076..67616a7 100644 --- a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs +++ b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs @@ -154,7 +154,7 @@ public sealed partial class TelnetWindow : RefreshableWindow private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() => { waiting.Add((line, colour, strong)); - flushWaiting ??= new RateLimited(TimeSpan.FromMilliseconds(250), AddWaiting); + flushWaiting ??= new RateLimited(TimeSpan.FromMilliseconds(250), AddWaiting, holdWhileTyping: true); flushWaiting.Request(); }); diff --git a/src/Nonemm.App/Windows/Throttled.cs b/src/Nonemm.App/Windows/Throttled.cs index df561c0..4fc93ac 100644 --- a/src/Nonemm.App/Windows/Throttled.cs +++ b/src/Nonemm.App/Windows/Throttled.cs @@ -9,12 +9,20 @@ public sealed class Throttled { private readonly DispatcherTimer timer; - public Throttled(TimeSpan delay, Action action) + /// With `holdWhileTyping` the action also waits until the operator has + /// stopped typing, looking again every few hundred milliseconds. + public Throttled(TimeSpan delay, Action action, bool holdWhileTyping = false) { timer = new DispatcherTimer { Interval = delay }; timer.Tick += (_, _) => { + if (holdWhileTyping && OperatorActivity.IsTyping) + { + timer.Interval = OperatorActivity.Recheck; + return; + } timer.Stop(); + timer.Interval = delay; action(); }; } diff --git a/src/Nonemm.Spotting/Bandmap.cs b/src/Nonemm.Spotting/Bandmap.cs index d3ed827..9b9dd4a 100644 --- a/src/Nonemm.Spotting/Bandmap.cs +++ b/src/Nonemm.Spotting/Bandmap.cs @@ -59,19 +59,20 @@ public sealed class Bandmap /// rather than once a spot. N1MM collects arriving spots the same way. public void AddRange(IEnumerable spots) { - int added = 0; + // the keys are worked out before the lock is taken, because each one + // looks up a band, and the lock is what the windows wait on + List<(string Key, Spot Spot)> incoming = [.. spots.Select(s => (Key(s), s))]; lock (guard) { - foreach (Spot spot in spots) + DateTime now = clock(); + foreach ((string key, Spot spot) in incoming) { - string key = Key(spot); this.spots[key] = spot; - arrived[key] = clock(); - added++; + arrived[key] = now; } Forget(); } - if (added > 0) + if (incoming.Count > 0) { Changed?.Invoke(this, EventArgs.Empty); } @@ -95,20 +96,34 @@ public sealed class Bandmap public void DropOlderThan(DateTime nowUtc) { - int dropped; + // the lifetime of each spot is worked out outside the lock: it looks + // up the mode of every spot, and the windows wait on this lock for + // their own lookups + (string Key, Spot Spot, DateTime At)[] held; lock (guard) { - List stale = spots - .Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) - > (LifetimeFor?.Invoke(pair.Value) ?? Lifetime)) - .Select(pair => pair.Key) - .ToList(); - foreach (string key in stale) + held = [.. spots.Select(pair => (pair.Key, pair.Value, arrived.GetValueOrDefault(pair.Key, nowUtc)))]; + } + List<(string Key, DateTime At)> stale = [.. held + .Where(h => nowUtc - h.At > (LifetimeFor?.Invoke(h.Spot) ?? Lifetime)) + .Select(h => (h.Key, h.At))]; + if (stale.Count == 0) + { + return; + } + int dropped = 0; + lock (guard) + { + foreach ((string key, DateTime at) in stale) { - spots.Remove(key); - arrived.Remove(key); + // a spot that came in again meanwhile is a new one + if (arrived.TryGetValue(key, out DateTime now) && now == at) + { + spots.Remove(key); + arrived.Remove(key); + dropped++; + } } - dropped = stale.Count; if (dropped > 0) { Forget();