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,