From d28ecc5dcb3a752a0c111b48e94450d1133bda74 Mon Sep 17 00:00:00 2001 From: Adam Krantz Date: Fri, 6 Mar 2026 05:54:48 -0800 Subject: [PATCH 1/2] Use grapheme iteration in DrawHotString MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrawHotString iterated by rune via EnumerateRunes and rendered each rune individually with AddRune. This breaks grapheme clusters: combining marks sent separately via AddRune fail to compose with their base character (e.g. e + combining acute renders as two separate characters instead of 'é'). Replace with GraphemeHelper.GetGraphemes iteration and AddStr, which preserves grapheme cluster integrity. The hotkey specifier check is unchanged — it only matches single-character graphemes. Added tests: - DrawHotString_CombiningMarks: 3 cases verifying combining marks compose correctly (acute accent, tilde, embedded in word) - DrawHotString_HotkeyWithCombiningMarks: hotkey + combining marks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ViewBase/View.Drawing.Primitives.cs | 7 +-- Tests/UnitTests/View/Draw/DrawTests.cs | 53 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/ViewBase/View.Drawing.Primitives.cs b/Terminal.Gui/ViewBase/View.Drawing.Primitives.cs index c5971598c8..a9c5c877e4 100644 --- a/Terminal.Gui/ViewBase/View.Drawing.Primitives.cs +++ b/Terminal.Gui/ViewBase/View.Drawing.Primitives.cs @@ -106,16 +106,17 @@ public void DrawHotString (string text, Attribute hotColor, Attribute normalColo Rune hotkeySpec = HotKeySpecifier == (Rune)0xffff ? (Rune)'_' : HotKeySpecifier; SetAttribute (normalColor); - foreach (Rune rune in text.EnumerateRunes ()) + foreach (string grapheme in GraphemeHelper.GetGraphemes (text)) { - if (rune == new Rune (hotkeySpec.Value)) + // The hotkey specifier is always a single simple character (e.g., '_') + if (grapheme.Length == 1 && new Rune (grapheme [0]) == new Rune (hotkeySpec.Value)) { SetAttribute (hotColor); continue; } - AddRune (rune); + AddStr (grapheme); SetAttribute (normalColor); } } diff --git a/Tests/UnitTests/View/Draw/DrawTests.cs b/Tests/UnitTests/View/Draw/DrawTests.cs index b6be67128e..ced3444584 100644 --- a/Tests/UnitTests/View/Draw/DrawTests.cs +++ b/Tests/UnitTests/View/Draw/DrawTests.cs @@ -616,6 +616,59 @@ public void DrawHotString_NonBmp (string expected) DriverAssert.AssertDriverContentsWithFrameAre (expected, output); } + /// + /// Verifies that iterates by grapheme cluster, + /// not by rune. When iterating by rune, combining marks (e.g., acute accent U+0301) are sent individually + /// via AddRune and fail to compose with their base character. Iterating by grapheme and using AddStr + /// ensures the combining mark stays attached to its base. + /// + [Theory] + [SetupFakeApplication] + [InlineData ("e\u0301", "é")] // e + combining acute → é + [InlineData ("n\u0303o", "ño")] // n + combining tilde + o → ño + [InlineData ("Les Mise\u0301rables", "Les Misérables")] // combining acute inside word + public void DrawHotString_CombiningMarks (string input, string expectedRendered) + { + // setup + var view = new View + { + App = ApplicationImpl.Instance, + Width = 20, Height = 1 + }; + + // execute + view.DrawHotString (input, Attribute.Default, Attribute.Default); + + // verify + DriverAssert.AssertDriverContentsWithFrameAre (expectedRendered, output); + } + + /// + /// Verifies that correctly handles + /// the hotkey specifier when combined with grapheme clusters. The hotkey specifier ('_') should + /// switch to hot color, and subsequent grapheme clusters (including combining marks) should render + /// correctly. + /// + [Fact] + [SetupFakeApplication] + public void DrawHotString_HotkeyWithCombiningMarks () + { + // setup — "_Re\u0301sume\u0301" → hotkey on 'R', combining accents compose correctly + var view = new View + { + App = ApplicationImpl.Instance, + Width = 20, Height = 1 + }; + + // execute + var hotColor = new Attribute (Color.Red, Color.Black); + var normalColor = new Attribute (Color.White, Color.Black); + view.DrawHotString ("_Re\u0301sume\u0301", hotColor, normalColor); + + // verify — the rendered text should show "Résumé" (without the underscore) + DriverAssert.AssertDriverContentsWithFrameAre ("Résumé", output); + } + // TODO: The tests below that use Label should use View instead. [Fact] [AutoInitShutdown] From f6e2053e13d785564c06c4248c428bd01a96857a Mon Sep 17 00:00:00 2001 From: Adam Krantz Date: Fri, 6 Mar 2026 07:24:33 -0800 Subject: [PATCH 2/2] Move tests to UnitTestsParallelizable Move DrawHotString grapheme tests from UnitTests to UnitTestsParallelizable per review feedback. Adapt tests to use CreateTestDriver() pattern instead of [SetupFakeApplication]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Tests/UnitTests/View/Draw/DrawTests.cs | 53 ---------------- .../ViewBase/Draw/DrawHotStringTests.cs | 61 +++++++++++++++++++ 2 files changed, 61 insertions(+), 53 deletions(-) create mode 100644 Tests/UnitTestsParallelizable/ViewBase/Draw/DrawHotStringTests.cs diff --git a/Tests/UnitTests/View/Draw/DrawTests.cs b/Tests/UnitTests/View/Draw/DrawTests.cs index ced3444584..b6be67128e 100644 --- a/Tests/UnitTests/View/Draw/DrawTests.cs +++ b/Tests/UnitTests/View/Draw/DrawTests.cs @@ -616,59 +616,6 @@ public void DrawHotString_NonBmp (string expected) DriverAssert.AssertDriverContentsWithFrameAre (expected, output); } - /// - /// Verifies that iterates by grapheme cluster, - /// not by rune. When iterating by rune, combining marks (e.g., acute accent U+0301) are sent individually - /// via AddRune and fail to compose with their base character. Iterating by grapheme and using AddStr - /// ensures the combining mark stays attached to its base. - /// - [Theory] - [SetupFakeApplication] - [InlineData ("e\u0301", "é")] // e + combining acute → é - [InlineData ("n\u0303o", "ño")] // n + combining tilde + o → ño - [InlineData ("Les Mise\u0301rables", "Les Misérables")] // combining acute inside word - public void DrawHotString_CombiningMarks (string input, string expectedRendered) - { - // setup - var view = new View - { - App = ApplicationImpl.Instance, - Width = 20, Height = 1 - }; - - // execute - view.DrawHotString (input, Attribute.Default, Attribute.Default); - - // verify - DriverAssert.AssertDriverContentsWithFrameAre (expectedRendered, output); - } - - /// - /// Verifies that correctly handles - /// the hotkey specifier when combined with grapheme clusters. The hotkey specifier ('_') should - /// switch to hot color, and subsequent grapheme clusters (including combining marks) should render - /// correctly. - /// - [Fact] - [SetupFakeApplication] - public void DrawHotString_HotkeyWithCombiningMarks () - { - // setup — "_Re\u0301sume\u0301" → hotkey on 'R', combining accents compose correctly - var view = new View - { - App = ApplicationImpl.Instance, - Width = 20, Height = 1 - }; - - // execute - var hotColor = new Attribute (Color.Red, Color.Black); - var normalColor = new Attribute (Color.White, Color.Black); - view.DrawHotString ("_Re\u0301sume\u0301", hotColor, normalColor); - - // verify — the rendered text should show "Résumé" (without the underscore) - DriverAssert.AssertDriverContentsWithFrameAre ("Résumé", output); - } - // TODO: The tests below that use Label should use View instead. [Fact] [AutoInitShutdown] diff --git a/Tests/UnitTestsParallelizable/ViewBase/Draw/DrawHotStringTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Draw/DrawHotStringTests.cs new file mode 100644 index 0000000000..781f9371f1 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Draw/DrawHotStringTests.cs @@ -0,0 +1,61 @@ +using UnitTests; + +namespace ViewBaseTests.Drawing; + +public class DrawHotStringTests (ITestOutputHelper output) : TestDriverBase +{ + /// + /// Verifies that iterates by grapheme cluster, + /// not by rune. When iterating by rune, combining marks (e.g., acute accent U+0301) are sent individually + /// via AddRune and fail to compose with their base character. Iterating by grapheme and using AddStr + /// ensures the combining mark stays attached to its base. + /// + [Theory] + [InlineData ("e\u0301", "é")] // e + combining acute → é + [InlineData ("n\u0303o", "ño")] // n + combining tilde + o → ño + [InlineData ("Les Mise\u0301rables", "Les Misérables")] // combining acute inside word + public void DrawHotString_CombiningMarks (string input, string expectedRendered) + { + // setup + IDriver driver = CreateTestDriver (); + driver.Clip = new Region (driver.Screen); + var view = new View + { + Driver = driver, + Width = 20, Height = 1 + }; + + // execute + view.DrawHotString (input, Attribute.Default, Attribute.Default); + + // verify + DriverAssert.AssertDriverContentsWithFrameAre (expectedRendered, output, driver); + } + + /// + /// Verifies that correctly handles + /// the hotkey specifier when combined with grapheme clusters. The hotkey specifier ('_') should + /// switch to hot color, and subsequent grapheme clusters (including combining marks) should render + /// correctly. + /// + [Fact] + public void DrawHotString_HotkeyWithCombiningMarks () + { + // setup — "_Re\u0301sume\u0301" → hotkey on 'R', combining accents compose correctly + IDriver driver = CreateTestDriver (); + driver.Clip = new Region (driver.Screen); + var view = new View + { + Driver = driver, + Width = 20, Height = 1 + }; + + // execute + var hotColor = new Attribute (Color.Red, Color.Black); + var normalColor = new Attribute (Color.White, Color.Black); + view.DrawHotString ("_Re\u0301sume\u0301", hotColor, normalColor); + + // verify — the rendered text should show "Résumé" (without the underscore) + DriverAssert.AssertDriverContentsWithFrameAre ("Résumé", output, driver); + } +}