From 362764794c839656292ceb076ea0e9a2667bf8eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:26:06 +0000 Subject: [PATCH] Wrap text inside a border or a horizontal stack to the width it gets [patch] BorderElement measured its child without the width its parent passed in, and a horizontal StackPanel measured children width-independently, so word-wrapped text in either reported one unwrapped row and only its first line was drawn. BorderElement now forwards the width inside the border, and a horizontal StackPanel measures each child against the width its earlier siblings and the spacing leave, in both arrange and measure. Replaces the test that locked in the border's width-blind measure. Fixes ktsu-dev/TUI#161 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016rdMXULeUT13t6FCwNfocx --- TUI.Test/StackPanelTests.cs | 88 ++++++++++++++++++++++-- TUI/Elements/Layouts/StackPanel.cs | 20 ++++-- TUI/Elements/Primitives/BorderElement.cs | 13 +++- 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/TUI.Test/StackPanelTests.cs b/TUI.Test/StackPanelTests.cs index b08469a..4026b07 100644 --- a/TUI.Test/StackPanelTests.cs +++ b/TUI.Test/StackPanelTests.cs @@ -569,15 +569,93 @@ public void StackPanelHorizontalChildSpansThePanelHeight() } /// - /// Tests that a container that does not use the available width, such as BorderElement, measures - /// the same with or without one. + /// Tests that a BorderElement passes the width it is measured against down to its child, less + /// the border, so word-wrapped text inside it reports a row per wrapped line (ktsu-dev/TUI#161). /// [TestMethod] - public void ContainerWithoutAWidthAwareLayoutMeasuresTheSameWithAWidth() + public void BorderElementMeasuresItsChildAgainstTheWidthInsideTheBorder() { - BorderElement border = [new TextElement("abc")]; + BorderElement border = [new TextElement("hello world foo") { WordWrap = true }]; - Assert.AreEqual(border.CalculateRequiredDimensions(), border.CalculateRequiredDimensions(2)); + Assert.AreEqual(new Dimensions(17, 3), border.CalculateRequiredDimensions()); + Assert.AreEqual(new Dimensions(11, 4), border.CalculateRequiredDimensions(12)); + } + + /// + /// Tests that word-wrapped text inside a border inside a vertical panel is given, and draws, + /// every wrapped line rather than only the first (ktsu-dev/TUI#161). + /// + [TestMethod] + public void StackPanelVerticalDrawsEveryWrappedLineOfTextInsideABorder() + { + TextElement text = new("hello world foo") { WordWrap = true }; + BorderElement border = [text]; + StackPanel panel = [border]; + panel.Dimensions = new Dimensions(12, 10); + panel.ArrangeChildren(); + border.ArrangeChildren(); + + RecordingConsoleProvider provider = new(); + panel.Render(provider); + + Assert.AreEqual(new Dimensions(12, 4), border.Dimensions); + Assert.AreEqual(new Dimensions(10, 2), text.Dimensions); + Assert.AreEqual(new Position(1, 1), Assert.ContainsSingle(provider.WritesOf("hello")).Position); + Assert.AreEqual(new Position(1, 2), Assert.ContainsSingle(provider.WritesOf("world foo")).Position); + } + + /// + /// Tests that a horizontal panel measures word-wrapped text against the width it has, so it + /// reports the wrapped height to a parent rather than one row (ktsu-dev/TUI#161). + /// + [TestMethod] + public void StackPanelHorizontalRequiredDimensionsWrapTextToTheAvailableWidth() + { + StackPanel panel = new() { Orientation = Orientation.Horizontal }; + panel.Add(new TextElement("hello world foo") { WordWrap = true }); + + Assert.AreEqual(new Dimensions(15, 1), panel.CalculateRequiredDimensions()); + Assert.AreEqual(new Dimensions(5, 3), panel.CalculateRequiredDimensions(8)); + } + + /// + /// Tests that a horizontal panel measures each child against the width its earlier siblings and + /// the spacing leave, as it does when arranging them (ktsu-dev/TUI#161). + /// + [TestMethod] + public void StackPanelHorizontalMeasuresEachChildAgainstTheRemainingWidth() + { + StackPanel panel = new() { Orientation = Orientation.Horizontal, Spacing = 1 }; + panel.Add(new TextElement("abc")); + panel.Add(new TextElement("hello world") { WordWrap = true }); + + Assert.AreEqual(new Dimensions(9, 2), panel.CalculateRequiredDimensions(9)); + } + + /// + /// Tests that word-wrapped text inside a horizontal panel inside a vertical panel is given a row + /// per wrapped line and draws them all, with the next row placed below it (ktsu-dev/TUI#161). + /// + [TestMethod] + public void StackPanelVerticalDrawsEveryWrappedLineOfTextInsideAHorizontalPanel() + { + StackPanel row = new() { Orientation = Orientation.Horizontal }; + TextElement text = new("hello world foo") { WordWrap = true }; + row.Add(text); + StackPanel panel = [row, new TextElement("next")]; + panel.Dimensions = new Dimensions(8, 10); + panel.ArrangeChildren(); + row.ArrangeChildren(); + + RecordingConsoleProvider provider = new(); + panel.Render(provider); + + Assert.AreEqual(new Dimensions(8, 3), row.Dimensions); + Assert.AreEqual(new Dimensions(5, 3), text.Dimensions); + Assert.AreEqual(new Position(0, 0), Assert.ContainsSingle(provider.WritesOf("hello")).Position); + Assert.AreEqual(new Position(0, 1), Assert.ContainsSingle(provider.WritesOf("world")).Position); + Assert.AreEqual(new Position(0, 2), Assert.ContainsSingle(provider.WritesOf("foo")).Position); + Assert.AreEqual(new Position(0, 3), Assert.ContainsSingle(provider.WritesOf("next")).Position); } /// diff --git a/TUI/Elements/Layouts/StackPanel.cs b/TUI/Elements/Layouts/StackPanel.cs index 321c565..4a4a666 100644 --- a/TUI/Elements/Layouts/StackPanel.cs +++ b/TUI/Elements/Layouts/StackPanel.cs @@ -91,11 +91,12 @@ protected override void OnArrangeChildren() { unplaced.Remove(child); - // A vertical child's width is known before its height, so it is measured against that - // width. Word-wrapped text needs this to report one row per wrapped line (ktsu-dev/TUI#131). + // A child is measured against the width it can have: the panel's width when stacking + // vertically, and the width not yet taken when stacking horizontally. Word-wrapped text + // needs this to report one row per wrapped line (ktsu-dev/TUI#131, ktsu-dev/TUI#161). Dimensions childDimensions = Orientation == Orientation.Vertical ? child.CalculateRequiredDimensions(contentArea.Width) - : child.CalculateRequiredDimensions(); + : child.CalculateRequiredDimensions(contentArea.Width - currentOffset); if (Orientation == Orientation.Vertical) { @@ -165,12 +166,18 @@ private Dimensions CalculateRequiredDimensionsForChildren(int? availableWidth) IUIElement[] visibleChildren = [.. GetVisibleChildren()]; int totalSpacing = Math.Max(0, (visibleChildren.Length - 1) * Spacing); + int consumedWidth = 0; foreach (IUIElement? child in visibleChildren) { - Dimensions childDimensions = Orientation == Orientation.Vertical && availableWidth is int width - ? child.CalculateRequiredDimensions(width) - : child.CalculateRequiredDimensions(); + // A horizontal child gets the width its earlier siblings and the spacing between them + // leave, as it does when arranged (ktsu-dev/TUI#161). + Dimensions childDimensions = availableWidth switch + { + int width when Orientation == Orientation.Vertical => child.CalculateRequiredDimensions(width), + int width => child.CalculateRequiredDimensions(Math.Max(0, width - consumedWidth)), + _ => child.CalculateRequiredDimensions(), + }; if (Orientation == Orientation.Vertical) { @@ -180,6 +187,7 @@ private Dimensions CalculateRequiredDimensionsForChildren(int? availableWidth) else { totalWidth += childDimensions.Width; + consumedWidth += childDimensions.Width + Spacing; maxHeight = Math.Max(maxHeight, childDimensions.Height); } } diff --git a/TUI/Elements/Primitives/BorderElement.cs b/TUI/Elements/Primitives/BorderElement.cs index be27642..f962c25 100644 --- a/TUI/Elements/Primitives/BorderElement.cs +++ b/TUI/Elements/Primitives/BorderElement.cs @@ -187,9 +187,18 @@ protected override void OnArrangeChildren() // class adds to this result, so only the child's own size belongs here (ktsu-dev/TUI#132). // A title is drawn as " title " between the corners, so the content area must be at least // two wider than the title or a layout sizes the border too narrow to show it (ktsu-dev/TUI#157). - protected override Dimensions OnCalculateRequiredDimensionsForChildren() + protected override Dimensions OnCalculateRequiredDimensionsForChildren() => + WithRoomForTitle(Children.Count == 0 ? Dimensions.Empty : Children.First().CalculateRequiredDimensions()); + + /// + // The base class has already taken the border off availableWidth, so the child is measured + // against the width it will be arranged into. Without this a word-wrapped child is measured + // unwrapped and given a single row (ktsu-dev/TUI#161). + protected override Dimensions OnCalculateRequiredDimensionsForChildren(int availableWidth) => + WithRoomForTitle(Children.Count == 0 ? Dimensions.Empty : Children.First().CalculateRequiredDimensions(availableWidth)); + + private Dimensions WithRoomForTitle(Dimensions childDimensions) { - Dimensions childDimensions = Children.Count == 0 ? Dimensions.Empty : Children.First().CalculateRequiredDimensions(); if (BorderStyle == BorderStyle.None || string.IsNullOrEmpty(Title)) { return childDimensions;