From 58e7d49883c39983e923d17323ca5ba9f7863614 Mon Sep 17 00:00:00 2001 From: Fabian Wetzel Date: Tue, 14 Apr 2026 00:33:59 +0200 Subject: [PATCH 1/3] added failing test for wide/special unicode chars (#1847) --- ...nder_Wide_Emoji_Hearts.Output.verified.txt | 6 ++++++ src/Spectre.Console.Tests/Unit/CellTests.cs | 18 ++++++++++++++++++ .../Unit/Widgets/Table/TableTests.cs | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/Spectre.Console.Tests/Expectations/Widgets/Table/Render_Wide_Emoji_Hearts.Output.verified.txt create mode 100644 src/Spectre.Console.Tests/Unit/CellTests.cs diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Table/Render_Wide_Emoji_Hearts.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Table/Render_Wide_Emoji_Hearts.Output.verified.txt new file mode 100644 index 000000000..dec848135 --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/Widgets/Table/Render_Wide_Emoji_Hearts.Output.verified.txt @@ -0,0 +1,6 @@ +┌──────────┬────────┬──────────────────────┐ +│ hearts │ length │ LengthInTextElements │ +├──────────┼────────┼──────────────────────┤ +│ wide ❤️ │ 2 │ 1 │ +│ normal ♥ │ 1 │ 1 │ +└──────────┴────────┴──────────────────────┘ diff --git a/src/Spectre.Console.Tests/Unit/CellTests.cs b/src/Spectre.Console.Tests/Unit/CellTests.cs new file mode 100644 index 000000000..db421e4c4 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/CellTests.cs @@ -0,0 +1,18 @@ +namespace Spectre.Console.Tests.Unit; + +public sealed class CellTests +{ + [Theory] + [InlineData("A", 1)] // ASCII + [InlineData("中", 2)] // CJK wide + [InlineData("♥", 1)] // U+2665 text heart, no variation selector + [InlineData("❤️", 2)] // U+2764 + U+FE0F emoji variation selector + [InlineData("👨‍👩‍👧", 2)] // ZWJ family sequence + [InlineData("❤️‍🔥", 2)] // U+2764 + FE0F + ZWJ + U+1F525 (heart on fire) + [InlineData("🇩🇪", 2)] // Regional Indicator pair (flag) + [InlineData("", 0)] // empty string + public void GetCellLength_Returns_Correct_Display_Width(string text, int expectedWidth) + { + Cell.GetCellLength(text).ShouldBe(expectedWidth); + } +} diff --git a/src/Spectre.Console.Tests/Unit/Widgets/Table/TableTests.cs b/src/Spectre.Console.Tests/Unit/Widgets/Table/TableTests.cs index be1b8094b..b7ed372c6 100644 --- a/src/Spectre.Console.Tests/Unit/Widgets/Table/TableTests.cs +++ b/src/Spectre.Console.Tests/Unit/Widgets/Table/TableTests.cs @@ -1034,4 +1034,23 @@ public void Should_Not_Throw_When_Rendering_Multiple_Long_CJK_Headers_In_Narrow_ // Then result.ShouldBeNull(); } + + [Fact] + [Expectation("Render_Wide_Emoji_Hearts")] + [GitHubIssue("https://github.com/spectreconsole/spectre.console/issues/1847")] + public Task Should_Render_Table_With_Wide_Emoji_Correctly() + { + // Given + var console = new TestConsole(); + var table = new Table(); + table.AddColumns("hearts", "length", "LengthInTextElements"); + table.AddRow("wide ❤️", "2", "1"); + table.AddRow("normal ♥", "1", "1"); + + // When + console.Write(table); + + // Then + return Verifier.Verify(console.Output); + } } \ No newline at end of file From fbeae037051ecfd519212f5464e57b28d0ffca99 Mon Sep 17 00:00:00 2001 From: Fabian Wetzel Date: Tue, 14 Apr 2026 01:08:24 +0200 Subject: [PATCH 2/3] added support for variation selectors, ZWJ sequences, and surrogate pairs in length calculation ( fixes #1847 ) --- src/Spectre.Console.Tests/Unit/CellTests.cs | 2 ++ src/Spectre.Console/Internal/Cell.cs | 11 +++++-- src/Spectre.Console/Rendering/Segment.cs | 32 +++++++++++++-------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/Spectre.Console.Tests/Unit/CellTests.cs b/src/Spectre.Console.Tests/Unit/CellTests.cs index db421e4c4..19c43cf8a 100644 --- a/src/Spectre.Console.Tests/Unit/CellTests.cs +++ b/src/Spectre.Console.Tests/Unit/CellTests.cs @@ -11,8 +11,10 @@ public sealed class CellTests [InlineData("❤️‍🔥", 2)] // U+2764 + FE0F + ZWJ + U+1F525 (heart on fire) [InlineData("🇩🇪", 2)] // Regional Indicator pair (flag) [InlineData("", 0)] // empty string + [InlineData("Hello World", 11)] public void GetCellLength_Returns_Correct_Display_Width(string text, int expectedWidth) { Cell.GetCellLength(text).ShouldBe(expectedWidth); } + } diff --git a/src/Spectre.Console/Internal/Cell.cs b/src/Spectre.Console/Internal/Cell.cs index d2f46fc75..6d49f5fe4 100644 --- a/src/Spectre.Console/Internal/Cell.cs +++ b/src/Spectre.Console/Internal/Cell.cs @@ -23,10 +23,11 @@ static Cell() #endif } - public static int GetCellLength(string text) => GetCellLength(text.AsSpan()); - - public static int GetCellLength(ReadOnlySpan text) + public static int GetCellLength(string text) { +#if !NETSTANDARD2_0 + return UnicodeCalculator.GetWidth(text); +#else var sum = 0; foreach (var rune in text) { @@ -34,8 +35,12 @@ public static int GetCellLength(ReadOnlySpan text) } return sum; +#endif } + public static int GetCellLength(ReadOnlySpan text) + => GetCellLength(text.ToString()); + public static int GetCellLength(char rune) { // TODO: We need to figure out why Segment.SplitLines fails diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 6f30e9970..6f840a424 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -108,6 +108,7 @@ public int CellCount() { return 0; } + if (Text == "\n") return 1; return Cell.GetCellLength(Text); } @@ -160,10 +161,12 @@ public Segment StripLineEndings() if (offset > 0) { var accumulated = 0; - foreach (var character in Text) + var enumerator = StringInfo.GetTextElementEnumerator(Text); + while (enumerator.MoveNext()) { - index++; - accumulated += Cell.GetCellLength(character); + var cluster = enumerator.GetTextElement(); + accumulated += Cell.GetCellLength(cluster); + index += cluster.Length; if (accumulated >= offset) { break; @@ -429,16 +432,18 @@ public static List Truncate(IEnumerable segments, int maxWidth var builder = new StringBuilder(); var accumulatedCellWidth = 0; - foreach (var character in segment.Text) + var truncateEnumerator = StringInfo.GetTextElementEnumerator(segment.Text); + while (truncateEnumerator.MoveNext()) { - var characterWidth = UnicodeCalculator.GetWidth(character); - if (accumulatedCellWidth + characterWidth > maxWidth) + var cluster = truncateEnumerator.GetTextElement(); + var clusterWidth = Cell.GetCellLength(cluster); + if (accumulatedCellWidth + clusterWidth > maxWidth) { break; } - builder.Append(character); - accumulatedCellWidth += characterWidth; + builder.Append(cluster); + accumulatedCellWidth += clusterWidth; } if (builder.Length == 0) @@ -571,17 +576,20 @@ internal static List SplitSegment(string text, int maxCellLength) var length = 0; var sb = new StringBuilder(); - foreach (var ch in text) + var splitEnumerator = StringInfo.GetTextElementEnumerator(text); + while (splitEnumerator.MoveNext()) { - if (length + UnicodeCalculator.GetWidth(ch) > maxCellLength) + var cluster = splitEnumerator.GetTextElement(); + var clusterWidth = Cell.GetCellLength(cluster); + if (length + clusterWidth > maxCellLength) { list.Add(sb.ToString()); sb.Clear(); length = 0; } - length += UnicodeCalculator.GetWidth(ch); - sb.Append(ch); + length += clusterWidth; + sb.Append(cluster); } list.Add(sb.ToString()); From 2b560aec6e421c8708e505d6951a2b2963f3ede2 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Thu, 16 Apr 2026 00:53:30 +0200 Subject: [PATCH 3/3] Update src/Spectre.Console/Rendering/Segment.cs --- src/Spectre.Console/Rendering/Segment.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 6f840a424..63b87754b 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -108,7 +108,11 @@ public int CellCount() { return 0; } - if (Text == "\n") return 1; + + if (Text == "\n") + { + return 1; + } return Cell.GetCellLength(Text); }