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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user