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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Provider Abstraction (SpectreConsoleProvider)
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
The tree is arranged on every pass, not only on a resize: showing an element, changing text, or
a size the host assigned to the root all change layout without one (ktsu-dev/TUI#133)
5. `UIContainerBase.Render()` renders itself then all visible children

## Code Patterns
Expand Down
90 changes: 90 additions & 0 deletions TUI.Test/UIApplicationLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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.Models;
using ktsu.TUI.Core.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Tests that <see cref="UIApplication"/> lays the tree out on every render pass.
/// </summary>
/// <remarks>
/// Layout used to run only when the root had no size yet or the terminal was resized, so a change
/// to the tree after the first frame kept its stale geometry (ktsu-dev/TUI#133).
/// </remarks>
[TestClass]
public sealed class UIApplicationLayoutTests
{
/// <summary>
/// An element made visible after the first frame must be laid out and drawn on the next one.
/// It used to keep its default position and empty size, so it was never drawn.
/// </summary>
[TestMethod]
public void RenderAfterShowingAHiddenElementDrawsIt()
{
// Arrange
RecordingConsoleProvider provider = new();
TextElement a = new("a");
TextElement b = new("b") { IsVisible = false };
StackPanel root = [a, b];
UIApplication app = new(provider) { RootElement = root };
app.Render();

// Act
b.IsVisible = true;
app.Render();

// Assert
Assert.Contains(
w => w.Text == "b" && w.Position == new Position(0, 1),
provider.Writes,
"The newly shown element should be drawn on the row below its sibling");
}

/// <summary>
/// A root the host sized itself must still have its children laid out. The arrange used to be
/// skipped because the root already had a size, so its children had none and nothing was drawn.
/// </summary>
[TestMethod]
public void RenderWithAHostAssignedRootSizeDrawsItsChildren()
{
// Arrange
RecordingConsoleProvider provider = new();
StackPanel root = [new TextElement("abc")];
root.Dimensions = new Dimensions(20, 5);
UIApplication app = new(provider) { RootElement = root };

// Act
app.Render();

// Assert
Assert.Contains(w => w.Text == "abc", provider.Writes, "The child of a host-sized root should be drawn");
Assert.AreEqual(new Dimensions(20, 5), root.Dimensions, "The host-assigned size should be kept");
}

/// <summary>
/// Text that grows after the first frame must push its siblings along on the next one.
/// </summary>
[TestMethod]
public void RenderAfterTextGrowsMovesTheFollowingSibling()
{
// Arrange
RecordingConsoleProvider provider = new();
TextElement first = new("ab");
TextElement second = new("cd");
StackPanel root = new(Orientation.Horizontal) { first, second };
UIApplication app = new(provider) { RootElement = root };
app.Render();
int before = second.Position.X;

// Act
first.Text = "abcdef";
app.Render();

// Assert
Assert.AreEqual(before + 4, second.Position.X, "The sibling should move right by the width the text gained");
}
}
23 changes: 12 additions & 11 deletions TUI/Services/UIApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
}

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

Check warning on line 133 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 133 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 @@ -270,7 +270,7 @@
}

/// <summary>
/// Brings the root element's size in line with the terminal, re-arranging the tree when it changes
/// Brings the root element's size in line with the terminal, then re-arranges the tree
/// </summary>
/// <param name="root">The root element to size</param>
/// <remarks>
Expand All @@ -287,20 +287,21 @@
// 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)
if (resized || root.Dimensions.IsEmpty)
{
return;
}
if (resized && _logger != null)
{
LogConsoleResized(_logger, console.Width, console.Height, null);
}

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

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.
// Arrange on every pass, not only when the size changed. Showing an element, changing
// text, or a size the host assigned all change the layout without a resize, and skipping
// the arrange left them drawn with stale geometry (ktsu-dev/TUI#133). The pass already
// clears and redraws everything, so an arrange is cheap beside it. Assigning Dimensions
// only invalidates, so the tree is walked to re-arrange every container, not just the root.
ArrangeTree(root);
}

Expand Down Expand Up @@ -337,7 +338,7 @@
RootElement != null && ConsoleProvider.Dimensions != _observedConsoleDimensions;

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

Check warning on line 341 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 52 to the 15 allowed.

Check warning on line 341 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 52 to the 15 allowed.
{
if (_logger != null)
{
Expand Down Expand Up @@ -415,7 +416,7 @@

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

Check warning on line 419 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 419 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 @@ -456,12 +457,12 @@

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

Check warning on line 460 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 460 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 465 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 465 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