From b52e26567f3a47df60ed291fdef03002e6e4ac11 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 05:26:22 +0000 Subject: [PATCH] fix: measure a titled BorderElement wide enough to draw its title [patch] The measure returned only the child's size, so a border sized by a layout was exactly as wide as its content and a longer title was cut short or not drawn at all. Reserve Title.Length + 2 columns in the content area when a title and a border are drawn; truncation in OnRender stays the fallback. Fixes ktsu-dev/TUI#157 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015YFg3aG1od37a8RytUTWQy --- TUI.Test/BorderElementRenderTests.cs | 41 ++++++++++++++++++++++++ TUI/Elements/Primitives/BorderElement.cs | 25 +++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/TUI.Test/BorderElementRenderTests.cs b/TUI.Test/BorderElementRenderTests.cs index 325f94e..a922ea3 100644 --- a/TUI.Test/BorderElementRenderTests.cs +++ b/TUI.Test/BorderElementRenderTests.cs @@ -2,6 +2,7 @@ namespace ktsu.TUI.Test; +using ktsu.TUI.Core.Elements.Layouts; using ktsu.TUI.Core.Elements.Primitives; using ktsu.TUI.Core.Models; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -230,4 +231,44 @@ public void TitleWithALineBreakIsDrawnOnOneRow(string title) Assert.IsFalse(provider.Writes.Any(w => w.Text.Contains('\r') || w.Text.Contains('\n')), "A write carried a line break"); Assert.AreEqual(0, provider.WritesOf(" ab cd ").Single().Position.Y); } + + /// + /// A border sized by a layout is measured wide enough for its title, so a title longer than + /// the content is drawn in full rather than cut short or dropped (ktsu-dev/TUI#157). + /// + [TestMethod] + public void TitleLongerThanTheContentIsDrawnInFullInsideAStackPanel() + { + // Arrange + StackPanel panel = new(Orientation.Horizontal) { Spacing = 1 }; + panel.AddChild(new BorderElement { Title = "Single", Child = new TextElement { Text = "Content" } }); + panel.AddChild(new BorderElement { Title = "Status", Child = new TextElement { Text = "OK" } }); + panel.Position = Position.Origin; + panel.Dimensions = new Dimensions(80, 24); + RecordingConsoleProvider provider = new(); + + // Act + panel.ArrangeChildren(); + panel.Render(provider); + + // Assert + Assert.ContainsSingle(provider.WritesOf(" Single ")); + Assert.ContainsSingle(provider.WritesOf(" Status ")); + } + + /// + /// The measure reserves room for the title only when a title is drawn: an untitled border, and a + /// border, are still exactly as wide as their content. + /// + [TestMethod] + public void MeasuredWidthMakesRoomForTheTitleOnlyWhenOneIsDrawn() + { + BorderElement titled = new() { Title = "Status", Child = new TextElement { Text = "OK" } }; + BorderElement untitled = new() { Child = new TextElement { Text = "OK" } }; + BorderElement borderless = new(BorderStyle.None) { Title = "Status", Child = new TextElement { Text = "OK" } }; + + Assert.AreEqual(" Status ".Length + 2, titled.CalculateRequiredDimensions().Width); + Assert.AreEqual("OK".Length + 2, untitled.CalculateRequiredDimensions().Width); + Assert.AreEqual("OK".Length + 2, borderless.CalculateRequiredDimensions().Width); + } } diff --git a/TUI/Elements/Primitives/BorderElement.cs b/TUI/Elements/Primitives/BorderElement.cs index c318779..be27642 100644 --- a/TUI/Elements/Primitives/BorderElement.cs +++ b/TUI/Elements/Primitives/BorderElement.cs @@ -151,9 +151,7 @@ protected override void OnRender(IConsoleProvider provider) { int maxTitleWidth = dimensions.Width - 4; // Leave space for border and padding - // A line break written to the terminal would move the rest of the title off the top - // border row and over the left edge, so each break becomes a space (ktsu-dev/TUI#151). - string singleLineTitle = Title.Replace("\r\n", " ", StringComparison.Ordinal).Replace('\r', ' ').Replace('\n', ' '); + string singleLineTitle = GetSingleLineTitle(); string displayTitle = singleLineTitle.Length > maxTitleWidth ? singleLineTitle[..maxTitleWidth] : singleLineTitle; string titleWithPadding = $" {displayTitle} "; @@ -186,9 +184,24 @@ protected override void OnArrangeChildren() /// // The border itself is reserved by the Padding(1) set in the constructor, which the base - // class adds to this result, so only the child's own size belongs here (ktsu-dev/TUI#132) - protected override Dimensions OnCalculateRequiredDimensionsForChildren() => - Children.Count == 0 ? Dimensions.Empty : Children.First().CalculateRequiredDimensions(); + // 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() + { + Dimensions childDimensions = Children.Count == 0 ? Dimensions.Empty : Children.First().CalculateRequiredDimensions(); + if (BorderStyle == BorderStyle.None || string.IsNullOrEmpty(Title)) + { + return childDimensions; + } + + return new Dimensions(Math.Max(childDimensions.Width, GetSingleLineTitle().Length + 2), childDimensions.Height); + } + + // A line break written to the terminal would move the rest of the title off the top + // border row and over the left edge, so each break becomes a space (ktsu-dev/TUI#151). + private string GetSingleLineTitle() => + Title.Replace("\r\n", " ", StringComparison.Ordinal).Replace('\r', ' ').Replace('\n', ' '); private static BorderCharacters GetBorderCharacters(BorderStyle style) {