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..19c43cf8a --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/CellTests.cs @@ -0,0 +1,20 @@ +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 + [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.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 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..63b87754b 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -109,6 +109,11 @@ public int CellCount() return 0; } + if (Text == "\n") + { + return 1; + } + return Cell.GetCellLength(Text); } @@ -160,10 +165,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 +436,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 +580,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());