Procházet zdrojové kódy

Fixes #4387. Runes should not be used on a cell, but rather should use a single grapheme rendering 1 or 2 columns (#4388)

* Fixes #4382. StringExtensions.GetColumns method should only return the total text width and not the sum of all runes width

* Trying to fix unit test error

* Update StringExtensions.cs

Co-authored-by: Copilot <[email protected]>

* Resolving merge conflicts

* Prevents Runes throwing if Grapheme is null

* Add unit test to prove that null and empty string doesn't not throws anything.

* Fix unit test failure

* Fix IsValidLocation for wide graphemes

* Add more combining

* Prevent set invalid graphemes

* Fix unit tests

* Grapheme doesn't support invalid code points like lone surrogates

* Fixes more unit tests

* Fix unit test

* Seems all test are fixed now

* Adjust CharMap scenario with graphemes

* Upgrade Wcwidth to version 4.0.0

* Reformat

* Trying fix CheckDefaultState assertion

* Revert "Trying fix CheckDefaultState assertion"

This reverts commit c9b46b796ad206f2dd310fa680163b9f7de878f2.

* Forgot to include driver.End in the test

* Reapply "Trying fix CheckDefaultState assertion"

This reverts commit 1060ac9b632c109dee785fa83057959575875ca2.

* Remove ToString

* Fix merge errors

* Change to conditional expression

* Assertion to prove that no exception throws during cell initialization.

* Remove unnecessary assignment

* Remove assignment to end

* Replace string concatenation with 'StringBuilder'.

* Replace more string concatenation with 'StringBuilder'

* Remove redundant call to 'ToString' because Rune cast to a String object.

* Replace foreach loop with Sum linq

---------

Co-authored-by: Tig <[email protected]>
Co-authored-by: Copilot <[email protected]>
BDisp před 3 týdny
rodič
revize
cd75a20c60
53 změnil soubory, kde provedl 2026 přidání a 1517 odebrání
  1. 1 1
      Directory.Packages.props
  2. 13 2
      Examples/UICatalog/Scenarios/CombiningMarks.cs
  3. 1 1
      Examples/UICatalog/Scenarios/LineDrawing.cs
  4. 8 8
      Examples/UICatalog/Scenarios/Sliders.cs
  5. 4 7
      Examples/UICatalog/Scenarios/SyntaxHighlighting.cs
  6. 86 70
      Terminal.Gui/Drawing/Cell.cs
  7. 49 0
      Terminal.Gui/Drawing/GraphemeHelper.cs
  8. 4 5
      Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs
  9. 13 17
      Terminal.Gui/Drivers/DriverImpl.cs
  10. 3 3
      Terminal.Gui/Drivers/IDriver.cs
  11. 4 4
      Terminal.Gui/Drivers/IOutputBuffer.cs
  12. 5 28
      Terminal.Gui/Drivers/OutputBase.cs
  13. 72 142
      Terminal.Gui/Drivers/OutputBufferImpl.cs
  14. 76 12
      Terminal.Gui/Text/StringExtensions.cs
  15. 169 159
      Terminal.Gui/Text/TextFormatter.cs
  16. 4 4
      Terminal.Gui/ViewBase/Adornment/ShadowView.cs
  17. 19 1
      Terminal.Gui/ViewBase/View.Drawing.Primitives.cs
  18. 7 7
      Terminal.Gui/ViewBase/View.Drawing.cs
  19. 13 13
      Terminal.Gui/Views/Autocomplete/AutocompleteFilepathContext.cs
  20. 2 2
      Terminal.Gui/Views/Autocomplete/ISuggestionGenerator.cs
  21. 1 1
      Terminal.Gui/Views/Autocomplete/PopupAutocomplete.cs
  22. 10 6
      Terminal.Gui/Views/Autocomplete/SingleWordSuggestionGenerator.cs
  23. 25 54
      Terminal.Gui/Views/CharMap/CharMap.cs
  24. 25 25
      Terminal.Gui/Views/Slider/Slider.cs
  25. 4 4
      Terminal.Gui/Views/TableView/TreeTableSource.cs
  26. 17 17
      Terminal.Gui/Views/TextInput/TextField.cs
  27. 58 52
      Terminal.Gui/Views/TextInput/TextModel.cs
  28. 27 28
      Terminal.Gui/Views/TextInput/TextView.cs
  29. 21 21
      Terminal.Gui/Views/TreeView/Branch.cs
  30. 13 18
      Tests/UnitTests/DriverAssert.cs
  31. 8 8
      Tests/UnitTests/Drivers/ClipRegionTests.cs
  32. 5 5
      Tests/UnitTests/View/Draw/ClipTests.cs
  33. 9 9
      Tests/UnitTests/View/Draw/DrawTests.cs
  34. 5 4
      Tests/UnitTests/View/TextTests.cs
  35. 7 7
      Tests/UnitTests/Views/LabelTests.cs
  36. 606 606
      Tests/UnitTests/Views/ProgressBarTests.cs
  37. 1 0
      Tests/UnitTests/Views/TextFieldTests.cs
  38. 5 8
      Tests/UnitTests/Views/TextViewTests.cs
  39. 7 7
      Tests/UnitTests/Views/TreeViewTests.cs
  40. 143 15
      Tests/UnitTestsParallelizable/Drawing/CellTests.cs
  41. 19 17
      Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs
  42. 4 4
      Tests/UnitTestsParallelizable/Drivers/ContentsTests.cs
  43. 47 1
      Tests/UnitTestsParallelizable/Drivers/DriverTests.cs
  44. 1 1
      Tests/UnitTestsParallelizable/Drivers/FakeDriverTests.cs
  45. 131 6
      Tests/UnitTestsParallelizable/Text/RuneTests.cs
  46. 166 36
      Tests/UnitTestsParallelizable/Text/StringTests.cs
  47. 27 0
      Tests/UnitTestsParallelizable/Text/TextFormatterDrawTests.cs
  48. 31 22
      Tests/UnitTestsParallelizable/Text/TextFormatterTests.cs
  49. 5 5
      Tests/UnitTestsParallelizable/View/Draw/ViewClearViewportTests.cs
  50. 6 6
      Tests/UnitTestsParallelizable/View/Draw/ViewDrawTextAndLineCanvasTests.cs
  51. 4 3
      Tests/UnitTestsParallelizable/Views/ListViewTests.cs
  52. 3 3
      Tests/UnitTestsParallelizable/Views/TextFieldTests.cs
  53. 32 32
      Tests/UnitTestsParallelizable/Views/TextViewTests.cs

+ 1 - 1
Directory.Packages.props

@@ -18,7 +18,7 @@
     <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="[9.0.0,10)" />
     <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.6" />
     <PackageVersion Include="System.IO.Abstractions" Version="[22.0.16,23)" />
-    <PackageVersion Include="Wcwidth" Version="[3.0.0,)" />
+    <PackageVersion Include="Wcwidth" Version="[4.0.0,)" />
     <PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="[1.21.2,2)" />
     <PackageVersion Include="Serilog" Version="4.2.0" />
     <PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.0" />

+ 13 - 2
Examples/UICatalog/Scenarios/CombiningMarks.cs

@@ -16,7 +16,8 @@ public class CombiningMarks : Scenario
             Application.Current!.SetNeedsDraw ();
 
             var i = -1;
-            top.AddStr ("Terminal.Gui only supports combining marks that normalize. See Issue #2616.");
+            top.Move (0, ++i);
+            top.AddStr ("Terminal.Gui supports all combining sequences that can be rendered as an unique grapheme.");
             top.Move (0, ++i);
             top.AddStr ("\u0301<- \"\\u0301\" using AddStr.");
             top.Move (0, ++i);
@@ -38,7 +39,7 @@ public class CombiningMarks : Scenario
             top.AddRune ('\u0301');
             top.AddRune ('\u0328');
             top.AddRune (']');
-            top.AddStr ("<- \"[a\\u0301\\u0301\\u0328]\" using AddRune for each.");
+            top.AddStr ("<- \"[a\\u0301\\u0301\\u0328]\" using AddRune for each. Avoid use AddRune for combining sequences because may result with empty blocks at end.");
             top.Move (0, ++i);
             top.AddStr ("[a\u0301\u0301\u0328]<- \"[a\\u0301\\u0301\\u0328]\" using AddStr.");
             top.Move (0, ++i);
@@ -82,6 +83,16 @@ public class CombiningMarks : Scenario
             top.AddStr ("[\U0001F468\U0001F469\U0001F9D2]<- \"[\\U0001F468\\U0001F469\\U0001F9D2]\" using AddStr.");
             top.Move (0, ++i);
             top.AddStr ("[\U0001F468\u200D\U0001F469\u200D\U0001F9D2]<- \"[\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F9D2]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466]<- \"[\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F467\\u200D\\U0001F466]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u0e32\u0e33]<- \"[\\u0e32\\u0e33]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\U0001F469\u200D\u2764\uFE0F\u200D\U0001F48B\u200D\U0001F468]<- \"[\\U0001F469\\u200D\\u2764\\uFE0F\\u200D\\U0001F48B\\u200D\\U0001F468]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u0061\uFE20\u0065\uFE21]<- \"[\\u0061\\uFE20\\u0065\\uFE21]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u1100\uD7B0]<- \"[\\u1100\\uD7B0]\" using AddStr.");
         };
 
         Application.Run (top);

+ 1 - 1
Examples/UICatalog/Scenarios/LineDrawing.cs

@@ -284,7 +284,7 @@ public class DrawingArea : View
                     SetCurrentAttribute (c.Value.Value.Attribute ?? GetAttributeForRole (VisualRole.Normal));
 
                     // TODO: #2616 - Support combining sequences that don't normalize
-                    AddRune (c.Key.X, c.Key.Y, c.Value.Value.Rune);
+                    AddStr (c.Key.X, c.Key.Y, c.Value.Value.Grapheme);
                 }
             }
         }

+ 8 - 8
Examples/UICatalog/Scenarios/Sliders.cs

@@ -86,17 +86,17 @@ public class Sliders : Scenario
                                 {
                                     if (single.Orientation == Orientation.Horizontal)
                                     {
-                                        single.Style.SpaceChar = new () { Rune = Glyphs.HLine };
-                                        single.Style.OptionChar = new () { Rune = Glyphs.HLine };
+                                        single.Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () };
+                                        single.Style.OptionChar = new () { Grapheme = Glyphs.HLine.ToString () };
                                     }
                                     else
                                     {
-                                        single.Style.SpaceChar = new () { Rune = Glyphs.VLine };
-                                        single.Style.OptionChar = new () { Rune = Glyphs.VLine };
+                                        single.Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () };
+                                        single.Style.OptionChar = new () { Grapheme = Glyphs.VLine.ToString () };
                                     }
                                 };
-        single.Style.SetChar = new () { Rune = Glyphs.ContinuousMeterSegment };
-        single.Style.DragChar = new () { Rune = Glyphs.ContinuousMeterSegment };
+        single.Style.SetChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () };
+        single.Style.DragChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () };
 
         v.Add (single);
 
@@ -257,7 +257,7 @@ public class Sliders : Scenario
                                                     {
                                                         s.Orientation = Orientation.Horizontal;
 
-                                                        s.Style.SpaceChar = new () { Rune = Glyphs.HLine };
+                                                        s.Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () };
 
                                                         if (prev == null)
                                                         {
@@ -275,7 +275,7 @@ public class Sliders : Scenario
                                                     {
                                                         s.Orientation = Orientation.Vertical;
 
-                                                        s.Style.SpaceChar = new () { Rune = Glyphs.VLine };
+                                                        s.Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () };
 
                                                         if (prev == null)
                                                         {

+ 4 - 7
Examples/UICatalog/Scenarios/SyntaxHighlighting.cs

@@ -152,12 +152,12 @@ public class SyntaxHighlighting : Scenario
                              ),
                          null,
                          new (
-                              "_Load Rune Cells",
+                              "_Load Text Cells",
                               "",
                               () => ApplyLoadCells ()
                              ),
                          new (
-                              "_Save Rune Cells",
+                              "_Save Text Cells",
                               "",
                               () => SaveCells ()
                              ),
@@ -240,12 +240,9 @@ public class SyntaxHighlighting : Scenario
         {
             string csName = color.Key;
 
-            foreach (Rune rune in csName.EnumerateRunes ())
-            {
-                cells.Add (new () { Rune = rune, Attribute = color.Value.Normal });
-            }
+            cells.AddRange (Cell.ToCellList (csName, color.Value.Normal));
 
-            cells.Add (new () { Rune = (Rune)'\n', Attribute = color.Value.Focus });
+            cells.Add (new () { Grapheme = "\n", Attribute = color.Value.Focus });
         }
 
         if (File.Exists (_path))

+ 86 - 70
Terminal.Gui/Drawing/Cell.cs

@@ -1,80 +1,108 @@
 
-
 namespace Terminal.Gui.Drawing;
 
 /// <summary>
 ///     Represents a single row/column in a Terminal.Gui rendering surface (e.g. <see cref="LineCanvas"/> and
 ///     <see cref="IDriver"/>).
 /// </summary>
-public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, Rune Rune = default)
+public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, string Grapheme = "")
 {
     /// <summary>The attributes to use when drawing the Glyph.</summary>
     public Attribute? Attribute { get; set; } = Attribute;
 
     /// <summary>
-    ///     Gets or sets a value indicating whether this <see cref="T:Terminal.Gui.Cell"/> has been modified since the
+    ///     Gets or sets a value indicating whether this <see cref="T:Terminal.Gui.Drawing.Cell"/> has been modified since the
     ///     last time it was drawn.
     /// </summary>
     public bool IsDirty { get; set; } = IsDirty;
 
-    private Rune _rune = Rune;
+    private string _grapheme = Grapheme;
 
-    /// <summary>The character to display. If <see cref="Rune"/> is <see langword="null"/>, then <see cref="Rune"/> is ignored.</summary>
-    public Rune Rune
+    /// <summary>
+    ///     The single grapheme cluster to display from this cell. If <see cref="Grapheme"/> is <see langword="null"/> or
+    ///     <see cref="string.Empty"/>, then <see cref="Cell"/> is ignored.
+    /// </summary>
+    public string Grapheme
     {
-        get => _rune;
+        readonly get => _grapheme;
         set
         {
-            _combiningMarks?.Clear ();
-            _rune = value;
-        }
-    }
+            if (GraphemeHelper.GetGraphemes(value).ToArray().Length > 1)
+            {
+                throw new InvalidOperationException ($"Only a single {nameof (Grapheme)} cluster is allowed per Cell.");
+            }
 
-    private List<Rune>? _combiningMarks;
+            if (!string.IsNullOrEmpty (value) && value.Length == 1 && char.IsSurrogate (value [0]))
+            {
+                throw new ArgumentException ($"Only valid Unicode scalar values are allowed in a single {nameof (Grapheme)} cluster.");
+            }
 
-    /// <summary>
-    ///     The combining marks for <see cref="Rune"/> that when combined makes this Cell a combining sequence. If
-    ///     <see cref="CombiningMarks"/> empty, then <see cref="CombiningMarks"/> is ignored.
-    /// </summary>
-    /// <remarks>
-    ///     Only valid in the rare case where <see cref="Rune"/> is a combining sequence that could not be normalized to a
-    ///     single Rune.
-    /// </remarks>
-    internal IReadOnlyList<Rune> CombiningMarks
-    {
-        // PERFORMANCE: Downside of the interface return type is that List<T> struct enumerator cannot be utilized, i.e. enumerator is allocated.
-        // If enumeration is used heavily in the future then might be better to expose the List<T> Enumerator directly via separate mechanism.
-        get
-        {
-            // Avoid unnecessary list allocation.
-            if (_combiningMarks == null)
+            try
             {
-                return Array.Empty<Rune> ();
+                _grapheme = !string.IsNullOrEmpty (value) && !value.IsNormalized (NormalizationForm.FormC)
+                                ? value.Normalize (NormalizationForm.FormC)
+                                : value;
+            }
+            catch (ArgumentException)
+            {
+                // leave text unnormalized
+                _grapheme = value;
             }
-            return _combiningMarks;
         }
     }
 
     /// <summary>
-    ///     Adds combining mark to the cell.
+    ///     The rune for <see cref="Grapheme"/> or runes for <see cref="Grapheme"/> that when combined makes this Cell a combining sequence.
     /// </summary>
-    /// <param name="combiningMark">The combining mark to add to the cell.</param>
-    internal void AddCombiningMark (Rune combiningMark)
+    /// <remarks>
+    ///     In the case where <see cref="Grapheme"/> has more than one rune it is a combining sequence that is normalized to a
+    ///     single Text which may occupies 1 or 2 columns.
+    /// </remarks>
+    public IReadOnlyList<Rune> Runes => string.IsNullOrEmpty (Grapheme) ? [] : Grapheme.EnumerateRunes ().ToList ();
+
+    /// <inheritdoc/>
+    public override string ToString ()
     {
-        _combiningMarks ??= [];
-        _combiningMarks.Add (combiningMark);
+        string visibleText = EscapeControlAndInvisible (Grapheme);
+
+        return $"[\"{visibleText}\":{Attribute}]";
     }
 
-    /// <summary>
-    ///     Clears combining marks of the cell.
-    /// </summary>
-    internal void ClearCombiningMarks ()
+    private static string EscapeControlAndInvisible (string text)
     {
-        _combiningMarks?.Clear ();
-    }
+        if (string.IsNullOrEmpty (text))
+        {
+            return "";
+        }
 
-    /// <inheritdoc/>
-    public override string ToString () { return $"['{Rune}':{Attribute}]"; }
+        var sb = new StringBuilder ();
+
+        foreach (var rune in text.EnumerateRunes ())
+        {
+            switch (rune.Value)
+            {
+                case '\0': sb.Append ("␀"); break;
+                case '\t': sb.Append ("\\t"); break;
+                case '\r': sb.Append ("\\r"); break;
+                case '\n': sb.Append ("\\n"); break;
+                case '\f': sb.Append ("\\f"); break;
+                case '\v': sb.Append ("\\v"); break;
+                default:
+                    if (char.IsControl ((char)rune.Value))
+                    {
+                        // show as \uXXXX
+                        sb.Append ($"\\u{rune.Value:X4}");
+                    }
+                    else
+                    {
+                        sb.Append (rune);
+                    }
+                    break;
+            }
+        }
+
+        return sb.ToString ();
+    }
 
     /// <summary>Converts the string into a <see cref="List{Cell}"/>.</summary>
     /// <param name="str">The string to convert.</param>
@@ -82,12 +110,8 @@ public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, Ru
     /// <returns></returns>
     public static List<Cell> ToCellList (string str, Attribute? attribute = null)
     {
-        List<Cell> cells = new ();
-
-        foreach (Rune rune in str.EnumerateRunes ())
-        {
-            cells.Add (new () { Rune = rune, Attribute = attribute });
-        }
+        List<Cell> cells = [];
+        cells.AddRange (GraphemeHelper.GetGraphemes (str).Select (grapheme => new Cell { Grapheme = grapheme, Attribute = attribute }));
 
         return cells;
     }
@@ -100,9 +124,7 @@ public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, Ru
     /// <returns>A <see cref="List{Cell}"/> for each line.</returns>
     public static List<List<Cell>> StringToLinesOfCells (string content, Attribute? attribute = null)
     {
-        List<Cell> cells = content.EnumerateRunes ()
-                                  .Select (x => new Cell { Rune = x, Attribute = attribute })
-                                  .ToList ();
+        List<Cell> cells = ToCellList (content, attribute);
 
         return SplitNewLines (cells);
     }
@@ -112,14 +134,14 @@ public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, Ru
     /// <returns></returns>
     public static string ToString (IEnumerable<Cell> cells)
     {
-        var str = string.Empty;
+        StringBuilder sb = new ();
 
         foreach (Cell cell in cells)
         {
-            str += cell.Rune.ToString ();
+            sb.Append (cell.Grapheme);
         }
 
-        return str;
+        return sb.ToString ();
     }
 
     /// <summary>Converts a <see cref="List{Cell}"/> generic collection into a string.</summary>
@@ -147,26 +169,19 @@ public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, Ru
 
     internal static List<Cell> StringToCells (string str, Attribute? attribute = null)
     {
-        List<Cell> cells = [];
-
-        foreach (Rune rune in str.ToRunes ())
-        {
-            cells.Add (new () { Rune = rune, Attribute = attribute });
-        }
-
-        return cells;
+        return ToCellList (str, attribute);
     }
 
-    internal static List<Cell> ToCells (IEnumerable<Rune> runes, Attribute? attribute = null)
+    internal static List<Cell> ToCells (IEnumerable<string> strings, Attribute? attribute = null)
     {
-        List<Cell> cells = new ();
+        StringBuilder sb = new ();
 
-        foreach (Rune rune in runes)
+        foreach (string str in strings)
         {
-            cells.Add (new () { Rune = rune, Attribute = attribute });
+            sb.Append (str);
         }
 
-        return cells;
+        return ToCellList (sb.ToString (), attribute);
     }
 
     private static List<List<Cell>> SplitNewLines (List<Cell> cells)
