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: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions TUI.Test/UIApplicationInvalidationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests that a change made outside a keypress is drawn while <see cref="UIApplication"/> waits
/// for input.
/// </summary>
/// <remarks>
/// The application used to render only at startup, after a key and after a resize, and nothing
/// listened to <see cref="Core.Contracts.IUIElement.Invalidated"/>, 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).
/// </remarks>
[TestClass]
public sealed class UIApplicationInvalidationTests
{
/// <summary>
/// How long any single step of a run is given before it is declared stuck.
/// </summary>
private static readonly TimeSpan StepTimeout = TimeSpan.FromSeconds(10);

/// <summary>
/// How often the input loop wakes in these tests.
/// </summary>
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(10);

/// <summary>
/// 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.
/// </summary>
public TestContext TestContext { get; set; } = null!;

/// <summary>
/// Text changed from another thread while the application waits for a key must be drawn
/// without a key being pressed.
/// </summary>
[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);
}

/// <summary>
/// A host that changes what is shown without going through an element can ask for a pass.
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// Asserts that a render pass begins after <paramref name="passesBefore"/> were counted.
/// </summary>
/// <param name="provider">The provider that counts passes.</param>
/// <param name="passesBefore">The count before the change.</param>
/// <param name="because">The assertion message if no pass happens in time.</param>
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);
}

/// <summary>
/// Asserts that <paramref name="task"/> completes within <see cref="StepTimeout"/>.
/// </summary>
/// <param name="task">The task to wait for.</param>
/// <param name="because">The assertion message if it does not complete in time.</param>
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);
}
}
82 changes: 79 additions & 3 deletions TUI/Services/UIApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,45 @@
/// </summary>
private Models.Dimensions? _observedConsoleDimensions;

/// <summary>
/// 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
/// <see cref="Interlocked"/>.
/// </summary>
private int _redrawRequested;

/// <summary>
/// The managed thread ID running <see cref="Render"/>, or 0 when no pass is in progress
/// </summary>
private int _renderingThreadId;

/// <inheritdoc />
public IUIElement? RootElement { get; set; }
/// <remarks>
/// The application listens to the root's <see cref="IUIElement.Invalidated"/>, 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).
/// </remarks>
public IUIElement? RootElement
{
get;
set
{
if (ReferenceEquals(field, value))
{
return;
}

field?.Invalidated -= OnRootInvalidated;

field = value;

if (field != null)
{
field.Invalidated += OnRootInvalidated;
RequestRender();
}
}
}

/// <inheritdoc />
public IConsoleProvider ConsoleProvider { get; } = Ensure.NotNull(consoleProvider);
Expand All @@ -130,7 +167,7 @@
}

IsRunning = true;
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

Check warning on line 170 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Dispose '_cancellationTokenSource' when it is no longer needed.

Check warning on line 170 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Dispose '_cancellationTokenSource' when it is no longer needed.
IDisposable? interruptRegistration = null;

try
Expand Down Expand Up @@ -196,6 +233,35 @@
Shutdown();
}

/// <summary>
/// Asks for a render pass without drawing from the calling thread
/// </summary>
/// <remarks>
/// Safe to call from any thread. The pass runs on the input loop's thread within one
/// <see cref="ResizePollInterval"/>, so it never races with a pass already drawing. Elements
/// that change through <see cref="IUIElement.Invalidate"/> request one for themselves; this is
/// for a host that changes what is shown some other way (ktsu-dev/TUI#154).
/// </remarks>
public void RequestRender() => Interlocked.Exchange(ref _redrawRequested, 1);

/// <summary>
/// Requests a render pass when anything in the tree changes
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="sender">The element that changed</param>
/// <param name="e">Unused</param>
private void OnRootInvalidated(object? sender, EventArgs e)
{
if (Volatile.Read(ref _renderingThreadId) != Environment.CurrentManagedThreadId)
{
RequestRender();
}
}

/// <inheritdoc />
public void Shutdown()
{
Expand Down Expand Up @@ -228,6 +294,11 @@
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)
Expand Down Expand Up @@ -267,6 +338,10 @@
LogRenderError(_logger, ex);
}
}
finally
{
Volatile.Write(ref _renderingThreadId, 0);
}
}

/// <summary>
Expand Down Expand Up @@ -338,7 +413,7 @@
RootElement != null && ConsoleProvider.Dimensions != _observedConsoleDimensions;

/// <inheritdoc />
public async Task ProcessInputAsync(CancellationToken cancellationToken = default)

Check warning on line 416 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 53 to the 15 allowed.

Check warning on line 416 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 53 to the 15 allowed.
{
if (_logger != null)
{
Expand Down Expand Up @@ -377,8 +452,9 @@
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();
}
Expand Down Expand Up @@ -416,7 +492,7 @@

if (!handled)
{
if (_logger != null)

Check warning on line 495 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Merge this if statement with the enclosing one.

Check warning on line 495 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Merge this if statement with the enclosing one.
{
LogInputNotHandled(_logger, null);
}
Expand Down Expand Up @@ -457,12 +533,12 @@

// Continue processing for recoverable errors
}
catch (OutOfMemoryException)

Check warning on line 536 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.

Check warning on line 536 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.
{
// Critical error - rethrow
throw;
}
catch (StackOverflowException)

Check warning on line 541 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.

Check warning on line 541 in TUI/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.
{
// Critical error - rethrow
throw;
Expand Down
Loading