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
2 changes: 1 addition & 1 deletion Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ public void FillRect (Rectangle rect, Rune rune)
// So we inline the logic instead.
SetAttributeAndDirty (c, r);
InvalidateOverlappedWideGlyph (c, r);
string grapheme = rune != default (Rune) ? rune.ToString () : " ";
string grapheme = rune != default (Rune) ? rune.ToString ().MakePrintable () : " ";
WriteGraphemeByWidth (c, r, grapheme, grapheme.GetColumns (), clipBounds);
}
}
Expand Down
8 changes: 4 additions & 4 deletions Terminal.Gui/Text/RuneExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,11 @@ public static bool IsSurrogatePair (this Rune rune)
}

/// <summary>
/// Ensures the rune is not a control character and can be displayed by translating characters below 0x20 to
/// equivalent, printable, Unicode chars.
/// Ensures the rune is not a control character and can be displayed by translating C0 controls (U+0000–U+001F),
/// DEL (U+007F), and C1 controls (U+0080–U+009F) to printable Unicode equivalents via the +U+2400 offset.
/// </summary>
/// <remarks>This is a Terminal.Gui extension method to <see cref="System.Text.Rune"/> to support TUI text manipulation.</remarks>
/// <param name="rune"></param>
/// <returns></returns>
/// <param name="rune">The rune to make printable.</param>
/// <returns>A printable rune safe for terminal display.</returns>
public static Rune MakePrintable (this Rune rune) { return Rune.IsControl (rune) ? new (rune.Value + 0x2400) : rune; }
}
37 changes: 30 additions & 7 deletions Terminal.Gui/Text/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,22 +132,45 @@ public static bool IsSurrogatePair (this string str)
}

/// <summary>
/// Ensures the text is not a control character and can be displayed by translating characters below 0x20 to
/// equivalent, printable, Unicode chars.
/// Ensures the text does not contain control characters that could be emitted verbatim to the terminal,
/// by translating C0 controls (U+0000–U+001F), DEL (U+007F), and C1 controls (U+0080–U+009F) to
/// printable Unicode equivalents via the +U+2400 offset. Multi-character graphemes whose first
/// character is a control are replaced with a space.
/// </summary>
/// <remarks>This is a Terminal.Gui extension method to <see cref="string"/> to support TUI text manipulation.</remarks>
/// <remarks>
/// <para>This is a Terminal.Gui extension method to <see cref="string"/> to support TUI text manipulation.</para>
/// <para>
/// Per UAX #29, control characters are always grapheme cluster boundaries. A well-formed grapheme
/// cluster produced by <see cref="System.Globalization.StringInfo.GetTextElementEnumerator(string)"/>
/// cannot contain embedded controls, so only the first character needs to be checked.
/// </para>
/// </remarks>
/// <param name="str">The text.</param>
/// <returns></returns>
/// <returns>A string safe for terminal display.</returns>
public static string MakePrintable (this string str)
{
if (str.Length > 1)
if (string.IsNullOrEmpty (str))
{
return str;
}

char ch = str [0];
char first = str [0];

// Fast path: single-char grapheme (covers the vast majority of calls)
if (str.Length == 1)
{
return char.IsControl (first) ? new string ((char)(first + 0x2400), 1) : str;
}

// Multi-char grapheme: per UAX #29, control characters are grapheme cluster boundaries,
// so a well-formed cluster from GetTextElementEnumerator cannot contain embedded controls.
// We only need to check the first character defensively for malformed input.
if (char.IsControl (first))
{
return " ";
}

return char.IsControl (ch) ? new string ((char)(ch + 0x2400), 1) : str;
return str;
}

/// <summary>Repeats the string <paramref name="n"/> times.</summary>
Expand Down
5 changes: 3 additions & 2 deletions Tests/UnitTestsParallelizable/Text/RuneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ public void MakePrintable_Combining_Character_Is_Not_Printable (int code)
[Theory]
[InlineData (0x0000001F, 0x241F)]
[InlineData (0x0000007F, 0x247F)]
[InlineData (0x0000009F, 0x249F)]
[InlineData (0x0000009F, 0x249F)] // C1 control → +0x2400 offset for distinct visual
[InlineData (0x0001001A, 0x1001A)]
public void MakePrintable_Converts_Control_Chars_To_Proper_Unicode (int code, int expected)
{
Expand Down Expand Up @@ -366,11 +366,12 @@ public void Rune_ColumnWidth_Versus_String_ConsoleWidth (string text, int string
public void Rune_Exceptions_Integers (int code) { Assert.Throws<ArgumentOutOfRangeException> (() => new Rune (code)); }

[Theory]
// Control characters (should be mapped to Control Pictures)
// Control characters (should be mapped to Control Pictures via +U+2400 offset)
[InlineData ('\u0000', 0x2400)] // NULL → ␀
[InlineData ('\u0009', 0x2409)] // TAB → ␉
[InlineData ('\u000A', 0x240A)] // LF → ␊
[InlineData ('\u000D', 0x240D)] // CR → ␍
[InlineData ('\u001B', 0x241B)] // ESC → ␛

// Printable characters (should remain unchanged)
[InlineData ('A', 'A')]
Expand Down
15 changes: 12 additions & 3 deletions Tests/UnitTestsParallelizable/Text/StringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,21 +215,30 @@ public void IsSurrogatePair_ReturnsExpected (string input, bool expected)
}

[Theory]
// Control characters (should be replaced with the "Control Pictures" block)
// Control characters (should be replaced with the "Control Pictures" block via +U+2400 offset)
[InlineData ("\u0000", "\u2400")] // NULL → ␀
[InlineData ("\u0009", "\u2409")] // TAB → ␉
[InlineData ("\u000A", "\u240A")] // LF → ␊
[InlineData ("\u000D", "\u240D")] // CR → ␍
[InlineData ("\u001B", "\u241B")] // ESC → ␛

// C1 controls (mapped via +U+2400 offset for distinct visuals)
[InlineData ("\u007F", "\u247F")] // DEL → Control Picture
[InlineData ("\u0080", "\u2480")] // C1 control → distinct visual
[InlineData ("\u009F", "\u249F")] // C1 control → distinct visual

// Printable characters (should remain unchanged)
[InlineData ("A", "A")]
[InlineData (" ", " ")]
[InlineData ("~", "~")]

// Multi-character string (should return unchanged)
// Multi-character strings (control at start → space, no controls → unchanged)
[InlineData ("AB", "AB")]
[InlineData ("Hello", "Hello")]
[InlineData ("\u0009A", "\u0009A")] // includes a control char, but length > 1

// Copilot - Security fix: multi-char graphemes starting with control chars are sanitized
[InlineData ("\u001BA", " ")] // ESC + A → space (unsafe control at start)
[InlineData ("\u0009A", " ")] // TAB + A → space (control at start of multi-char)
public void MakePrintable_ReturnsExpected (string input, string expected)
{
// Act
Expand Down
Loading