@@ -179,14 +194,15 @@ public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, Ru
         // ASCII code 10 = Line Feed.
         for (; i < cells.Count; i++)
         {
-            if (cells [i].Rune.Value == 13)
+            if (cells [i].Grapheme.Length == 1 && cells [i].Grapheme [0] == 13)
             {
                 hasCR = true;
 
                 continue;
             }
 
-            if (cells [i].Rune.Value == 10)
+            if ((cells [i].Grapheme.Length == 1 && cells [i].Grapheme [0] == 10)
+                || cells [i].Grapheme == "\r\n")
             {
                 if (i - start > 0)
                 {

+ 49 - 0
Terminal.Gui/Drawing/GraphemeHelper.cs

@@ -0,0 +1,49 @@
+using System.Globalization;
+
+namespace Terminal.Gui.Drawing;
+
+/// <summary>
+///     Provides utility methods for enumerating Unicode grapheme clusters (user-perceived characters)
+///     in a string. A grapheme cluster may consist of one or more <see cref="Rune"/> values,
+///     including combining marks or zero-width joiner (ZWJ) sequences such as emoji family groups.
+/// </summary>
+/// <remarks>
+///     <para>
+///         This helper uses <see cref="StringInfo.GetTextElementEnumerator(string)"/> to enumerate
+///         text elements according to the Unicode Standard Annex #29 (UAX #29) rules for
+///         extended grapheme clusters.
+///     </para>
+///     <para>
+///         On legacy Windows consoles (e.g., <c>cmd.exe</c>, <c>conhost.exe</c>), complex grapheme
+///         sequences such as ZWJ emoji or combining marks may not render correctly, even though
+///         the underlying string data is valid.
+///     </para>
+///     <para>
+///         For most accurate visual rendering, prefer modern terminals such as Windows Terminal
+///         or Linux-based terminals with full Unicode and font support.
+///     </para>
+/// </remarks>
+public static class GraphemeHelper
+{
+    /// <summary>
+    ///     Enumerates extended grapheme clusters from a string.
+    ///     Handles surrogate pairs, combining marks, and basic ZWJ sequences.
+    ///     Safe for legacy consoles; memory representation is correct.
+    /// </summary>
+    public static IEnumerable<string> GetGraphemes (string text)
+    {
+        if (string.IsNullOrEmpty (text))
+        {
+            yield break;
+        }
+
+        TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator (text);
+
+        while (enumerator.MoveNext ())
+        {
+            string element = enumerator.GetTextElement ();
+
+            yield return element;
+        }
+    }
+}

+ 4 - 5
Terminal.Gui/Drawing/LineCanvas/LineCanvas.cs

@@ -173,7 +173,7 @@ public class LineCanvas : IDisposable
                 intersectionsBufferList.Clear ();
                 foreach (var line in _lines)
                 {
-                    if (line.Intersects (x, y) is IntersectionDefinition intersect)
+                    if (line.Intersects (x, y) is { } intersect)
                     {
                         intersectionsBufferList.Add (intersect);
                     }
@@ -217,9 +217,8 @@ public class LineCanvas : IDisposable
             for (int x = inArea.X; x < inArea.X + inArea.Width; x++)
             {
                 IntersectionDefinition [] intersects = _lines
-                    // ! nulls are filtered out by the next Where filter
-                    .Select (l => l.Intersects (x, y)!)
-                    .Where (i => i is not null)
+                    .Select (l => l.Intersects (x, y))
+                    .OfType<IntersectionDefinition> () // automatically filters nulls and casts
                     .ToArray ();
 
                 Rune? rune = GetRuneForIntersects (intersects);
@@ -413,7 +412,7 @@ public class LineCanvas : IDisposable
 
         if (rune.HasValue)
         {
-            cell.Rune = rune.Value;
+            cell.Grapheme = rune.ToString ()!;
         }
 
         cell.Attribute = GetAttributeForIntersects (intersects);

+ 13 - 17
Terminal.Gui/Drivers/DriverImpl.cs

@@ -253,8 +253,16 @@ internal class DriverImpl : IDriver
     /// <inheritdoc/>
     public bool IsRuneSupported (Rune rune) => Rune.IsValid (rune.Value);
 
-    /// <inheritdoc/>
-    public bool IsValidLocation (Rune rune, int col, int row) => OutputBuffer.IsValidLocation (rune, col, row);
+    /// <summary>Tests whether the specified coordinate are valid for drawing the specified Text.</summary>
+    /// <param name="text">Used to determine if one or two columns are required.</param>
+    /// <param name="col">The column.</param>
+    /// <param name="row">The row.</param>
+    /// <returns>
+    ///     <see langword="false"/> if the coordinate is outside the screen bounds or outside of
+    ///     <see cref="IDriver.Clip"/>.
+    ///     <see langword="true"/> otherwise.
+    /// </returns>
+    public bool IsValidLocation (string text, int col, int row) { return OutputBuffer.IsValidLocation (text, col, row); }
 
     /// <inheritdoc/>
     public void Move (int col, int row) { OutputBuffer.Move (col, row); }
@@ -379,26 +387,14 @@ internal class DriverImpl : IDriver
         {
             for (var c = 0; c < Cols; c++)
             {
-                Rune rune = contents [r, c].Rune;
+                string text = contents [r, c].Grapheme;
 
-                if (rune.DecodeSurrogatePair (out char []? sp))
-                {
-                    sb.Append (sp);
-                }
-                else
-                {
-                    sb.Append ((char)rune.Value);
-                }
+                sb.Append (text);
 
-                if (rune.GetColumns () > 1)
+                if (text.GetColumns () > 1)
                 {
                     c++;
                 }
-
-                // See Issue #2616
-                //foreach (var combMark in contents [r, c].CombiningMarks) {
-                //	sb.Append ((char)combMark.Value);
-                //}
             }
 
             sb.AppendLine ();

+ 3 - 3
Terminal.Gui/Drivers/IDriver.cs

@@ -124,8 +124,8 @@ public interface IDriver
     /// </returns>
     bool IsRuneSupported (Rune rune);
 
-    /// <summary>Tests whether the specified coordinate are valid for drawing the specified Rune.</summary>
-    /// <param name="rune">Used to determine if one or two columns are required.</param>
+    /// <summary>Tests whether the specified coordinate are valid for drawing the specified Text.</summary>
+    /// <param name="text">Used to determine if one or two columns are required.</param>
     /// <param name="col">The column.</param>
     /// <param name="row">The row.</param>
     /// <returns>
@@ -133,7 +133,7 @@ public interface IDriver
     ///     <see cref="IDriver.Clip"/>.
     ///     <see langword="true"/> otherwise.
     /// </returns>
-    bool IsValidLocation (Rune rune, int col, int row);
+    bool IsValidLocation (string text, int col, int row);
 
     /// <summary>
     ///     Updates <see cref="IDriver.Col"/> and <see cref="IDriver.Row"/> to the specified column and row in

+ 4 - 4
Terminal.Gui/Drivers/IOutputBuffer.cs

@@ -84,15 +84,15 @@ public interface IOutputBuffer
     void FillRect (Rectangle rect, char rune);
 
     /// <summary>
-    ///     Tests whether the specified coordinate is valid for drawing the specified Rune.
+    ///     Tests whether the specified coordinate is valid for drawing the specified Text.
     /// </summary>
-    /// <param name="rune">Used to determine if one or two columns are required.</param>
+    /// <param name="text">Used to determine if one or two columns are required.</param>
     /// <param name="col">The column.</param>
     /// <param name="row">The row.</param>
     /// <returns>
-    ///     True if the coordinate is valid for the Rune; false otherwise.
+    ///     True if the coordinate is valid for the Text; false otherwise.
     /// </returns>
-    bool IsValidLocation (Rune rune, int col, int row);
+    bool IsValidLocation (string text, int col, int row);
 
     /// <summary>
     ///     The first cell index on left of screen - basically always 0.

+ 5 - 28
Terminal.Gui/Drivers/OutputBase.cs

@@ -76,25 +76,6 @@ public abstract class OutputBase
 
                     outputWidth++;
 
-                    // Handle special cases that AppendCellAnsi doesn't cover
-                    Rune rune = cell.Rune;
-                    if (cell.CombiningMarks.Count > 0)
-                    {
-                        // AtlasEngine does not support NON-NORMALIZED combining marks in a way
-                        // compatible with the driver architecture. Any CMs (except in the first col)
-                        // are correctly combined with the base char, but are ALSO treated as 1 column
-                        // width codepoints E.g. `echo "[e`u{0301}`u{0301}]"` will output `[é  ]`.
-                        // 
-                        // For now, we just ignore the list of CMs.
-                        //foreach (var combMark in Contents [row, col].CombiningMarks) {
-                        //	output.Append (combMark);
-                    }
-                    else if (rune.IsSurrogatePair () && rune.GetColumns () < 2)
-                    {
-                        WriteToConsole (output, ref lastCol, row, ref outputWidth);
-                        SetCursorPositionImpl (col - 1, row);
-                    }
-
                     buffer.Contents [row, col].IsDirty = false;
                 }
             }
@@ -216,16 +197,12 @@ public abstract class OutputBase
             redrawTextStyle = attribute.Value.Style;
         }
 
-        // Add the character
-        const int MAX_CHARS_PER_RUNE = 2;
-        Span<char> runeBuffer = stackalloc char [MAX_CHARS_PER_RUNE];
-        Rune rune = cell.Rune;
-        int runeCharsWritten = rune.EncodeToUtf16 (runeBuffer);
-        ReadOnlySpan<char> runeChars = runeBuffer [..runeCharsWritten];
-        output.Append (runeChars);
+        // Add the grapheme
+        string grapheme = cell.Grapheme;
+        output.Append (grapheme);
 
-        // Handle wide characters
-        if (rune.GetColumns () > 1 && currentCol + 1 < maxCol)
+        // Handle wide grapheme
+        if (grapheme.GetColumns () > 1 && currentCol + 1 < maxCol)
         {
             currentCol++; // Skip next cell for wide character
         }

+ 72 - 142
Terminal.Gui/Drivers/OutputBufferImpl.cs

@@ -65,7 +65,9 @@ public class OutputBufferImpl : IOutputBuffer
     /// <summary>The topmost row in the terminal.</summary>
     public virtual int Top { get; set; } = 0;
 
-    /// <inheritdoc/>
+    /// <summary>
+    /// Indicates which lines have been modified and need to be redrawn.
+    /// </summary>
     public bool [] DirtyLines { get; set; } = [];
 
     // QUESTION: When non-full screen apps are supported, will this represent the app size, or will that be in Application?
@@ -112,85 +114,50 @@ public class OutputBufferImpl : IOutputBuffer
     ///         will be added instead.
     ///     </para>
     /// </remarks>
-    /// <param name="rune">Rune to add.</param>
-    public void AddRune (Rune rune)
-    {
-        int runeWidth = -1;
-        bool validLocation = IsValidLocation (rune, Col, Row);
-
-        if (Contents is null)
-        {
-            return;
-        }
-
-        Clip ??= new (Screen);
+    /// <param name="rune">Text to add.</param>
+    public void AddRune (Rune rune) { AddStr (rune.ToString ()); }
 
-        Rectangle clipRect = Clip!.GetBounds ();
+    /// <summary>
+    ///     Adds the specified <see langword="char"/> to the display at the current cursor position. This method is a
+    ///     convenience method that calls <see cref="AddRune(Rune)"/> with the <see cref="Rune"/> constructor.
+    /// </summary>
+    /// <param name="c">Character to add.</param>
+    public void AddRune (char c) { AddRune (new Rune (c)); }
 
-        if (validLocation)
+    /// <summary>Adds the <paramref name="str"/> to the display at the cursor position.</summary>
+    /// <remarks>
+    ///     <para>
+    ///         When the method returns, <see cref="Col"/> will be incremented by the number of columns
+    ///         <paramref name="str"/> required, unless the new column value is outside the <see cref="Clip"/> or screen
+    ///         dimensions defined by <see cref="Cols"/>.
+    ///     </para>
+    ///     <para>If <paramref name="str"/> requires more columns than are available, the output will be clipped.</para>
+    /// </remarks>
+    /// <param name="str">String.</param>
+    public void AddStr (string str)
+    {
+        foreach (string grapheme in GraphemeHelper.GetGraphemes (str))
         {
-            rune = rune.MakePrintable ();
-            runeWidth = rune.GetColumns ();
+            string text = grapheme;
 
-            lock (Contents)
-            {
-                if (runeWidth == 0 && rune.IsCombiningMark ())
-                {
-                    // AtlasEngine does not support NON-NORMALIZED combining marks in a way
-                    // compatible with the driver architecture. Any CMs (except in the first col)
-                    // are correctly combined with the base char, but are ALSO treated as 1 column
-                    // width codepoints E.g. `echo "[e`u{0301}`u{0301}]"` will output `[é  ]`.
-                    // 
-                    // Until this is addressed (see Issue #), we do our best by 
-                    // a) Attempting to normalize any CM with the base char to it's left
-                    // b) Ignoring any CMs that don't normalize
-                    if (Col > 0)
-                    {
-                        if (Contents [Row, Col - 1].CombiningMarks.Count > 0)
-                        {
-                            // Just add this mark to the list
-                            Contents [Row, Col - 1].AddCombiningMark (rune);
-
-                            // Ignore. Don't move to next column (let the driver figure out what to do).
-                        }
-                        else
-                        {
-                            // Attempt to normalize the cell to our left combined with this mark
-                            string combined = Contents [Row, Col - 1].Rune + rune.ToString ();
+            int textWidth = -1;
+            bool validLocation = IsValidLocation (text, Col, Row);
 
-                            // Normalize to Form C (Canonical Composition)
-                            string normalized = combined.Normalize (NormalizationForm.FormC);
+            if (Contents is null)
+            {
+                return;
+            }
 
-                            if (normalized.Length == 1)
-                            {
-                                // It normalized! We can just set the Cell to the left with the
-                                // normalized codepoint 
-                                Contents [Row, Col - 1].Rune = (Rune)normalized [0];
+            Clip ??= new (Screen);
 
-                                // Ignore. Don't move to next column because we're already there
-                            }
-                            else
-                            {
-                                // It didn't normalize. Add it to the Cell to left's CM list
-                                Contents [Row, Col - 1].AddCombiningMark (rune);
+            Rectangle clipRect = Clip!.GetBounds ();
 
-                                // Ignore. Don't move to next column (let the driver figure out what to do).
-                            }
-                        }
+            if (validLocation)
+            {
+                text = text.MakePrintable ();
+                textWidth = text.GetColumns ();
 
-                        Contents [Row, Col - 1].Attribute = CurrentAttribute;
-                        Contents [Row, Col - 1].IsDirty = true;
-                    }
-                    else
-                    {
-                        // Most drivers will render a combining mark at col 0 as the mark
-                        Contents [Row, Col].Rune = rune;
-                        Contents [Row, Col].Attribute = CurrentAttribute;
-                        Contents [Row, Col].IsDirty = true;
-                        Col++;
-                    }
-                }
-                else
+                lock (Contents)
                 {
                     Contents [Row, Col].Attribute = CurrentAttribute;
                     Contents [Row, Col].IsDirty = true;
@@ -198,49 +165,45 @@ public class OutputBufferImpl : IOutputBuffer
                     if (Col > 0)
                     {
                         // Check if cell to left has a wide glyph
-                        if (Contents [Row, Col - 1].Rune.GetColumns () > 1)
+                        if (Contents [Row, Col - 1].Grapheme.GetColumns () > 1)
                         {
                             // Invalidate cell to left
-                            Contents [Row, Col - 1].Rune = Rune.ReplacementChar;
+                            Contents [Row, Col - 1].Grapheme = Rune.ReplacementChar.ToString ();
                             Contents [Row, Col - 1].IsDirty = true;
                         }
                     }
 
-                    if (runeWidth < 1)
-                    {
-                        Contents [Row, Col].Rune = Rune.ReplacementChar;
-                    }
-                    else if (runeWidth == 1)
+                    if (textWidth is 0 or 1)
                     {
-                        Contents [Row, Col].Rune = rune;
+                        Contents [Row, Col].Grapheme = text;
 
                         if (Col < clipRect.Right - 1)
                         {
                             Contents [Row, Col + 1].IsDirty = true;
                         }
                     }
-                    else if (runeWidth == 2)
+                    else if (textWidth == 2)
                     {
                         if (!Clip.Contains (Col + 1, Row))
                         {
                             // We're at the right edge of the clip, so we can't display a wide character.
                             // TODO: Figure out if it is better to show a replacement character or ' '
-                            Contents [Row, Col].Rune = Rune.ReplacementChar;
+                            Contents [Row, Col].Grapheme = Rune.ReplacementChar.ToString ();
                         }
                         else if (!Clip.Contains (Col, Row))
                         {
                             // Our 1st column is outside the clip, so we can't display a wide character.
-                            Contents [Row, Col + 1].Rune = Rune.ReplacementChar;
+                            Contents [Row, Col + 1].Grapheme = Rune.ReplacementChar.ToString ();
                         }
                         else
                         {
-                            Contents [Row, Col].Rune = rune;
+                            Contents [Row, Col].Grapheme = text;
 
                             if (Col < clipRect.Right - 1)
                             {
                                 // Invalidate cell to right so that it doesn't get drawn
                                 // TODO: Figure out if it is better to show a replacement character or ' '
-                                Contents [Row, Col + 1].Rune = Rune.ReplacementChar;
+                                Contents [Row, Col + 1].Grapheme = Rune.ReplacementChar.ToString ();
                                 Contents [Row, Col + 1].IsDirty = true;
                             }
                         }
@@ -248,67 +211,37 @@ public class OutputBufferImpl : IOutputBuffer
                     else
                     {
                         // This is a non-spacing character, so we don't need to do anything
-                        Contents [Row, Col].Rune = (Rune)' ';
+                        Contents [Row, Col].Grapheme = " ";
                         Contents [Row, Col].IsDirty = false;
                     }
 
                     DirtyLines [Row] = true;
                 }
             }
-        }
 
-        if (runeWidth is < 0 or > 0)
-        {
             Col++;
-        }
 
-        if (runeWidth > 1)
-        {
-            Debug.Assert (runeWidth <= 2);
-
-            if (validLocation && Col < clipRect.Right)
+            if (textWidth > 1)
             {
-                lock (Contents!)
+                Debug.Assert (textWidth <= 2);
+
+                if (validLocation && Col < clipRect.Right)
                 {
-                    // This is a double-width character, and we are not at the end of the line.
-                    // Col now points to the second column of the character. Ensure it doesn't
-                    // Get rendered.
-                    Contents [Row, Col].IsDirty = false;
-                    Contents [Row, Col].Attribute = CurrentAttribute;
+                    lock (Contents!)
+                    {
+                        // This is a double-width character, and we are not at the end of the line.
+                        // Col now points to the second column of the character. Ensure it doesn't
+                        // Get rendered.
+                        Contents [Row, Col].IsDirty = false;
+                        Contents [Row, Col].Attribute = CurrentAttribute;
 
-                    // TODO: Determine if we should wipe this out (for now now)
-                    //Contents [Row, Col].Rune = (Rune)' ';
+                        // TODO: Determine if we should wipe this out (for now now)
+                        //Contents [Row, Col].Text = (Text)' ';
+                    }
                 }
-            }
 
-            Col++;
-        }
-    }
-
-    /// <summary>
-    ///     Adds the specified <see langword="char"/> to the display at the current cursor position. This method is a
-    ///     convenience method that calls <see cref="AddRune(Rune)"/> with the <see cref="Rune"/> constructor.
-    /// </summary>
-    /// <param name="c">Character to add.</param>
-    public void AddRune (char c) { AddRune (new Rune (c)); }
-
-    /// <summary>Adds the <paramref name="str"/> to the display at the cursor position.</summary>
-    /// <remarks>
-    ///     <para>
-    ///         When the method returns, <see cref="Col"/> will be incremented by the number of columns
-    ///         <paramref name="str"/> required, unless the new column value is outside of the <see cref="Clip"/> or screen
-    ///         dimensions defined by <see cref="Cols"/>.
-    ///     </para>
-    ///     <para>If <paramref name="str"/> requires more columns than are available, the output will be clipped.</para>
-    /// </remarks>
-    /// <param name="str">String.</param>
-    public void AddStr (string str)
-    {
-        List<Rune> runes = str.EnumerateRunes ().ToList ();
-
-        for (var i = 0; i < runes.Count; i++)
-        {
-            AddRune (runes [i]);
+                Col++;
+            }
         }
     }
 
@@ -331,7 +264,7 @@ public class OutputBufferImpl : IOutputBuffer
                 {
                     Contents [row, c] = new ()
                     {
-                        Rune = (Rune)' ',
+                        Grapheme = " ",
                         Attribute = new Attribute (Color.White, Color.Black),
                         IsDirty = true
                     };
@@ -345,22 +278,19 @@ public class OutputBufferImpl : IOutputBuffer
         //ClearedContents?.Invoke (this, EventArgs.Empty);
     }
 
-    /// <summary>Tests whether the specified coordinate are valid for drawing the specified Rune.</summary>
-    /// <param name="rune">Used to determine if one or two columns are required.</param>
+    /// <summary>Tests whether the specified coordinate are valid for drawing the specified Text.</summary>
+    /// <param name="text">Used to determine if one or two columns are required.</param>
     /// <param name="col">The column.</param>
     /// <param name="row">The row.</param>
     /// <returns>
     ///     <see langword="false"/> if the coordinate is outside the screen bounds or outside of <see cref="Clip"/>.
     ///     <see langword="true"/> otherwise.
     /// </returns>
-    public bool IsValidLocation (Rune rune, int col, int row)
+    public bool IsValidLocation (string text, int col, int row)
     {
-        if (rune.GetColumns () < 2)
-        {
-            return col >= 0 && row >= 0 && col < Cols && row < Rows && Clip!.Contains (col, row);
-        }
+        int textWidth = text.GetColumns ();
 
-        return Clip!.Contains (col, row) || Clip!.Contains (col + 1, row);
+        return col >= 0 && row >= 0 && col + textWidth <= Cols && row < Rows && Clip!.Contains (col, row);
     }
 
     /// <inheritdoc/>
@@ -383,14 +313,14 @@ public class OutputBufferImpl : IOutputBuffer
             {
                 for (int c = rect.X; c < rect.X + rect.Width; c++)
                 {
-                    if (!IsValidLocation (rune, c, r))
+                    if (!IsValidLocation (rune.ToString (), c, r))
                     {
                         continue;
                     }
 
                     Contents [r, c] = new ()
                     {
-                        Rune = rune != default (Rune) ? rune : (Rune)' ',
+                        Grapheme = rune != default (Rune) ? rune.ToString () : " ",
                         Attribute = CurrentAttribute, IsDirty = true
                     };
                 }

+ 76 - 12
Terminal.Gui/Text/StringExtensions.cs

@@ -1,5 +1,4 @@
 using System.Buffers;
-using System.Globalization;
 
 namespace Terminal.Gui.Text;
 
@@ -54,8 +53,9 @@ public static class StringExtensions
     /// <summary>Gets the number of columns the string occupies in the terminal.</summary>
     /// <remarks>This is a Terminal.Gui extension method to <see cref="string"/> to support TUI text manipulation.</remarks>
     /// <param name="str">The string to measure.</param>
+    /// <param name="ignoreLessThanZero">Indicates whether to ignore values ​​less than zero, such as control keys.</param>
     /// <returns></returns>
-    public static int GetColumns (this string str)
+    public static int GetColumns (this string str, bool ignoreLessThanZero = true)
     {
         if (string.IsNullOrEmpty (str))
         {
@@ -63,17 +63,25 @@ public static class StringExtensions
         }
 
         var total = 0;
-        TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator (str);
 
-        while (enumerator.MoveNext ())
+        foreach (string grapheme in GraphemeHelper.GetGraphemes (str))
         {
-            string element = enumerator.GetTextElement ();
-
             // Get the maximum rune width within this grapheme cluster
-            int width = element
-                        .EnumerateRunes ()
-                        .Max (r => Math.Max (r.GetColumns (), 0));
-            total += width;
+            int clusterWidth = grapheme.EnumerateRunes ()
+                                       .Sum (r =>
+                                             {
+                                                 int w = r.GetColumns ();
+
+                                                 return ignoreLessThanZero && w < 0 ? 0 : w;
+                                             });
+
+            // Clamp to realistic max display width
+            if (clusterWidth > 2)
+            {
+                clusterWidth = 2;
+            }
+
+            total += clusterWidth;
         }
 
         return total;
@@ -94,7 +102,7 @@ public static class StringExtensions
     ///     A <see langword="bool"/> indicating if all elements of the <see cref="ReadOnlySpan{T}"/> are ASCII digits (
     ///     <see langword="true"/>) or not (<see langword="false"/>
     /// </returns>
-    public static bool IsAllAsciiDigits (this ReadOnlySpan<char> stringSpan) { return stringSpan.ToString ().All (char.IsAsciiDigit); }
+    public static bool IsAllAsciiDigits (this ReadOnlySpan<char> stringSpan) { return !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiDigit); }
 
     /// <summary>
     ///     Determines if this <see cref="ReadOnlySpan{T}"/> of <see langword="char"/> is composed entirely of ASCII
@@ -105,7 +113,7 @@ public static class StringExtensions
     ///     A <see langword="bool"/> indicating if all elements of the <see cref="ReadOnlySpan{T}"/> are ASCII digits (
     ///     <see langword="true"/>) or not (<see langword="false"/>
     /// </returns>
-    public static bool IsAllAsciiHexDigits (this ReadOnlySpan<char> stringSpan) { return stringSpan.ToString ().All (char.IsAsciiHexDigit); }
+    public static bool IsAllAsciiHexDigits (this ReadOnlySpan<char> stringSpan) { return !stringSpan.IsEmpty && stringSpan.ToString ().All (char.IsAsciiHexDigit); }
 
     /// <summary>Repeats the string <paramref name="n"/> times.</summary>
     /// <remarks>This is a Terminal.Gui extension method to <see cref="string"/> to support TUI text manipulation.</remarks>
@@ -206,4 +214,60 @@ public static class StringExtensions
 
         return encoding.GetString (bytes.ToArray ());
     }
+
+    /// <summary>Converts a <see cref="string"/> generic collection into a string.</summary>
+    /// <param name="strings">The enumerable string to convert.</param>
+    /// <returns></returns>
+    public static string ToString (IEnumerable<string> strings) { return string.Concat (strings); }
+
+    /// <summary>Converts the string into a <see cref="List{String}"/>.</summary>
+    /// <remarks>This is a Terminal.Gui extension method to <see cref="string"/> to support TUI text manipulation.</remarks>
+    /// <param name="str">The string to convert.</param>
+    /// <returns></returns>
+    public static List<string> ToStringList (this string str)
+    {
+        List<string> strings = [];
+
+        foreach (string grapheme in GraphemeHelper.GetGraphemes (str))
+        {
+            strings.Add (grapheme);
+        }
+
+        return strings;
+    }
+
+    /// <summary>Reports whether a string is a surrogate code point.</summary>
+    /// <remarks>This is a Terminal.Gui extension method to <see cref="string"/> to support TUI text manipulation.</remarks>
+    /// <param name="str">The string to probe.</param>
+    /// <returns><see langword="true"/> if the string is a surrogate code point; <see langword="false"/> otherwise.</returns>
+    public static bool IsSurrogatePair (this string str)
+    {
+        if (str.Length != 2)
+        {
+            return false;
+        }
+
+        Rune rune = Rune.GetRuneAt (str, 0);
+
+        return rune.IsSurrogatePair ();
+    }
+
+    /// <summary>
+    ///     Ensures the text is not a control character and can be displayed by translating characters below 0x20 to
+    ///     equivalent, printable, Unicode chars.
+    /// </summary>
+    /// <remarks>This is a Terminal.Gui extension method to <see cref="string"/> to support TUI text manipulation.</remarks>
+    /// <param name="str">The text.</param>
+    /// <returns></returns>
+    public static string MakePrintable (this string str)
+    {
+        if (str.Length > 1)
+        {
+            return str;
+        }
+
+        char ch = str [0];
+
+        return char.IsControl (ch) ? new ((char)(ch + 0x2400), 1) : str;
+    }
 }

+ 169 - 159
Terminal.Gui/Text/TextFormatter.cs

@@ -122,9 +122,10 @@ public class TextFormatter
                 break;
             }
 
-            Rune [] runes = linesFormatted [line].ToRunes ();
+            string strings = linesFormatted [line];
+            string[] graphemes = GraphemeHelper.GetGraphemes (strings).ToArray ();
 
-            // When text is justified, we lost left or right, so we use the direction to align. 
+            // When text is justified, we lost left or right, so we use the direction to align.
 
             int x = 0, y = 0;
 
@@ -139,7 +140,7 @@ public class TextFormatter
                 }
                 else
                 {
-                    int runesWidth = StringExtensions.ToString (runes).GetColumns ();
+                    int runesWidth = strings.GetColumns ();
                     x = screen.Right - runesWidth;
                     CursorPosition = screen.Width - runesWidth + (_hotKeyPos > -1 ? _hotKeyPos : 0);
                 }
@@ -195,7 +196,7 @@ public class TextFormatter
                 }
                 else
                 {
-                    int runesWidth = StringExtensions.ToString (runes).GetColumns ();
+                    int runesWidth = strings.GetColumns ();
                     x = screen.Left + (screen.Width - runesWidth) / 2;
 
                     CursorPosition = (screen.Width - runesWidth) / 2 + (_hotKeyPos > -1 ? _hotKeyPos : 0);
@@ -213,7 +214,7 @@ public class TextFormatter
             {
                 if (isVertical)
                 {
-                    y = screen.Bottom - runes.Length;
+                    y = screen.Bottom - graphemes.Length;
                 }
                 else
                 {
@@ -249,7 +250,7 @@ public class TextFormatter
             {
                 if (isVertical)
                 {
-                    int s = (screen.Height - runes.Length) / 2;
+                    int s = (screen.Height - graphemes.Length) / 2;
                     y = screen.Top + s;
                 }
                 else
@@ -270,14 +271,14 @@ public class TextFormatter
             int size = isVertical ? screen.Height : screen.Width;
             int current = start + colOffset;
             List<Point?> lastZeroWidthPos = null!;
-            Rune rune = default;
-            int zeroLengthCount = isVertical ? runes.Sum (r => r.GetColumns () == 0 ? 1 : 0) : 0;
+            string text = default;
+            int zeroLengthCount = isVertical ? strings.EnumerateRunes ().Sum (r => r.GetColumns () == 0 ? 1 : 0) : 0;
 
             for (int idx = (isVertical ? start - y : start - x) + colOffset;
                  current < start + size + zeroLengthCount;
                  idx++)
             {
-                Rune lastRuneUsed = rune;
+                string lastTextUsed = text;
 
                 if (lastZeroWidthPos is null)
                 {
@@ -291,17 +292,17 @@ public class TextFormatter
                         continue;
                     }
 
-                    if (!FillRemaining && idx > runes.Length - 1)
+                    if (!FillRemaining && idx > graphemes.Length - 1)
                     {
                         break;
                     }
 
                     if ((!isVertical
                          && (current - start > maxScreen.Left + maxScreen.Width - screen.X + colOffset
-                             || (idx < runes.Length && runes [idx].GetColumns () > screen.Width)))
+                             || (idx < graphemes.Length && graphemes [idx].GetColumns () > screen.Width)))
                         || (isVertical
                             && ((current > start + size + zeroLengthCount && idx > maxScreen.Top + maxScreen.Height - screen.Y)
-                                || (idx < runes.Length && runes [idx].GetColumns () > screen.Width))))
+                                || (idx < graphemes.Length && graphemes [idx].GetColumns () > screen.Width))))
                     {
                         break;
                     }
@@ -312,13 +313,13 @@ public class TextFormatter
 
                 //	break;
 
-                rune = (Rune)' ';
+                text = " ";
 
                 if (isVertical)
                 {
-                    if (idx >= 0 && idx < runes.Length)
+                    if (idx >= 0 && idx < graphemes.Length)
                     {
-                        rune = runes [idx];
+                        text = graphemes [idx];
                     }
 
                     if (lastZeroWidthPos is null)
@@ -334,7 +335,7 @@ public class TextFormatter
 
                         if (foundIdx > -1)
                         {
-                            if (rune.IsCombiningMark ())
+                            if (Rune.GetRuneAt (text, 0).IsCombiningMark ())
                             {
                                 lastZeroWidthPos [foundIdx] =
                                     new Point (
@@ -347,7 +348,7 @@ public class TextFormatter
                                               current
                                              );
                             }
-                            else if (!rune.IsCombiningMark () && lastRuneUsed.IsCombiningMark ())
+                            else if (!Rune.GetRuneAt (text, 0).IsCombiningMark () && Rune.GetRuneAt (lastTextUsed, 0).IsCombiningMark ())
                             {
                                 current++;
                                 driver?.Move (x, current);
@@ -367,13 +368,13 @@ public class TextFormatter
                 {
                     driver?.Move (current, y);
 
-                    if (idx >= 0 && idx < runes.Length)
+                    if (idx >= 0 && idx < graphemes.Length)
                     {
-                        rune = runes [idx];
+                        text = graphemes [idx];
                     }
                 }
 
-                int runeWidth = GetRuneWidth (rune, TabWidth);
+                int runeWidth = GetTextWidth (text, TabWidth);
 
                 if (HotKeyPos > -1 && idx == HotKeyPos)
                 {
@@ -383,7 +384,7 @@ public class TextFormatter
                     }
 
                     driver?.SetAttribute (hotColor);
-                    driver?.AddRune (rune);
+                    driver?.AddStr (text);
                     driver?.SetAttribute (normalColor);
                 }
                 else
@@ -412,7 +413,7 @@ public class TextFormatter
                         }
                     }
 
-                    driver?.AddRune (rune);
+                    driver?.AddStr (text);
                 }
 
                 if (isVertical)
