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();
|
Spots.ContextMenu = BuildMenu();
|
||||||
// replacing the grid's rows is the dearest thing this window does, and
|
// replacing the grid's rows is the dearest thing this window does, and
|
||||||
// spots arrive and the radio moves many times a second
|
// 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();
|
EventHandler spotsChanged = (_, _) => limited.Request();
|
||||||
session.Bandmap.Changed += spotsChanged;
|
session.Bandmap.Changed += spotsChanged;
|
||||||
Closed += (_, _) =>
|
Closed += (_, _) =>
|
||||||
@@ -131,7 +131,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
|||||||
private void OnTick()
|
private void OnTick()
|
||||||
{
|
{
|
||||||
ShowNotice();
|
ShowNotice();
|
||||||
if (IsPaused)
|
if (IsPaused || OperatorActivity.IsTyping)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ public sealed class BandmapView : Control
|
|||||||
PlaceLabels();
|
PlaceLabels();
|
||||||
foreach (BandmapLabel label in placed)
|
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);
|
DrawLabel(context, label, ink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ public sealed partial class EntryWindow : Window
|
|||||||
|
|
||||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
|
OperatorActivity.KeyPressed();
|
||||||
if (Logging is null)
|
if (Logging is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed partial class GraylineWindow : RefreshableWindow
|
|||||||
|
|
||||||
public override void RefreshFromSession()
|
public override void RefreshFromSession()
|
||||||
{
|
{
|
||||||
later ??= new Throttled(TimeSpan.FromSeconds(15), Refresh);
|
later ??= new Throttled(TimeSpan.FromSeconds(15), Refresh, holdWhileTyping: true);
|
||||||
later.Request();
|
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
|
/// 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
|
/// 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
|
/// 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
|
public sealed class RateLimited
|
||||||
{
|
{
|
||||||
private readonly TimeSpan every;
|
private readonly TimeSpan every;
|
||||||
private readonly Action action;
|
private readonly Action action;
|
||||||
|
private readonly bool holdWhileTyping;
|
||||||
private readonly DispatcherTimer timer = new();
|
private readonly DispatcherTimer timer = new();
|
||||||
private readonly Coalesced onUiThread;
|
private readonly Coalesced onUiThread;
|
||||||
private long lastRun = long.MinValue;
|
private long lastRun = long.MinValue;
|
||||||
private bool cancelled;
|
private bool cancelled;
|
||||||
|
|
||||||
public RateLimited(TimeSpan every, Action action)
|
public RateLimited(TimeSpan every, Action action, bool holdWhileTyping = false)
|
||||||
{
|
{
|
||||||
this.every = every;
|
this.every = every;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
|
this.holdWhileTyping = holdWhileTyping;
|
||||||
onUiThread = new Coalesced(Ask);
|
onUiThread = new Coalesced(Ask);
|
||||||
timer.Tick += (_, _) =>
|
timer.Tick += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -56,6 +59,12 @@ public sealed class RateLimited
|
|||||||
|
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
|
if (holdWhileTyping && OperatorActivity.IsTyping)
|
||||||
|
{
|
||||||
|
timer.Interval = OperatorActivity.Recheck;
|
||||||
|
timer.Start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
lastRun = Stopwatch.GetTimestamp();
|
lastRun = Stopwatch.GetTimestamp();
|
||||||
action();
|
action();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed partial class ScoreWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
this.session = session;
|
this.session = session;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
limited = new RateLimited(TimeSpan.FromSeconds(1), Refresh);
|
limited = new RateLimited(TimeSpan.FromSeconds(1), Refresh, holdWhileTyping: true);
|
||||||
Closed += (_, _) => limited.Cancel();
|
Closed += (_, _) => limited.Cancel();
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public sealed partial class StatisticsWindow : RefreshableWindow
|
|||||||
|
|
||||||
ShowPaletteBox();
|
ShowPaletteBox();
|
||||||
DispatcherTimer every = new() { Interval = TimeSpan.FromSeconds(30) };
|
DispatcherTimer every = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||||
every.Tick += (_, _) => Refresh();
|
every.Tick += (_, _) => RefreshWhenQuiet();
|
||||||
every.Start();
|
every.Start();
|
||||||
Closed += (_, _) => every.Stop();
|
Closed += (_, _) => every.Stop();
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -74,9 +74,34 @@ public sealed partial class StatisticsWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
if (session.Logging is { } logging && logging.Log.Qsos.Count != countShown)
|
if (session.Logging is { } logging && logging.Log.Qsos.Count != countShown)
|
||||||
{
|
{
|
||||||
|
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();
|
Refresh();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private bool waiting;
|
||||||
|
|
||||||
private StatisticsField Row =>
|
private StatisticsField Row =>
|
||||||
StatisticsFields.Find(StatisticsFields.Rows, session.Settings.StatsRow);
|
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(() =>
|
private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
waiting.Add((line, colour, strong));
|
waiting.Add((line, colour, strong));
|
||||||
flushWaiting ??= new RateLimited(TimeSpan.FromMilliseconds(250), AddWaiting);
|
flushWaiting ??= new RateLimited(TimeSpan.FromMilliseconds(250), AddWaiting, holdWhileTyping: true);
|
||||||
flushWaiting.Request();
|
flushWaiting.Request();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,20 @@ public sealed class Throttled
|
|||||||
{
|
{
|
||||||
private readonly DispatcherTimer timer;
|
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 = new DispatcherTimer { Interval = delay };
|
||||||
timer.Tick += (_, _) =>
|
timer.Tick += (_, _) =>
|
||||||
{
|
{
|
||||||
|
if (holdWhileTyping && OperatorActivity.IsTyping)
|
||||||
|
{
|
||||||
|
timer.Interval = OperatorActivity.Recheck;
|
||||||
|
return;
|
||||||
|
}
|
||||||
timer.Stop();
|
timer.Stop();
|
||||||
|
timer.Interval = delay;
|
||||||
action();
|
action();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,19 +59,20 @@ public sealed class Bandmap
|
|||||||
/// rather than once a spot. N1MM collects arriving spots the same way.
|
/// rather than once a spot. N1MM collects arriving spots the same way.
|
||||||
public void AddRange(IEnumerable<Spot> spots)
|
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)
|
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;
|
this.spots[key] = spot;
|
||||||
arrived[key] = clock();
|
arrived[key] = now;
|
||||||
added++;
|
|
||||||
}
|
}
|
||||||
Forget();
|
Forget();
|
||||||
}
|
}
|
||||||
if (added > 0)
|
if (incoming.Count > 0)
|
||||||
{
|
{
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
@@ -95,20 +96,34 @@ public sealed class Bandmap
|
|||||||
|
|
||||||
public void DropOlderThan(DateTime nowUtc)
|
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)
|
lock (guard)
|
||||||
{
|
{
|
||||||
List<string> stale = spots
|
held = [.. spots.Select(pair => (pair.Key, pair.Value, arrived.GetValueOrDefault(pair.Key, nowUtc)))];
|
||||||
.Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc)
|
}
|
||||||
> (LifetimeFor?.Invoke(pair.Value) ?? Lifetime))
|
List<(string Key, DateTime At)> stale = [.. held
|
||||||
.Select(pair => pair.Key)
|
.Where(h => nowUtc - h.At > (LifetimeFor?.Invoke(h.Spot) ?? Lifetime))
|
||||||
.ToList();
|
.Select(h => (h.Key, h.At))];
|
||||||
foreach (string key in stale)
|
if (stale.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int dropped = 0;
|
||||||
|
lock (guard)
|
||||||
|
{
|
||||||
|
foreach ((string key, DateTime at) in stale)
|
||||||
|
{
|
||||||
|
// a spot that came in again meanwhile is a new one
|
||||||
|
if (arrived.TryGetValue(key, out DateTime now) && now == at)
|
||||||
{
|
{
|
||||||
spots.Remove(key);
|
spots.Remove(key);
|
||||||
arrived.Remove(key);
|
arrived.Remove(key);
|
||||||
|
dropped++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dropped = stale.Count;
|
|
||||||
if (dropped > 0)
|
if (dropped > 0)
|
||||||
{
|
{
|
||||||
Forget();
|
Forget();
|
||||||
|
|||||||
Reference in New Issue
Block a user