Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions src/M0LTE.Tait.Codeplug.Cli/M0LTE.Tait.Codeplug.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<PackageReference Include="Terminal.Gui" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="M0LTE.Tait.Codeplug.Tests" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\M0LTE.Tait.Codeplug\M0LTE.Tait.Codeplug.csproj" />
</ItemGroup>
Expand Down
38 changes: 38 additions & 0 deletions src/M0LTE.Tait.Codeplug.Cli/Tui.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,18 @@ internal static class Tui
private static Window _window = null!;
private static IApplication _app = null!;

/// <summary>The loop rate the library starts with, restored the moment a key or mouse event arrives.</summary>
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)
Expand All @@ -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;
}

/// <summary>
/// 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. <see cref="TuiIdlePolicy"/> has the numbers.
/// </summary>
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);
}

/// <summary>Someone is here: back to the normal loop rate, and the quiet clock restarts.</summary>
private static void NoteInput()
{
_lastInputUtc = DateTime.UtcNow;
Application.MaximumIterationsPerSecond = _activeIterationsPerSecond;
}

private static Window Build()
{
var win = new Window
Expand Down
59 changes: 59 additions & 0 deletions src/M0LTE.Tait.Codeplug.Cli/TuiIdlePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace M0LTE.Tait.Codeplug.Cli;

/// <summary>
/// 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.
/// </summary>
internal static class TuiIdlePolicy
{
/// <summary>A pause long enough that a slightly slower loop will not be felt.</summary>
internal static readonly TimeSpan PausedAfter = TimeSpan.FromSeconds(10);

/// <summary>Long enough that whoever started the tool has walked away from it.</summary>
internal static readonly TimeSpan AwayAfter = TimeSpan.FromSeconds(60);

/// <summary>Iterations per second during a pause: 100ms worst case, which reads as instant.</summary>
internal const ushort PausedIterationsPerSecond = 10;

/// <summary>Iterations per second once nobody is there. 250ms worst case on the key that wakes it.</summary>
internal const ushort AwayIterationsPerSecond = 4;

/// <summary>
/// The rate to run at. <paramref name="activeRate"/> 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.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<ItemGroup>
<ProjectReference Include="..\..\src\M0LTE.Tait.Codeplug\M0LTE.Tait.Codeplug.csproj" />
<ProjectReference Include="..\..\src\M0LTE.Tait.Codeplug.Cli\M0LTE.Tait.Codeplug.Cli.csproj" />
</ItemGroup>

</Project>
70 changes: 70 additions & 0 deletions tests/M0LTE.Tait.Codeplug.Tests/TuiIdlePolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using M0LTE.Tait.Codeplug.Cli;

namespace M0LTE.Tait.Codeplug.Tests;

/// <summary>
/// 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 <see cref="TuiIdlePolicy"/>); 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.
/// </summary>
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);
}
}