diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f2ed5..0a642e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ What changed in each release. The section for a version is lifted into that vers Newest first. Add a section before tagging. +## 0.6.1 - 2026-08-21 + +- **The interactive mode stops talking to the terminal when nobody is using it.** Terminal.Gui runs its main loop 25 times a second whether or not anything has changed, and rewrites cursor state every time round: sitting there with nothing happening, the tool was emitting ~315 bytes a second in 25 separate writes, for as long as it was open. The loop now steps down after ten seconds untouched and again after a minute. Measured idle output falls from 315 bytes/sec to 128 after a short pause and to 54 after a long one. +- Typing is not affected: ten keypresses measured at 29-49 ms before the change and 29-49 ms after, because ten seconds is far longer than any pause in typing. What it costs is the single keypress that wakes it up after a long pause, measured over four attempts at 248, 60, 235 and 70 ms - up to a quarter of a second, once, and everything after it is back to normal. + +This is a candidate fix for "the UI goes laggy after a few minutes", not a confirmed one, and it is worth being straight about which. On a local terminal the lag does not reproduce: twelve minutes idle held latency flat at 44-73 ms, CPU at 2.1% and file handles constant, and 150 open-and-close cycles of the channel editor held latency flat at ~60 ms with no handle growth. What the tool was doing wrong regardless is the constant output, which over SSH is 25 packets a second the far end can never stop servicing. If it still goes laggy, the thing to say is which terminal and what connection - SSH, tmux, mosh, Windows Terminal - because that is where the remaining suspects live. + ## 0.6.0 - 2026-08-21 - **Keyboard navigation between panels.** `F6` (and `Shift+F6`) moves between Radio, Channels, PDN preset and Log; `Tab` moves within a panel. The panel holding the keyboard now lights its border white, so where you are is visible rather than guesswork. Previously `Tab` could not leave the Radio panel at all, which also meant `Enter` on the channel list never fired: the list could not be reached. diff --git a/README.md b/README.md index 70c1934..8eb1a4d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ Run it with no arguments and you get a screen instead of a verb: pick a port, re The radio work runs off the UI thread, so the screen stays live through the ~25s read and the 90s the connect will wait for your power-cycle. +Left alone, it goes quiet: the main loop steps down after ten seconds untouched and again after a minute, so an editor left open over SSH is not writing to your terminal 25 times a second all afternoon. Typing is unaffected; the one key that wakes it after a long pause can take up to a quarter of a second to register, and everything after it is normal. + Colours are true-colour: a dark slate palette, green for read, amber for write (it is the one that changes your radio), red for errors. Terminal.Gui maps them down on a 16- or 256-colour terminal, so it stays legible on a plain console. To try the editor without a radio on the bench, open a saved codeplug: `tait-codeplug tui radio.m8p`. diff --git a/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj b/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj index a675803..a7ac0c2 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj +++ b/src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs index b1c5a6e..2f95419 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs +++ b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs @@ -44,12 +44,18 @@ internal static class Tui private static Window _window = null!; private static IApplication _app = null!; + /// The loop rate the library starts with, restored the moment a key or mouse event arrives. + private static ushort _activeIterationsPerSecond; + + private static DateTime _lastInputUtc = DateTime.UtcNow; + internal static int Run(CodeplugImage? initial = null, string? source = null) { _app = Application.Create(); try { _app.Init(); + GoQuietWhenLeftAlone(); TuiTheme.Apply(); _window = Build(); if (initial is not null) @@ -70,12 +76,44 @@ internal static int Run(CodeplugImage? initial = null, string? source = null) } finally { + // The loop rate is a static on the library, so hand it back as we found it rather than + // leaving a slowed-down rate behind for anything that runs the TUI again in-process. + if (_activeIterationsPerSecond > 0) + { + Application.MaximumIterationsPerSecond = _activeIterationsPerSecond; + } + _app.Dispose(); } return 0; } + /// + /// Stop talking to the terminal when nobody is using the tool. The main loop otherwise ticks - and + /// rewrites cursor state - 25 times a second for ever, which is invisible locally and is a redraw + /// the far end of an SSH link can never stop servicing. has the numbers. + /// + private static void GoQuietWhenLeftAlone() + { + _activeIterationsPerSecond = Application.MaximumIterationsPerSecond; + _lastInputUtc = DateTime.UtcNow; + + _app.Keyboard.KeyDown += (_, _) => NoteInput(); + _app.Mouse.MouseEvent += (_, _) => NoteInput(); + + _app.Iteration += (_, _) => + Application.MaximumIterationsPerSecond = + TuiIdlePolicy.RateFor(DateTime.UtcNow - _lastInputUtc, _activeIterationsPerSecond); + } + + /// Someone is here: back to the normal loop rate, and the quiet clock restarts. + private static void NoteInput() + { + _lastInputUtc = DateTime.UtcNow; + Application.MaximumIterationsPerSecond = _activeIterationsPerSecond; + } + private static Window Build() { var win = new Window diff --git a/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs b/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs new file mode 100644 index 0000000..b77ef95 --- /dev/null +++ b/src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs @@ -0,0 +1,59 @@ +namespace M0LTE.Tait.Codeplug.Cli; + +/// +/// How fast the interactive mode's main loop should tick, given how long it is since anyone touched +/// the keyboard or mouse. +/// +/// Terminal.Gui iterates its main loop ~25 times a second whether or not anything has changed, and +/// every iteration rewrites cursor state whether or not the cursor moved. Measured on an idle TUI +/// with nothing happening at all: ~318 bytes/second of escape sequences, in 25 separate writes per +/// second, for as long as the tool is open. Locally that costs nothing you would notice. Over SSH it +/// is 25 packets a second the far end must keep servicing for ever, and a link that is congested or +/// stalls never gets a quiet moment to catch up. +/// +/// So the loop runs at the library's normal rate while the tool is being used, and steps down when it +/// is left alone. Stepping down in two stages keeps the cost off the cases that would be noticed: a +/// pause to think does not slow anything perceptibly, and only walking away drops it right down. +/// +/// touched within 10s library rate (25/s) ~315 bytes/sec typing unaffected +/// 10s to 60s 10/s ~128 bytes/sec waking key measured at 45ms +/// over 60s 4/s ~54 bytes/sec waking key measured at 60-248ms +/// +/// The first keypress after a pause pays that once, and restores the fast rate for everything after +/// it. Ten seconds is far longer than any pause in typing, so the cost never lands on a burst of +/// keystrokes. +/// +internal static class TuiIdlePolicy +{ + /// A pause long enough that a slightly slower loop will not be felt. + internal static readonly TimeSpan PausedAfter = TimeSpan.FromSeconds(10); + + /// Long enough that whoever started the tool has walked away from it. + internal static readonly TimeSpan AwayAfter = TimeSpan.FromSeconds(60); + + /// Iterations per second during a pause: 100ms worst case, which reads as instant. + internal const ushort PausedIterationsPerSecond = 10; + + /// Iterations per second once nobody is there. 250ms worst case on the key that wakes it. + internal const ushort AwayIterationsPerSecond = 4; + + /// + /// The rate to run at. is whatever the library was configured with at + /// startup, so this never invents a rate of its own for the active case, and never speeds the loop + /// up beyond what the library asked for. + /// + internal static ushort RateFor(TimeSpan sinceLastInput, ushort activeRate) + { + if (sinceLastInput >= AwayAfter) + { + return Math.Min(AwayIterationsPerSecond, activeRate); + } + + if (sinceLastInput >= PausedAfter) + { + return Math.Min(PausedIterationsPerSecond, activeRate); + } + + return activeRate; + } +} diff --git a/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj b/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj index 20bc487..3be6e5d 100644 --- a/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj +++ b/tests/M0LTE.Tait.Codeplug.Tests/M0LTE.Tait.Codeplug.Tests.csproj @@ -2,6 +2,7 @@ + diff --git a/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs b/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs new file mode 100644 index 0000000..b75e506 --- /dev/null +++ b/tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs @@ -0,0 +1,70 @@ +using M0LTE.Tait.Codeplug.Cli; + +namespace M0LTE.Tait.Codeplug.Tests; + +/// +/// The interactive mode steps its main loop down when it is left alone, so that a tool sitting there +/// doing nothing stops writing to the terminal 25 times a second. The rates themselves are measured +/// (see ); what is worth pinning down here is the shape of the decision, +/// because getting it wrong is either a tool that feels sluggish to type into or one that never goes +/// quiet. +/// +public class TuiIdlePolicyTests +{ + private const ushort LibraryRate = 25; + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(9)] + [InlineData(9.999)] + public void Typing_runs_at_the_librarys_own_rate(double secondsSinceInput) + { + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate) + .Should().Be(LibraryRate, "a pause shorter than the threshold must not slow typing down"); + } + + [Theory] + [InlineData(10)] + [InlineData(30)] + [InlineData(59.999)] + public void A_pause_steps_down_to_a_rate_that_still_reads_as_instant(double secondsSinceInput) + { + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate) + .Should().Be(TuiIdlePolicy.PausedIterationsPerSecond); + } + + [Theory] + [InlineData(60)] + [InlineData(3600)] + public void Walking_away_steps_down_again(double secondsSinceInput) + { + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(secondsSinceInput), LibraryRate) + .Should().Be(TuiIdlePolicy.AwayIterationsPerSecond); + } + + [Fact] + public void The_step_down_is_monotonic() + { + ushort previous = LibraryRate; + + for (double s = 0; s <= 120; s += 0.5) + { + ushort rate = TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(s), LibraryRate); + rate.Should().BeLessThanOrEqualTo(previous, $"the rate must never rise again at {s}s idle"); + previous = rate; + } + } + + [Fact] + public void It_never_speeds_the_loop_up_beyond_what_the_library_asked_for() + { + // A library (or a future version of it) that already ticks slowly than our idle rates must be + // left alone: stepping "down" to 10/s would be stepping up. + const ushort SlowLibrary = 2; + + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(0), SlowLibrary).Should().Be(SlowLibrary); + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(30), SlowLibrary).Should().Be(SlowLibrary); + TuiIdlePolicy.RateFor(TimeSpan.FromSeconds(300), SlowLibrary).Should().Be(SlowLibrary); + } +}