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
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,20 @@ Provider Abstraction (SpectreConsoleProvider)

**Rendering flow:**
1. `UIApplication` manages the main loop and input processing
2. Elements are arranged via `ArrangeChildren()` which sets child `Position` and `Dimensions`
2. Elements are arranged via `ArrangeChildren()` which sets child `Position` and `Dimensions`.
It reaches one level only — a container arranges its own children, not theirs — so anything
that resizes a subtree has to walk it
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)
4. `UIContainerBase.Render()` renders itself then all visible children
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
loop wakes on a timer (`UIApplication.ResizePollInterval`) as well as on input, because a
resize delivers no keypress to wake it — each tick compares the size and draws only when it
changed
5. `UIContainerBase.Render()` renders itself then all visible children

## Code Patterns

Expand Down
145 changes: 134 additions & 11 deletions TUI.Core/Services/UIApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
private static readonly Action<ILogger, Exception?> LogInterruptReceived =
LoggerMessage.Define(LogLevel.Information, new EventId(17, nameof(LogInterruptReceived)), "Interrupt signal received, shutting down");

private static readonly Action<ILogger, int, int, Exception?> LogConsoleResized =
LoggerMessage.Define<int, int>(LogLevel.Debug, new EventId(18, nameof(LogConsoleResized)), "Console resized to {Width}x{Height}, re-arranging the layout");

/// <summary>
/// Gets the source of process interrupt signals that shuts the application down
/// </summary>
Expand All @@ -78,6 +81,23 @@
/// </remarks>
internal IInterruptSource InterruptSource { get; init; } = new ConsoleInterruptSource();

/// <summary>
/// Gets how often the input loop wakes to re-check the terminal size while it is waiting for
/// a key
/// </summary>
/// <remarks>
/// A resize delivers no input, so a loop that only wakes on a keypress cannot notice one.
/// Reading <see cref="IConsoleProvider.Dimensions"/> is cheap and nothing is drawn unless the
/// size actually changed, so this is a size comparison ten times a second rather than a
/// redraw. Tests shorten it so a resize is picked up without waiting out a real frame.
/// </remarks>
internal TimeSpan ResizePollInterval { get; init; } = TimeSpan.FromMilliseconds(100);

/// <summary>
/// The terminal size the current layout was computed for, or null before the first render
/// </summary>
private Models.Dimensions? _observedConsoleDimensions;

/// <inheritdoc />
public IUIElement? RootElement { get; set; }

Expand All @@ -100,7 +120,7 @@
}

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

Check warning on line 123 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Dispose '_cancellationTokenSource' when it is no longer needed.

Check warning on line 123 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

try
Expand Down Expand Up @@ -181,6 +201,11 @@
/// Each pass clears the console and redraws every visible element. Dirty tracking is not used
/// to skip elements — combining a full clear with a dirty-only redraw is what made static
/// elements disappear on the frame after their first draw (ktsu-dev/TUI#109).
/// <para>
/// Each pass also re-checks the terminal size, so a window the user resized mid-run is laid
/// out at its new size on the next frame rather than staying pinned to the size at launch
/// (ktsu-dev/TUI#111).
/// </para>
/// </remarks>
public void Render()
{
Expand All @@ -204,11 +229,9 @@
// together: a clear without a full redraw erases whatever the last pass drew.
ConsoleProvider.Clear();

// Set root element dimensions to console dimensions if not set
if (RootElement.Dimensions.IsEmpty)
{
RootElement.Dimensions = ConsoleProvider.Dimensions;
}
// Size the layout to the terminal before drawing it, so a resize since the last pass
// is reflected in this one.
SyncRootToConsole(RootElement);

// Render the root element
RootElement.Render(ConsoleProvider);
Expand Down Expand Up @@ -236,24 +259,124 @@
}
}

/// <summary>
/// Brings the root element's size in line with the terminal, re-arranging the tree when it changes
/// </summary>
/// <param name="root">The root element to size</param>
/// <remarks>
/// The size used to be taken once, on the first pass that found the root unsized, and never
/// looked at again — so resizing the window left every layout pinned to the size at launch
/// (ktsu-dev/TUI#111).
/// </remarks>
private void SyncRootToConsole(IUIElement root)
{
Models.Dimensions console = ConsoleProvider.Dimensions;
bool resized = _observedConsoleDimensions is Models.Dimensions observed && observed != console;
_observedConsoleDimensions = console;

// Adopt the terminal size when the root has none of its own, and again whenever the
// terminal is resized. In between, a size the host assigned to the root is left alone — a
// resize is the one thing that overrides it, since the old size no longer fits the window.
if (!resized && !root.Dimensions.IsEmpty)
{
return;
}

if (resized && _logger != null)
{
LogConsoleResized(_logger, console.Width, console.Height, null);
}

root.Dimensions = console;

// Assigning Dimensions only invalidates; it does not re-run layout. Walk the tree so every
// container re-arranges inside its new size, not just the root.
ArrangeTree(root);
}

/// <summary>
/// Re-arranges <paramref name="element"/> and every container beneath it, parents first
/// </summary>
/// <param name="element">The element to arrange</param>
/// <remarks>
/// A container's <see cref="IUIContainer.ArrangeChildren"/> sizes and positions its own
/// children but does not reach theirs, so arranging only the root would relayout the top level
/// and leave everything under it at the old size. Parents are arranged first because a child
/// container can only lay its own children out once it knows its new size.
/// </remarks>
private static void ArrangeTree(IUIElement element)
{
if (element is not IUIContainer container)
{
return;
}

container.ArrangeChildren();

foreach (IUIElement child in container.Children)
{
ArrangeTree(child);
}
}

