diff --git a/CLAUDE.md b/CLAUDE.md index 8332d21..73d4f4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,12 @@ Provider Abstraction (SpectreConsoleProvider) 3. Each pass is a full clear followed by a full redraw: `UIApplication.Render()` clears the console and every visible element draws again. `Invalidate()` marks an element as changed and raises `Invalidated`, but it does not gate drawing — a full clear combined with a dirty-only - redraw erases static elements rather than preserving them (ktsu-dev/TUI#109) + redraw erases static elements rather than preserving them (ktsu-dev/TUI#109). + What `Invalidated` does drive is *whether* a pass happens: `UIApplication` subscribes to the + root's event and flags a redraw, which the input loop's poll tick draws, so a change made off + the loop thread (a timer, a background task) appears without a keypress. The handler only sets + a flag, never draws, and ignores invalidations a pass raises on its own thread while arranging. + `UIApplication.RequestRender()` sets the same flag for a host (ktsu-dev/TUI#154) 4. Each pass also re-reads `ConsoleProvider.Dimensions`. When the terminal has changed size the root takes the new size and the whole tree is re-arranged, so a resized window is laid out on the next frame instead of staying pinned to the size at launch (ktsu-dev/TUI#111). The input diff --git a/TUI.Test/UIApplicationInvalidationTests.cs b/TUI.Test/UIApplicationInvalidationTests.cs new file mode 100644 index 0000000..2f53ed0 --- /dev/null +++ b/TUI.Test/UIApplicationInvalidationTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.TUI.Test; + +using ktsu.TUI.Core.Elements.Layouts; +using ktsu.TUI.Core.Elements.Primitives; +using ktsu.TUI.Core.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a change made outside a keypress is drawn while waits +/// for input. +/// +/// +/// The application used to render only at startup, after a key and after a resize, and nothing +/// listened to , so a clock, a progress value +/// or a status set when a background task finished stayed frozen until a key was pressed +/// (ktsu-dev/TUI#154). +/// +[TestClass] +public sealed class UIApplicationInvalidationTests +{ + /// + /// How long any single step of a run is given before it is declared stuck. + /// + private static readonly TimeSpan StepTimeout = TimeSpan.FromSeconds(10); + + /// + /// How often the input loop wakes in these tests. + /// + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(10); + + /// + /// Gets or sets the test context MSTest injects, used for its cancellation token so a run + /// started by a test ends when the test run itself is cancelled. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Text changed from another thread while the application waits for a key must be drawn + /// without a key being pressed. + /// + [TestMethod] + public async Task TextChangedOffTheLoopThreadIsDrawnWithoutAKeypress() + { + // Arrange + BlockingConsoleProvider provider = new(); + TextElement clock = new("00:00:00"); + StackPanel root = [clock]; + UIApplication app = new(provider) { RootElement = root, ResizePollInterval = PollInterval }; + + Task run = app.RunAsync(TestContext.CancellationToken); + await AssertCompletesAsync(provider.ReadStarted, "The application should start waiting for input").ConfigureAwait(false); + int passesBefore = provider.ClearCount; + + // Act + await Task.Run(() => clock.Text = "00:00:01", TestContext.CancellationToken).ConfigureAwait(false); + + // Assert + await AssertRenderPassAfterAsync(provider, passesBefore, "Changing the text should produce a render pass with no key pressed").ConfigureAwait(false); + + app.Shutdown(); + await AssertCompletesAsync(run, "The run should still end when asked to shut down").ConfigureAwait(false); + } + + /// + /// A host that changes what is shown without going through an element can ask for a pass. + /// + [TestMethod] + public async Task RequestRenderDrawsWithoutAKeypress() + { + // Arrange + BlockingConsoleProvider provider = new(); + UIApplication app = new(provider) { RootElement = new StackPanel(), ResizePollInterval = PollInterval }; + + Task run = app.RunAsync(TestContext.CancellationToken); + await AssertCompletesAsync(provider.ReadStarted, "The application should start waiting for input").ConfigureAwait(false); + int passesBefore = provider.ClearCount; + + // Act + app.RequestRender(); + + // Assert + await AssertRenderPassAfterAsync(provider, passesBefore, "RequestRender should produce a render pass with no key pressed").ConfigureAwait(false); + + app.Shutdown(); + await AssertCompletesAsync(run, "The run should still end when asked to shut down").ConfigureAwait(false); + } + + /// + /// The sizes a pass assigns while arranging the tree invalidate elements too. Those must not + /// request another pass, or the application would redraw on every poll forever. + /// + [TestMethod] + public async Task ARenderPassDoesNotRequestTheNextOne() + { + // Arrange + BlockingConsoleProvider provider = new(); + StackPanel root = [new TextElement("left"), new BorderElement { Child = new TextElement("nested") }]; + UIApplication app = new(provider) { RootElement = root, ResizePollInterval = PollInterval }; + + Task run = app.RunAsync(TestContext.CancellationToken); + await AssertCompletesAsync(provider.ReadStarted, "The application should start waiting for input").ConfigureAwait(false); + await Task.Delay(PollInterval * 5, TestContext.CancellationToken).ConfigureAwait(false); + int passesBefore = provider.ClearCount; + + // Act + await Task.Delay(PollInterval * 20, TestContext.CancellationToken).ConfigureAwait(false); + + // Assert + Assert.AreEqual(passesBefore, provider.ClearCount, "With nothing changing, the application should not keep redrawing"); + + app.Shutdown(); + await AssertCompletesAsync(run, "The run should still end when asked to shut down").ConfigureAwait(false); + } + + /// + /// Asserts that a render pass begins after were counted. + /// + /// The provider that counts passes. + /// The count before the change. + /// The assertion message if no pass happens in time. + private async Task AssertRenderPassAfterAsync(BlockingConsoleProvider provider, int passesBefore, string because) + { + DateTime deadline = DateTime.UtcNow + StepTimeout; + while (provider.ClearCount == passesBefore && DateTime.UtcNow < deadline) + { + await Task.Delay(PollInterval, TestContext.CancellationToken).ConfigureAwait(false); + } + + Assert.IsGreaterThan(passesBefore, provider.ClearCount, because); + } + + /// + /// Asserts that completes within . + /// + /// The task to wait for. + /// The assertion message if it does not complete in time. + private static async Task AssertCompletesAsync(Task task, string because) + { + Task finished = await Task.WhenAny(task, Task.Delay(StepTimeout)).ConfigureAwait(false); + Assert.AreSame(task, finished, because); + + // Observed separately so a task that failed reports its own exception, not the timeout. + await task.ConfigureAwait(false); + } +} diff --git a/TUI/Services/UIApplication.cs b/TUI/Services/UIApplication.cs index eb618fb..d2e3a0d 100644 --- a/TUI/Services/UIApplication.cs +++ b/TUI/Services/UIApplication.cs @@ -108,8 +108,45 @@ public class UIApplication(IConsoleProvider consoleProvider, ILogger private Models.Dimensions? _observedConsoleDimensions; + /// + /// 1 when an element changed since the last render pass began, and a pass should draw it even + /// though no key was pressed; 0 otherwise. An int so it can be set from any thread with + /// . + /// + private int _redrawRequested; + + /// + /// The managed thread ID running , or 0 when no pass is in progress + /// + private int _renderingThreadId; + /// - public IUIElement? RootElement { get; set; } + /// + /// The application listens to the root's , which every + /// element bubbles up to it, so a change made outside a keypress (a timer, a background task) + /// is drawn on the next poll of the input loop (ktsu-dev/TUI#154). + /// + public IUIElement? RootElement + { + get; + set + { + if (ReferenceEquals(field, value)) + { + return; + } + + field?.Invalidated -= OnRootInvalidated; + + field = value; + + if (field != null) + { + field.Invalidated += OnRootInvalidated; + RequestRender(); + } + } + } /// public IConsoleProvider ConsoleProvider { get; } = Ensure.NotNull(consoleProvider); @@ -196,6 +233,35 @@ private void OnInterrupt() Shutdown(); } + /// + /// Asks for a render pass without drawing from the calling thread + /// + /// + /// Safe to call from any thread. The pass runs on the input loop's thread within one + /// , so it never races with a pass already drawing. Elements + /// that change through request one for themselves; this is + /// for a host that changes what is shown some other way (ktsu-dev/TUI#154). + /// + public void RequestRender() => Interlocked.Exchange(ref _redrawRequested, 1); + + /// + /// Requests a render pass when anything in the tree changes + /// + /// + /// Only flags the request: the event can fire on any thread, and drawing belongs to the input + /// loop. Invalidations a render pass raises on its own thread, such as the arrange assigning + /// sizes, are ignored, or every pass would request the next one. + /// + /// The element that changed + /// Unused + private void OnRootInvalidated(object? sender, EventArgs e) + { + if (Volatile.Read(ref _renderingThreadId) != Environment.CurrentManagedThreadId) + { + RequestRender(); + } + } + /// public void Shutdown() { @@ -228,6 +294,11 @@ public void Render() return; } + // This pass draws whatever was requested before it began. A change made on another thread + // from here on requests the next one. + Interlocked.Exchange(ref _redrawRequested, 0); + Volatile.Write(ref _renderingThreadId, Environment.CurrentManagedThreadId); + try { if (_logger != null) @@ -267,6 +338,10 @@ public void Render() LogRenderError(_logger, ex); } } + finally + { + Volatile.Write(ref _renderingThreadId, 0); + } } /// @@ -377,8 +452,9 @@ public async Task ProcessInputAsync(CancellationToken cancellationToken = defaul if (!pendingRead.IsCompleted) { // The timer won the race, so no key arrived. Redraw only if the terminal - // changed size while we waited, and go back to the same pending read. - if (HasConsoleResized()) + // changed size or an element changed while we waited, and go back to the + // same pending read. + if (Volatile.Read(ref _redrawRequested) == 1 || HasConsoleResized()) { Render(); }