@@ -427,11 +428,11 @@ public class TextFormatter
                     current += runeWidth;
                 }
 
-                int nextRuneWidth = idx + 1 > -1 && idx + 1 < runes.Length
-                                        ? runes [idx + 1].GetColumns ()
+                int nextRuneWidth = idx + 1 > -1 && idx + 1 < graphemes.Length
+                                        ? graphemes [idx + 1].GetColumns ()
                                         : 0;
 
-                if (!isVertical && idx + 1 < runes.Length && current + nextRuneWidth > start + size)
+                if (!isVertical && idx + 1 < graphemes.Length && current + nextRuneWidth > start + size)
                 {
                     break;
                 }
@@ -929,9 +930,10 @@ public class TextFormatter
                 break;
             }
 
-            Rune [] runes = linesFormatted [line].ToRunes ();
+            string strings = linesFormatted [line];
+            string [] graphemes = GraphemeHelper.GetGraphemes (strings).ToArray ();
 
-            // When text is justified, we lost left or right, so we use the direction to align. 
+            // When text is justified, we lost left or right, so we use the direction to align.
             int x = 0, y = 0;
 
             switch (Alignment)
@@ -946,17 +948,17 @@ public class TextFormatter
                     }
                 case Alignment.End:
                     {
-                        int runesWidth = StringExtensions.ToString (runes).GetColumns ();
-                        x = screen.Right - runesWidth;
+                        int stringsWidth = strings.GetColumns ();
+                        x = screen.Right - stringsWidth;
 
                         break;
                     }
                 case Alignment.Start when isVertical:
                     {
-                        int runesWidth = line > 0
-                                         ? GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth)
-                                         : 0;
-                        x = screen.Left + runesWidth;
+                        int stringsWidth = line > 0
+                                               ? GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth)
+                                               : 0;
+                        x = screen.Left + stringsWidth;
 
                         break;
                     }
@@ -966,7 +968,7 @@ public class TextFormatter
                     break;
                 case Alignment.Fill when isVertical:
                     {
-                        int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth);
+                        int stringsWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth);
                         int prevLineWidth = line > 0 ? GetColumnsRequiredForVerticalText (linesFormatted, line - 1, 1, TabWidth) : 0;
                         int firstLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, 1, TabWidth);
                         int lastLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, linesFormatted.Count - 1, 1, TabWidth);
@@ -975,7 +977,7 @@ public class TextFormatter
                         x = line == 0
                                 ? screen.Left
                                 : line < linesFormatted.Count - 1
-                                    ? screen.Width - runesWidth <= lastLineWidth ? screen.Left + prevLineWidth : screen.Left + line * interval
+                                    ? screen.Width - stringsWidth <= lastLineWidth ? screen.Left + prevLineWidth : screen.Left + line * interval
                                     : screen.Right - lastLineWidth;
 
                         break;
@@ -986,16 +988,16 @@ public class TextFormatter
                     break;
                 case Alignment.Center when isVertical:
                     {
-                        int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth);
+                        int stringsWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth);
                         int linesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth);
-                        x = screen.Left + linesWidth + (screen.Width - runesWidth) / 2;
+                        x = screen.Left + linesWidth + (screen.Width - stringsWidth) / 2;
 
                         break;
                     }
                 case Alignment.Center:
                     {
-                        int runesWidth = StringExtensions.ToString (runes).GetColumns ();
-                        x = screen.Left + (screen.Width - runesWidth) / 2;
+                        int stringsWidth = strings.GetColumns ();
+                        x = screen.Left + (screen.Width - stringsWidth) / 2;
 
                         break;
                     }
@@ -1009,7 +1011,7 @@ public class TextFormatter
             {
                 // Vertical Alignment
                 case Alignment.End when isVertical:
-                    y = screen.Bottom - runes.Length;
+                    y = screen.Bottom - graphemes.Length;
 
                     break;
                 case Alignment.End:
@@ -1039,7 +1041,7 @@ public class TextFormatter
                     }
                 case Alignment.Center when isVertical:
                     {
-                        int s = (screen.Height - runes.Length) / 2;
+                        int s = (screen.Height - graphemes.Length) / 2;
                         y = screen.Top + s;
 
                         break;
@@ -1061,7 +1063,7 @@ public class TextFormatter
             int start = isVertical ? screen.Top : screen.Left;
             int size = isVertical ? screen.Height : screen.Width;
             int current = start + colOffset;
-            int zeroLengthCount = isVertical ? runes.Sum (r => r.GetColumns () == 0 ? 1 : 0) : 0;
+            int zeroLengthCount = isVertical ? strings.EnumerateRunes ().Sum (r => r.GetColumns () == 0 ? 1 : 0) : 0;
 
             int lineX = x, lineY = y, lineWidth = 0, lineHeight = 1;
 
@@ -1079,23 +1081,23 @@ public class TextFormatter
                     continue;
                 }
 
-                if (!FillRemaining && idx > runes.Length - 1)
+                if (!FillRemaining && idx > graphemes.Length - 1)
                 {
                     break;
                 }
 
                 if ((!isVertical
                      && (current - start > maxScreen.Left + maxScreen.Width - screen.X + colOffset
-                         || (idx < runes.Length && runes [idx].GetColumns () > screen.Width)))
+                         || (idx < graphemes.Length && graphemes [idx].GetColumns () > screen.Width)))
                     || (isVertical
                         && ((current > start + size + zeroLengthCount && idx > maxScreen.Top + maxScreen.Height - screen.Y)
-                            || (idx < runes.Length && runes [idx].GetColumns () > screen.Width))))
+                            || (idx < graphemes.Length && graphemes [idx].GetColumns () > screen.Width))))
                 {
                     break;
                 }
 
-                Rune rune = idx >= 0 && idx < runes.Length ? runes [idx] : (Rune)' ';
-                int runeWidth = GetRuneWidth (rune, TabWidth);
+                string text = idx >= 0 && idx < graphemes.Length ? graphemes [idx] : " ";
+                int runeWidth = GetStringWidth (text, TabWidth);
 
                 if (isVertical)
                 {
@@ -1114,11 +1116,11 @@ public class TextFormatter
 
                 current += isVertical && runeWidth > 0 ? 1 : runeWidth;
 
-                int nextRuneWidth = idx + 1 > -1 && idx + 1 < runes.Length
-                                        ? runes [idx + 1].GetColumns ()
+                int nextStringWidth = idx + 1 > -1 && idx + 1 < graphemes.Length
+                                        ? graphemes [idx + 1].GetColumns ()
                                         : 0;
 
-                if (!isVertical && idx + 1 < runes.Length && current + nextRuneWidth > start + size)
+                if (!isVertical && idx + 1 < graphemes.Length && current + nextStringWidth > start + size)
                 {
                     break;
                 }
@@ -1331,33 +1333,34 @@ public class TextFormatter
     /// <returns>A list of text without the newline characters.</returns>
     public static List<string> SplitNewLine (string text)
     {
-        List<Rune> runes = text.ToRuneList ();
+        List<string> graphemes = GraphemeHelper.GetGraphemes (text).ToList ();
         List<string> lines = new ();
         var start = 0;
 
-        for (var i = 0; i < runes.Count; i++)
+        for (var i = 0; i < graphemes.Count; i++)
         {
             int end = i;
 
-            switch (runes [i].Value)
+            switch (graphemes [i])
             {
-                case '\n':
-                    lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start)));
+                case "\n":
+                case "\r\n":
+                    lines.Add (StringExtensions.ToString (graphemes.GetRange (start, end - start)));
                     i++;
                     start = i;
 
                     break;
 
-                case '\r':
-                    if (i + 1 < runes.Count && runes [i + 1].Value == '\n')
+                case "\r":
+                    if (i + 1 < graphemes.Count && graphemes [i + 1] == "\n")
                     {
-                        lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start)));
+                        lines.Add (StringExtensions.ToString (graphemes.GetRange (start, end - start)));
                         i += 2;
                         start = i;
                     }
                     else
                     {
-                        lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start)));
+                        lines.Add (StringExtensions.ToString (graphemes.GetRange (start, end - start)));
                         i++;
                         start = i;
                     }
@@ -1366,14 +1369,14 @@ public class TextFormatter
             }
         }
 
-        switch (runes.Count)
+        switch (graphemes.Count)
         {
             case > 0 when lines.Count == 0:
-                lines.Add (StringExtensions.ToString (runes));
+                lines.Add (StringExtensions.ToString (graphemes));
 
                 break;
-            case > 0 when start < runes.Count:
-                lines.Add (StringExtensions.ToString (runes.GetRange (start, runes.Count - start)));
+            case > 0 when start < graphemes.Count:
+                lines.Add (StringExtensions.ToString (graphemes.GetRange (start, graphemes.Count - start)));
 
                 break;
             default:
@@ -1401,16 +1404,19 @@ public class TextFormatter
         }
 
         // if value is not wide enough
-        if (text.EnumerateRunes ().Sum (c => c.GetColumns ()) < width)
+        string [] graphemes = GraphemeHelper.GetGraphemes (text).ToArray ();
+        int totalColumns = graphemes.Sum (s => s.GetColumns ());
+
+        if (totalColumns < width)
         {
             // pad it out with spaces to the given Alignment
-            int toPad = width - text.EnumerateRunes ().Sum (c => c.GetColumns ());
+            int toPad = width - totalColumns;
 
             return text + new string (' ', toPad);
         }
 
         // value is too wide
-        return new (text.TakeWhile (c => (width -= ((Rune)c).GetColumns ()) >= 0).ToArray ());
+        return string.Concat (graphemes.TakeWhile (t => (width -= t.GetColumns ()) >= 0));
     }
 
     /// <summary>Formats the provided text to fit within the width provided using word wrapping.</summary>
@@ -1451,18 +1457,18 @@ public class TextFormatter
             return lines;
         }
 
-        List<Rune> runes = StripCRLF (text).ToRuneList ();
+        List<string> graphemes = GraphemeHelper.GetGraphemes (StripCRLF (text)).ToList ();
 
         int start = Math.Max (
-                              !runes.Contains ((Rune)' ') && textFormatter is { VerticalAlignment: Alignment.End } && IsVerticalDirection (textDirection)
-                                  ? runes.Count - width
+                              !graphemes.Contains (" ") && textFormatter is { VerticalAlignment: Alignment.End } && IsVerticalDirection (textDirection)
+                                  ? graphemes.Count - width
                                   : 0,
                               0);
         int end;
 
         if (preserveTrailingSpaces)
         {
-            while ((end = start) < runes.Count)
+            while (start < graphemes.Count)
             {
                 end = GetNextWhiteSpace (start, width, out bool incomplete);
 
@@ -1473,7 +1479,7 @@ public class TextFormatter
                     break;
                 }
 
-                lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start)));
+                lines.Add (StringExtensions.ToString (graphemes.GetRange (start, end - start)));
                 start = end;
 
                 if (incomplete)