/// <summary>
/// Gets whether the terminal has changed size since the last render pass
/// </summary>
/// <returns>True when a redraw is needed to pick the new size up</returns>
private bool HasConsoleResized() =>
RootElement != null && ConsoleProvider.Dimensions != _observedConsoleDimensions;

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

Check warning on line 330 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 330 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 44 to the 15 allowed.
{
if (_logger != null)
{
LogStartingInputProcessing(_logger, null);
}

// One read is carried across iterations. The resize poll below wakes the loop without a
// keypress, and starting a fresh read each time it woke would leave several reads racing
// for the next key.
Task<Models.InputResult>? pendingRead = null;

while (!cancellationToken.IsCancellationRequested && IsRunning)
{
try
{
// Abandon the read when cancellation is requested. A provider parked in
// Console.ReadKey does not observe the token, so awaiting it directly would keep
// the loop alive until the user pressed an unrelated key after asking to exit.
Models.InputResult input = await ConsoleProvider.ReadInputAsync()
.WaitAsync(cancellationToken)
.ConfigureAwait(false);
pendingRead ??= ConsoleProvider.ReadInputAsync();

if (!pendingRead.IsCompleted)
{
// Wake on a timer as well as on input. A resize produces no input at all, so a
// loop that only wakes for a key cannot notice one. Cancelling the delay is
// also what lets a shutdown request end a run blocked on the keyboard: a
// provider parked in Console.ReadKey does not observe the token itself.
Task idle = Task.Delay(ResizePollInterval, cancellationToken);
await Task.WhenAny(pendingRead, idle).ConfigureAwait(false);

if (cancellationToken.IsCancellationRequested)
{
break;
}

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

continue;
}
}

// Cleared before the await so a read that failed is not retried forever by the
// recoverable-error branches below.
Task<Models.InputResult> completedRead = pendingRead;
pendingRead = null;
Models.InputResult input = await completedRead.ConfigureAwait(false);

if (_logger != null)
{
Expand All @@ -276,7 +399,7 @@

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

Check warning on line 402 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Merge this if statement with the enclosing one.

Check warning on line 402 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

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

Check warning on line 433 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 433 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / 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 438 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 438 in TUI.Core/Services/UIApplication.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add logic to this catch clause or eliminate it and rethrow the exception automatically.
{
// Critical error - rethrow
throw;
Expand Down
36 changes: 31 additions & 5 deletions TUI.Test/BlockingConsoleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,40 @@ internal sealed class BlockingConsoleProvider : IConsoleProvider
/// </summary>
internal bool CursorVisible => cursorVisible;

/// <inheritdoc />
public Dimensions Dimensions { get; set; } = new(80, 24);
private readonly Lock dimensionsLock = new();
private int clearCount;

/// <inheritdoc />
public void Clear()
/// <remarks>
/// Guarded because a resize test writes it from the test thread while the application reads it
/// from the thread running the loop — which is exactly the situation the resize poll exists for.
/// </remarks>
public Dimensions Dimensions
{
// Nothing to record: these tests assert on lifecycle, not on drawn output.
}
get
{
lock (dimensionsLock)
{
return field;
}
}

set
{
lock (dimensionsLock)
{
field = value;
}
}
} = new(80, 24);

/// <summary>
/// Gets the number of times <see cref="Clear"/> was called, which is once per render pass.
/// </summary>
internal int ClearCount => Volatile.Read(ref clearCount);

/// <inheritdoc />
public void Clear() => Interlocked.Increment(ref clearCount);

/// <inheritdoc />
public void Render(IUIElement element, Position position) => element?.Render(this);
Expand Down
45 changes: 45 additions & 0 deletions TUI.Test/RecordingLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.TUI.Test;

using ktsu.TUI.Core.Services;
using Microsoft.Extensions.Logging;

/// <summary>
/// An <see cref="ILogger{TCategoryName}"/> that records the messages written to it, so a test can
/// assert what the application reported rather than only that it did not throw.
/// </summary>
internal sealed class RecordingLogger : ILogger<UIApplication>
{
private readonly List<string> messages = [];

/// <summary>
/// Gets the messages logged so far, in call order.
/// </summary>
internal IEnumerable<string> Messages
{
get
{
lock (messages)
{
return [.. messages];
}
}
}

/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;

/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
string message = formatter is null ? string.Empty : formatter(state, exception);
lock (messages)
{
messages.Add(message);
}
}
}
40 changes: 0 additions & 40 deletions TUI.Test/UIApplicationLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ namespace ktsu.TUI.Test;

using ktsu.TUI.Core.Models;
using ktsu.TUI.Core.Services;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
Expand Down Expand Up @@ -262,43 +261,4 @@ private static async Task AssertCompletesAsync(Task task, string because)
// Observed separately so a task that failed reports its own exception, not the timeout.
await task.ConfigureAwait(false);
}

/// <summary>
/// An <see cref="ILogger{TCategoryName}"/> that records the messages written to it, so a test
/// can assert what the application reported rather than only that it did not throw.
/// </summary>
private sealed class RecordingLogger : ILogger<UIApplication>
{
private readonly List<string> messages = [];

/// <summary>
/// Gets the messages logged so far, in call order.
/// </summary>
internal IEnumerable<string> Messages
{
get
{
lock (messages)
{
return [.. messages];
}
}
}

/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;

/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
string message = formatter is null ? string.Empty : formatter(state, exception);
lock (messages)
{
messages.Add(message);
}
}
}
}
Loading