Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions TUI.Test/TextElementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,49 @@ public void WordWrapLeavesNoEmptyLineWhenAWordDividesExactly()
Assert.AreEqual("def", provider.Writes[1].Text);
}

private static readonly string[] ExpectedIndentedWrappedLines = [" key: aa", "bb cc"];

/// <summary>
/// Turning on word wrap must not change a line that already fits: indentation and runs of
/// spaces stay as written (ktsu-dev/TUI#150).
/// </summary>
[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);
}

/// <summary>
/// A paragraph that does wrap keeps its indentation and inner spacing, and loses spaces only
/// where a line breaks (ktsu-dev/TUI#150).
/// </summary>
[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));
}

/// <summary>
/// An element that is not visible must draw nothing.
/// </summary>
Expand Down
97 changes: 75 additions & 22 deletions TUI/Elements/Primitives/TextElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,20 +207,40 @@ private static string[] SplitIntoLines(string text, int wrapWidth)
})];
}

/// <summary>
/// Wraps one paragraph to <paramref name="maxWidth"/> 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).
/// </summary>
/// <param name="text">The paragraph to wrap, with no line breaks in it</param>
/// <param name="maxWidth">The width to wrap to, in cells</param>
/// <returns>The wrapped lines, in order</returns>
private static string[] WrapText(string text, int maxWidth)
{
if (maxWidth <= 0)
if (maxWidth <= 0 || MeasureCells(text) <= maxWidth)
{
return [text];
}

List<string> 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)
{
Expand All @@ -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))
Expand All @@ -262,6 +265,56 @@ private static string[] WrapText(string text, int maxWidth)
return [.. lines];
}

/// <summary>
/// Adds full-width slices of a word longer than <paramref name="maxWidth"/> to
/// <paramref name="lines"/>, and returns the tail that fits, to start the next line with
/// </summary>
/// <param name="word">The word to break</param>
/// <param name="maxWidth">The width to break it to, in cells</param>
/// <param name="lines">The lines to add the full slices to</param>
/// <returns>What is left of the word once it fits</returns>
private static string BreakOverlongWord(string word, int maxWidth, List<string> 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;
}

/// <summary>
/// Splits text into alternating runs of spaces and runs of everything else, so that joining
/// the pieces gives back the original text
/// </summary>
/// <param name="text">The text to split</param>
/// <returns>The runs, in order; none is empty</returns>
private static IEnumerable<string> 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;
}
}
}

/// <summary>
/// Measures text in terminal cells, the unit the console lays it out in. A <see cref="string.Length"/>
/// count is wrong for wide characters such as CJK ideographs, which take two cells for one char,
Expand Down
Loading