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();
}