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