diff --git a/TUI.Test/TextElementTests.cs b/TUI.Test/TextElementTests.cs index 54a941f..a0f6adf 100644 --- a/TUI.Test/TextElementTests.cs +++ b/TUI.Test/TextElementTests.cs @@ -288,6 +288,49 @@ public void WordWrapLeavesNoEmptyLineWhenAWordDividesExactly() Assert.AreEqual("def", provider.Writes[1].Text); } + private static readonly string[] ExpectedIndentedWrappedLines = [" key: aa", "bb cc"]; + + /// + /// Turning on word wrap must not change a line that already fits: indentation and runs of + /// spaces stay as written (ktsu-dev/TUI#150). + /// + [TestMethod] + public void WordWrapLeavesALineThatFitsUnchanged() + { + // Arrange + TextElement element = CreateElement(" indented two", width: 40, height: 3); + element.WordWrap = true; + RecordingConsoleProvider provider = new(); + + // Act + element.Render(provider); + + // Assert + Assert.AreEqual(" indented two", provider.Writes.Single().Text); + Assert.AreEqual(17, element.CalculateRequiredDimensions(40).Width); + } + + /// + /// A paragraph that does wrap keeps its indentation and inner spacing, and loses spaces only + /// where a line breaks (ktsu-dev/TUI#150). + /// + [TestMethod] + public void WordWrapKeepsSpacesExceptAtBreakPoints() + { + // Arrange + TextElement element = CreateElement(" key: aa bb cc", width: 10, height: 5); + element.WordWrap = true; + RecordingConsoleProvider provider = new(); + + // Act + element.Render(provider); + + // Assert + Assert.AreSequenceEqual( + ExpectedIndentedWrappedLines, + provider.Writes.Select(w => w.Text)); + } + /// /// An element that is not visible must draw nothing. /// diff --git a/TUI/Elements/Primitives/TextElement.cs b/TUI/Elements/Primitives/TextElement.cs index 21a153b..305181f 100644 --- a/TUI/Elements/Primitives/TextElement.cs +++ b/TUI/Elements/Primitives/TextElement.cs @@ -207,20 +207,40 @@ private static string[] SplitIntoLines(string text, int wrapWidth) })]; } + /// + /// Wraps one paragraph to cells. Spaces are kept as written, + /// including leading indentation and runs between words, and are dropped only where a line + /// breaks; a paragraph that already fits comes back unchanged (ktsu-dev/TUI#150). + /// + /// The paragraph to wrap, with no line breaks in it + /// The width to wrap to, in cells + /// The wrapped lines, in order private static string[] WrapText(string text, int maxWidth) { - if (maxWidth <= 0) + if (maxWidth <= 0 || MeasureCells(text) <= maxWidth) { return [text]; } List lines = []; - string[] words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); string currentLine = string.Empty; + string pendingSpaces = string.Empty; - foreach (string word in words) + foreach (string word in SplitKeepingSpaces(text)) { - string testLine = string.IsNullOrEmpty(currentLine) ? word : $"{currentLine} {word}"; + if (word[0] == ' ') + { + // Spaces at the start of a wrapped line are what was left over from the break + if (currentLine.Length > 0 || lines.Count == 0) + { + pendingSpaces = word; + } + + continue; + } + + string testLine = currentLine + pendingSpaces + word; + pendingSpaces = string.Empty; if (MeasureCells(testLine) <= maxWidth) { @@ -234,24 +254,7 @@ private static string[] WrapText(string text, int maxWidth) lines.Add(currentLine); } - // A word longer than max width has to be broken, and one slice is not enough: - // keep slicing until what is left actually fits, or the tail overflows the line. - string remainder = word; - while (MeasureCells(remainder) > maxWidth) - { - string slice = TakeCells(remainder, maxWidth); - if (slice.Length == 0) - { - // A single character wider than the whole line still has to go somewhere, - // or the loop would never shrink the remainder. - slice = StringInfo.GetNextTextElement(remainder); - } - - lines.Add(slice); - remainder = remainder[slice.Length..]; - } - - currentLine = remainder; + currentLine = BreakOverlongWord(word, maxWidth, lines); } if (!string.IsNullOrEmpty(currentLine)) @@ -262,6 +265,56 @@ private static string[] WrapText(string text, int maxWidth) return [.. lines]; } + /// + /// Adds full-width slices of a word longer than to + /// , and returns the tail that fits, to start the next line with + /// + /// The word to break + /// The width to break it to, in cells + /// The lines to add the full slices to + /// What is left of the word once it fits + private static string BreakOverlongWord(string word, int maxWidth, List lines) + { + // A word longer than max width has to be broken, and one slice is not enough: + // keep slicing until what is left actually fits, or the tail overflows the line. + string remainder = word; + while (MeasureCells(remainder) > maxWidth) + { + string slice = TakeCells(remainder, maxWidth); + if (slice.Length == 0) + { + // A single character wider than the whole line still has to go somewhere, + // or the loop would never shrink the remainder. + slice = StringInfo.GetNextTextElement(remainder); + } + + lines.Add(slice); + remainder = remainder[slice.Length..]; + } + + return remainder; + } + + /// + /// Splits text into alternating runs of spaces and runs of everything else, so that joining + /// the pieces gives back the original text + /// + /// The text to split + /// The runs, in order; none is empty + private static IEnumerable SplitKeepingSpaces(string text) + { + int start = 0; + for (int i = 1; i <= text.Length; i++) + { + bool runIsSpaces = text[start] == ' '; + if (i == text.Length || text[i] == ' ' != runIsSpaces) + { + yield return text[start..i]; + start = i; + } + } + } + /// /// Measures text in terminal cells, the unit the console lays it out in. A /// count is wrong for wide characters such as CJK ideographs, which take two cells for one char,