@@ -1490,14 +1496,14 @@ public class TextFormatter
             {
                 while ((end = start
                               + GetLengthThatFits (
-                                                   runes.GetRange (start, runes.Count - start),
+                                                   string.Concat (graphemes.GetRange (start, graphemes.Count - start)),
                                                    width,
                                                    tabWidth,
                                                    textDirection
                                                   ))
-                       < runes.Count)
+                       < graphemes.Count)
                 {
-                    while (runes [end].Value != ' ' && end > start)
+                    while (graphemes [end] != " " && end > start)
                     {
                         end--;
                     }
@@ -1506,22 +1512,22 @@ public class TextFormatter
                     {
                         end = start
                               + GetLengthThatFits (
-                                                   runes.GetRange (end, runes.Count - end),
+                                                   string.Concat (graphemes.GetRange (end, graphemes.Count - end)),
                                                    width,
                                                    tabWidth,
                                                    textDirection
                                                   );
                     }
 
-                    var str = StringExtensions.ToString (runes.GetRange (start, end - start));
+                    var str = StringExtensions.ToString (graphemes.GetRange (start, end - start));
                     int zeroLength = text.EnumerateRunes ().Sum (r => r.GetColumns () == 0 ? 1 : 0);
 
-                    if (end > start && GetRuneWidth (str, tabWidth, textDirection) <= width + zeroLength)
+                    if (end > start && GetTextWidth (str, tabWidth, textDirection) <= width + zeroLength)
                     {
                         lines.Add (str);
                         start = end;
 
-                        if (runes [end].Value == ' ')
+                        if (graphemes [end] == " ")
                         {
                             start++;
                         }
@@ -1535,9 +1541,9 @@ public class TextFormatter
             }
             else
             {
-                while ((end = start + width) < runes.Count)
+                while ((end = start + width) < graphemes.Count)
                 {
-                    while (runes [end].Value != ' ' && end > start)
+                    while (graphemes [end] != " " && end > start)
                     {
                         end--;
                     }
@@ -1549,11 +1555,11 @@ public class TextFormatter
 
                     var zeroLength = 0;
 
-                    for (int i = end; i < runes.Count - start; i++)
+                    for (int i = end; i < graphemes.Count - start; i++)
                     {
-                        Rune r = runes [i];
+                        string s = graphemes [i];
 
-                        if (r.GetColumns () == 0)
+                        if (s.GetColumns () == 0)
                         {
                             zeroLength++;
                         }
@@ -1565,7 +1571,7 @@ public class TextFormatter
 
                     lines.Add (
                                StringExtensions.ToString (
-                                                          runes.GetRange (
+                                                          graphemes.GetRange (
                                                                           start,
                                                                           end - start + zeroLength
                                                                          )
@@ -1574,7 +1580,7 @@ public class TextFormatter
                     end += zeroLength;
                     start = end;
 
-                    if (runes [end].Value == ' ')
+                    if (graphemes [end] == " ")
                     {
                         start++;
                     }
@@ -1588,13 +1594,13 @@ public class TextFormatter
             int length = cLength;
             incomplete = false;
 
-            while (length < cWidth && to < runes.Count)
+            while (length < cWidth && to < graphemes.Count)
             {
-                Rune rune = runes [to];
+                string grapheme = graphemes [to];
 
                 if (IsHorizontalDirection (textDirection))
                 {
-                    length += rune.GetColumns ();
+                    length += grapheme.GetColumns (false);
                 }
                 else
                 {
@@ -1603,7 +1609,7 @@ public class TextFormatter
 
                 if (length > cWidth)
                 {
-                    if (to >= runes.Count || (length > 1 && cWidth <= 1))
+                    if (to >= graphemes.Count || (length > 1 && cWidth <= 1))
                     {
                         incomplete = true;
                     }
@@ -1611,15 +1617,15 @@ public class TextFormatter
                     return to;
                 }
 
-                switch (rune.Value)
+                switch (grapheme)
                 {
-                    case ' ' when length == cWidth:
+                    case " " when length == cWidth:
                         return to + 1;
-                    case ' ' when length > cWidth:
+                    case " " when length > cWidth:
                         return to;
-                    case ' ':
+                    case " ":
                         return GetNextWhiteSpace (to + 1, cWidth, out incomplete, length);
-                    case '\t':
+                    case "\t":
                         {
                             length += tabWidth + 1;
 
@@ -1644,8 +1650,8 @@ public class TextFormatter
 
             return cLength switch
             {
-                > 0 when to < runes.Count && runes [to].Value != ' ' && runes [to].Value != '\t' => from,
-                > 0 when to < runes.Count && (runes [to].Value == ' ' || runes [to].Value == '\t') => from,
+                > 0 when to < graphemes.Count && graphemes [to] != " " && graphemes [to] != "\t" => from,
+                > 0 when to < graphemes.Count && (graphemes [to] == " " || graphemes [to] == "\t") => from,
                 _ => to
             };
         }
@@ -1653,7 +1659,7 @@ public class TextFormatter
         if (start < text.GetRuneCount ())
         {
             string str = ReplaceTABWithSpaces (
-                                               StringExtensions.ToString (runes.GetRange (start, runes.Count - start)),
+                                               StringExtensions.ToString (graphemes.GetRange (start, graphemes.Count - start)),
                                                tabWidth
                                               );
 
@@ -1717,42 +1723,42 @@ public class TextFormatter
         }
 
         text = ReplaceTABWithSpaces (text, tabWidth);
-        List<Rune> runes = text.ToRuneList ();
-        int zeroLength = runes.Sum (r => r.GetColumns () == 0 ? 1 : 0);
+        List<string> graphemes = GraphemeHelper.GetGraphemes (text).ToList ();
+        int zeroLength = graphemes.Sum (s => s.EnumerateRunes ().Sum (r => r.GetColumns() == 0 ? 1 : 0));
 
-        if (runes.Count - zeroLength > width)
+        if (graphemes.Count - zeroLength > width)
         {
             if (IsHorizontalDirection (textDirection))
             {
                 if (textFormatter is { Alignment: Alignment.End })
                 {
-                    return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection);
+                    return GetRangeThatFits (graphemes, graphemes.Count - width, text, width, tabWidth, textDirection);
                 }
 
                 if (textFormatter is { Alignment: Alignment.Center })
                 {
-                    return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
+                    return GetRangeThatFits (graphemes, Math.Max ((graphemes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
                 }
 
-                return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection);
+                return GetRangeThatFits (graphemes, 0, text, width, tabWidth, textDirection);
             }
 
             if (IsVerticalDirection (textDirection))
             {
                 if (textFormatter is { VerticalAlignment: Alignment.End })
                 {
-                    return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection);
+                    return GetRangeThatFits (graphemes, graphemes.Count - width, text, width, tabWidth, textDirection);
                 }
 
                 if (textFormatter is { VerticalAlignment: Alignment.Center })
                 {
-                    return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
+                    return GetRangeThatFits (graphemes, Math.Max ((graphemes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
                 }
 
-                return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection);
+                return GetRangeThatFits (graphemes, 0, text, width, tabWidth, textDirection);
             }
 
-            return StringExtensions.ToString (runes.GetRange (0, width + zeroLength));
+            return StringExtensions.ToString (graphemes.GetRange (0, width + zeroLength));
         }
 
         if (justify)
@@ -1764,18 +1770,18 @@ public class TextFormatter
         {
             if (textFormatter is { Alignment: Alignment.End })
             {
-                if (GetRuneWidth (text, tabWidth, textDirection) > width)
+                if (GetTextWidth (text, tabWidth, textDirection) > width)
                 {
-                    return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection);
+                    return GetRangeThatFits (graphemes, graphemes.Count - width, text, width, tabWidth, textDirection);
                 }
             }
             else if (textFormatter is { Alignment: Alignment.Center })
             {
-                return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
+                return GetRangeThatFits (graphemes, Math.Max ((graphemes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
             }
-            else if (GetRuneWidth (text, tabWidth, textDirection) > width)
+            else if (GetTextWidth (text, tabWidth, textDirection) > width)
             {
-                return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection);
+                return GetRangeThatFits (graphemes, 0, text, width, tabWidth, textDirection);
             }
         }
 
@@ -1783,28 +1789,28 @@ public class TextFormatter
         {
             if (textFormatter is { VerticalAlignment: Alignment.End })
             {
-                if (runes.Count - zeroLength > width)
+                if (graphemes.Count - zeroLength > width)
                 {
-                    return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection);
+                    return GetRangeThatFits (graphemes, graphemes.Count - width, text, width, tabWidth, textDirection);
                 }
             }
             else if (textFormatter is { VerticalAlignment: Alignment.Center })
             {
-                return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
+                return GetRangeThatFits (graphemes, Math.Max ((graphemes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection);
             }
-            else if (runes.Count - zeroLength > width)
+            else if (graphemes.Count - zeroLength > width)
             {
-                return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection);
+                return GetRangeThatFits (graphemes, 0, text, width, tabWidth, textDirection);
             }
         }
 
         return text;
     }
 
-    private static string GetRangeThatFits (List<Rune> runes, int index, string text, int width, int tabWidth, TextDirection textDirection)
+    private static string GetRangeThatFits (List<string> strings, int index, string text, int width, int tabWidth, TextDirection textDirection)
     {
         return StringExtensions.ToString (
-                                          runes.GetRange (
+                                          strings.GetRange (
                                                           Math.Max (index, 0),
                                                           GetLengthThatFits (text, width, tabWidth, textDirection)
                                                          )
@@ -1842,7 +1848,7 @@ public class TextFormatter
 
         if (IsHorizontalDirection (textDirection))
         {
-            textCount = words.Sum (arg => GetRuneWidth (arg, tabWidth, textDirection));
+            textCount = words.Sum (arg => GetTextWidth (arg, tabWidth, textDirection));
         }
         else
         {
@@ -2137,11 +2143,11 @@ public class TextFormatter
              i < (linesCount == -1 ? lines.Count : startLine + linesCount);
              i++)
         {
-            string runes = lines [i];
+            string strings = lines [i];
 
-            if (runes.Length > 0)
+            if (strings.Length > 0)
             {
-                max += runes.EnumerateRunes ().Max (r => GetRuneWidth (r, tabWidth));
+                max += strings.EnumerateRunes ().Max (r => GetRuneWidth (r, tabWidth));
             }
         }
 
@@ -2163,7 +2169,7 @@ public class TextFormatter
     {
         List<string> result = SplitNewLine (text);
 
-        return result.Max (x => GetRuneWidth (x, tabWidth));
+        return result.Max (x => GetTextWidth (x, tabWidth));
     }
 
     /// <summary>
@@ -2182,13 +2188,13 @@ public class TextFormatter
     public static int GetSumMaxCharWidth (string text, int startIndex = -1, int length = -1, int tabWidth = 0)
     {
         var max = 0;
-        Rune [] runes = text.ToRunes ();
+        string [] graphemes = GraphemeHelper.GetGraphemes (text).ToArray ();
 
         for (int i = startIndex == -1 ? 0 : startIndex;
-             i < (length == -1 ? runes.Length : startIndex + length);
+             i < (length == -1 ? graphemes.Length : startIndex + length);
              i++)
         {
-            max += GetRuneWidth (runes [i], tabWidth);
+            max += GetStringWidth (graphemes [i], tabWidth);
         }
 
         return max;
@@ -2206,51 +2212,38 @@ public class TextFormatter
     /// <returns>The index of the text that fit the width.</returns>
     public static int GetLengthThatFits (string text, int width, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
     {
-        return GetLengthThatFits (text?.ToRuneList () ?? [], width, tabWidth, textDirection);
-    }
-
-    /// <summary>Gets the number of the Runes in a list of Runes that will fit in <paramref name="width"/>.</summary>
-    /// <remarks>
-    ///     This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
-    ///     glyphs (e.g. Arabic).
-    /// </remarks>
-    /// <param name="runes">The list of runes.</param>
-    /// <param name="width">The width.</param>
-    /// <param name="tabWidth">The width used for a tab.</param>
-    /// <param name="textDirection">The text direction.</param>
-    /// <returns>The index of the last Rune in <paramref name="runes"/> that fit in <paramref name="width"/>.</returns>
-    public static int GetLengthThatFits (List<Rune> runes, int width, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
-    {
-        if (runes is null || runes.Count == 0)
+        if (string.IsNullOrEmpty (text))
         {
             return 0;
         }
 
-        var runesLength = 0;
-        var runeIdx = 0;
+        var textLength = 0;
+        var stringIdx = 0;
 
-        for (; runeIdx < runes.Count; runeIdx++)
+        foreach (string grapheme in GraphemeHelper.GetGraphemes (text))
         {
-            int runeWidth = GetRuneWidth (runes [runeIdx], tabWidth, textDirection);
+            int textWidth = GetStringWidth (grapheme, tabWidth, textDirection);
 
-            if (runesLength + runeWidth > width)
+            if (textLength + textWidth > width)
             {
                 break;
             }
 
-            runesLength += runeWidth;
+            textLength += textWidth;
+            stringIdx++;
         }
 
-        return runeIdx;
+        return stringIdx;
     }
 
-    private static int GetRuneWidth (string str, int tabWidth, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
+    private static int GetTextWidth (string str, int tabWidth, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
     {
         int runesWidth = 0;
-        foreach (Rune rune in str.EnumerateRunes ())
+        foreach (string grapheme in GraphemeHelper.GetGraphemes (str))
         {
-            runesWidth += GetRuneWidth (rune, tabWidth, textDirection);
+            runesWidth += GetStringWidth (grapheme, tabWidth, textDirection);
         }
+
         return runesWidth;
     }
 
@@ -2271,6 +2264,23 @@ public class TextFormatter
         return runeWidth;
     }
 
+    private static int GetStringWidth (string str, int tabWidth, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
+    {
+        int textWidth = IsHorizontalDirection (textDirection) ? str.GetColumns (false) : str.GetColumns () == 0 ? 0 : 1;
+
+        if (str == "\t")
+        {
+            return tabWidth;
+        }
+
+        if (textWidth is < 0 or > 0)
+        {
+            return Math.Max (textWidth, 1);
+        }
+
+        return textWidth;
+    }
+
     /// <summary>Gets the index position from the list based on the <paramref name="width"/>.</summary>
     /// <remarks>
     ///     This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
@@ -2282,23 +2292,23 @@ public class TextFormatter
     /// <returns>The index of the list that fit the width.</returns>
     public static int GetMaxColsForWidth (List<string> lines, int width, int tabWidth = 0)
     {
-        var runesLength = 0;
+        var textLength = 0;
         var lineIdx = 0;
 
         for (; lineIdx < lines.Count; lineIdx++)
         {
-            List<Rune> runes = lines [lineIdx].ToRuneList ();
+            string [] graphemes = GraphemeHelper.GetGraphemes (lines [lineIdx]).ToArray ();
 
-            int maxRruneWidth = runes.Count > 0
-                                    ? runes.Max (r => GetRuneWidth (r, tabWidth))
+            int maxTextWidth = graphemes.Length > 0
+                                    ? graphemes.Max (r => GetStringWidth (r, tabWidth))
                                     : 1;
 
-            if (runesLength + maxRruneWidth > width)
+            if (textLength + maxTextWidth > width)
             {
                 break;
             }
 
-            runesLength += maxRruneWidth;
+            textLength += maxTextWidth;
         }
 
         return lineIdx;

+ 4 - 4
Terminal.Gui/ViewBase/Adornment/ShadowView.cs

@@ -100,7 +100,7 @@ internal class ShadowView : View
 
                 if (c < ScreenContents?.GetLength (1) && r < ScreenContents?.GetLength (0))
                 {
-                    AddRune (ScreenContents [r, c].Rune);
+                    AddStr (ScreenContents [r, c].Grapheme);
                 }
             }
         }
@@ -134,7 +134,7 @@ internal class ShadowView : View
 
                 if (ScreenContents is { } && screen.X < ScreenContents.GetLength (1) && r < ScreenContents.GetLength (0))
                 {
-                    AddRune (ScreenContents [r, c].Rune);
+                    AddStr (ScreenContents [r, c].Grapheme);
                 }
             }
         }
@@ -142,7 +142,7 @@ internal class ShadowView : View
 
     private Attribute GetAttributeUnderLocation (Point location)
     {
-        if (SuperView is not Adornment adornment
+        if (SuperView is not Adornment
             || location.X < 0
             || location.X >= App?.Screen.Width
             || location.Y < 0
@@ -171,7 +171,7 @@ internal class ShadowView : View
         if (newAttribute.Background == Color.DarkGray)
         {
             List<View?> currentViewsUnderMouse = GetViewsUnderLocation (location, ViewportSettingsFlags.Transparent);
-            View? underView = currentViewsUnderMouse!.LastOrDefault ();
+            View? underView = currentViewsUnderMouse.LastOrDefault ();
             attr = underView?.GetAttributeForRole (VisualRole.Normal) ?? Attribute.Default;
 
             newAttribute = new (

+ 19 - 1
Terminal.Gui/ViewBase/View.Drawing.Primitives.cs

@@ -32,7 +32,6 @@ public partial class View
         Driver?.AddRune (rune);
     }
 
-
     /// <summary>
     ///     Adds the specified <see langword="char"/> to the display at the current cursor position. This method is a
     ///     convenience method that calls <see cref="AddRune(Rune)"/> with the <see cref="Rune"/> constructor.
@@ -72,6 +71,25 @@ public partial class View
     {
         Driver?.AddStr (str);
     }
+
+    /// <summary>Draws the specified <paramref name="str"/> in the specified viewport-relative column and row of the View.</summary>
+    /// <para>
+    ///     If the provided coordinates are outside the visible content area, this method does nothing.
+    /// </para>
+    /// <remarks>
+    ///     The top-left corner of the visible content area is <c>ViewPort.Location</c>.
+    /// </remarks>
+    /// <param name="col">Column (viewport-relative).</param>
+    /// <param name="row">Row (viewport-relative).</param>
+    /// <param name="str">The Text.</param>
+    public void AddStr (int col, int row, string str)
+    {
+        if (Move (col, row))
+        {
+            Driver?.AddStr (str);
+        }
+    }
+
     /// <summary>Utility function to draw strings that contain a hotkey.</summary>
     /// <param name="text">String to display, the hotkey specifier before a letter flags the next letter as the hotkey.</param>
     /// <param name="hotColor">Hot color.</param>

+ 7 - 7
Terminal.Gui/ViewBase/View.Drawing.cs

@@ -194,11 +194,11 @@ public partial class View // Drawing APIs
         else
         {
             // Set the clip to be just the thicknesses of the adornments
-            // TODO: Put this union logic in a method on View? 
+            // TODO: Put this union logic in a method on View?
             Region? clipAdornments = Margin!.Thickness.AsRegion (Margin!.FrameToScreen ());
-            clipAdornments?.Combine (Border!.Thickness.AsRegion (Border!.FrameToScreen ()), RegionOp.Union);
-            clipAdornments?.Combine (Padding!.Thickness.AsRegion (Padding!.FrameToScreen ()), RegionOp.Union);
-            clipAdornments?.Combine (originalClip, RegionOp.Intersect);
+            clipAdornments.Combine (Border!.Thickness.AsRegion (Border!.FrameToScreen ()), RegionOp.Union);
+            clipAdornments.Combine (Padding!.Thickness.AsRegion (Padding!.FrameToScreen ()), RegionOp.Union);
+            clipAdornments.Combine (originalClip, RegionOp.Intersect);
             SetClip (clipAdornments);
         }
 
@@ -239,7 +239,7 @@ public partial class View // Drawing APIs
     {
         // We do not attempt to draw Margin. It is drawn in a separate pass.
 
-        // Each of these renders lines to this View's LineCanvas 
+        // Each of these renders lines to this View's LineCanvas
         // Those lines will be finally rendered in OnRenderLineCanvas
         if (Border is { } && Border.Thickness != Thickness.Empty)
         {
@@ -660,7 +660,7 @@ public partial class View // Drawing APIs
                     Driver.Move (p.Key.X, p.Key.Y);
 
                     // TODO: #2616 - Support combining sequences that don't normalize
-                    AddRune (p.Value.Value.Rune);
+                    AddStr (p.Value.Value.Grapheme);
                 }
             }
 
@@ -687,7 +687,7 @@ public partial class View // Drawing APIs
                 context!.ClipDrawnRegion (ViewportToScreen (Viewport));
 
                 // Exclude the drawn region from the clip
-                ExcludeFromClip (context!.GetDrawnRegion ());
+                ExcludeFromClip (context.GetDrawnRegion ());
 
                 // Exclude the Border and Padding from the clip
                 ExcludeFromClip (Border?.Thickness.AsRegion (Border.FrameToScreen ()));

+ 13 - 13
Terminal.Gui/Views/Autocomplete/AutocompleteFilepathContext.cs

@@ -12,16 +12,16 @@ internal class AutocompleteFilepathContext (string currentLine, int cursorPositi
 
 internal class FilepathSuggestionGenerator : ISuggestionGenerator
 {
-    private FileDialogState state;
+    private FileDialogState _state;
 
     public IEnumerable<Suggestion> GenerateSuggestions (AutocompleteContext context)
     {
         if (context is AutocompleteFilepathContext fileState)
         {
-            state = fileState.State;
+            _state = fileState.State;
         }
 
-        if (state is null)
+        if (_state is null)
         {
             return Enumerable.Empty<Suggestion> ();
         }
@@ -42,7 +42,7 @@ internal class FilepathSuggestionGenerator : ISuggestionGenerator
             return Enumerable.Empty<Suggestion> ();
         }
 
-        if (term.Equals (state?.Directory?.Name))
+        if (term.Equals (_state?.Directory?.Name))
         {
             // Clear suggestions
             return Enumerable.Empty<Suggestion> ();
@@ -50,13 +50,13 @@ internal class FilepathSuggestionGenerator : ISuggestionGenerator
 
         bool isWindows = RuntimeInformation.IsOSPlatform (OSPlatform.Windows);
 
-        string [] suggestions = state.Children.Where (d => !d.IsParent)
-                                     .Select (
-                                              e => e.FileSystemInfo is IDirectoryInfo d
-                                                       ? d.Name + Path.DirectorySeparatorChar
-                                                       : e.FileSystemInfo.Name
-                                             )
-                                     .ToArray ();
+        string [] suggestions = _state!.Children.Where (d => !d.IsParent)
+                                       .Select (
+                                                e => e.FileSystemInfo is IDirectoryInfo d
+                                                         ? d.Name + Path.DirectorySeparatorChar
+                                                         : e.FileSystemInfo.Name
+                                               )
+                                       .ToArray ();
 
         string [] validSuggestions = suggestions
                                      .Where (
@@ -82,9 +82,9 @@ internal class FilepathSuggestionGenerator : ISuggestionGenerator
                                .ToList ();
     }
 
-    public bool IsWordChar (Rune rune)
+    public bool IsWordChar (string text)
     {
-        if (rune.Value == '\n')
+        if (text == "\n")
         {
             return false;
         }

+ 2 - 2
Terminal.Gui/Views/Autocomplete/ISuggestionGenerator.cs

@@ -8,9 +8,9 @@ public interface ISuggestionGenerator
     IEnumerable<Suggestion> GenerateSuggestions (AutocompleteContext context);
 
     /// <summary>
-    ///     Returns <see langword="true"/> if <paramref name="rune"/> is a character that would continue autocomplete
+    ///     Returns <see langword="true"/> if <paramref name="text"/> is a character that would continue autocomplete
     ///     suggesting. Returns <see langword="false"/> if it is a 'breaking' character (i.e. terminating current word
     ///     boundary)
     /// </summary>
-    bool IsWordChar (Rune rune);
+    bool IsWordChar (string text);
 }

+ 1 - 1
Terminal.Gui/Views/Autocomplete/PopupAutocomplete.cs

@@ -188,7 +188,7 @@ public abstract partial class PopupAutocomplete : AutocompleteBase
     /// <returns><c>true</c>if the key can be handled <c>false</c>otherwise.</returns>
     public override bool ProcessKey (Key key)
     {
-        if (SuggestionGenerator.IsWordChar ((Rune)key))
+        if (SuggestionGenerator.IsWordChar (key.AsRune.ToString ()))
         {
             Visible = true;
             _closed = false;

+ 10 - 6
Terminal.Gui/Views/Autocomplete/SingleWordSuggestionGenerator.cs

@@ -18,10 +18,10 @@ public class SingleWordSuggestionGenerator : ISuggestionGenerator
         // if there is nothing to pick from
         if (AllSuggestions.Count == 0)
         {
-            return Enumerable.Empty<Suggestion> ();
+            return [];
         }
 
-        List<Rune> line = context.CurrentLine.Select (c => c.Rune).ToList ();
+        List<string> line = context.CurrentLine.Select (c => c.Grapheme).ToList ();
         string currentWord = IdxToWord (line, context.CursorPosition, out int startIdx);
         context.CursorPosition = startIdx < 1 ? startIdx : Math.Min (startIdx + 1, line.Count);
 
@@ -44,9 +44,13 @@ public class SingleWordSuggestionGenerator : ISuggestionGenerator
     ///     Return true if the given symbol should be considered part of a word and can be contained in matches. Base
     ///     behavior is to use <see cref="char.IsLetterOrDigit(char)"/>
     /// </summary>
-    /// <param name="rune">The rune.</param>
+    /// <param name="text">The text.</param>
     /// <returns></returns>
-    public virtual bool IsWordChar (Rune rune) { return char.IsLetterOrDigit ((char)rune.Value); }
+    public virtual bool IsWordChar (string text)
+    {
+        return !string.IsNullOrEmpty (text)
+               && Rune.IsLetterOrDigit (text.EnumerateRunes ().First ());
+    }
 
     /// <summary>
     ///     <para>
@@ -65,7 +69,7 @@ public class SingleWordSuggestionGenerator : ISuggestionGenerator
     /// <param name="startIdx">The start index of the word.</param>
     /// <param name="columnOffset"></param>
     /// <returns></returns>
-    protected virtual string IdxToWord (List<Rune> line, int idx, out int startIdx, int columnOffset = 0)
+    protected virtual string IdxToWord (List<string> line, int idx, out int startIdx, int columnOffset = 0)
     {
         var sb = new StringBuilder ();
         startIdx = idx;
@@ -94,7 +98,7 @@ public class SingleWordSuggestionGenerator : ISuggestionGenerator
         {
             if (IsWordChar (line [startIdx]))
             {
-                sb.Insert (0, (char)line [startIdx].Value);
+                sb.Insert (0, line [startIdx]);
             }
             else
             {

+ 25 - 54
Terminal.Gui/Views/CharMap/CharMap.cs

@@ -147,10 +147,7 @@ public class CharMap : View, IDesignable
                     break;
                 }
 
-                var rune = new Rune (cp);
-                Span<char> utf16 = new char [2];
-                rune.EncodeToUtf16 (utf16);
-                UnicodeCategory cat = CharUnicodeInfo.GetUnicodeCategory (utf16 [0]);
+                UnicodeCategory cat = CharUnicodeInfo.GetUnicodeCategory (cp);
                 if (cat == ShowUnicodeCategory.Value)
                 {
                     anyVisible = true;
@@ -684,7 +681,7 @@ public class CharMap : View, IDesignable
                 // Don't render out-of-range scalars
                 if (scalar > MAX_CODE_POINT)
                 {
-                    AddRune (' ');
+                    AddStr (" ");
                     if (visibleRow == selectedRowIndex && col == selectedCol)
                     {
                         SetAttributeForRole (VisualRole.Normal);
@@ -692,22 +689,20 @@ public class CharMap : View, IDesignable
                     continue;
                 }
 
-                var rune = (Rune)'?';
+                string grapheme = "?";
 
                 if (Rune.IsValid (scalar))
                 {
-                    rune = new (scalar);
+                    grapheme = new Rune (scalar).ToString ();
                 }
 
-                int width = rune.GetColumns ();
+                int width = grapheme.GetColumns ();
 
                 // Compute visibility based on ShowUnicodeCategory
                 bool isVisible = Rune.IsValid (scalar);
                 if (isVisible && ShowUnicodeCategory.HasValue)
                 {
-                    Span<char> filterUtf16 = new char [2];
-                    rune.EncodeToUtf16 (filterUtf16);
-                    UnicodeCategory cat = CharUnicodeInfo.GetUnicodeCategory (filterUtf16 [0]);
+                    UnicodeCategory cat = CharUnicodeInfo.GetUnicodeCategory (scalar);
                     isVisible = cat == ShowUnicodeCategory.Value;
                 }
 
@@ -716,11 +711,11 @@ public class CharMap : View, IDesignable
                     // Glyph row
                     if (isVisible)
                     {
-                        RenderRune (rune, width);
+                        RenderGrapheme (grapheme, width, scalar);
                     }
                     else
                     {
-                        AddRune (' ');
+                        AddStr (" ");
                     }
                 }
                 else
@@ -735,7 +730,7 @@ public class CharMap : View, IDesignable
                     }
                     else
                     {
-                        AddRune (' ');
+                        AddStr (" ");
                     }
                 }
 
@@ -749,21 +744,18 @@ public class CharMap : View, IDesignable
 
         return true;
 
-        void RenderRune (Rune rune, int width)
+        void RenderGrapheme (string grapheme, int width, int scalar)
         {
             // Get the UnicodeCategory
-            Span<char> utf16 = new char [2];
-            int charCount = rune.EncodeToUtf16 (utf16);
-
             // Get the bidi class for the first code unit
             // For most bidi characters, the first code unit is sufficient
-            UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory (utf16 [0]);
+            UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory (scalar);
 
             switch (category)
             {
                 case UnicodeCategory.OtherNotAssigned:
                     SetAttributeForRole (VisualRole.Highlight);
-                    AddRune (Rune.ReplacementChar);
+                    AddStr (Rune.ReplacementChar.ToString ());
                     SetAttributeForRole (VisualRole.Normal);
 
                     break;
@@ -772,7 +764,7 @@ public class CharMap : View, IDesignable
                 // These report width of 0 and don't render on their own.
                 case UnicodeCategory.Format:
                     SetAttributeForRole (VisualRole.Highlight);
-                    AddRune ('F');
+                    AddStr ("F");
                     SetAttributeForRole (VisualRole.Normal);
 
                     break;
@@ -785,36 +777,7 @@ public class CharMap : View, IDesignable
                 case UnicodeCategory.EnclosingMark:
                     if (width > 0)
                     {
-                        AddRune (rune);
-                    }
-                    else
-                    {
-                        if (rune.IsCombiningMark ())
-                        {
-                            // This is a hack to work around the fact that combining marks
-                            // a) can't be rendered on their own
-                            // b) that don't normalize are not properly supported in 
-                            //    any known terminal (esp Windows/AtlasEngine). 
-                            // See Issue #2616
-                            var sb = new StringBuilder ();
-                            sb.Append ('a');
-                            sb.Append (rune);
-
-                            // Try normalizing after combining with 'a'. If it normalizes, at least 
-                            // it'll show on the 'a'. If not, just show the replacement char.
-                            string normal = sb.ToString ().Normalize (NormalizationForm.FormC);
-
-                            if (normal.Length == 1)
-                            {
-                                AddRune ((Rune)normal [0]);
-                            }
-                            else
-                            {
-                                SetAttributeForRole (VisualRole.Highlight);
-                                AddRune ('M');
-                                SetAttributeForRole (VisualRole.Normal);
-                            }
-                        }
+                        AddStr (grapheme);
                     }
 
                     break;
@@ -824,20 +787,28 @@ public class CharMap : View, IDesignable
                 case UnicodeCategory.LineSeparator:
                 case UnicodeCategory.ParagraphSeparator:
                 case UnicodeCategory.Surrogate:
-                    AddRune (rune);
+                    AddStr (grapheme);
 
                     break;
+                case UnicodeCategory.OtherLetter:
+                    AddStr (grapheme);
 
+                    if (width == 0)
+                    {
+                        AddStr (" ");
+                    }
+
+                    break;
                 default:
 
                     // Draw the rune
                     if (width > 0)
                     {
-                        AddRune (rune);
+                        AddStr (grapheme);
                     }
                     else
                     {
-                        throw new InvalidOperationException ($"The Rune \"{rune}\" (U+{rune.Value:x6}) has zero width and no special-case UnicodeCategory logic applies.");
+                        throw new InvalidOperationException ($"The Rune \"{grapheme}\" (U+{Rune.GetRuneAt (grapheme, 0).Value:x6}) has zero width and no special-case UnicodeCategory logic applies.");
                     }
 
                     break;

+ 25 - 25
Terminal.Gui/Views/Slider/Slider.cs

@@ -74,13 +74,13 @@ public class Slider<T> : View, IOrientation
         switch (_config._sliderOrientation)
         {
             case Orientation.Horizontal:
-                Style.SpaceChar = new () { Rune = Glyphs.HLine }; // '─'
-                Style.OptionChar = new () { Rune = Glyphs.BlackCircle }; // '┼●🗹□⏹'
+                Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () }; // '─'
+                Style.OptionChar = new () { Grapheme = Glyphs.BlackCircle.ToString () }; // '┼●🗹□⏹'
 
                 break;
             case Orientation.Vertical:
-                Style.SpaceChar = new () { Rune = Glyphs.VLine };
-                Style.OptionChar = new () { Rune = Glyphs.BlackCircle };
+                Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () };
+                Style.OptionChar = new () { Grapheme = Glyphs.BlackCircle.ToString () };
 
                 break;
         }
@@ -105,12 +105,12 @@ public class Slider<T> : View, IOrientation
         */
 
         _config._legendsOrientation = _config._sliderOrientation;
-        Style.EmptyChar = new () { Rune = new (' ') };
-        Style.SetChar = new () { Rune = Glyphs.ContinuousMeterSegment }; // ■
-        Style.RangeChar = new () { Rune = Glyphs.Stipple }; // ░ ▒ ▓   // Medium shade not blinking on curses.
-        Style.StartRangeChar = new () { Rune = Glyphs.ContinuousMeterSegment };
-        Style.EndRangeChar = new () { Rune = Glyphs.ContinuousMeterSegment };
-        Style.DragChar = new () { Rune = Glyphs.Diamond };
+        Style.EmptyChar = new () { Grapheme = " " };
+        Style.SetChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () }; // ■
+        Style.RangeChar = new () { Grapheme = Glyphs.Stipple.ToString () }; // ░ ▒ ▓   // Medium shade not blinking on curses.
+        Style.StartRangeChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () };
+        Style.EndRangeChar = new () { Grapheme = Glyphs.ContinuousMeterSegment.ToString () };
+        Style.DragChar = new () { Grapheme = Glyphs.Diamond.ToString () };
 
         // TODO: Support left & right (top/bottom)
         // First = '├',
@@ -256,11 +256,11 @@ public class Slider<T> : View, IOrientation
         switch (_config._sliderOrientation)
         {
             case Orientation.Horizontal:
-                Style.SpaceChar = new () { Rune = Glyphs.HLine }; // '─'
+                Style.SpaceChar = new () { Grapheme = Glyphs.HLine.ToString () }; // '─'
 
                 break;
             case Orientation.Vertical:
-                Style.SpaceChar = new () { Rune = Glyphs.VLine };
+                Style.SpaceChar = new () { Grapheme = Glyphs.VLine.ToString () };
 
                 break;
         }
@@ -799,7 +799,7 @@ public class Slider<T> : View, IOrientation
 
         if (_dragPosition.HasValue && _moveRenderPosition.HasValue)
         {
-            AddRune (_moveRenderPosition.Value.X, _moveRenderPosition.Value.Y, Style.DragChar.Rune);
+            AddStr (_moveRenderPosition.Value.X, _moveRenderPosition.Value.Y, Style.DragChar.Grapheme);
         }
 
         return true;
@@ -875,11 +875,11 @@ public class Slider<T> : View, IOrientation
                                       ? Style.RangeChar.Attribute ?? normalAttr
                                       : Style.SpaceChar.Attribute ?? normalAttr
                                  );
-            Rune rune = isSet && _config._type == SliderType.LeftRange ? Style.RangeChar.Rune : Style.SpaceChar.Rune;
+            string text = isSet && _config._type == SliderType.LeftRange ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme;
 
             for (var i = 0; i < _config._startSpacing; i++)
             {
-                MoveAndAdd (x, y, rune);
+                MoveAndAdd (x, y, text);
 
                 if (isVertical)
                 {
@@ -897,7 +897,7 @@ public class Slider<T> : View, IOrientation
 
             for (var i = 0; i < _config._startSpacing; i++)
             {
-                MoveAndAdd (x, y, Style.EmptyChar.Rune);
+                MoveAndAdd (x, y, Style.EmptyChar.Grapheme);
 
                 if (isVertical)
                 {
@@ -951,25 +951,25 @@ public class Slider<T> : View, IOrientation
                                       drawRange ? Style.RangeChar.Attribute ?? setAttr : Style.OptionChar.Attribute ?? normalAttr
                                      );
 
-                Rune rune = drawRange ? Style.RangeChar.Rune : Style.OptionChar.Rune;
+                string text = drawRange ? Style.RangeChar.Grapheme : Style.OptionChar.Grapheme;
 
                 if (isSet)
                 {
                     if (_setOptions [0] == i)
                     {
-                        rune = Style.StartRangeChar.Rune;
+                        text = Style.StartRangeChar.Grapheme;
                     }
                     else if (_setOptions.Count > 1 && _setOptions [1] == i)
                     {
-                        rune = Style.EndRangeChar.Rune;
+                        text = Style.EndRangeChar.Grapheme;
                     }
                     else if (_setOptions.Contains (i))
                     {
-                        rune = Style.SetChar.Rune;
+                        text = Style.SetChar.Grapheme;
                     }
                 }
 
-                MoveAndAdd (x, y, rune);
+                MoveAndAdd (x, y, text);
 
                 if (isVertical)
                 {
@@ -992,7 +992,7 @@ public class Slider<T> : View, IOrientation
 
                     for (var s = 0; s < _config._cachedInnerSpacing; s++)
                     {
-                        MoveAndAdd (x, y, drawRange && isSet ? Style.RangeChar.Rune : Style.SpaceChar.Rune);
+                        MoveAndAdd (x, y, drawRange && isSet ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme);
 
                         if (isVertical)
                         {
@@ -1017,11 +1017,11 @@ public class Slider<T> : View, IOrientation
                                       ? Style.RangeChar.Attribute ?? normalAttr
                                       : Style.SpaceChar.Attribute ?? normalAttr
                                  );
-            Rune rune = isSet && _config._type == SliderType.RightRange ? Style.RangeChar.Rune : Style.SpaceChar.Rune;
+            string text = isSet && _config._type == SliderType.RightRange ? Style.RangeChar.Grapheme : Style.SpaceChar.Grapheme;
 
             for (var i = 0; i < remaining; i++)
             {
-                MoveAndAdd (x, y, rune);
+                MoveAndAdd (x, y, text);
 
                 if (isVertical)
                 {
@@ -1039,7 +1039,7 @@ public class Slider<T> : View, IOrientation
 
             for (var i = 0; i < remaining; i++)
             {
-                MoveAndAdd (x, y, Style.EmptyChar.Rune);
+                MoveAndAdd (x, y, Style.EmptyChar.Grapheme);
 
                 if (isVertical)
                 {

+ 4 - 4
Terminal.Gui/Views/TableView/TreeTableSource.cs

@@ -88,14 +88,14 @@ public class TreeTableSource<T> : IEnumerableTableSource<T>, IDisposable where T
     {
         Branch<T> branch = RowToBranch (row);
 
-        // Everything on line before the expansion run and branch text
-        Rune [] prefix = branch.GetLinePrefix ().ToArray ();
-        Rune expansion = branch.GetExpandableSymbol ();
+        // Everything on the line before the expansion run and branch text
+        string [] prefix = branch.GetLinePrefix ().ToArray ();
+        string expansion = branch.GetExpandableSymbol ();
         string lineBody = _tree.AspectGetter (branch.Model) ?? "";
 
         var sb = new StringBuilder ();
 
-        foreach (Rune p in prefix)
+        foreach (string p in prefix)
         {
             sb.Append (p);
         }

+ 17 - 17
Terminal.Gui/Views/TextInput/TextField.cs

@@ -17,7 +17,7 @@ public class TextField : View, IDesignable
     private int _selectedStart; // -1 represents there is no text selection.
     private string _selectedText;
     private int _start;
-    private List<Rune> _text;
+    private List<string> _text;
 
     /// <summary>
     ///     Initializes a new instance of the <see cref="TextField"/> class.
@@ -541,7 +541,7 @@ public class TextField : View, IDesignable
             ClearAllSelection ();
 
             // Note we use NewValue here; TextChanging subscribers may have changed it
-            _text = args.Result.EnumerateRunes ().ToList ();
+            _text = args.Result.ToStringList ();
 
             if (!Secret && !_historyText.IsFromHistory)
             {
@@ -629,7 +629,7 @@ public class TextField : View, IDesignable
         }
 
         Clipboard.Contents = SelectedText;
-        List<Rune> newText = DeleteSelectedText ();
+        List<string> newText = DeleteSelectedText ();
         Text = StringExtensions.ToString (newText);
         Adjust ();
     }
@@ -700,7 +700,7 @@ public class TextField : View, IDesignable
         }
         else
         {
-            List<Rune> newText = DeleteSelectedText ();
+            List<string> newText = DeleteSelectedText ();
             Text = StringExtensions.ToString (newText);
             Adjust ();
         }
@@ -734,7 +734,7 @@ public class TextField : View, IDesignable
         }
         else
         {
-            List<Rune> newText = DeleteSelectedText ();
+            List<string> newText = DeleteSelectedText ();
             Text = StringExtensions.ToString (newText);
             Adjust ();
         }
@@ -943,8 +943,8 @@ public class TextField : View, IDesignable
 
         for (int idx = p; idx < tcount; idx++)
         {
-            Rune rune = _text [idx];
-            int cols = rune.GetColumns ();
+            string text = _text [idx];
+            int cols = text.GetColumns ();
 
             if (!Enabled)
             {
@@ -980,7 +980,7 @@ public class TextField : View, IDesignable
 
             if (col + cols <= width)
             {
-                AddRune (Secret ? Glyphs.Dot : rune);
+                AddStr (Secret ? Glyphs.Dot.ToString () : text);
             }
 
             if (!TextModel.SetCol (ref col, width, cols))
@@ -1254,7 +1254,7 @@ public class TextField : View, IDesignable
 
     private void ContextMenu_KeyChanged (object sender, KeyChangedEventArgs e) { KeyBindings.Replace (e.OldKey.KeyCode, e.NewKey.KeyCode); }
 
-    private List<Rune> DeleteSelectedText ()
+    private List<string> DeleteSelectedText ()
     {
         SetSelectedStartSelectedLength ();
         int selStart = SelectedStart > -1 ? _start : _cursorPosition;
@@ -1270,7 +1270,7 @@ public class TextField : View, IDesignable
         ClearAllSelection ();
         _cursorPosition = selStart >= newText.GetRuneCount () ? newText.GetRuneCount () : selStart;
 
-        return newText.ToRuneList ();
+        return newText.ToStringList ();
     }
 
     private void GenerateSuggestions ()
@@ -1318,7 +1318,7 @@ public class TextField : View, IDesignable
                           new (_cursorPosition, 0)
                          );
 
-        List<Rune> newText = _text;
+        List<string> newText = _text;
 
         if (SelectedLength > 0)
         {
@@ -1339,7 +1339,7 @@ public class TextField : View, IDesignable
 
             if (_cursorPosition == newText.Count + 1)
             {
-                SetText (newText.Concat (kbstr).ToList ());
+                SetText (newText.Concat (kbstr.Select (r => r.ToString ())).ToList ());
             }
             else
             {
@@ -1350,7 +1350,7 @@ public class TextField : View, IDesignable
 
                 SetText (
                          newText.GetRange (0, _preTextChangedCursorPos)
-                                .Concat (kbstr)
+                                .Concat (kbstr.Select (r => r.ToString ()))
                                 .Concat (
                                          newText.GetRange (
                                                            _preTextChangedCursorPos,
@@ -1367,7 +1367,7 @@ public class TextField : View, IDesignable
         {
             SetText (
                      newText.GetRange (0, _preTextChangedCursorPos)
-                            .Concat (kbstr)
+                            .Concat (kbstr.Select (r => r.ToString ()))
                             .Concat (
                                      newText.GetRange (
                                                        Math.Min (_preTextChangedCursorPos + 1, newText.Count),
@@ -1729,7 +1729,7 @@ public class TextField : View, IDesignable
         TitleTextFormatter.Draw (driver: Driver, screen: ViewportToScreen (new Rectangle (0, 0, Viewport.Width, 1)), normalColor: captionAttribute, hotColor: hotKeyAttribute);
     }
 
-    private void SetClipboard (IEnumerable<Rune> text)
+    private void SetClipboard (IEnumerable<string> text)
     {
         if (!Secret)
         {
@@ -1755,8 +1755,8 @@ public class TextField : View, IDesignable
         }
     }
 
-    private void SetText (List<Rune> newText) { Text = StringExtensions.ToString (newText); }
-    private void SetText (IEnumerable<Rune> newText) { SetText (newText.ToList ()); }
+    private void SetText (List<string> newText) { Text = StringExtensions.ToString (newText); }
+    private void SetText (IEnumerable<string> newText) { SetText (newText.ToList ()); }
 
     private void ShowContextMenu (bool keyboard)
     {

+ 58 - 52
Terminal.Gui/Views/TextInput/TextModel.cs

@@ -71,7 +71,7 @@ internal class TextModel
         for (int i = first; i < last; i++)
         {
             List<Cell> line = GetLine (i);
-            int tabSum = line.Sum (c => c.Rune.Value == '\t' ? Math.Max (tabWidth - 1, 0) : 0);
+            int tabSum = line.Sum (c => c.Grapheme == "\t" ? Math.Max (tabWidth - 1, 0) : 0);
             int l = line.Count + tabSum;
 
             if (l > maxLength)
@@ -222,7 +222,7 @@ internal class TextModel
 
             if (cell is { })
             {
-                rune = cell.Value.Rune;
+                rune = Rune.GetRuneAt (cell.Value.Grapheme, 0);
             }
             else
             {
@@ -299,10 +299,11 @@ internal class TextModel
                     }
 
                     List<Cell> line = GetLine (nRow);
+                    Rune firstRune = Rune.GetRuneAt (line [0].Grapheme, 0);
 
                     if (nCol == 0
                         && nRow == fromRow
-                        && (Rune.IsLetterOrDigit (line [0].Rune) || Rune.IsPunctuation (line [0].Rune) || Rune.IsSymbol (line [0].Rune)))
+                        && (Rune.IsLetterOrDigit (firstRune) || Rune.IsPunctuation (firstRune) || Rune.IsSymbol (firstRune)))
                     {
                         return;
                     }
@@ -366,7 +367,7 @@ internal class TextModel
 
         try
         {
-            Rune rune = _lines [row].Count > 0 ? RuneAt (col, row)!.Value.Rune : default (Rune);
+            Rune rune = _lines [row].Count > 0 ? Rune.GetRuneAt (RuneAt (col, row)!.Value.Grapheme, 0) : default (Rune);
             RuneType runeType = GetRuneType (rune);
 
             int lastValidCol = IsSameRuneType (rune, runeType, useSameRuneType) && (Rune.IsLetterOrDigit (rune) || Rune.IsPunctuation (rune) || Rune.IsSymbol (rune))
@@ -425,10 +426,11 @@ internal class TextModel
                     }
 
                     List<Cell> line = GetLine (nRow);
+                    Rune firstRune = Rune.GetRuneAt (line [0].Grapheme, 0);
 
                     if (nCol == line.Count
                         && nRow == fromRow
-                        && (Rune.IsLetterOrDigit (line [0].Rune) || Rune.IsPunctuation (line [0].Rune) || Rune.IsSymbol (line [0].Rune)))
+                        && (Rune.IsLetterOrDigit (firstRune) || Rune.IsPunctuation (firstRune) || Rune.IsSymbol (firstRune)))
                     {
                         return;
                     }
@@ -475,10 +477,10 @@ internal class TextModel
         }
 
         if (startCol > 0
-            && StringExtensions.ToString (line.GetRange (startCol, col - startCol).Select (c => c.Rune).ToList ()).Trim () == ""
-            && (col - startCol > 1 || (col - startCol > 0 && line [startCol - 1].Rune == (Rune)' ')))
+            && StringExtensions.ToString (line.GetRange (startCol, col - startCol).Select (c => c.Grapheme).ToList ()).Trim () == ""
+            && (col - startCol > 1 || (col - startCol > 0 && line [startCol - 1].Grapheme == " ")))
         {
-            while (startCol > 0 && line [startCol - 1].Rune == (Rune)' ')
+            while (startCol > 0 && line [startCol - 1].Grapheme == " ")
             {
                 startCol--;
             }
@@ -495,13 +497,13 @@ internal class TextModel
 
         if (selectWordOnly)
         {
-            List<Rune> selRunes = line.GetRange (startCol, col - startCol).Select (c => c.Rune).ToList ();
+            List<string> selText = line.GetRange (startCol, col - startCol).Select (c => c.Grapheme).ToList ();
 
-            if (StringExtensions.ToString (selRunes).Trim () != "")
+            if (StringExtensions.ToString (selText).Trim () != "")
             {
-                for (int i = selRunes.Count - 1; i > -1; i--)
+                for (int i = selText.Count - 1; i > -1; i--)
                 {
-                    if (selRunes [i] == (Rune)' ')
+                    if (selText [i] == " ")
                     {
                         col--;
                     }
@@ -519,18 +521,18 @@ internal class TextModel
 
     internal static int CalculateLeftColumn (List<Cell> t, int start, int end, int width, int tabWidth = 0)
     {
-        List<Rune> runes = new ();
+        List<string> strings = new ();
 
         foreach (Cell cell in t)
         {
-            runes.Add (cell.Rune);
+            strings.Add (cell.Grapheme);
         }
 
-        return CalculateLeftColumn (runes, start, end, width, tabWidth);
+        return CalculateLeftColumn (strings, start, end, width, tabWidth);
     }
 
     // Returns the left column in a range of the string.
-    internal static int CalculateLeftColumn (List<Rune> t, int start, int end, int width, int tabWidth = 0)
+    internal static int CalculateLeftColumn (List<string> t, int start, int end, int width, int tabWidth = 0)
     {
         if (t is null || t.Count == 0)
         {
@@ -538,15 +540,15 @@ internal class TextModel
         }
 
         var size = 0;
-        int tcount = end > t.Count - 1 ? t.Count - 1 : end;
+        int tCount = end > t.Count - 1 ? t.Count - 1 : end;
         var col = 0;
 
-        for (int i = tcount; i >= 0; i--)
+        for (int i = tCount; i >= 0; i--)
         {
-            Rune rune = t [i];
-            size += rune.GetColumns ();
+            string text = t [i];
+            size += text.GetColumns (false);
 
-            if (rune.Value == '\t')
+            if (text == "\t")
             {
                 size += tabWidth + 1;
             }
@@ -576,23 +578,23 @@ internal class TextModel
         List<Cell> t,
         int start = -1,
         int end = -1,
-        bool checkNextRune = true,
+        bool checkNextText = true,
         int tabWidth = 0
     )
     {
-        List<Rune> runes = new ();
+        List<string> strings = new ();
 
         foreach (Cell cell in t)
         {
-            runes.Add (cell.Rune);
+            strings.Add (cell.Grapheme);
         }
 
-        return DisplaySize (runes, start, end, checkNextRune, tabWidth);
+        return DisplaySize (strings, start, end, checkNextText, tabWidth);
     }
 
     // Returns the size and length in a range of the string.
     internal static (int size, int length) DisplaySize (
-        List<Rune> t,
+        List<string> t,
         int start = -1,
         int end = -1,
         bool checkNextRune = true,
@@ -607,35 +609,35 @@ internal class TextModel
         var size = 0;
         var len = 0;
 
-        int tcount = end == -1 ? t.Count :
+        int tCount = end == -1 ? t.Count :
                      end > t.Count ? t.Count : end;
         int i = start == -1 ? 0 : start;
 
-        for (; i < tcount; i++)
+        for (; i < tCount; i++)
         {
-            Rune rune = t [i];
-            size += rune.GetColumns ();
-            len += rune.GetEncodingLength (Encoding.Unicode);
+            string text = t [i];
+            size += text.GetColumns (false);
+            len += text.Length;
 
-            if (rune.Value == '\t')
+            if (text == "\t")
             {
                 size += tabWidth + 1;
                 len += tabWidth - 1;
             }
 
-            if (checkNextRune && i == tcount - 1 && t.Count > tcount && IsWideRune (t [i + 1], tabWidth, out int s, out int l))
+            if (checkNextRune && i == tCount - 1 && t.Count > tCount && IsWideText (t [i + 1], tabWidth, out int s, out int l))
             {
                 size += s;
                 len += l;
             }
         }
 
-        bool IsWideRune (Rune r, int tWidth, out int s, out int l)
+        bool IsWideText (string s1, int tWidth, out int s, out int l)
         {
-            s = r.GetColumns ();
-            l = r.GetEncodingLength ();
+            s = s1.GetColumns ();
+            l = Encoding.Unicode.GetByteCount (s1);
 
-            if (r.Value == '\t')
+            if (s1 == "\t")
             {
                 s += tWidth + 1;
                 l += tWidth - 1;
@@ -744,17 +746,17 @@ internal class TextModel
 
     internal static int GetColFromX (List<Cell> t, int start, int x, int tabWidth = 0)
     {
-        List<Rune> runes = new ();
+        List<string> strings = new ();
 
         foreach (Cell cell in t)
         {
-            runes.Add (cell.Rune);
+            strings.Add (cell.Grapheme);
         }
 
-        return GetColFromX (runes, start, x, tabWidth);
+        return GetColFromX (strings, start, x, tabWidth);
     }
 
-    internal static int GetColFromX (List<Rune> t, int start, int x, int tabWidth = 0)
+    internal static int GetColFromX (List<string> t, int start, int x, int tabWidth = 0)
     {
         if (x < 0)
         {
@@ -766,10 +768,10 @@ internal class TextModel
 
         for (int i = start; i < t.Count; i++)
         {
-            Rune r = t [i];
-            size += r.GetColumns ();
+            string s = t [i];
+            size += s.GetColumns ();
 
-            if (r.Value == '\t')
+            if (s == "\t")
             {
                 size += tabWidth + 1;
             }
@@ -1055,18 +1057,21 @@ internal class TextModel
         if (col + 1 < line.Count)
         {
             col++;
-            rune = line [col].Rune;
+            rune = Rune.GetRuneAt (line [col].Grapheme, 0);
+            Rune prevRune = Rune.GetRuneAt (line [col - 1].Grapheme, 0);
 
             if (col + 1 == line.Count
                 && !Rune.IsLetterOrDigit (rune)
-                && !Rune.IsWhiteSpace (line [col - 1].Rune)
-                && IsSameRuneType (line [col - 1].Rune, GetRuneType (rune), useSameRuneType))
+                && !Rune.IsWhiteSpace (prevRune)
+                                       && IsSameRuneType (prevRune, GetRuneType (rune), useSameRuneType))
             {
                 col++;
             }
 
+            prevRune = Rune.GetRuneAt (line [col - 1].Grapheme, 0);
+
             if (!Rune.IsWhiteSpace (rune)
-                && (Rune.IsWhiteSpace (line [col - 1].Rune) || !IsSameRuneType (line [col - 1].Rune, GetRuneType (rune), useSameRuneType)))
+                && (Rune.IsWhiteSpace (prevRune) || !IsSameRuneType (prevRune, GetRuneType (rune), useSameRuneType)))
             {
                 return false;
             }
@@ -1097,12 +1102,13 @@ internal class TextModel
         if (col > 0)
         {
             col--;
-            rune = line [col].Rune;
+            rune = Rune.GetRuneAt (line [col].Grapheme, 0);
+            Rune nextRune = Rune.GetRuneAt (line [col + 1].Grapheme, 0);
 
             if ((!Rune.IsWhiteSpace (rune)
-                 && !Rune.IsWhiteSpace (line [col + 1].Rune)
-                 && !IsSameRuneType (line [col + 1].Rune, GetRuneType (rune), useSameRuneType))
-                || (Rune.IsWhiteSpace (rune) && !Rune.IsWhiteSpace (line [col + 1].Rune)))
+                 && !Rune.IsWhiteSpace (nextRune)
+                 && !IsSameRuneType (nextRune, GetRuneType (rune), useSameRuneType))
+                || (Rune.IsWhiteSpace (rune) && !Rune.IsWhiteSpace (nextRune)))
             {
                 return false;
             }

+ 27 - 28
Terminal.Gui/Views/TextInput/TextView.cs

@@ -1481,7 +1481,7 @@ public class TextView : View, IDesignable
     }
 
     /// <summary>Loads the contents of the <see cref="Cell"/> list into the <see cref="TextView"/>.</summary>
-    /// <param name="cells">Rune cells list to load the contents from.</param>
+    /// <param name="cells">Text cells list to load the contents from.</param>
     public void Load (List<Cell> cells)
     {
         SetWrapModel ();
@@ -1801,8 +1801,8 @@ public class TextView : View, IDesignable
 
             for (int idxCol = _leftColumn; idxCol < lineRuneCount; idxCol++)
             {
-                Rune rune = idxCol >= lineRuneCount ? (Rune)' ' : line [idxCol].Rune;
-                int cols = rune.GetColumns ();
+                string text = idxCol >= lineRuneCount ? " " : line [idxCol].Grapheme;
+                int cols = text.GetColumns (false);
 
                 if (idxCol < line.Count && IsSelecting && PointInSelection (idxCol, idxRow))
                 {
@@ -1821,7 +1821,7 @@ public class TextView : View, IDesignable
                     OnDrawNormalColor (line, idxCol, idxRow);
                 }
 
-                if (rune.Value == '\t')
+                if (text == "\t")
                 {
                     cols += TabWidth + 1;
 
@@ -1840,7 +1840,7 @@ public class TextView : View, IDesignable
                 }
                 else
                 {
-                    AddRune (col, row, rune);
+                    AddStr (col, row, text);
 
                     // Ensures that cols less than 0 to be 1 because it will be converted to a printable rune
                     cols = Math.Max (cols, 1);
@@ -1851,7 +1851,7 @@ public class TextView : View, IDesignable
                     break;
                 }
 
-                if (idxCol + 1 < lineRuneCount && col + line [idxCol + 1].Rune.GetColumns () > right)
+                if (idxCol + 1 < lineRuneCount && col + line [idxCol + 1].Grapheme.GetColumns () > right)
                 {
                     break;
                 }
@@ -2047,9 +2047,9 @@ public class TextView : View, IDesignable
                     break;
                 }
 
-                int cols = line [idx].Rune.GetColumns ();
+                int cols = line [idx].Grapheme.GetColumns ();
 
-                if (line [idx].Rune.Value == '\t')
+                if (line [idx].Grapheme == "\t")
                 {
                     cols += TabWidth + 1;
                 }
@@ -2806,12 +2806,12 @@ public class TextView : View, IDesignable
             cells = line.GetRange (startCol, endCol - startCol);
             cellsList.Add (cells);
 
-            return StringFromRunes (cells);
+            return StringFromCells (cells);
         }
 
         cells = line.GetRange (startCol, line.Count - startCol);
         cellsList.Add (cells);
-        string res = StringFromRunes (cells);
+        string res = StringFromCells (cells);
 
         for (int row = startRow + 1; row < maxRow; row++)
         {
@@ -2821,14 +2821,14 @@ public class TextView : View, IDesignable
 
             res = res
                   + Environment.NewLine
-                  + StringFromRunes (cells);
+                  + StringFromCells (cells);
         }
 
         line = model is null ? _model.GetLine (maxRow) : model.GetLine (maxRow);
         cellsList.AddRange ([]);
         cells = line.GetRange (0, endCol);
         cellsList.Add (cells);
-        res = res + Environment.NewLine + StringFromRunes (cells);
+        res = res + Environment.NewLine + StringFromCells (cells);
 
         return res;
     }
@@ -3108,7 +3108,7 @@ public class TextView : View, IDesignable
         {
             if (Used)
             {
-                Insert (new () { Rune = a.AsRune, Attribute = attribute });
+                Insert (new () { Grapheme = a.AsRune.ToString (), Attribute = attribute });
                 CurrentColumn++;
 
                 if (CurrentColumn >= _leftColumn + Viewport.Width)
@@ -3119,7 +3119,7 @@ public class TextView : View, IDesignable
             }
             else
             {
-                Insert (new () { Rune = a.AsRune, Attribute = attribute });
+                Insert (new () { Grapheme = a.AsRune.ToString (), Attribute = attribute });
                 CurrentColumn++;
             }
         }
@@ -3207,7 +3207,7 @@ public class TextView : View, IDesignable
             int restCount = currentLine.Count - CurrentColumn;
             List<Cell> rest = currentLine.GetRange (CurrentColumn, restCount);
             var val = string.Empty;
-            val += StringFromRunes (rest);
+            val += StringFromCells (rest);
 
             if (_lastWasKill)
             {
@@ -3313,7 +3313,7 @@ public class TextView : View, IDesignable
             int restCount = CurrentColumn;
             List<Cell> rest = currentLine.GetRange (0, restCount);
             var val = string.Empty;
-            val += StringFromRunes (rest);
+            val += StringFromCells (rest);
 
             if (_lastWasKill)
             {
@@ -3842,7 +3842,7 @@ public class TextView : View, IDesignable
 
             List<Cell> currentLine = GetCurrentLine ();
 
-            if (currentLine.Count > 0 && currentLine [CurrentColumn - 1].Rune.Value == '\t')
+            if (currentLine.Count > 0 && currentLine[CurrentColumn - 1].Grapheme == "\t")
             {
                 _historyText.Add (new () { new (currentLine) }, CursorPosition);
 
@@ -4601,29 +4601,28 @@ public class TextView : View, IDesignable
         _isButtonShift = false;
     }
 
-    private string StringFromRunes (List<Cell> cells)
+    private string StringFromCells (List<Cell> cells)
     {
-        if (cells is null)
-        {
-            throw new ArgumentNullException (nameof (cells));
-        }
+        ArgumentNullException.ThrowIfNull (cells);
 
         var size = 0;
-
         foreach (Cell cell in cells)
         {
-            size += cell.Rune.GetEncodingLength ();
+            string t = cell.Grapheme;
+            size += Encoding.Unicode.GetByteCount (t);
         }
 
-        var encoded = new byte [size];
+        byte [] encoded = new byte [size];
         var offset = 0;
-
         foreach (Cell cell in cells)
         {
-            offset += cell.Rune.Encode (encoded, offset);
+            string t = cell.Grapheme;
+            int bytesWritten = Encoding.Unicode.GetBytes (t, 0, t.Length, encoded, offset);
+            offset += bytesWritten;
         }
 
-        return StringExtensions.ToString (encoded);
+        // decode using the same encoding and the bytes actually written
+        return Encoding.Unicode.GetString (encoded, 0, offset);
     }
 
     private void TextView_SuperViewChanged (object sender, SuperViewChangedEventArgs e)

+ 21 - 21
Terminal.Gui/Views/TreeView/Branch.cs

@@ -87,9 +87,9 @@ internal class Branch<T> where T : class
             isSelected ? _tree.HasFocus ? _tree.GetAttributeForRole (VisualRole.Focus) : _tree.GetAttributeForRole (VisualRole.HotNormal) : _tree.GetAttributeForRole (VisualRole.Normal);
         Attribute symbolColor = _tree.Style.HighlightModelTextOnly ? _tree.GetAttributeForRole (VisualRole.Normal) : textColor;
 
-        // Everything on line before the expansion run and branch text
-        Rune [] prefix = GetLinePrefix ().ToArray ();
-        Rune expansion = GetExpandableSymbol ();
+        // Everything on the line before the expansion run and branch text
+        string [] prefix = GetLinePrefix ().ToArray ();
+        string expansion = GetExpandableSymbol ();
         string lineBody = _tree.AspectGetter (Model) ?? "";
 
         _tree.Move (0, y);
@@ -99,7 +99,7 @@ internal class Branch<T> where T : class
         Attribute attr = symbolColor;
 
         // Draw the line prefix (all parallel lanes or whitespace and an expand/collapse/leaf symbol)
-        foreach (Rune r in prefix)
+        foreach (string s in prefix)
         {
             if (toSkip > 0)
             {
@@ -107,8 +107,8 @@ internal class Branch<T> where T : class
             }
             else
             {
-                cells.Add (NewCell (attr, r));
-                availableWidth -= r.GetColumns ();
+                cells.Add (NewCell (attr, s));
+                availableWidth -= s.GetColumns ();
             }
         }
 
@@ -212,7 +212,7 @@ internal class Branch<T> where T : class
         }
 
         attr = modelColor;
-        cells.AddRange (lineBody.Select (r => NewCell (attr, new (r))));
+        cells.AddRange (lineBody.Select (c => NewCell (attr, c.ToString ())));
 
         if (availableWidth > 0)
         {
@@ -220,7 +220,7 @@ internal class Branch<T> where T : class
 
             cells.AddRange (
                             Enumerable.Repeat (
-                                               NewCell (attr, new (' ')),
+                                               NewCell (attr, " "),
                                                availableWidth
                                               )
                            );
@@ -243,7 +243,7 @@ internal class Branch<T> where T : class
             foreach (Cell cell in cells)
             {
                 _tree.SetAttribute ((Attribute)cell.Attribute!);
-                _tree.AddRune (cell.Rune);
+                _tree.AddStr (cell.Grapheme);
             }
         }
 
@@ -288,21 +288,21 @@ internal class Branch<T> where T : class
     ///     object to indicate whether it <see cref="IsExpanded"/> or not (or it is a leaf).
     /// </summary>
     /// <returns></returns>
-    public Rune GetExpandableSymbol ()
+    public string GetExpandableSymbol ()
     {
         Rune leafSymbol = _tree.Style.ShowBranchLines ? Glyphs.HLine : (Rune)' ';
 
         if (IsExpanded)
         {
-            return _tree.Style.CollapseableSymbol ?? leafSymbol;
+            return _tree.Style.CollapseableSymbol.ToString () ?? leafSymbol.ToString ();
         }
 
         if (CanExpand ())
         {
-            return _tree.Style.ExpandableSymbol ?? leafSymbol;
+            return _tree.Style.ExpandableSymbol.ToString () ?? leafSymbol.ToString ();
         }
 
-        return leafSymbol;
+        return leafSymbol.ToString ();
     }
 
     /// <summary>
@@ -409,14 +409,14 @@ internal class Branch<T> where T : class
     ///     any tree branches (if enabled).
     /// </summary>
     /// <returns></returns>
-    internal IEnumerable<Rune> GetLinePrefix ()
+    internal IEnumerable<string> GetLinePrefix ()
     {
         // If not showing line branches or this is a root object.
         if (!_tree.Style.ShowBranchLines)
         {
             for (var i = 0; i < Depth; i++)
             {
-                yield return new (' ');
+                yield return new (" ");
             }
 
             yield break;
@@ -427,23 +427,23 @@ internal class Branch<T> where T : class
         {
             if (cur.IsLast ())
             {
-                yield return new (' ');
+                yield return new (" ");
             }
             else
             {
-                yield return Glyphs.VLine;
+                yield return Glyphs.VLine.ToString ();
             }
 
-            yield return new (' ');
+            yield return new (" ");
         }
 
         if (IsLast ())
         {
-            yield return Glyphs.LLCorner;
+            yield return Glyphs.LLCorner.ToString ();
         }
         else
         {
-            yield return Glyphs.LeftTee;
+            yield return Glyphs.LeftTee.ToString ();
         }
     }
 
@@ -531,5 +531,5 @@ internal class Branch<T> where T : class
         return Parent.ChildBranches.LastOrDefault () == this;
     }
 
-    private static Cell NewCell (Attribute attr, Rune r) { return new () { Rune = r, Attribute = new (attr) }; }
+    private static Cell NewCell (Attribute attr, string s) { return new () { Grapheme = s, Attribute = new (attr) }; }
 }

+ 13 - 18
Tests/UnitTests/DriverAssert.cs

@@ -198,7 +198,7 @@ internal partial class DriverAssert
         IDriver? driver = null
     )
     {
-        List<List<Rune>> lines = [];
+        List<List<string>> lines = [];
         var sb = new StringBuilder ();
         driver ??= Application.Driver!;
 
@@ -211,13 +211,13 @@ internal partial class DriverAssert
 
         for (var rowIndex = 0; rowIndex < driver.Rows; rowIndex++)
         {
-            List<Rune> runes = [];
+            List<string> strings = [];
 
             for (var colIndex = 0; colIndex < driver.Cols; colIndex++)
             {
-                Rune runeAtCurrentLocation = contents! [rowIndex, colIndex].Rune;
+                string textAtCurrentLocation = contents! [rowIndex, colIndex].Grapheme;
 
-                if (runeAtCurrentLocation != _spaceRune)
+                if (textAtCurrentLocation != _spaceRune.ToString ())
                 {
                     if (x == -1)
                     {
@@ -226,11 +226,11 @@ internal partial class DriverAssert
 
                         for (var i = 0; i < colIndex; i++)
                         {
-                            runes.InsertRange (i, [_spaceRune]);
+                            strings.InsertRange (i, [_spaceRune.ToString ()]);
                         }
                     }
 
-                    if (runeAtCurrentLocation.GetColumns () > 1)
+                    if (textAtCurrentLocation.GetColumns () > 1)
                     {
                         colIndex++;
                     }
@@ -245,18 +245,13 @@ internal partial class DriverAssert
 
                 if (x > -1)
                 {
-                    runes.Add (runeAtCurrentLocation);
+                    strings.Add (textAtCurrentLocation);
                 }
-
-                // See Issue #2616
-                //foreach (var combMark in contents [r, c].CombiningMarks) {
-                //	runes.Add (combMark);
-                //}
             }
 
-            if (runes.Count > 0)
+            if (strings.Count > 0)
             {
-                lines.Add (runes);
+                lines.Add (strings);
             }
         }
 
@@ -270,13 +265,13 @@ internal partial class DriverAssert
         }
 
         // Remove trailing whitespace on each line
-        foreach (List<Rune> row in lines)
+        foreach (List<string> row in lines)
         {
             for (int c = row.Count - 1; c >= 0; c--)
             {
-                Rune rune = row [c];
+                string text = row [c];
 
-                if (rune != (Rune)' ' || row.Sum (x => x.GetColumns ()) == w)
+                if (text != " " || row.Sum (x => x.GetColumns ()) == w)
                 {
                     break;
                 }
@@ -285,7 +280,7 @@ internal partial class DriverAssert
             }
         }
 
-        // Convert Rune list to string
+        // Convert Text list to string
         for (var r = 0; r < lines.Count; r++)
         {
             var line = StringExtensions.ToString (lines [r]);

+ 8 - 8
Tests/UnitTests/Drivers/ClipRegionTests.cs

@@ -16,24 +16,24 @@ public class ClipRegionTests (ITestOutputHelper output)
 
         Application.Driver!.Move (0, 0);
         Application.Driver!.AddRune ('x');
-        Assert.Equal ((Rune)'x', Application.Driver!.Contents! [0, 0].Rune);
+        Assert.Equal ("x", Application.Driver!.Contents! [0, 0].Grapheme);
 
         Application.Driver?.Move (5, 5);
         Application.Driver?.AddRune ('x');
-        Assert.Equal ((Rune)'x', Application.Driver!.Contents [5, 5].Rune);
+        Assert.Equal ("x", Application.Driver!.Contents [5, 5].Grapheme);
 
         // Clear the contents
         Application.Driver?.FillRect (new Rectangle (0, 0, Application.Driver.Rows, Application.Driver.Cols), ' ');
-        Assert.Equal ((Rune)' ', Application.Driver?.Contents [0, 0].Rune);
+        Assert.Equal (" ", Application.Driver?.Contents [0, 0].Grapheme);
 
         // Setup the region with a single rectangle, fill screen with 'x'
         Application.Driver!.Clip = new (new Rectangle (5, 5, 5, 5));
         Application.Driver.FillRect (new Rectangle (0, 0, Application.Driver.Rows, Application.Driver.Cols), 'x');
-        Assert.Equal ((Rune)' ', Application.Driver?.Contents [0, 0].Rune);
-        Assert.Equal ((Rune)' ', Application.Driver?.Contents [4, 9].Rune);
-        Assert.Equal ((Rune)'x', Application.Driver?.Contents [5, 5].Rune);
-        Assert.Equal ((Rune)'x', Application.Driver?.Contents [9, 9].Rune);
-        Assert.Equal ((Rune)' ', Application.Driver?.Contents [10, 10].Rune);
+        Assert.Equal (" ", Application.Driver?.Contents [0, 0].Grapheme);
+        Assert.Equal (" ", Application.Driver?.Contents [4, 9].Grapheme);
+        Assert.Equal ("x", Application.Driver?.Contents [5, 5].Grapheme);
+        Assert.Equal ("x", Application.Driver?.Contents [9, 9].Grapheme);
+        Assert.Equal (" ", Application.Driver?.Contents [10, 10].Grapheme);
 
         Application.Shutdown ();
     }

+ 5 - 5
Tests/UnitTests/View/Draw/ClipTests.cs

@@ -49,17 +49,17 @@ public class ClipTests (ITestOutputHelper _output)
         view.Draw ();
 
         // Only valid location w/in Viewport is 0, 0 (view) - 2, 2 (screen)
-        Assert.Equal ((Rune)' ', Application.Driver?.Contents! [2, 2].Rune);
+        Assert.Equal (" ", Application.Driver?.Contents! [2, 2].Grapheme);
 
         // When we exit Draw, the view is excluded from the clip. So drawing at 0,0, is not valid and is clipped.
         view.AddRune (0, 0, Rune.ReplacementChar);
-        Assert.Equal ((Rune)' ', Application.Driver?.Contents! [2, 2].Rune);
+        Assert.Equal (" ", Application.Driver?.Contents! [2, 2].Grapheme);
 
         view.AddRune (-1, -1, Rune.ReplacementChar);
-        Assert.Equal ((Rune)'P', Application.Driver?.Contents! [1, 1].Rune);
+        Assert.Equal ("P", Application.Driver?.Contents! [1, 1].Grapheme);
 
         view.AddRune (1, 1, Rune.ReplacementChar);
-        Assert.Equal ((Rune)'P', Application.Driver?.Contents! [3, 3].Rune);
+        Assert.Equal ("P", Application.Driver?.Contents! [3, 3].Grapheme);
     }
 
     [Theory]
@@ -233,7 +233,7 @@ public class ClipTests (ITestOutputHelper _output)
         //                            01 2345678901234 56 78 90 12 34 56 
         //                            │� |0123456989│� ン  ラ イ ン で  す 。
         expectedOutput = """
-                         │�│0123456789│ンラインです。
+                         │�│0123456789│ ンラインです。
                          """;
 
         DriverAssert.AssertDriverContentsWithFrameAre (expectedOutput, _output);

+ 9 - 9
Tests/UnitTests/View/Draw/DrawTests.cs

@@ -14,19 +14,19 @@ public class DrawTests (ITestOutputHelper output)
     [Trait ("Category", "Unicode")]
     public void CJK_Compatibility_Ideographs_ConsoleWidth_ColumnWidth_Equal_Two ()
     {
-        const string us = "\U0000f900";
+        const string s = "\U0000f900";
         var r = (Rune)0xf900;
 
-        Assert.Equal ("豈", us);
+        Assert.Equal ("豈", s);
         Assert.Equal ("豈", r.ToString ());
-        Assert.Equal (us, r.ToString ());
+        Assert.Equal (s, r.ToString ());
 
-        Assert.Equal (2, us.GetColumns ());
+        Assert.Equal (2, s.GetColumns ());
         Assert.Equal (2, r.GetColumns ());
 
-        var win = new Window { Title = us };
+        var win = new Window { Title = s };
         var view = new View { Text = r.ToString (), Height = Dim.Fill (), Width = Dim.Fill () };
-        var tf = new TextField { Text = us, Y = 1, Width = 3 };
+        var tf = new TextField { Text = s, Y = 1, Width = 3 };
         win.Add (view, tf);
         Toplevel top = new ();
         top.Add (win);
@@ -36,9 +36,9 @@ public class DrawTests (ITestOutputHelper output)
 
         const string expectedOutput = """
 
-                                      ┌┤├────┐
-                                      │
-                                      │
+                                      ┌┤├────┐
+                                      │
+                                      │
                                       └────────┘
                                       """;
         DriverAssert.AssertDriverContentsWithFrameAre (expectedOutput, output);

+ 5 - 4
Tests/UnitTests/View/TextTests.cs

@@ -1,4 +1,5 @@
-using UnitTests;
+using System.Text;
+using UnitTests;
 using Xunit.Abstractions;
 
 namespace UnitTests.ViewTests;
@@ -699,14 +700,14 @@ w ";
 
         string GetContents ()
         {
-            var text = "";
+            var sb = new StringBuilder ();
 
             for (var i = 0; i < 4; i++)
             {
-                text += Application.Driver?.Contents [0, i].Rune;
+                sb.Append (Application.Driver?.Contents! [0, i].Grapheme);
             }
 
-            return text;
+            return sb.ToString ();
         }
 
         Application.End (rs);

+ 7 - 7
Tests/UnitTests/Views/LabelTests.cs

@@ -171,7 +171,7 @@ This TextFormatter (tf2) is rewritten.                                 ",
     [AutoInitShutdown]
     public void Label_Draw_Horizontal_Simple_Runes ()
     {
-        var label = new Label { Text = "Demo Simple Rune" };
+        var label = new Label { Text = "Demo Simple Text" };
         var top = new Toplevel ();
         top.Add (label);
         Application.Begin (top);
@@ -180,7 +180,7 @@ This TextFormatter (tf2) is rewritten.                                 ",
         Assert.Equal (new (0, 0, 16, 1), label.Frame);
 
         var expected = @"
-Demo Simple Rune
+Demo Simple Text
 ";
 
         Rectangle pos = DriverAssert.AssertDriverContentsWithFrameAre (expected, output);
@@ -190,9 +190,9 @@ Demo Simple Rune
 
     [Fact]
     [AutoInitShutdown]
-    public void Label_Draw_Vertical_Simple_Runes ()
+    public void Label_Draw_Vertical_Simple_Text ()
     {
-        var label = new Label { TextDirection = TextDirection.TopBottom_LeftRight, Text = "Demo Simple Rune" };
+        var label = new Label { TextDirection = TextDirection.TopBottom_LeftRight, Text = "Demo Simple Text" };
         var top = new Toplevel ();
         top.Add (label);
         Application.Begin (top);
@@ -213,10 +213,10 @@ p
 l
 e
  
-R
-u
-n
+T
 e
+x
+t
 ";
 
         Rectangle pos = DriverAssert.AssertDriverContentsWithFrameAre (expected, output);

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 606 - 606
Tests/UnitTests/Views/ProgressBarTests.cs


+ 1 - 0
Tests/UnitTests/Views/TextFieldTests.cs

@@ -95,6 +95,7 @@ public class TextFieldTests (ITestOutputHelper output)
 
         Assert.Equal (11, caption.Length);
         Assert.Equal (10, caption.EnumerateRunes ().Sum (c => c.GetColumns ()));
+        Assert.Equal (10, caption.GetColumns ());
 
         TextField tf = GetTextFieldsInView ();
 

+ 5 - 8
Tests/UnitTests/Views/TextViewTests.cs

@@ -6922,7 +6922,7 @@ line.
     {
         string [] lines = _textView.Text.Split (Environment.NewLine);
 
-        if (lines == null || lines.Length == 0)
+        if (lines is { Length: 0 })
         {
             return 0;
         }
@@ -7034,11 +7034,11 @@ line.
         List<List<Cell>> text =
         [
             Cell.ToCells (
-                          "This is the first line.".ToRunes ()
+                          "This is the first line.".ToStringList ()
                          ),
 
             Cell.ToCells (
-                          "This is the second line.".ToRunes ()
+                          "This is the second line.".ToStringList ()
                          )
         ];
         TextView tv = CreateTextView ();
@@ -7101,12 +7101,9 @@ line.  ",
         {
             string csName = color.Key;
 
-            foreach (Rune rune in csName.EnumerateRunes ())
-            {
-                cells.Add (new () { Rune = rune, Attribute = color.Value.Normal });
-            }
+            cells.AddRange (Cell.ToCellList (csName, color.Value.Normal));
 
-            cells.Add (new () { Rune = (Rune)'\n', Attribute = color.Value.Focus });
+            cells.Add (new () { Grapheme = "\n", Attribute = color.Value.Focus });
         }
 
         TextView tv = CreateTextView ();

+ 7 - 7
Tests/UnitTests/Views/TreeViewTests.cs

@@ -979,10 +979,10 @@ public class TreeViewTests (ITestOutputHelper output)
         Assert.All (eventArgs, ea => Assert.Equal (ea.Tree, tv));
         Assert.All (eventArgs, ea => Assert.False (ea.Handled));
 
-        Assert.Equal ("├-root one", eventArgs [0].Cells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ());
-        Assert.Equal ("│ ├─leaf 1", eventArgs [1].Cells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ());
-        Assert.Equal ("│ └─leaf 2", eventArgs [2].Cells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ());
-        Assert.Equal ("└─root two", eventArgs [3].Cells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ());
+        Assert.Equal ("├-root one", eventArgs [0].Cells.Aggregate ("", (s, n) => s += n.Grapheme).TrimEnd ());
+        Assert.Equal ("│ ├─leaf 1", eventArgs [1].Cells.Aggregate ("", (s, n) => s += n.Grapheme).TrimEnd ());
+        Assert.Equal ("│ └─leaf 2", eventArgs [2].Cells.Aggregate ("", (s, n) => s += n.Grapheme).TrimEnd ());
+        Assert.Equal ("└─root two", eventArgs [3].Cells.Aggregate ("", (s, n) => s += n.Grapheme).TrimEnd ());
 
         Assert.Equal (1, eventArgs [0].IndexOfExpandCollapseSymbol);
         Assert.Equal (3, eventArgs [1].IndexOfExpandCollapseSymbol);
@@ -1092,9 +1092,9 @@ oot two
         Assert.All (eventArgs, ea => Assert.Equal (ea.Tree, tv));
         Assert.All (eventArgs, ea => Assert.False (ea.Handled));
 
-        Assert.Equal ("─leaf 1", eventArgs [0].Cells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ());
-        Assert.Equal ("─leaf 2", eventArgs [1].Cells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ());
-        Assert.Equal ("oot two", eventArgs [2].Cells.Aggregate ("", (s, n) => s += n.Rune).TrimEnd ());
+        Assert.Equal ("─leaf 1", eventArgs [0].Cells.Aggregate ("", (s, n) => s += n.Grapheme).TrimEnd ());
+        Assert.Equal ("─leaf 2", eventArgs [1].Cells.Aggregate ("", (s, n) => s += n.Grapheme).TrimEnd ());
+        Assert.Equal ("oot two", eventArgs [2].Cells.Aggregate ("", (s, n) => s += n.Grapheme).TrimEnd ());
 
         Assert.Equal (0, eventArgs [0].IndexOfExpandCollapseSymbol);
         Assert.Equal (0, eventArgs [1].IndexOfExpandCollapseSymbol);

+ 143 - 15
Tests/UnitTestsParallelizable/Drawing/CellTests.cs

@@ -1,17 +1,39 @@
 using System.Text;
-using Xunit.Abstractions;
 
 namespace UnitTests_Parallelizable.DrawingTests;
 
-public class CellTests ()
+public class CellTests
 {
     [Fact]
     public void Constructor_Defaults ()
     {
         var c = new Cell ();
         Assert.True (c is { });
-        Assert.Equal (0, c.Rune.Value);
+        Assert.Empty (c.Runes);
         Assert.Null (c.Attribute);
+        Assert.False (c.IsDirty);
+        Assert.Null (c.Grapheme);
+    }
+
+    [Theory]
+    [InlineData (null, new uint [] { })]
+    [InlineData ("", new uint [] { })]
+    [InlineData ("a", new uint [] { 0x0061 })]
+    [InlineData ("👩‍❤️‍💋‍👨", new uint [] { 0x1F469, 0x200D, 0x2764, 0xFE0F, 0x200D, 0x1F48B, 0x200D, 0x1F468 })]
+    [InlineData ("æ", new uint [] { 0x00E6 })]
+    [InlineData ("a︠", new uint [] { 0x0061, 0xFE20 })]
+    [InlineData ("e︡", new uint [] { 0x0065, 0xFE21 })]
+    public void Runes_From_Grapheme (string grapheme, uint [] expected)
+    {
+        // Arrange
+        var c = new Cell { Grapheme = grapheme };
+
+        // Act
+        Rune [] runes = expected.Select (u => new Rune (u)).ToArray ();
+
+        // Assert
+        Assert.Equal (grapheme, c.Grapheme);
+        Assert.Equal (runes, c.Runes);
     }
 
     [Fact]
@@ -21,32 +43,138 @@ public class CellTests ()
 
         var c2 = new Cell
         {
-            Rune = new ('a'), Attribute = new (Color.Red)
+            Grapheme = "a", Attribute = new (Color.Red)
         };
         Assert.False (c1.Equals (c2));
         Assert.False (c2.Equals (c1));
 
-        c1.Rune = new ('a');
+        c1.Grapheme = "a";
         c1.Attribute = new ();
-        Assert.Equal (c1.Rune, c2.Rune);
+        Assert.Equal (c1.Grapheme, c2.Grapheme);
         Assert.False (c1.Equals (c2));
         Assert.False (c2.Equals (c1));
     }
 
     [Fact]
-    public void ToString_Override ()
+    public void Set_Text_With_Invalid_Grapheme_Throws ()
     {
-        var c1 = new Cell ();
+        Assert.Throws<InvalidOperationException> (() => new Cell { Grapheme = "ab" });
+        Assert.Throws<InvalidOperationException> (() => new Cell { Grapheme = "\u0061\u0062" }); // ab
+    }
 
-        var c2 = new Cell
+    [Theory]
+    [MemberData (nameof (ToStringTestData))]
+    public void ToString_Override (string text, Attribute? attribute, string expected)
+    {
+        var c = new Cell (attribute, true, text);
+        string result = c.ToString ();
+
+        Assert.Equal (expected, result);
+    }
+
+    public static IEnumerable<object []> ToStringTestData ()
+    {
+        yield return ["", null, "[\"\":]"];
+        yield return ["a", null, "[\"a\":]"];
+        yield return ["\t", null, "[\"\\t\":]"];
+        yield return ["\r", null, "[\"\\r\":]"];
+        yield return ["\n", null, "[\"\\n\":]"];
+        yield return ["\r\n", null, "[\"\\r\\n\":]"];
+        yield return ["\f", null, "[\"\\f\":]"];
+        yield return ["\v", null, "[\"\\v\":]"];
+        yield return ["\x1B", null, "[\"\\u001B\":]"];
+        yield return ["\\", new Attribute (Color.Blue), "[\"\\\":[Blue,Blue,None]]"];
+        yield return ["😀", null, "[\"😀\":]"];
+        yield return ["👨‍👩‍👦‍👦", null, "[\"👨‍👩‍👦‍👦\":]"];
+        yield return ["A", new Attribute (Color.Red) { Style = TextStyle.Blink }, "[\"A\":[Red,Red,Blink]]"];
+        yield return ["\U0001F469\u200D\u2764\uFE0F\u200D\U0001F48B\u200D\U0001F468", null, "[\"👩‍❤️‍💋‍👨\":]"];
+    }
+
+    [Fact]
+    public void Graphemes_Decomposed_Normalize ()
+    {
+        Cell c1 = new ()
         {
-            Rune = new ('a'), Attribute = new (Color.Red)
+            // 'e' + '◌́' COMBINING ACUTE ACCENT (U+0301)
+            Grapheme = "e\u0301" // visually "é"
         };
-        Assert.Equal ("['\0':]", c1.ToString ());
 
-        Assert.Equal (
-                      "['a':[Red,Red,None]]",
-                      c2.ToString ()
-                     );
+        Cell c2 = new ()
+        {
+            // NFC single code point (U+00E9)
+            Grapheme = "é"
+        };
+
+        // Validation
+        Assert.Equal ("é", c1.Grapheme); // Proper normalized grapheme
+        Assert.Equal (c1.Grapheme, c2.Grapheme);
+        Assert.Equal (c1.Runes.Count, c2.Runes.Count);
+        Assert.Equal (new (0x00E9), c2.Runes [0]);
+    }
+
+    [Fact]
+    public void Cell_IsDirty_Flag_Works ()
+    {
+        var c = new Cell ();
+        Assert.False (c.IsDirty);
+        c.IsDirty = true;
+        Assert.True (c.IsDirty);
+        c.IsDirty = false;
+        Assert.False (c.IsDirty);
+    }
+
+    [Theory]
+    [InlineData ("\uFDD0", false)]
+    [InlineData ("\uFDEF", false)]
+    [InlineData ("\uFFFE", true)]
+    [InlineData ("\uFFFF", false)]
+    [InlineData ("\U0001FFFE", false)]
+    [InlineData ("\U0001FFFF", false)]
+    [InlineData ("\U0010FFFE", false)]
+    [InlineData ("\U0010FFFF", false)]
+    public void IsNormalized_ArgumentException (string text, bool throws)
+    {
+        try
+        {
+            bool normalized = text.IsNormalized (NormalizationForm.FormC);
+
+            Assert.True (normalized);
+            Assert.False (throws);
+        }
+        catch (ArgumentException)
+        {
+            Assert.True (throws);
+        }
+
+        Assert.Null (Record.Exception (() => new Cell { Grapheme = text }));
+    }
+
+    [Fact]
+    public void Surrogate_Normalize_Throws_And_Cell_Setter_Throws ()
+    {
+        // Create the lone high surrogate at runtime (safe)
+        string s = new string ((char)0xD800, 1);
+
+        // Confirm the runtime string actually contains the surrogate
+        Assert.Equal (0xD800, s [0]);
+
+        // Normalize should throw
+        Assert.Throws<ArgumentException> (() => s.Normalize (NormalizationForm.FormC));
+
+        // And if your Grapheme setter normalizes, assignment should throw as well
+        Assert.Throws<ArgumentException> (() => new Cell () { Grapheme = s });
+
+        // Create the lone low surrogate at runtime (safe)
+        s = new string ((char)0xDC00, 1);
+
+        // Confirm the runtime string actually contains the surrogate
+        Assert.Equal (0xDC00, s [0]);
+
+        // Normalize should throw
+        Assert.Throws<ArgumentException> (() => s.Normalize (NormalizationForm.FormC));
+
+        // And if your Grapheme setter normalizes, assignment should throw as well
+        Assert.Throws<ArgumentException> (() => new Cell () { Grapheme = s });
     }
+
 }

+ 19 - 17
Tests/UnitTestsParallelizable/Drivers/AddRuneTests.cs

@@ -19,7 +19,7 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
         driver.Rows = 25;
         driver.Cols = 80;
         driver.AddRune (new Rune ('a'));
-        Assert.Equal ((Rune)'a', driver.Contents [0, 0].Rune);
+        Assert.Equal ("a", driver.Contents [0, 0].Grapheme);
 
         driver.End ();
     }
@@ -29,28 +29,30 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
     {
         IDriver driver = CreateFakeDriver ();
 
-        var expected = new Rune ('ắ');
+        var expected = "ắ";
 
         var text = "\u1eaf";
         driver.AddStr (text);
-        Assert.Equal (expected, driver.Contents [0, 0].Rune);
-        Assert.Equal ((Rune)' ', driver.Contents [0, 1].Rune);
+        Assert.Equal (expected, driver.Contents [0, 0].Grapheme);
+        Assert.Equal (" ", driver.Contents [0, 1].Grapheme);
 
         driver.ClearContents ();
         driver.Move (0, 0);
 
+        expected = "ắ";
         text = "\u0103\u0301";
         driver.AddStr (text);
-        Assert.Equal (expected, driver.Contents [0, 0].Rune);
-        Assert.Equal ((Rune)' ', driver.Contents [0, 1].Rune);
+        Assert.Equal (expected, driver.Contents [0, 0].Grapheme);
+        Assert.Equal (" ", driver.Contents [0, 1].Grapheme);
 
         driver.ClearContents ();
         driver.Move (0, 0);
 
+        expected = "ắ";
         text = "\u0061\u0306\u0301";
         driver.AddStr (text);
-        Assert.Equal (expected, driver.Contents [0, 0].Rune);
-        Assert.Equal ((Rune)' ', driver.Contents [0, 1].Rune);
+        Assert.Equal (expected, driver.Contents [0, 0].Grapheme);
+        Assert.Equal (" ", driver.Contents [0, 1].Grapheme);
 
         //		var s = "a\u0301\u0300\u0306";
 
@@ -86,7 +88,7 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
         {
             for (var row = 0; row < driver.Rows; row++)
             {
-                Assert.Equal ((Rune)' ', driver.Contents [row, col].Rune);
+                Assert.Equal (" ", driver.Contents [row, col].Grapheme);
             }
         }
 
@@ -99,12 +101,12 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
         IDriver driver = CreateFakeDriver ();
 
         driver.AddRune ('a');
-        Assert.Equal ((Rune)'a', driver.Contents [0, 0].Rune);
+        Assert.Equal ("a", driver.Contents [0, 0].Grapheme);
         Assert.Equal (0, driver.Row);
         Assert.Equal (1, driver.Col);
 
         driver.AddRune ('b');
-        Assert.Equal ((Rune)'b', driver.Contents [0, 1].Rune);
+        Assert.Equal ("b", driver.Contents [0, 1].Grapheme);
         Assert.Equal (0, driver.Row);
         Assert.Equal (2, driver.Col);
 
@@ -116,7 +118,7 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
 
         // Add a rune to the last column of the first row; should increment the row or col even though it's now invalid
         driver.AddRune ('c');
-        Assert.Equal ((Rune)'c', driver.Contents [0, lastCol].Rune);
+        Assert.Equal ("c", driver.Contents [0, lastCol].Grapheme);
         Assert.Equal (lastCol + 1, driver.Col);
 
         // Add a rune; should succeed but do nothing as it's outside of Contents
@@ -127,7 +129,7 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
         {
             for (var row = 0; row < driver.Rows; row++)
             {
-                Assert.NotEqual ((Rune)'d', driver.Contents [row, col].Rune);
+                Assert.NotEqual ("d", driver.Contents [row, col].Grapheme);
             }
         }
 
@@ -146,12 +148,12 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
         Assert.Equal (2, rune.GetColumns ());
 
         driver.AddRune (rune);
-        Assert.Equal (rune, driver.Contents [0, 0].Rune);
+        Assert.Equal (rune.ToString (), driver.Contents [0, 0].Grapheme);
         Assert.Equal (0, driver.Row);
         Assert.Equal (2, driver.Col);
 
         //driver.AddRune ('b');
-        //Assert.Equal ((Rune)'b', driver.Contents [0, 1].Rune);
+        //Assert.Equal ((Text)'b', driver.Contents [0, 1].Text);
         //Assert.Equal (0, driver.Row);
         //Assert.Equal (2, driver.Col);
 
@@ -163,7 +165,7 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
 
         //// Add a rune to the last column of the first row; should increment the row or col even though it's now invalid
         //driver.AddRune ('c');
-        //Assert.Equal ((Rune)'c', driver.Contents [0, lastCol].Rune);
+        //Assert.Equal ((Text)'c', driver.Contents [0, lastCol].Text);
         //Assert.Equal (lastCol + 1, driver.Col);
 
         //// Add a rune; should succeed but do nothing as it's outside of Contents
@@ -171,7 +173,7 @@ public class AddRuneTests (ITestOutputHelper output) : FakeDriverBase
         //Assert.Equal (lastCol + 2, driver.Col);
         //for (var col = 0; col < driver.Cols; col++) {
         //	for (var row = 0; row < driver.Rows; row++) {
-        //		Assert.NotEqual ((Rune)'d', driver.Contents [row, col].Rune);
+        //		Assert.NotEqual ((Text)'d', driver.Contents [row, col].Text);
         //	}
         //}
 

+ 4 - 4
Tests/UnitTestsParallelizable/Drivers/ContentsTests.cs

@@ -36,7 +36,7 @@ public class ContentsTests (ITestOutputHelper output) : FakeDriverBase
         // a + ogonek + acute = <U+0061, U+0328, U+0301> ( ą́ )
         var oGonek = new Rune (0x0328); // Combining ogonek (a small hook or comma shape)
         combined = "a" + oGonek + acuteAccent;
-        expected = ("a" + oGonek).Normalize (NormalizationForm.FormC); // See Issue #2616
+        expected = ("a" + oGonek + acuteAccent).Normalize (NormalizationForm.FormC); // See Issue #2616
 
         driver.Move (0, 0);
         driver.AddStr (combined);
@@ -44,7 +44,7 @@ public class ContentsTests (ITestOutputHelper output) : FakeDriverBase
 
         // e + ogonek + acute = <U+0061, U+0328, U+0301> ( ę́́ )
         combined = "e" + oGonek + acuteAccent;
-        expected = ("e" + oGonek).Normalize (NormalizationForm.FormC); // See Issue #2616
+        expected = ("e" + oGonek + acuteAccent).Normalize (NormalizationForm.FormC); // See Issue #2616
 
         driver.Move (0, 0);
         driver.AddStr (combined);
@@ -52,7 +52,7 @@ public class ContentsTests (ITestOutputHelper output) : FakeDriverBase
 
         // i + ogonek + acute = <U+0061, U+0328, U+0301> ( į́́́ )
         combined = "i" + oGonek + acuteAccent;
-        expected = ("i" + oGonek).Normalize (NormalizationForm.FormC); // See Issue #2616
+        expected = ("i" + oGonek + acuteAccent).Normalize (NormalizationForm.FormC); // See Issue #2616
 
         driver.Move (0, 0);
         driver.AddStr (combined);
@@ -60,7 +60,7 @@ public class ContentsTests (ITestOutputHelper output) : FakeDriverBase
 
         // u + ogonek + acute = <U+0061, U+0328, U+0301> ( ų́́́́ )
         combined = "u" + oGonek + acuteAccent;
-        expected = ("u" + oGonek).Normalize (NormalizationForm.FormC); // See Issue #2616
+        expected = ("u" + oGonek + acuteAccent).Normalize (NormalizationForm.FormC); // See Issue #2616
 
         driver.Move (0, 0);
         driver.AddStr (combined);

+ 47 - 1
Tests/UnitTestsParallelizable/Drivers/DriverTests.cs

@@ -1,6 +1,52 @@
 using UnitTests;
+using Xunit.Abstractions;
 
 namespace UnitTests_Parallelizable.DriverTests;
 
 public class DriverTests : FakeDriverBase
-{ }
+{
+    [Theory]
+    [InlineData (null, true)]
+    [InlineData ("", true)]
+    [InlineData ("a", true)]
+    [InlineData ("👩‍❤️‍💋‍👨", false)]
+    public void IsValidLocation (string text, bool positive)
+    {
+        IDriver driver = CreateFakeDriver ();
+        driver.SetScreenSize (10, 10);
+
+        // positive
+        Assert.True (driver.IsValidLocation (text, 0, 0));
+        Assert.True (driver.IsValidLocation (text, 1, 1));
+        Assert.Equal (positive, driver.IsValidLocation (text, driver.Cols - 1, driver.Rows - 1));
+
+        // negative
+        Assert.False (driver.IsValidLocation (text, -1, 0));
+        Assert.False (driver.IsValidLocation (text, 0, -1));
+        Assert.False (driver.IsValidLocation (text, -1, -1));
+        Assert.False (driver.IsValidLocation (text, driver.Cols, driver.Rows - 1));
+        Assert.False (driver.IsValidLocation (text, driver.Cols, driver.Rows - 1));
+        Assert.False (driver.IsValidLocation (text, driver.Cols, driver.Rows));
+
+        // Define a clip rectangle
+        driver.Clip = new (new Rectangle (5, 5, 5, 5));
+
+        // positive
+        Assert.True (driver.IsValidLocation (text, 5, 5));
+        Assert.Equal (positive, driver.IsValidLocation (text, 9, 9));
+
+        // negative
+        Assert.False (driver.IsValidLocation (text, 4, 5));
+        Assert.False (driver.IsValidLocation (text, 5, 4));
+        Assert.False (driver.IsValidLocation (text, 10, 9));
+        Assert.False (driver.IsValidLocation (text, 9, 10));
+        Assert.False (driver.IsValidLocation (text, -1, 0));
+        Assert.False (driver.IsValidLocation (text, 0, -1));
+        Assert.False (driver.IsValidLocation (text, -1, -1));
+        Assert.False (driver.IsValidLocation (text, driver.Cols, driver.Rows - 1));
+        Assert.False (driver.IsValidLocation (text, driver.Cols, driver.Rows - 1));
+        Assert.False (driver.IsValidLocation (text, driver.Cols, driver.Rows));
+
+        driver.End ();
+    }
+}

+ 1 - 1
Tests/UnitTestsParallelizable/Drivers/FakeDriverTests.cs

@@ -155,7 +155,7 @@ public class FakeDriverTests (ITestOutputHelper output) : FakeDriverBase
         {
             for (int col = rect.X; col < rect.X + rect.Width; col++)
             {
-                Assert.Equal ((Rune)'X', driver.Contents [row, col].Rune);
+                Assert.Equal ("X", driver.Contents [row, col].Grapheme);
             }
         }
     }

+ 131 - 6
Tests/UnitTestsParallelizable/Text/RuneTests.cs

@@ -88,7 +88,7 @@ public class RuneTests
                     1
                 )] // the letters 법 join to form the Korean word for "rice:" U+BC95 법 (read from top left to bottom right)
     [InlineData ("\U0001F468\u200D\U0001F469\u200D\U0001F467", "👨‍👩‍👧", 8, 2, 8)] // Man, Woman and Girl emoji.
-    [InlineData ("\u0915\u093f", "कि", 2, 1, 2)] // Hindi कि with DEVANAGARI LETTER KA and DEVANAGARI VOWEL SIGN I
+    //[InlineData ("\u0915\u093f", "कि", 2, 2, 2)] // Hindi कि with DEVANAGARI LETTER KA and DEVANAGARI VOWEL SIGN I
     [InlineData (
                     "\u0e4d\u0e32",
                     "ํา",
@@ -213,7 +213,7 @@ public class RuneTests
     [InlineData (
                     '\u1161',
                     "ᅡ",
-                    1,
+                    0,
                     1,
                     3
                 )] // ᅡ Hangul Jungseong A - Unicode Hangul Jamo for join with column width equal to 0 alone.
@@ -231,7 +231,7 @@ public class RuneTests
                 )]  // ䷀Hexagram For The Creative Heaven -  U+4dc0 - https://github.com/microsoft/terminal/blob/main/src/types/unicode_width_overrides.xml
                     // See https://github.com/microsoft/terminal/issues/19389
 
-    [InlineData ('\ud7b0', "ힰ", 1, 1, 3)] // ힰ ┤Hangul Jungseong O-Yeo - ힰ U+d7b0')]
+    [InlineData ('\ud7b0', "ힰ", 0, 1, 3)] // ힰ ┤Hangul Jungseong O-Yeo - ힰ U+d7b0')]
     [InlineData ('\uf61e', "", 1, 1, 3)] // Private Use Area
     [InlineData ('\u23f0', "⏰", 2, 1, 3)] // Alarm Clock - ⏰ U+23f0
     [InlineData ('\u1100', "ᄀ", 2, 1, 3)] // ᄀ Hangul Choseong Kiyeok
@@ -365,6 +365,42 @@ public class RuneTests
     [InlineData ('\ud801')]
     public void Rune_Exceptions_Integers (int code) { Assert.Throws<ArgumentOutOfRangeException> (() => new Rune (code)); }
 
+    [Theory]
+    // Control characters (should be mapped to Control Pictures)
+    [InlineData ('\u0000', 0x2400)]  // NULL → ␀
+    [InlineData ('\u0009', 0x2409)]  // TAB → ␉
+    [InlineData ('\u000A', 0x240A)]  // LF → ␊
+    [InlineData ('\u000D', 0x240D)]  // CR → ␍
+
+    // Printable characters (should remain unchanged)
+    [InlineData ('A', 'A')]
+    [InlineData (' ', ' ')]
+    [InlineData ('~', '~')]
+    public void MakePrintable_ReturnsExpected (char inputChar, int expectedCodePoint)
+    {
+        // Arrange
+        Rune input = new Rune (inputChar);
+
+        // Act
+        Rune result = input.MakePrintable ();
+
+        // Assert
+        Assert.Equal (expectedCodePoint, result.Value);
+    }
+
+    [Fact]
+    public void MakePrintable_SupplementaryRune_RemainsUnchanged ()
+    {
+        // Arrange: supplementary character outside BMP (not a control)
+        Rune input = new Rune (0x1F600); // 😀 grinning face emoji
+
+        // Act
+        Rune result = input.MakePrintable ();
+
+        // Assert
+        Assert.Equal (input.Value, result.Value);
+    }
+
     [Theory]
     [InlineData (new [] { '\ud799', '\udc21' })]
     public void Rune_Exceptions_Utf16_Encode (char [] code)
@@ -954,11 +990,9 @@ public class RuneTests
         Assert.Equal (runeCount, us.GetRuneCount ());
         Assert.Equal (stringCount, s.Length);
 
-        TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator (s);
-
         var textElementCount = 0;
 
-        while (enumerator.MoveNext ())
+        foreach (string _ in GraphemeHelper.GetGraphemes (s))
         {
             textElementCount++; // For versions prior to Net5.0 the StringInfo class might handle some grapheme clusters incorrectly.
         }
@@ -1064,4 +1098,95 @@ public class RuneTests
 
         return true;
     }
+
+    [Theory]
+    [InlineData (0x0041, new byte [] { 0x41 })]                  // 'A', ASCII
+    [InlineData (0x00E9, new byte [] { 0xC3, 0xA9 })]            // 'é', 2-byte UTF-8
+    [InlineData (0x20AC, new byte [] { 0xE2, 0x82, 0xAC })]      // '€', 3-byte UTF-8
+    [InlineData (0x1F600, new byte [] { 0xF0, 0x9F, 0x98, 0x80 })] // 😀 emoji, 4-byte UTF-8
+    public void Encode_WritesExpectedBytes (int codePoint, byte [] expectedBytes)
+    {
+        // Arrange
+        Rune rune = new Rune (codePoint);
+        byte [] buffer = new byte [10]; // extra space
+        for (int i = 0; i < buffer.Length; i++)
+        {
+            buffer [i] = 0xFF;
+        }
+
+        // Act
+        int written = rune.Encode (buffer);
+
+        // Assert
+        Assert.Equal (expectedBytes.Length, written);
+        for (int i = 0; i < written; i++)
+        {
+            Assert.Equal (expectedBytes [i], buffer [i]);
+        }
+    }
+
+    [Fact]
+    public void Encode_WithStartAndCount_WritesPartialBytes ()
+    {
+        // Arrange: U+1F600 😀 (4 bytes)
+        Rune rune = new Rune (0x1F600);
+        byte [] buffer = new byte [10];
+        for (int i = 0; i < buffer.Length; i++)
+        {
+            buffer [i] = 0xFF;
+        }
+
+        // Act: write starting at index 2, limit count to 2 bytes
+        int written = rune.Encode (buffer, start: 2, count: 2);
+
+        // Assert
+        Assert.Equal (2, written);
+        // Original UTF-8 bytes: F0 9F 98 80
+        Assert.Equal (0xF0, buffer [2]);
+        Assert.Equal (0x9F, buffer [3]);
+        // Remaining buffer untouched
+        Assert.Equal (0xFF, buffer [0]);
+        Assert.Equal (0xFF, buffer [1]);
+        Assert.Equal (0xFF, buffer [4]);
+    }
+
+    [Fact]
+    public void Encode_WithCountGreaterThanRuneBytes_WritesAllBytes ()
+    {
+        // Arrange: é → C3 A9
+        Rune rune = new Rune ('é');
+        byte [] buffer = new byte [10];
+        for (int i = 0; i < buffer.Length; i++)
+        {
+            buffer [i] = 0xFF;
+        }
+
+        // Act: count larger than needed
+        int written = rune.Encode (buffer, start: 1, count: 10);
+
+        // Assert
+        Assert.Equal (2, written);
+        Assert.Equal (0xC3, buffer [1]);
+        Assert.Equal (0xA9, buffer [2]);
+        Assert.Equal (0xFF, buffer [3]); // next byte untouched
+    }
+
+    [Fact]
+    public void Encode_ZeroCount_WritesNothing ()
+    {
+        Rune rune = new Rune ('A');
+        byte [] buffer = new byte [5];
+        for (int i = 0; i < buffer.Length; i++)
+        {
+            buffer [i] = 0xFF;
+        }
+
+        int written = rune.Encode (buffer, start: 0, count: 0);
+
+        Assert.Equal (0, written);
+        foreach (var b in buffer)
+        {
+            Assert.Equal (0xFF, b); // buffer untouched
+        }
+    }
 }

+ 166 - 36
Tests/UnitTestsParallelizable/Text/StringTests.cs

@@ -4,6 +4,13 @@
 
 public class StringTests
 {
+    [Fact]
+    public void TestGetColumns_Null ()
+    {
+        string? str = null;
+        Assert.Equal (0, str!.GetColumns ());
+    }
+
     [Fact]
     public void TestGetColumns_Empty ()
     {
@@ -11,6 +18,20 @@ public class StringTests
         Assert.Equal (0, str.GetColumns ());
     }
 
+    [Fact]
+    public void TestGetColumns_SingleRune ()
+    {
+        var str = "a";
+        Assert.Equal (1, str.GetColumns ());
+    }
+
+    [Fact]
+    public void TestGetColumns_Zero_Width ()
+    {
+        var str = "\u200D";
+        Assert.Equal (0, str.GetColumns ());
+    }
+
     [Theory]
     [InlineData ("a", 1)]
     [InlineData ("á", 1)]
@@ -30,53 +51,162 @@ public class StringTests
 
     // Test known wide codepoints
     [Theory]
-    [InlineData ("🙂", 2)]
-    [InlineData ("a🙂", 3)]
-    [InlineData ("🙂a", 3)]
-    [InlineData ("👨‍👩‍👦‍👦", 2)]
-    [InlineData ("👨‍👩‍👦‍👦🙂", 4)]
-    [InlineData ("👨‍👩‍👦‍👦🙂a", 5)]
-    [InlineData ("👨‍👩‍👦‍👦a🙂", 5)]
-    [InlineData ("👨‍👩‍👦‍👦👨‍👩‍👦‍👦", 4)]
-    [InlineData ("山", 2)] // The character for "mountain" in Chinese/Japanese/Korean (山), Unicode U+5C71
-    [InlineData ("山🙂", 4)] // The character for "mountain" in Chinese/Japanese/Korean (山), Unicode U+5C71
-    //[InlineData ("\ufe20\ufe21", 2)] // Combining Ligature Left Half ︠ - U+fe20 -https://github.com/microsoft/terminal/blob/main/src/types/unicode_width_overrides.xml
-    //				 // Combining Ligature Right Half - U+fe21 -https://github.com/microsoft/terminal/blob/main/src/types/unicode_width_overrides.xml
-    public void TestGetColumns_MultiRune_WideBMP (string str, int expected) { Assert.Equal (expected, str.GetColumns ()); }
+    [InlineData ("🙂", 2, 1, 2)]
+    [InlineData ("a🙂", 3, 2, 3)]
+    [InlineData ("🙂a", 3, 2, 3)]
+    [InlineData ("👨‍👩‍👦‍👦", 8, 1, 2)]
+    [InlineData ("👨‍👩‍👦‍👦🙂", 10, 2, 4)]
+    [InlineData ("👨‍👩‍👦‍👦🙂a", 11, 3, 5)]
+    [InlineData ("👨‍👩‍👦‍👦a🙂", 11, 3, 5)]
+    [InlineData ("👨‍👩‍👦‍👦👨‍👩‍👦‍👦", 16, 2, 4)]
+    [InlineData ("าำ", 2, 1, 2)] // า U+0E32 - THAI CHARACTER SARA AA with ำ U+0E33 - THAI CHARACTER SARA AM
+    [InlineData ("山", 2, 1, 2)] // The character for "mountain" in Chinese/Japanese/Korean (山), Unicode U+5C71
+    [InlineData ("山🙂", 4, 2, 4)] // The character for "mountain" in Chinese/Japanese/Korean (山), Unicode U+5C71
+    [InlineData ("a\ufe20e\ufe21", 2, 2, 2)] // Combining Ligature Left Half ︠ - U+fe20 -https://github.com/microsoft/terminal/blob/main/src/types/unicode_width_overrides.xml
+    // Combining Ligature Right Half - U+fe21 -https://github.com/microsoft/terminal/blob/main/src/types/unicode_width_overrides.xml
+    //[InlineData ("क", 1, 1, 1)] // क U+0915 Devanagari Letter Ka
+    //[InlineData ("ि", 1, 1, 1)] // U+093F Devanagari Vowel Sign I ि (i-kar).
+    //[InlineData ("कि", 2, 1, 2)] // "कि" is U+0915 for the base consonant "क" with U+093F for the vowel sign "ि" (i-kar).
+    [InlineData ("ᄀ", 2, 1, 2)] // ᄀ U+1100 HANGUL CHOSEONG KIYEOK (consonant)
+    [InlineData ("ᅡ", 0, 1, 0)] // ᅡ U+1161 HANGUL JUNGSEONG A (vowel)
+    [InlineData ("가", 2, 1, 2)] // ᄀ U+1100 HANGUL CHOSEONG KIYEOK (consonant) with ᅡ U+1161 HANGUL JUNGSEONG A (vowel)
+    [InlineData ("ᄒ", 2, 1, 2)] // ᄒ U+1112 Hangul Choseong Hieuh
+    [InlineData ("ᅵ", 0, 1, 0)] // ᅵ U+1175 Hangul Jungseong I
+    [InlineData ("ᇂ", 0, 1, 0)] // ᇂ U+11C2 Hangul Jongseong Hieuh
+    [InlineData ("힣", 2, 1, 2)] // ᄒ (choseong h) + ᅵ (jungseong i) + ᇂ (jongseong h)
+    [InlineData ("ힰ", 0, 1, 0)]    // U+D7B0 ힰ Hangul Jungseong O-Yeo
+    [InlineData ("ᄀힰ", 2, 1, 2)]  // ᄀ U+1100 HANGUL CHOSEONG KIYEOK (consonant) with U+D7B0 ힰ Hangul Jungseong O-Yeo
+    //[InlineData ("षि", 2, 1, 2)] // U+0937 ष DEVANAGARI LETTER SSA with U+093F ि COMBINING DEVANAGARI VOWEL SIGN I
+    public void TestGetColumns_MultiRune_WideBMP_Graphemes (string str, int expectedRunesWidth, int expectedGraphemesCount, int expectedWidth)
+    {
+        Assert.Equal (expectedRunesWidth, str.EnumerateRunes ().Sum (r => r.GetColumns ()));
+        Assert.Equal (expectedGraphemesCount, GraphemeHelper.GetGraphemes (str).ToArray ().Length);
+        Assert.Equal (expectedWidth, str.GetColumns ());
+    }
 
-    [Fact]
-    public void TestGetColumns_Null ()
+    [Theory]
+    [InlineData (null)]
+    [InlineData ("")]
+    public void TestGetColumns_Does_Not_Throws_With_Null_And_Empty_String (string? text)
     {
-        string? str = null;
-        Assert.Equal (0, str!.GetColumns ());
+        // ReSharper disable once InvokeAsExtensionMethod
+        Assert.Equal (0, StringExtensions.GetColumns (text!));
     }
 
-    [Fact]
-    public void TestGetColumns_SingleRune ()
+    public class ReadOnlySpanExtensionsTests
     {
-        var str = "a";
-        Assert.Equal (1, str.GetColumns ());
+        [Theory]
+        [InlineData ("12345", true)] // all ASCII digits
+        [InlineData ("0", true)] // single ASCII digit
+        [InlineData ("", false)] // empty span
+        [InlineData ("12a45", false)] // contains a letter
+        [InlineData ("123", false)] // full-width Unicode digits (not ASCII)
+        [InlineData ("12 34", false)] // contains space
+        [InlineData ("١٢٣", false)] // Arabic-Indic digits
+        public void IsAllAsciiDigits_WorksAsExpected (string input, bool expected)
+        {
+            // Arrange
+            ReadOnlySpan<char> span = input.AsSpan ();
+
+            // Act
+            bool result = span.IsAllAsciiDigits ();
+
+            // Assert
+            Assert.Equal (expected, result);
+        }
     }
 
-    [Fact]
-    public void TestGetColumns_Zero_Width ()
+    [Theory]
+    [InlineData ("0", true)]
+    [InlineData ("9", true)]
+    [InlineData ("A", true)]
+    [InlineData ("F", true)]
+    [InlineData ("a", true)]
+    [InlineData ("f", true)]
+    [InlineData ("123ABC", true)]
+    [InlineData ("abcdef", true)]
+    [InlineData ("G", false)]        // 'G' not hex
+    [InlineData ("Z9", false)]       // 'Z' not hex
+    [InlineData ("12 34", false)]    // space not hex
+    [InlineData ("", false)]         // empty string
+    [InlineData ("123", false)]    // full-width digits, not ASCII
+    [InlineData ("0xFF", false)]     // includes 'x'
+    public void IsAllAsciiHexDigits_ReturnsExpected (string input, bool expected)
     {
-        var str = "\u200D";
-        Assert.Equal (0, str.GetColumns ());
+        // Arrange
+        ReadOnlySpan<char> span = input.AsSpan ();
+
+        // Act
+        bool result = span.IsAllAsciiHexDigits ();
+
+        // Assert
+        Assert.Equal (expected, result);
     }
 
     [Theory]
-    [InlineData (null)]
-    [InlineData ("")]
-    public void TestGetColumns_Does_Not_Throws_With_Null_And_Empty_String (string? text)
+    [MemberData (nameof (GetStringConcatCases))]
+    public void ToString_ReturnsExpected (IEnumerable<string> input, string expected)
     {
-        if (text is null)
-        {
-            Assert.Equal (0, StringExtensions.GetColumns (text!));
-        }
-        else
-        {
-            Assert.Equal (0, text.GetColumns ());
-        }
+        // Act
+        string result = StringExtensions.ToString (input);
+
+        // Assert
+        Assert.Equal (expected, result);
+    }
+
+    public static IEnumerable<object []> GetStringConcatCases ()
+    {
+        yield return [new string [] { }, string.Empty]; // Empty sequence
+        yield return [new [] { "" }, string.Empty]; // Single empty string
+        yield return [new [] { "A" }, "A"]; // Single element
+        yield return [new [] { "A", "B" }, "AB"]; // Simple concatenation
+        yield return [new [] { "Hello", " ", "World" }, "Hello World"]; // Multiple parts
+        yield return [new [] { "123", "456", "789" }, "123456789"]; // Numeric strings
+        yield return [new [] { "👩‍", "🧒" }, "👩‍🧒"]; // Grapheme sequence
+        yield return [new [] { "α", "β", "γ" }, "αβγ"]; // Unicode letters
+        yield return [new [] { "A", null, "B" }, "AB"]; // Null ignored by string.Concat
+    }
+
+    [Theory]
+    [InlineData ("", false)]                                // Empty string
+    [InlineData ("A", false)]                               // Single BMP character
+    [InlineData ("AB", false)]                              // Two BMP chars, not a surrogate pair
+    [InlineData ("👩", true)]                               // Single emoji surrogate pair (U+1F469)
+    [InlineData ("🧒", true)]                               // Another emoji surrogate pair (U+1F9D2)
+    [InlineData ("𐍈", true)]                               // Gothic letter hwair (U+10348)
+    [InlineData ("A👩", false)]                             // One BMP + one surrogate half
+    [InlineData ("👩‍", false)]                              // Surrogate pair + ZWJ (length != 2)
+    public void IsSurrogatePair_ReturnsExpected (string input, bool expected)
+    {
+        // Act
+        bool result = input.IsSurrogatePair ();
+
+        // Assert
+        Assert.Equal (expected, result);
+    }
+
+    [Theory]
+    // Control characters (should be replaced with the "Control Pictures" block)
+    [InlineData ("\u0000", "\u2400")]  // NULL → ␀
+    [InlineData ("\u0009", "\u2409")]  // TAB → ␉
+    [InlineData ("\u000A", "\u240A")]  // LF → ␊
+    [InlineData ("\u000D", "\u240D")]  // CR → ␍
+
+    // Printable characters (should remain unchanged)
+    [InlineData ("A", "A")]
+    [InlineData (" ", " ")]
+    [InlineData ("~", "~")]
+
+    // Multi-character string (should return unchanged)
+    [InlineData ("AB", "AB")]
+    [InlineData ("Hello", "Hello")]
+    [InlineData ("\u0009A", "\u0009A")] // includes a control char, but length > 1
+    public void MakePrintable_ReturnsExpected (string input, string expected)
+    {
+        // Act
+        string result = input.MakePrintable ();
+
+        // Assert
+        Assert.Equal (expected, result);
     }
 }

+ 27 - 0
Tests/UnitTestsParallelizable/Text/TextFormatterDrawTests.cs

@@ -658,5 +658,32 @@ Nice       Work")]
         DriverAssert.AssertDriverContentsWithFrameAre (expectedDraw, output, driver);
     }
 
+    [Theory]
+    [InlineData ("\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466", 2, 1, TextDirection.LeftRight_TopBottom, "👨‍👩‍👧‍👦")]
+    [InlineData ("\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466", 2, 1, TextDirection.TopBottom_LeftRight, "👨‍👩‍👧‍👦")]
+    public void Draw_Emojis_With_Zero_Width_Joiner (
+        string text,
+        int width,
+        int height,
+        TextDirection direction,
+        string expectedDraw
+    )
+    {
+        IDriver driver = CreateFakeDriver ();
+
+        TextFormatter tf = new ()
+        {
+            Direction = direction,
+            ConstrainToSize = new (width, height),
+            Text = text,
+            WordWrap = false
+        };
+        Assert.Equal (width, text.GetColumns ());
+
+        tf.Draw (driver, new (0, 0, width, height), Attribute.Default, Attribute.Default);
+
+        DriverAssert.AssertDriverContentsWithFrameAre (expectedDraw, output, driver);
+    }
+
     #endregion
 }

+ 31 - 22
Tests/UnitTestsParallelizable/Text/TextFormatterTests.cs

@@ -792,19 +792,16 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
     [MemberData (nameof (CMGlyphs))]
     public void GetLengthThatFits_List_Simple_And_Wide_Runes (string text, int columns, int expectedLength)
     {
-        List<Rune> runes = text.ToRuneList ();
-        Assert.Equal (expectedLength, TextFormatter.GetLengthThatFits (runes, columns));
+        Assert.Equal (expectedLength, TextFormatter.GetLengthThatFits (text, columns));
     }
 
     [Theory]
     [InlineData ("test", 3, 3)]
     [InlineData ("test", 4, 4)]
     [InlineData ("test", 10, 4)]
-    public void GetLengthThatFits_Runelist (string text, int columns, int expectedLength)
+    public void GetLengthThatFits_For_String (string text, int columns, int expectedLength)
     {
-        List<Rune> runes = text.ToRuneList ();
-
-        Assert.Equal (expectedLength, TextFormatter.GetLengthThatFits (runes, columns));
+        Assert.Equal (expectedLength, TextFormatter.GetLengthThatFits (text, columns));
     }
 
     [Theory]
@@ -833,7 +830,8 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
     public void GetLengthThatFits_With_Combining_Runes ()
     {
         var text = "Les Mise\u0328\u0301rables";
-        Assert.Equal (16, TextFormatter.GetLengthThatFits (text, 14));
+        Assert.Equal (14, TextFormatter.GetLengthThatFits (text, 14));
+        Assert.Equal ("Les Misę́rables", text);
     }
 
     [Fact]
@@ -841,14 +839,18 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
     {
         List<string> text = new () { "Les Mis", "e\u0328\u0301", "rables" };
         Assert.Equal (1, TextFormatter.GetMaxColsForWidth (text, 1));
+        Assert.Equal ("Les Mis", text [0]);
+        Assert.Equal ("ę́", text [1]);
+        Assert.Equal ("rables", text [^1]);
     }
 
-    //[Fact]
-    //public void GetWidestLineLength_With_Combining_Runes ()
-    //{
-    //    var text = "Les Mise\u0328\u0301rables";
-    //    Assert.Equal (1, TextFormatter.GetWidestLineLength (text, 1, 1));
-    //}
+    [Fact]
+    public void GetWidestLineLength_With_Combining_Runes ()
+    {
+        var text = "Les Mise\u0328\u0301rables";
+        Assert.Equal (14, TextFormatter.GetWidestLineLength (text, 1));
+        Assert.Equal ("Les Misę́rables", text);
+    }
 
     [Fact]
     public void Internal_Tests ()
@@ -2451,6 +2453,7 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
         Assert.Equal (expected, breakLines);
 
         // Double space Complex example - this is how VS 2022 does it
+        // which I think is not correct.
         //text = "A  sentence      has words.  ";
         //breakLines = "";
         //wrappedLines = TextFormatter.WordWrapText (text, width, preserveTrailingSpaces: true);
@@ -2762,8 +2765,7 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
                         "ฮ",
                         "ฯ",
                         "ะั",
-                        "า",
-                        "ำ"
+                        "าำ"
                     }
                 )]
     public void WordWrap_Unicode_SingleWordLine (
@@ -2798,7 +2800,17 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
         Assert.True (
                      expectedClippedWidth >= (wrappedLines.Count > 0 ? wrappedLines.Max (l => l.GetColumns ()) : 0)
                     );
-        Assert.Equal (resultLines, wrappedLines);
+
+        if (maxWidth == 1)
+        {
+            List<string> newResultLines = resultLines.ToList ();
+            newResultLines [^1] = "";
+            Assert.Equal (newResultLines, wrappedLines);
+        }
+        else
+        {
+            Assert.Equal (resultLines, wrappedLines);
+        }
     }
 
     /// <summary>WordWrap strips CRLF</summary>
@@ -3075,8 +3087,8 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
     }
 
     [Theory]
-    [InlineData (14, 1, TextDirection.LeftRight_TopBottom, "Les Misęrables")]
-    [InlineData (1, 14, TextDirection.TopBottom_LeftRight, "L\ne\ns\n \nM\ni\ns\nę\nr\na\nb\nl\ne\ns")]
+    [InlineData (14, 1, TextDirection.LeftRight_TopBottom, "Les Misę́rables")]
+    [InlineData (1, 14, TextDirection.TopBottom_LeftRight, "L\ne\ns\n \nM\ni\ns\nę́\nr\na\nb\nl\ne\ns")]
     [InlineData (
                     4,
                     4,
@@ -3085,7 +3097,7 @@ public class TextFormatterTests (ITestOutputHelper output) : FakeDriverBase
 LMre
 eias
 ssb 
- ęl "
+ ę́l "
                 )]
     public void Draw_With_Combining_Runes (int width, int height, TextDirection textDirection, string expected)
     {
@@ -3111,7 +3123,6 @@ ssb
         driver.End ();
     }
 
-
     [Theory]
     [InlineData (17, 1, TextDirection.LeftRight_TopBottom, 4, "This is a     Tab")]
     [InlineData (1, 17, TextDirection.TopBottom_LeftRight, 4, "T\nh\ni\ns\n \ni\ns\n \na\n \n \n \n \n \nT\na\nb")]
@@ -3187,7 +3198,6 @@ ssb
         driver.End ();
     }
 
-
     [Theory]
     [InlineData (17, 1, TextDirection.LeftRight_TopBottom, 4, "This is a     Tab")]
     [InlineData (1, 17, TextDirection.TopBottom_LeftRight, 4, "T\nh\ni\ns\n \ni\ns\n \na\n \n \n \n \n \nT\na\nb")]
@@ -3224,5 +3234,4 @@ ssb
 
         driver.End ();
     }
-
 }

+ 5 - 5
Tests/UnitTestsParallelizable/View/Draw/ViewClearViewportTests.cs

@@ -36,7 +36,7 @@ public class ViewClearViewportTests () : FakeDriverBase
         {
             for (int x = viewportScreen.X; x < viewportScreen.X + viewportScreen.Width; x++)
             {
-                Assert.Equal (new Rune (' '), driver.Contents [y, x].Rune);
+                Assert.Equal (" ", driver.Contents [y, x].Grapheme);
             }
         }
     }
@@ -75,7 +75,7 @@ public class ViewClearViewportTests () : FakeDriverBase
         {
             for (int x = toClear.X; x < toClear.X + toClear.Width; x++)
             {
-                Assert.Equal (new Rune (' '), driver.Contents [y, x].Rune);
+                Assert.Equal (" ", driver.Contents [y, x].Grapheme);
             }
         }
     }
@@ -154,7 +154,7 @@ public class ViewClearViewportTests () : FakeDriverBase
         {
             for (int x = viewportScreen.X; x < viewportScreen.X + viewportScreen.Width; x++)
             {
-                Assert.Equal (new Rune ('X'), driver.Contents [y, x].Rune);
+                Assert.Equal ("X", driver.Contents [y, x].Grapheme);
             }
         }
     }
@@ -309,7 +309,7 @@ public class ViewClearViewportTests () : FakeDriverBase
         {
             for (int x = viewportScreen.X; x < viewportScreen.X + viewportScreen.Width; x++)
             {
-                Assert.Equal (new Rune (' '), driver.Contents [y, x].Rune);
+                Assert.Equal (" ", driver.Contents [y, x].Grapheme);
             }
         }
     }
@@ -353,7 +353,7 @@ public class ViewClearViewportTests () : FakeDriverBase
             {
                 for (int x = toClear.X; x < toClear.X + toClear.Width; x++)
                 {
-                    Assert.Equal (new Rune (' '), driver.Contents [y, x].Rune);
+                    Assert.Equal (" ", driver.Contents[y, x].Grapheme);
                 }
             }
         }

+ 6 - 6
Tests/UnitTestsParallelizable/View/Draw/ViewDrawTextAndLineCanvasTests.cs

@@ -80,10 +80,10 @@ public class ViewDrawTextAndLineCanvasTests () : FakeDriverBase
         // Text should appear at the content location
         Point screenPos = view.ContentToScreen (Point.Empty);
 
-        Assert.Equal ('T', (char)driver.Contents [screenPos.Y, screenPos.X].Rune.Value);
-        Assert.Equal ('e', (char)driver.Contents [screenPos.Y, screenPos.X + 1].Rune.Value);
-        Assert.Equal ('s', (char)driver.Contents [screenPos.Y, screenPos.X + 2].Rune.Value);
-        Assert.Equal ('t', (char)driver.Contents [screenPos.Y, screenPos.X + 3].Rune.Value);
+        Assert.Equal ("T", driver.Contents [screenPos.Y, screenPos.X].Grapheme);
+        Assert.Equal ("e", driver.Contents [screenPos.Y, screenPos.X + 1].Grapheme);
+        Assert.Equal ("s", driver.Contents [screenPos.Y, screenPos.X + 2].Grapheme);
+        Assert.Equal ("t", driver.Contents [screenPos.Y, screenPos.X + 3].Grapheme);
     }
 
     [Fact]
@@ -273,7 +273,7 @@ public class ViewDrawTextAndLineCanvasTests () : FakeDriverBase
         // Verify the line was drawn (check for horizontal line character)
         for (int i = 0; i < 5; i++)
         {
-            Assert.NotEqual (new Rune (' '), driver.Contents [screenPos.Y, screenPos.X + i].Rune);
+            Assert.NotEqual (" ", driver.Contents [screenPos.Y, screenPos.X + i].Grapheme);
         }
     }
 
@@ -409,7 +409,7 @@ public class ViewDrawTextAndLineCanvasTests () : FakeDriverBase
         bool lineRendered = true;
         for (int i = 0; i < 5; i++)
         {
-            if (driver.Contents [screenPos.Y, screenPos.X + i].Rune.Value == ' ')
+            if (driver.Contents [screenPos.Y, screenPos.X + i].Grapheme == " ")
             {
                 lineRendered = false;
                 break;

+ 4 - 3
Tests/UnitTestsParallelizable/Views/ListViewTests.cs

@@ -2,6 +2,7 @@
 using System.Collections;
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
+using System.Text;
 using Moq;
 using Terminal.Gui;
 using UnitTests;
@@ -1278,14 +1279,14 @@ Item 6",
 
         string GetContents (int line)
         {
-            var item = "";
+            var sb = new StringBuilder ();
 
             for (var i = 0; i < 7; i++)
             {
-                item += (app.Driver?.Contents!) [line, i].Rune;
+                sb.Append ((app?.Driver?.Contents!) [line, i].Grapheme);
             }
 
-            return item;
+            return sb.ToString ();
         }
 
         top.Dispose ();

+ 3 - 3
Tests/UnitTestsParallelizable/Views/TextFieldTests.cs

@@ -622,14 +622,14 @@ public class TextFieldTests (ITestOutputHelper output) : FakeDriverBase
 
         string GetContents ()
         {
-            var item = "";
+            var sb = new StringBuilder ();
 
             for (var i = 0; i < 16; i++)
             {
-                item += driver.Contents [0, i]!.Rune;
+                sb.Append (driver.Contents! [0, i]!.Grapheme);
             }
 
-            return item;
+            return sb.ToString ();
         }
     }
 }

+ 32 - 32
Tests/UnitTestsParallelizable/Views/TextViewTests.cs

@@ -1444,23 +1444,23 @@ public class TextViewTests
     public void Internal_Tests ()
     {
         var txt = "This is a text.";
-        List<Cell> txtRunes = Cell.StringToCells (txt);
-        Assert.Equal (txt.Length, txtRunes.Count);
-        Assert.Equal ('T', txtRunes [0].Rune.Value);
-        Assert.Equal ('h', txtRunes [1].Rune.Value);
-        Assert.Equal ('i', txtRunes [2].Rune.Value);
-        Assert.Equal ('s', txtRunes [3].Rune.Value);
-        Assert.Equal (' ', txtRunes [4].Rune.Value);
-        Assert.Equal ('i', txtRunes [5].Rune.Value);
-        Assert.Equal ('s', txtRunes [6].Rune.Value);
-        Assert.Equal (' ', txtRunes [7].Rune.Value);
-        Assert.Equal ('a', txtRunes [8].Rune.Value);
-        Assert.Equal (' ', txtRunes [9].Rune.Value);
-        Assert.Equal ('t', txtRunes [10].Rune.Value);
-        Assert.Equal ('e', txtRunes [11].Rune.Value);
-        Assert.Equal ('x', txtRunes [12].Rune.Value);
-        Assert.Equal ('t', txtRunes [13].Rune.Value);
-        Assert.Equal ('.', txtRunes [^1].Rune.Value);
+        List<Cell> txtStrings = Cell.StringToCells (txt);
+        Assert.Equal (txt.Length, txtStrings.Count);
+        Assert.Equal ("T", txtStrings [0].Grapheme);
+        Assert.Equal ("h", txtStrings [1].Grapheme);
+        Assert.Equal ("i", txtStrings [2].Grapheme);
+        Assert.Equal ("s", txtStrings [3].Grapheme);
+        Assert.Equal (" ", txtStrings [4].Grapheme);
+        Assert.Equal ("i", txtStrings [5].Grapheme);
+        Assert.Equal ("s", txtStrings [6].Grapheme);
+        Assert.Equal (" ", txtStrings [7].Grapheme);
+        Assert.Equal ("a", txtStrings [8].Grapheme);
+        Assert.Equal (" ", txtStrings [9].Grapheme);
+        Assert.Equal ("t", txtStrings [10].Grapheme);
+        Assert.Equal ("e", txtStrings [11].Grapheme);
+        Assert.Equal ("x", txtStrings [12].Grapheme);
+        Assert.Equal ("t", txtStrings [13].Grapheme);
+        Assert.Equal (".", txtStrings [^1].Grapheme);
 
         var col = 0;
         Assert.True (TextModel.SetCol (ref col, 80, 79));
@@ -1469,19 +1469,19 @@ public class TextViewTests
 
         var start = 0;
         var x = 8;
-        Assert.Equal (8, TextModel.GetColFromX (txtRunes, start, x));
-        Assert.Equal ('a', txtRunes [start + x].Rune.Value);
+        Assert.Equal (8, TextModel.GetColFromX (txtStrings, start, x));
+        Assert.Equal ("a", txtStrings [start + x].Grapheme);
         start = 1;
         x = 7;
-        Assert.Equal (7, TextModel.GetColFromX (txtRunes, start, x));
-        Assert.Equal ('a', txtRunes [start + x].Rune.Value);
+        Assert.Equal (7, TextModel.GetColFromX (txtStrings, start, x));
+        Assert.Equal ("a", txtStrings [start + x].Grapheme);
 
-        Assert.Equal ((15, 15), TextModel.DisplaySize (txtRunes));
-        Assert.Equal ((6, 6), TextModel.DisplaySize (txtRunes, 1, 7));
+        Assert.Equal ((15, 15), TextModel.DisplaySize (txtStrings));
+        Assert.Equal ((6, 6), TextModel.DisplaySize (txtStrings, 1, 7));
 
-        Assert.Equal (0, TextModel.CalculateLeftColumn (txtRunes, 0, 7, 8));
-        Assert.Equal (1, TextModel.CalculateLeftColumn (txtRunes, 0, 8, 8));
-        Assert.Equal (2, TextModel.CalculateLeftColumn (txtRunes, 0, 9, 8));
+        Assert.Equal (0, TextModel.CalculateLeftColumn (txtStrings, 0, 7, 8));
+        Assert.Equal (1, TextModel.CalculateLeftColumn (txtStrings, 0, 8, 8));
+        Assert.Equal (2, TextModel.CalculateLeftColumn (txtStrings, 0, 9, 8));
 
         var tm = new TextModel ();
         tm.AddLine (0, Cell.StringToCells ("This is first line."));
@@ -2050,9 +2050,9 @@ public class TextViewTests
         Assert.True (c1.Equals (c2));
         Assert.True (c2.Equals (c1));
 
-        c1.Rune = new ('a');
+        c1.Grapheme = new ("a");
         c1.Attribute = new ();
-        c2.Rune = new ('a');
+        c2.Grapheme = new ("a");
         c2.Attribute = new ();
         Assert.True (c1.Equals (c2));
         Assert.True (c2.Equals (c1));
@@ -2063,10 +2063,10 @@ public class TextViewTests
     {
         List<Cell> cells = new ()
         {
-            new () { Rune = new ('T') },
-            new () { Rune = new ('e') },
-            new () { Rune = new ('s') },
-            new () { Rune = new ('t') }
+            new () { Grapheme = new ("T") },
+            new () { Grapheme = new ("e") },
+            new () { Grapheme = new ("s") },
+            new () { Grapheme = new ("t") }
         };
         TextView tv = CreateTextView ();
         var top = new Toplevel ();

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů