From 2d77c07d9e1bfb366080c2b710869facb7c19154 Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Fri, 21 Aug 2026 18:47:25 +0000 Subject: [PATCH] feat(tui): let the console driver be chosen, and measure what a repaint costs Typing into the editor is slow on Windows with the tool running locally, which rules out the link and leaves the repaint itself. Terminal.Gui redraws the whole screen for every inserted character, and what that costs depends entirely on how the driver hands it to the console - a cost that is far higher per call on Windows than on a Unix pty, and one that cannot be measured from here. So make it measurable and changeable where it matters: tait-codeplug tui --driver list what this platform offers tait-codeplug tui --bench time one repaint on the default driver tait-codeplug tui --bench --driver ansi tait-codeplug tui --driver ansi radio.m8p Terminal.Gui ships three drivers (windows, ansi, dotnet) and picks one per platform. The pick is now overridable, and --bench reports the screen size, the driver and a median over 30 repaints, so the quickest one for a given console can be found in about a minute rather than guessed at. The benchmark has to run inside the application loop: outside it the driver is not live, the terminal has not answered the size query, and a draw costs nothing because nothing reaches the console. The first version measured 0.0 ms against a 0x0 screen, which is exactly the sort of number that looks like good news. For scale, on Linux in tmux at 100x30: ansi 17.7 ms, dotnet 8.5 ms per repaint. Both feel instant. If the Windows driver comes out in the hundreds, that is the whole complaint, and switching driver is the fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FkDFej82QnbjMJYFcwYyAZ --- CHANGELOG.md | 15 ++ README.md | 14 +- src/M0LTE.Tait.Codeplug.Cli/Program.cs | 51 ++++++- src/M0LTE.Tait.Codeplug.Cli/Tui.cs | 11 +- src/M0LTE.Tait.Codeplug.Cli/TuiBench.cs | 133 ++++++++++++++++++ .../TuiDriverChoice.cs | 61 ++++++++ .../TuiDriverChoiceTests.cs | 68 +++++++++ 7 files changed, 344 insertions(+), 9 deletions(-) create mode 100644 src/M0LTE.Tait.Codeplug.Cli/TuiBench.cs create mode 100644 src/M0LTE.Tait.Codeplug.Cli/TuiDriverChoice.cs create mode 100644 tests/M0LTE.Tait.Codeplug.Tests/TuiDriverChoiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 131c5e5..c8c81f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ What changed in each release. The section for a version is lifted into that vers Newest first. Add a section before tagging. +## 0.8.0 - 2026-08-21 + +- **`tui --driver `**, and `tui --driver list` to see what your platform offers. Terminal.Gui ships three console drivers (`windows`, `ansi`, `dotnet`) and picks one for you. Since a repaint costs whatever the driver and console between them make it cost, and that varies enormously, this makes it something you can change rather than something you are stuck with. +- **`tui --bench`** times what one screen repaint actually costs on your console, because a repaint is exactly what one typed character costs. Run it per driver and use the quickest: + +``` +tait-codeplug tui --bench +tait-codeplug tui --bench --driver ansi +tait-codeplug tui --bench --driver dotnet +``` + + It prints the screen size, the driver, and a median over 30 repaints. For scale, on Linux in tmux at 100x30 this machine gives 17.7 ms on `ansi` and 8.5 ms on `dotnet`; anything under about 30 ms feels instant, and a few hundred milliseconds is the editor feeling sluggish. + +This is aimed at the report of typing being slow in the editor **on Windows, with the tool running locally**. That rules out the link, which leaves how the driver hands a repaint to the console, and on Windows that cost is far higher per call than on a Unix pty. Which of the three drivers is quickest there is not something that can be settled from a Linux box, so the tool now measures it where it matters. + ## 0.7.0 - 2026-08-21 - **"Power-cycle the radio now" is a prompt, not a line in the log.** A read or a write puts it on the screen where it cannot be missed, and it takes itself back down the moment the radio answers - the normal case needs no keystroke at all. Cancel, or Esc, abandons the operation, which is the way out when the radio is not going to answer rather than sitting through the full 90-second wait. diff --git a/README.md b/README.md index 8cebba7..efc7212 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,21 @@ Left alone, it goes quiet: the main loop steps down after ten seconds untouched It is, and it is worth knowing why before you go looking for a fault at your end. Terminal.Gui repaints the whole screen for every character typed into a text box - about 7-8 bytes per cell on screen, so 22 KB on a 100x30 terminal and 82 KB at 200x50, per keystroke. A minimal Terminal.Gui app does the same, so it is the library rather than this tool, and there is nothing to configure around it. -Locally you will not notice. Across an SSH link to a maximised terminal it is a second or two per character. What helps: +How much that costs you depends on the console and on which of Terminal.Gui's three drivers is in front of it, and the difference between them is large. Measure it on your own machine rather than trusting a number from someone else's: + +```sh +tait-codeplug tui --driver list # what this platform offers +tait-codeplug tui --bench # time one repaint on the default driver +tait-codeplug tui --bench --driver ansi +``` + +Under about 30ms per repaint feels instant; a few hundred milliseconds is the editor feeling sluggish. If another driver is quicker, use it: `tait-codeplug tui --driver ansi radio.m8p`. + +Beyond that: - Make the terminal window smaller while you are editing: 80x24 costs a sixth of what 200x50 does. - Skip the editor for a single value: `tait-codeplug patch /dev/ttyUSB0 ch0.rxfreq 144.812500` does a read-modify-write with no typing in a UI at all. -- Run the tool on the machine the radio is plugged into, rather than across the link. +- Over SSH, run the tool on the machine the radio is plugged into rather than across the link. 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. diff --git a/src/M0LTE.Tait.Codeplug.Cli/Program.cs b/src/M0LTE.Tait.Codeplug.Cli/Program.cs index 913522d..5bde671 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/Program.cs +++ b/src/M0LTE.Tait.Codeplug.Cli/Program.cs @@ -60,7 +60,7 @@ case "--upgrade": return SelfUpgrade.RunAsync().GetAwaiter().GetResult(); case "tui": - return CmdTui(args.Length > 1 ? args[1] : null); + return CmdTui(args); case "help": case "--help": case "-h": @@ -106,17 +106,54 @@ static int CmdChannel(string[] args) } } -// tui [file.m8p] - the interactive editor, optionally opened on a saved codeplug rather than -// starting empty and reading the radio. Same screen either way. -static int CmdTui(string? path) +// tui [--driver ] [file.m8p] - the interactive editor, optionally opened on a saved codeplug +// rather than starting empty and reading the radio. Same screen either way. +static int CmdTui(string[] args) { + string? driver = null; + string? path = null; + bool bench = false; + + for (int i = 1; i < args.Length; i++) + { + if (args[i] == "--bench") + { + bench = true; + continue; + } + + if (args[i] == "--driver") + { + driver = i + 1 < args.Length + ? args[i + 1] + : throw new FormatException("--driver needs a name, or 'list' to see what is available"); + i++; + continue; + } + + path ??= args[i]; + } + + if (string.Equals(driver, "list", StringComparison.OrdinalIgnoreCase)) + { + TuiDriverChoice.PrintAvailable(Console.Out); + return 0; + } + + string? resolved = TuiDriverChoice.Resolve(driver); + + if (bench) + { + return TuiBench.Run(resolved, Console.Out); + } + if (path is null) { - return Tui.Run(); + return Tui.Run(driver: resolved); } CodeplugImage image = CodeplugImage.LoadM8p(File.ReadAllText(path)); - return Tui.Run(image, path); + return Tui.Run(image, path, resolved); } static int CmdParse(string source) @@ -327,6 +364,8 @@ static void PrintUsage() Console.WriteLine("usage:"); Console.WriteLine(" (no arguments) interactive mode: pick a port, read, edit, write"); Console.WriteLine(" tui [file.m8p] interactive mode, optionally opened on a saved codeplug"); + Console.WriteLine(" tui --driver [file.m8p] force a console driver (try this if typing is slow)"); + Console.WriteLine(" tui --bench [--driver ] time what one screen repaint costs on this console"); Console.WriteLine(" --upgrade replace this binary with the latest GitHub release"); Console.WriteLine(" parse verify checksums + section map (file or live radio)"); Console.WriteLine(" dump decode every mapped field (file or live radio)"); diff --git a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs index f653032..605a3c8 100644 --- a/src/M0LTE.Tait.Codeplug.Cli/Tui.cs +++ b/src/M0LTE.Tait.Codeplug.Cli/Tui.cs @@ -64,11 +64,20 @@ internal static class Tui private static DateTime _lastInputUtc = DateTime.UtcNow; - internal static int Run(CodeplugImage? initial = null, string? source = null) + /// A codeplug to open on, or null to start empty. + /// Where came from, for the log line. + /// A Terminal.Gui driver name to force, or null to let it choose. See + /// for why this is worth being able to change. + internal static int Run(CodeplugImage? initial = null, string? source = null, string? driver = null) { _app = Application.Create(); try { + if (driver is not null) + { + _app.ForceDriver = driver; + } + _app.Init(); GoQuietWhenLeftAlone(); TuiTheme.Apply(); diff --git a/src/M0LTE.Tait.Codeplug.Cli/TuiBench.cs b/src/M0LTE.Tait.Codeplug.Cli/TuiBench.cs new file mode 100644 index 0000000..e58957d --- /dev/null +++ b/src/M0LTE.Tait.Codeplug.Cli/TuiBench.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using Terminal.Gui.App; + +namespace M0LTE.Tait.Codeplug.Cli; + +/// +/// Times what one screen repaint costs on the console this is actually running on. +/// +/// Terminal.Gui repaints the whole screen for every character typed into a text box, so a repaint IS +/// a keystroke as far as the editor is concerned. How long that takes is entirely down to the console +/// and the driver in front of it, and it is not something that can be measured from anywhere else - +/// hence a benchmark that ships in the tool rather than a number quoted from someone else's machine. +/// +/// Run it per driver to find the quickest one for your console: +/// tait-codeplug tui --bench +/// tait-codeplug tui --bench --driver ansi +/// tait-codeplug tui --bench --driver windows +/// +internal static class TuiBench +{ + private const int WarmUp = 5; + private const int Iterations = 30; + + internal static int Run(string? driver, TextWriter output) + { + ArgumentNullException.ThrowIfNull(output); + + var timings = new List(Iterations); + int rows; + int columns; + string driverInUse; + + IApplication app = Application.Create(); + try + { + if (driver is not null) + { + app.ForceDriver = driver; + } + + app.Init(); + + using var window = BenchWindow(out Action mutate); + int screenRows = 0; + int screenColumns = 0; + string seenDriver = "(unknown)"; + + // The timing has to happen inside the running loop: outside it the driver is not live, the + // terminal has not answered the size query yet, and a draw costs nothing because nothing + // reaches the console. So run the app, do the work on the first tick, and stop. + app.AddTimeout(TimeSpan.FromMilliseconds(250), () => + { + seenDriver = driver ?? Terminal.Gui.Drivers.DriverRegistry.GetDefaultDriver().Name; + screenRows = app.Screen.Height; + screenColumns = app.Screen.Width; + + for (int i = 0; i < WarmUp + Iterations; i++) + { + mutate(); + long start = Stopwatch.GetTimestamp(); + app.LayoutAndDraw(true); + double ms = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + if (i >= WarmUp) + { + timings.Add(ms); + } + } + + app.RequestStop(window); + return false; + }); + + app.Run(window); + rows = screenRows; + columns = screenColumns; + driverInUse = seenDriver; + } + finally + { + app.Dispose(); + } + + if (timings.Count == 0) + { + output.WriteLine("the benchmark did not get a chance to draw anything - is this a real terminal?"); + return 1; + } + + timings.Sort(); + double median = timings[timings.Count / 2]; + double worst = timings[^1]; + double best = timings[0]; + int cells = rows * columns; + + output.WriteLine(); + output.WriteLine(FormattableString.Invariant($"screen : {columns}x{rows} ({cells} cells)")); + output.WriteLine(FormattableString.Invariant($"driver : {driverInUse.ToLowerInvariant()}{(driver is null ? " (the default on this platform)" : " (forced)")}")); + output.WriteLine(FormattableString.Invariant($"repaints : {Iterations} timed, {WarmUp} discarded as warm-up")); + output.WriteLine(FormattableString.Invariant($"per repaint : {median:F1} ms median ({best:F1} best, {worst:F1} worst)")); + output.WriteLine(); + output.WriteLine("A repaint is what one character typed into a text box costs, because Terminal.Gui"); + output.WriteLine("redraws the whole screen for it. Under about 30ms feels instant; a few hundred"); + output.WriteLine("milliseconds is the editor feeling sluggish. Try --driver to compare, and"); + output.WriteLine("a smaller window: the cost scales with the number of cells on screen."); + return 0; + } + + /// + /// A window the size of the real one, with something on it that changes every repaint, so the + /// driver has actual work to do rather than an unchanged screen. + /// + private static Terminal.Gui.Views.Window BenchWindow(out Action mutate) + { + var window = new Terminal.Gui.Views.Window { Title = "tait-codeplug repaint benchmark" }; + var label = new Terminal.Gui.Views.Label + { + X = 2, + Y = 2, + Text = "measuring what one screen repaint costs on this console...", + }; + window.Add(label); + + int n = 0; + mutate = () => + { + n++; + label.Text = $"measuring what one screen repaint costs on this console... {n}"; + window.SetNeedsDraw(); + }; + + return window; + } +} diff --git a/src/M0LTE.Tait.Codeplug.Cli/TuiDriverChoice.cs b/src/M0LTE.Tait.Codeplug.Cli/TuiDriverChoice.cs new file mode 100644 index 0000000..1b4fd48 --- /dev/null +++ b/src/M0LTE.Tait.Codeplug.Cli/TuiDriverChoice.cs @@ -0,0 +1,61 @@ +using Terminal.Gui.Drivers; + +namespace M0LTE.Tait.Codeplug.Cli; + +/// +/// Which Terminal.Gui console driver the interactive mode should use. +/// +/// Terminal.Gui picks one per platform, and on Windows that choice matters a lot more than it does +/// elsewhere. Every character typed into a text box repaints the whole screen (~7-8 bytes per cell, +/// so 22 KB at 100x30 and 82 KB at 200x50), and how expensive that is depends entirely on how the +/// driver hands it to the console: one buffered write is cheap, thousands of small console calls is +/// not, and on Windows the per-call cost is far higher than on a Unix pty. +/// +/// So rather than guess which driver is quickest on someone else's console, this makes it a switch: +/// `tait-codeplug tui --driver ansi`, and `--driver list` to see what this platform offers. +/// +internal static class TuiDriverChoice +{ + /// + /// Resolve a user-supplied driver name to the name Terminal.Gui knows, case-insensitively. + /// Returns null for "default", meaning let the library choose as it always has. + /// + /// The name is not one this platform supports. + internal static string? Resolve(string? requested) + { + if (string.IsNullOrWhiteSpace(requested) + || requested.Equals("default", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string[] supported = SupportedNames(); + string? match = Array.Find(supported, n => n.Equals(requested.Trim(), StringComparison.OrdinalIgnoreCase)); + + return match ?? throw new FormatException( + $"unknown driver '{requested}'. This platform supports: {string.Join(", ", supported.Select(n => n.ToLowerInvariant()))}, " + + "or 'default' to let Terminal.Gui choose."); + } + + /// The driver names Terminal.Gui reports as usable on the machine this is running on. + internal static string[] SupportedNames() + => DriverRegistry.GetSupportedDrivers().Select(d => d.Name).ToArray(); + + /// What --driver list prints. + internal static void PrintAvailable(TextWriter output) + { + ArgumentNullException.ThrowIfNull(output); + + output.WriteLine("console drivers available on this machine:"); + foreach (DriverRegistry.DriverDescriptor d in DriverRegistry.GetSupportedDrivers()) + { + string isDefault = d.Name == DriverRegistry.GetDefaultDriver().Name ? " (default here)" : string.Empty; + output.WriteLine($" {d.Name.ToLowerInvariant(),-10} {d.DisplayName}{isDefault}"); + } + + output.WriteLine(); + output.WriteLine("Use with: tait-codeplug tui --driver [file.m8p]"); + output.WriteLine("Worth trying if typing into the editor feels slow: the drivers differ a lot in"); + output.WriteLine("how much work a screen repaint costs, and which is quickest depends on your console."); + } +} diff --git a/tests/M0LTE.Tait.Codeplug.Tests/TuiDriverChoiceTests.cs b/tests/M0LTE.Tait.Codeplug.Tests/TuiDriverChoiceTests.cs new file mode 100644 index 0000000..e64a305 --- /dev/null +++ b/tests/M0LTE.Tait.Codeplug.Tests/TuiDriverChoiceTests.cs @@ -0,0 +1,68 @@ +using AwesomeAssertions; +using M0LTE.Tait.Codeplug.Cli; +using Xunit; + +namespace M0LTE.Tait.Codeplug.Tests; + +/// +/// `--driver` exists because how much a screen repaint costs depends on the console driver, and which +/// one is quickest is not something that can be decided from here - it has to be tried on the machine +/// that is running it. So the only thing worth pinning down is that the argument is handled sanely: +/// the default stays the library's choice, names are not case-sensitive, and a typo says what the +/// options are rather than falling back silently to something else. +/// +public class TuiDriverChoiceTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("default")] + [InlineData("DEFAULT")] + public void Nothing_asked_for_means_the_library_chooses(string? requested) + { + TuiDriverChoice.Resolve(requested).Should().BeNull(); + } + + [Fact] + public void A_supported_name_resolves_whatever_case_it_is_given_in() + { + string canonical = TuiDriverChoice.SupportedNames()[0]; + + TuiDriverChoice.Resolve(canonical).Should().Be(canonical); + TuiDriverChoice.Resolve(canonical.ToLowerInvariant()).Should().Be(canonical); + TuiDriverChoice.Resolve(canonical.ToUpperInvariant()).Should().Be(canonical); + TuiDriverChoice.Resolve($" {canonical} ").Should().Be(canonical); + } + + [Fact] + public void An_unknown_name_is_refused_and_says_what_is_available() + { + Action resolve = () => TuiDriverChoice.Resolve("curses"); + + resolve.Should().Throw() + .WithMessage("*curses*") + .WithMessage($"*{TuiDriverChoice.SupportedNames()[0].ToLowerInvariant()}*"); + } + + [Fact] + public void Every_platform_offers_at_least_one_driver() + { + TuiDriverChoice.SupportedNames().Should().NotBeEmpty(); + } + + [Fact] + public void The_listing_names_the_default_so_you_know_what_you_are_comparing_against() + { + var output = new StringWriter(); + + TuiDriverChoice.PrintAvailable(output); + + string text = output.ToString(); + text.Should().Contain("(default here)"); + foreach (string name in TuiDriverChoice.SupportedNames()) + { + text.Should().Contain(name.ToLowerInvariant()); + } + } +}