Keep long refreshes out of the way of typing

Tracing key presses with eight windows open and a busy feed showed the
UI thread running 36 tasks of 8 ms or more in 25 seconds (up to 93 ms),
nearly all of them the Available window replacing its grid rows. A key
pressed meanwhile waits for the task.

- OperatorActivity records the last key press. The Available, Score,
  Telnet, Grayline and Statistics refreshes wait until there has been
  no key press for 800 ms.
- Bandmap no longer holds its lock while it works out spot lifetimes or
  spot keys, so the entry window's lookups do not wait on it.
- The bandmap view stops drawing labels pushed below its edge.

Tasks of 8 ms or more dropped from 36 (1,259 ms in all) to 8 (205 ms).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 14:10:47 +00:00
co-authored by Claude Sonnet 5.5
parent aa54f8dcbe
commit 7a728af469
11 changed files with 119 additions and 26 deletions
@@ -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;
}
+6
View File
@@ -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);
}
}
@@ -433,6 +433,7 @@ public sealed partial class EntryWindow : Window
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
OperatorActivity.KeyPressed();
if (Logging is null)
{
return;
@@ -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();
}
@@ -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;
}
}
}
+11 -2
View File
@@ -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();
}
+1 -1
View File
@@ -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();
}
@@ -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);
+1 -1
View File
@@ -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();
});
+9 -1
View File
@@ -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();
};
}
+31 -16
View File
@@ -59,19 +59,20 @@ public sealed class Bandmap
/// rather than once a spot. N1MM collects arriving spots the same way.
public void AddRange(IEnumerable<Spot> 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<string> 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();