diff --git a/TUI.Test/SpectreConsoleProviderTests.cs b/TUI.Test/SpectreConsoleProviderTests.cs index d2666ad..5ff376d 100644 --- a/TUI.Test/SpectreConsoleProviderTests.cs +++ b/TUI.Test/SpectreConsoleProviderTests.cs @@ -2,6 +2,7 @@ namespace ktsu.TUI.Test; +using ktsu.TUI.Core.Elements.Primitives; using ktsu.TUI.Core.Models; using ktsu.TUI.Core.Services; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -283,4 +284,37 @@ public void PartiallyTransparentBackgroundIsDrawnOpaque() Assert.Contains("48;2;0;0;255", output.ToString()); } + + /// + /// Tests that no control character from element text reaches the terminal, so text cannot + /// clear the screen or move the cursor itself (ktsu-dev/TUI#159). + /// + [TestMethod] + public void TextElementWritesNoControlCharactersToTheTerminal() + { + (SpectreConsoleProvider provider, StringWriter output) = CreateProvider(); + TextElement element = new("x\u001b[2Jy") + { + Position = Position.Origin, + Dimensions = new Dimensions(Width, 1), + }; + + element.Render(provider); + + Assert.AreEqual("\u001b[1;1Hx\uFFFD[2Jy", output.ToString()); + } + + /// + /// Tests that the provider replaces control characters in text written to it directly, as well + /// as in text an element has already normalised (ktsu-dev/TUI#159). + /// + [TestMethod] + public void WriteAtReplacesControlCharacters() + { + (SpectreConsoleProvider provider, StringWriter output) = CreateProvider(); + + provider.WriteAt("a\tb\u001bc\u009bd", new Position(0, 0)); + + Assert.AreEqual("\u001b[1;1Ha\uFFFDb\uFFFDc\uFFFDd", output.ToString()); + } } diff --git a/TUI.Test/TextElementTests.cs b/TUI.Test/TextElementTests.cs index a0f6adf..9341134 100644 --- a/TUI.Test/TextElementTests.cs +++ b/TUI.Test/TextElementTests.cs @@ -588,4 +588,78 @@ public void WideTextIsMeasuredAndAlignedInCells() Assert.AreEqual(6, required.Width); Assert.AreEqual(4, provider.Writes.Single().Position.X); } + + /// + /// A tab inside a border must be measured as the cells it moves the cursor across, and drawn as + /// those cells, so the text stops at the border. It used to count as one cell and reach the + /// terminal raw, which jumped to the next tab stop and overwrote the right border + /// (ktsu-dev/TUI#159). + /// + [TestMethod] + public void TabInABorderMeasuresToTheWidthItDraws() + { + // Arrange + BorderElement border = []; + border.Child = new TextElement("Name\tValue"); + border.Position = Position.Origin; + border.Dimensions = border.CalculateRequiredDimensions(); + border.ArrangeChildren(); + RecordingConsoleProvider provider = new(); + + // Act + border.Render(provider); + + // Assert + Assert.AreEqual(new Dimensions(15, 3), border.Dimensions, "\"Name\" then a tab to column 8 then \"Value\" is 13 cells, plus the border"); + RecordingConsoleProvider.Write write = provider.WritesOf("Name Value").Single(); + Assert.AreEqual(1, write.Position.X); + Assert.IsLessThanOrEqualTo(border.Dimensions.Width - 1, write.Position.X + CellWidth.Of(write.Text), "The text must stop before the right border"); + } + + /// + /// A tab must move to the next tab stop counted from the start of its own line, not from the + /// start of the text (ktsu-dev/TUI#159). + /// + [TestMethod] + public void TabsExpandToTheNextTabStopOnEachLine() + { + // Arrange + TextElement element = CreateElement("a\tb\nabcdefgh\tc\n\td"); + RecordingConsoleProvider provider = new(); + + // Act + element.Render(provider); + + // Assert + string[] written = [.. provider.Writes.Select(write => write.Text)]; + Assert.HasCount(3, written); + Assert.AreEqual("a b", written[0]); + Assert.AreEqual("abcdefgh c", written[1]); + Assert.AreEqual(" d", written[2]); + } + + /// + /// An escape or other control character in the text must not be written, or the text could + /// clear the screen or move the cursor in the middle of a frame (ktsu-dev/TUI#159). + /// + /// The text holding a control character. + /// What should be drawn instead. + [TestMethod] + [DataRow("x\u001b[2Jy", "x\uFFFD[2Jy")] + [DataRow("bell\u0007", "bell\uFFFD")] + [DataRow("\u009b2J", "\uFFFD2J")] + [DataRow("del\u007f", "del\uFFFD")] + public void ControlCharactersAreDrawnAsAPlaceholder(string text, string drawn) + { + // Arrange + TextElement element = CreateElement(text); + RecordingConsoleProvider provider = new(); + + // Act + element.Render(provider); + + // Assert + Assert.AreEqual(drawn, provider.Writes.Single().Text); + Assert.AreEqual(new Dimensions(CellWidth.Of(drawn), 1), element.CalculateRequiredDimensions()); + } } diff --git a/TUI/Elements/Primitives/TextElement.cs b/TUI/Elements/Primitives/TextElement.cs index 305181f..8c86b9d 100644 --- a/TUI/Elements/Primitives/TextElement.cs +++ b/TUI/Elements/Primitives/TextElement.cs @@ -3,6 +3,7 @@ namespace ktsu.TUI.Core.Elements.Primitives; using System.Globalization; +using System.Text; using ktsu.TUI.Core.Contracts; using ktsu.TUI.Core.Models; using Spectre.Console; @@ -12,6 +13,18 @@ namespace ktsu.TUI.Core.Elements.Primitives; /// public class TextElement : UIElementBase { + /// + /// The distance between tab stops, in cells. A tab in the text moves to the next multiple of + /// this, counted from the start of its line. + /// + public const int TabWidth = 8; + + /// + /// What a control character in the text is drawn as, so it takes one visible cell instead of + /// reaching the terminal as a command. + /// + public const char ControlCharacterPlaceholder = '\uFFFD'; + /// /// Gets or sets the text to display /// @@ -186,14 +199,16 @@ private int CalculateVerticalPosition(int totalLines, int availableHeight, int b /// /// Breaks text into the lines that are measured and drawn: one per embedded line break, each - /// then wrapped to when it is positive (ktsu-dev/TUI#135) + /// then wrapped to when it is positive (ktsu-dev/TUI#135). + /// Tabs and other control characters are normalised first, so that what is measured is what + /// is drawn (ktsu-dev/TUI#159). /// /// The text to break /// The width to wrap each line to, or 0 to leave lines unwrapped /// The lines, in order; a blank line in the text stays as an empty line private static string[] SplitIntoLines(string text, int wrapWidth) { - string[] paragraphs = text.Split(["\r\n", "\n", "\r"], StringSplitOptions.None); + string[] paragraphs = [.. text.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).Select(NormalizeControlCharacters)]; if (wrapWidth <= 0) { return paragraphs; @@ -207,6 +222,50 @@ private static string[] SplitIntoLines(string text, int wrapWidth) })]; } + /// + /// Makes one line safe to measure and draw cell by cell. A tab becomes the spaces up to the + /// next tab stop, and any other control character becomes . + /// Written raw, a tab jumps the terminal cursor past the cells it was measured as, and an + /// escape or other C0/C1 character lets the text move the cursor, recolour or clear the + /// screen (ktsu-dev/TUI#159). + /// + /// The line to normalise, with no line breaks in it + /// The line with no control characters left in it + internal static string NormalizeControlCharacters(string line) + { + if (!line.Any(char.IsControl)) + { + return line; + } + + StringBuilder normalized = new(line.Length); + int cells = 0; + TextElementEnumerator elements = StringInfo.GetTextElementEnumerator(line); + while (elements.MoveNext()) + { + // A control character is always a text element of its own + string element = elements.GetTextElement(); + if (element == "\t") + { + int spaces = TabWidth - (cells % TabWidth); + normalized.Append(' ', spaces); + cells += spaces; + } + else if (element.Length == 1 && char.IsControl(element[0])) + { + normalized.Append(ControlCharacterPlaceholder); + cells++; + } + else + { + normalized.Append(element); + cells += element.GetCellWidth(); + } + } + + return normalized.ToString(); + } + /// /// Wraps one paragraph to cells. Spaces are kept as written, /// including leading indentation and runs between words, and are dropped only where a line diff --git a/TUI/Services/SpectreConsoleProvider.cs b/TUI/Services/SpectreConsoleProvider.cs index c47c5dd..51835ba 100644 --- a/TUI/Services/SpectreConsoleProvider.cs +++ b/TUI/Services/SpectreConsoleProvider.cs @@ -3,6 +3,7 @@ namespace ktsu.TUI.Core.Services; using ktsu.TUI.Core.Contracts; +using ktsu.TUI.Core.Elements.Primitives; using ktsu.TUI.Core.Models; using Spectre.Console; @@ -65,6 +66,11 @@ public void WriteAt(string text, Position position, TextStyle? style = null) return; } + // Nothing written may command the terminal: a control character from the text would move + // the cursor, recolour or clear the screen. Elements normalise their own text, so this only + // catches text reaching the provider some other way (ktsu-dev/TUI#159). + text = ReplaceControlCharacters(text); + // Clip to the screen before moving the cursor. SetCursorPosition ignores an off-screen // position, so writing anyway would put the text wherever the last write left the cursor, // and text running past the right edge would wrap onto the next line (ktsu-dev/TUI#139). @@ -208,4 +214,20 @@ private static string BuildStyleString(TextStyle style) return string.Join(" ", parts); } + + private static string ReplaceControlCharacters(string text) + { + if (!text.Any(char.IsControl)) + { + return text; + } + + return string.Create(text.Length, text, static (chars, source) => + { + for (int i = 0; i < source.Length; i++) + { + chars[i] = char.IsControl(source[i]) ? TextElement.ControlCharacterPlaceholder : source[i]; + } + }); + } }