diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
index d01321c1d9..efe5a8d00e 100644
--- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
+++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
@@ -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);
}
}
diff --git a/Terminal.Gui/Text/RuneExtensions.cs b/Terminal.Gui/Text/RuneExtensions.cs
index ffa20ddb62..075a525248 100644
--- a/Terminal.Gui/Text/RuneExtensions.cs
+++ b/Terminal.Gui/Text/RuneExtensions.cs
@@ -198,11 +198,11 @@ public static bool IsSurrogatePair (this Rune rune)
}
///
- /// 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.
///
/// This is a Terminal.Gui extension method to to support TUI text manipulation.
- ///
- ///
+ /// The rune to make printable.
+ /// A printable rune safe for terminal display.
public static Rune MakePrintable (this Rune rune) { return Rune.IsControl (rune) ? new (rune.Value + 0x2400) : rune; }
}
diff --git a/Terminal.Gui/Text/StringExtensions.cs b/Terminal.Gui/Text/StringExtensions.cs
index 4693a28d7d..ec923de921 100644
--- a/Terminal.Gui/Text/StringExtensions.cs
+++ b/Terminal.Gui/Text/StringExtensions.cs
@@ -132,22 +132,45 @@ public static bool IsSurrogatePair (this string str)
}
///
- /// 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.
///
- /// This is a Terminal.Gui extension method to to support TUI text manipulation.
+ ///
+ /// This is a Terminal.Gui extension method to to support TUI text manipulation.
+ ///
+ /// Per UAX #29, control characters are always grapheme cluster boundaries. A well-formed grapheme
+ /// cluster produced by
+ /// cannot contain embedded controls, so only the first character needs to be checked.
+ ///
+ ///
/// The text.
- ///
+ /// A string safe for terminal display.
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;
}
/// Repeats the string times.
diff --git a/Tests/UnitTestsParallelizable/Text/RuneTests.cs b/Tests/UnitTestsParallelizable/Text/RuneTests.cs
index a2ec053f92..70ec54fc54 100644
--- a/Tests/UnitTestsParallelizable/Text/RuneTests.cs
+++ b/Tests/UnitTestsParallelizable/Text/RuneTests.cs
@@ -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)
{
@@ -366,11 +366,12 @@ public void Rune_ColumnWidth_Versus_String_ConsoleWidth (string text, int string
public void Rune_Exceptions_Integers (int code) { Assert.Throws (() => 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')]
diff --git a/Tests/UnitTestsParallelizable/Text/StringTests.cs b/Tests/UnitTestsParallelizable/Text/StringTests.cs
index 656fa7ae53..9b104f6d92 100644
--- a/Tests/UnitTestsParallelizable/Text/StringTests.cs
+++ b/Tests/UnitTestsParallelizable/Text/StringTests.cs
@@ -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