diff --git a/CLAUDE.md b/CLAUDE.md
index 401d302..8332d21 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/TUI.Test/UIApplicationLayoutTests.cs b/TUI.Test/UIApplicationLayoutTests.cs
new file mode 100644
index 0000000..ebbe138
--- /dev/null
+++ b/TUI.Test/UIApplicationLayoutTests.cs
@@ -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;
+
+///
+/// Tests that lays the tree out on every render pass.
+///
+///
+/// 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).
+///
+[TestClass]
+public sealed class UIApplicationLayoutTests
+{
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
+ ///
+ /// Text that grows after the first frame must push its siblings along on the next one.
+ ///
+ [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");
+ }
+}
diff --git a/TUI/Services/UIApplication.cs b/TUI/Services/UIApplication.cs
index cc2b9ec..eb618fb 100644
--- a/TUI/Services/UIApplication.cs
+++ b/TUI/Services/UIApplication.cs
@@ -270,7 +270,7 @@ public void Render()
}
///
- /// 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
///
/// The root element to size
///
@@ -287,20 +287,21 @@ private void SyncRootToConsole(IUIElement root)
// 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);
}