Skip to content

Commit e62e88a

Browse files
committed
Add surrogate pair (emoji) support via char-to-Rune migration
Migrate Cell.Character from char to System.Text.Rune across the entire rendering pipeline to support Unicode characters outside the BMP, including emoji (U+1F600+) and supplementary CJK ideographs. Key changes: - Cell.Character and ConsoleBuffer internal cell: char → Rune - UnicodeWidth: add Rune-aware width functions with precise Unicode 15.0 East_Asian_Width "W" ranges (not blanket emoji ranges) - MarkupParser: Rune-aware iteration for Parse, StripLength, Truncate - CharacterBuffer/ConsoleBuffer: Rune overloads for SetCell, WriteString - ConsoleBuffer: pre-process continuation cells to prevent move artifacts - Fix dropdown/bar graph controls using .Length instead of display width - Add emoji to dropdown demo as visual test fixtures - Add StringBuilderExtensions.AppendRune helper - 50+ new wide char and emoji tests (1227 total passing)
1 parent b50d681 commit e62e88a

50 files changed

Lines changed: 3519 additions & 460 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Examples/DemoApp/DemoWindows/DropdownWindow.cs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,24 @@ public static Window Create(ConsoleWindowSystem ws)
2323
.WithMargin(1, 1, 1, 0)
2424
.Build();
2525

26-
var header = Controls.Markup("[bold yellow] Meal Planner[/]")
26+
var header = Controls.Markup("[bold yellow] \U0001F37D Meal Planner[/]")
2727
.StickyTop()
2828
.Build();
2929

3030
var statusBar = Controls.Markup("[dim]Use \u2191\u2193 to browse | Type to search | Esc: Close[/]")
3131
.StickyBottom()
3232
.Build();
3333

34-
var cuisineLabel = Controls.Markup("[bold]Cuisine Type[/]").WithMargin(1, 1, 1, 0).Build();
34+
var cuisineLabel = Controls.Markup("[bold]\U0001F35C Cuisine Type[/]").WithMargin(1, 1, 1, 0).Build();
3535
var cuisine = Controls.Dropdown("Choose cuisine...")
36-
.AddItem(new DropdownItem("Japanese", "\u25cf", Color.Red) { Tag = "Japanese" })
37-
.AddItem(new DropdownItem("Italian", "\u25cf", Color.Green) { Tag = "Italian" })
38-
.AddItem(new DropdownItem("Mexican", "\u25cf", Color.Orange1) { Tag = "Mexican" })
39-
.AddItem(new DropdownItem("Indian", "\u25cf", Color.Yellow) { Tag = "Indian" })
40-
.AddItem(new DropdownItem("Thai", "\u25cf", Color.Magenta1) { Tag = "Thai" })
41-
.AddItem(new DropdownItem("Chinese", "\u25cf", Color.Red) { Tag = "Chinese" })
42-
.AddItem(new DropdownItem("French", "\u25cf", Color.Blue) { Tag = "French" })
43-
.AddItem(new DropdownItem("Korean", "\u25cf", Color.Cyan1) { Tag = "Korean" })
36+
.AddItem(new DropdownItem("\U0001F363 日本料理 Japanese", "\u25cf", Color.Red) { Tag = "Japanese" })
37+
.AddItem(new DropdownItem("\U0001F355 Italian", "\u25cf", Color.Green) { Tag = "Italian" })
38+
.AddItem(new DropdownItem("\U0001F32E Mexican", "\u25cf", Color.Orange1) { Tag = "Mexican" })
39+
.AddItem(new DropdownItem("\U0001F35B Indian", "\u25cf", Color.Yellow) { Tag = "Indian" })
40+
.AddItem(new DropdownItem("\U0001F958 Thai", "\u25cf", Color.Magenta1) { Tag = "Thai" })
41+
.AddItem(new DropdownItem("\U0001F95F 中華料理 Chinese", "\u25cf", Color.Red) { Tag = "Chinese" })
42+
.AddItem(new DropdownItem("\U0001F950 French", "\u25cf", Color.Blue) { Tag = "French" })
43+
.AddItem(new DropdownItem("\U0001F356 한국 Korean", "\u25cf", Color.Cyan1) { Tag = "Korean" })
4444
.AddItem(new DropdownItem("Mediterranean", "\u25cf", Color.Green) { Tag = "Mediterranean" })
4545
.AddItem(new DropdownItem("Greek", "\u25cf", Color.Cyan1) { Tag = "Greek" })
4646
.AddItem(new DropdownItem("Vietnamese", "\u25cf", Color.Yellow) { Tag = "Vietnamese" })
@@ -49,32 +49,32 @@ public static Window Create(ConsoleWindowSystem ws)
4949
.WithMargin(1, 0, 1, 1)
5050
.Build();
5151

52-
var dietaryLabel = Controls.Markup("[bold]Dietary Restriction[/]").WithMargin(1, 0, 1, 0).Build();
52+
var dietaryLabel = Controls.Markup("[bold]\U0001F96C Dietary Restriction[/]").WithMargin(1, 0, 1, 0).Build();
5353
var dietary = Controls.Dropdown("Choose diet...")
5454
.AddItems("None", "Vegetarian", "Vegan", "Gluten-Free", "Keto", "Paleo", "Pescatarian")
5555
.SelectedIndex(DefaultDietaryIndex)
5656
.WithMargin(1, 0, 1, 1)
5757
.Build();
5858

59-
var spiceLabel = Controls.Markup("[bold]Spice Level[/]").WithMargin(1, 0, 1, 0).Build();
59+
var spiceLabel = Controls.Markup("[bold]\U0001F336 Spice Level[/]").WithMargin(1, 0, 1, 0).Build();
6060
var spice = Controls.Dropdown("Choose spice...")
61-
.AddItem(new DropdownItem("Mild", "\u25cf", Color.Green))
62-
.AddItem(new DropdownItem("Medium", "\u25cf", Color.Yellow))
63-
.AddItem(new DropdownItem("Hot", "\u25cf", Color.Orange1))
64-
.AddItem(new DropdownItem("Extra Hot", "\u25cf", Color.Red))
65-
.AddItem(new DropdownItem("Inferno", "\u25cf", Color.DarkRed))
61+
.AddItem(new DropdownItem("\U0001F7E2 Mild", "\u25cf", Color.Green))
62+
.AddItem(new DropdownItem("\U0001F7E1 Medium", "\u25cf", Color.Yellow))
63+
.AddItem(new DropdownItem("\U0001F7E0 Hot", "\u25cf", Color.Orange1))
64+
.AddItem(new DropdownItem("\U0001F534 Extra Hot", "\u25cf", Color.Red))
65+
.AddItem(new DropdownItem("\U0001F480 Inferno", "\u25cf", Color.DarkRed))
6666
.SelectedIndex(DefaultSpiceIndex)
6767
.WithMargin(1, 0, 1, 1)
6868
.Build();
6969

70-
var budgetLabel = Controls.Markup("[bold]Budget[/]").WithMargin(1, 0, 1, 0).Build();
70+
var budgetLabel = Controls.Markup("[bold]\U0001F4B0 Budget[/]").WithMargin(1, 0, 1, 0).Build();
7171
var budget = Controls.Dropdown("Choose budget...")
7272
.AddItems("$ Budget", "$$ Moderate", "$$$ Premium", "$$$$ Luxury")
7373
.SelectedIndex(DefaultBudgetIndex)
7474
.WithMargin(1, 0, 1, 1)
7575
.Build();
7676

77-
var servingsLabel = Controls.Markup("[bold]Servings[/]").WithMargin(1, 0, 1, 0).Build();
77+
var servingsLabel = Controls.Markup("[bold]\U0001F465 Servings[/]").WithMargin(1, 0, 1, 0).Build();
7878
var servings = Controls.Dropdown("Choose servings...")
7979
.AddItems("1 person", "2 people", "3 people", "4 people", "6 people", "8 people")
8080
.SelectedIndex(DefaultServingsIndex)

SharpConsoleUI.Tests/Controls/ImageControlTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text;
12
using SharpConsoleUI.Configuration;
23
using SharpConsoleUI.Controls;
34
using SharpConsoleUI.Imaging;
@@ -63,7 +64,7 @@ public void PaintDOM_WritesHalfBlockCharacters()
6364
control.PaintDOM(charBuffer, bounds, bounds, Color.White, Color.Black);
6465

6566
var cell = charBuffer.GetCell(0, 0);
66-
Assert.Equal(ImagingDefaults.HalfBlockChar, cell.Character);
67+
Assert.Equal(new Rune(ImagingDefaults.HalfBlockChar), cell.Character);
6768
}
6869

6970
#endregion
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using Xunit;
2+
using SharpConsoleUI.Layout;
3+
using SharpConsoleUI.Drawing;
4+
using System.Text;
5+
6+
namespace SharpConsoleUI.Tests.Drawing
7+
{
8+
public class BufferTextExtensionsWideCharTests
9+
{
10+
[Fact]
11+
public void WriteStringCentered_CjkText_CenteredByDisplayWidth()
12+
{
13+
// "中文" = 2 chars, each width 2 = 4 display columns
14+
// In a 10-wide buffer, centered: (10 - 4) / 2 = 3 -> starts at column 3
15+
var buffer = new CharacterBuffer(10, 5);
16+
string cjk = "\u4E2D\u6587"; // 中文
17+
18+
buffer.WriteStringCentered(0, cjk, Color.White, Color.Black);
19+
20+
Assert.Equal(new Rune('\u4E2D'), buffer.GetCell(3, 0).Character);
21+
// Column 4 is the wide continuation of 中
22+
Assert.Equal(new Rune('\u6587'), buffer.GetCell(5, 0).Character);
23+
// Column 6 is the wide continuation of 文
24+
}
25+
26+
[Fact]
27+
public void WriteStringCentered_MixedText_CenteredCorrectly()
28+
{
29+
// "Hi中文" = H(1) + i(1) + 中(2) + 文(2) = 6 display columns
30+
// In a 12-wide buffer, centered: (12 - 6) / 2 = 3 -> starts at column 3
31+
var buffer = new CharacterBuffer(12, 5);
32+
string mixed = "Hi\u4E2D\u6587"; // Hi中文
33+
34+
buffer.WriteStringCentered(0, mixed, Color.White, Color.Black);
35+
36+
Assert.Equal(new Rune('H'), buffer.GetCell(3, 0).Character);
37+
Assert.Equal(new Rune('i'), buffer.GetCell(4, 0).Character);
38+
Assert.Equal(new Rune('\u4E2D'), buffer.GetCell(5, 0).Character);
39+
// Column 6 is continuation of 中
40+
Assert.Equal(new Rune('\u6587'), buffer.GetCell(7, 0).Character);
41+
// Column 8 is continuation of 文
42+
}
43+
44+
[Fact]
45+
public void WriteStringRight_CjkText_AlignedByDisplayWidth()
46+
{
47+
// "中文" = 4 display columns
48+
// In a 10-wide buffer, right-aligned: 10 - 4 = 6 -> starts at column 6
49+
var buffer = new CharacterBuffer(10, 5);
50+
string cjk = "\u4E2D\u6587"; // 中文
51+
52+
buffer.WriteStringRight(0, cjk, Color.White, Color.Black);
53+
54+
Assert.Equal(new Rune('\u4E2D'), buffer.GetCell(6, 0).Character);
55+
// Column 7 is continuation of 中
56+
Assert.Equal(new Rune('\u6587'), buffer.GetCell(8, 0).Character);
57+
// Column 9 is continuation of 文
58+
}
59+
60+
[Fact]
61+
public void WriteStringRight_MixedText_AlignedCorrectly()
62+
{
63+
// "AB中" = A(1) + B(1) + 中(2) = 4 display columns
64+
// In a 10-wide buffer, right-aligned: 10 - 4 = 6 -> starts at column 6
65+
var buffer = new CharacterBuffer(10, 5);
66+
string mixed = "AB\u4E2D"; // AB中
67+
68+
buffer.WriteStringRight(0, mixed, Color.White, Color.Black);
69+
70+
Assert.Equal(new Rune('A'), buffer.GetCell(6, 0).Character);
71+
Assert.Equal(new Rune('B'), buffer.GetCell(7, 0).Character);
72+
Assert.Equal(new Rune('\u4E2D'), buffer.GetCell(8, 0).Character);
73+
// Column 9 is continuation of 中
74+
}
75+
76+
[Fact]
77+
public void WrapText_CjkText_WrapsAtDisplayWidth()
78+
{
79+
// "中文字体" = 4 chars, each width 2 = 8 display columns
80+
// With width=4, each line fits 2 CJK chars (4 columns)
81+
// Should wrap into 2 lines: "中文" and "字体"
82+
var buffer = new CharacterBuffer(20, 5);
83+
string cjk = "\u4E2D\u6587\u5B57\u4F53"; // 中文字体
84+
85+
buffer.WriteWrappedText(0, 0, 4, cjk, Color.White, Color.Black);
86+
87+
// First line: 中文
88+
Assert.Equal(new Rune('\u4E2D'), buffer.GetCell(0, 0).Character);
89+
Assert.Equal(new Rune('\u6587'), buffer.GetCell(2, 0).Character);
90+
91+
// Second line: 字体
92+
Assert.Equal(new Rune('\u5B57'), buffer.GetCell(0, 1).Character);
93+
Assert.Equal(new Rune('\u4F53'), buffer.GetCell(2, 1).Character);
94+
}
95+
96+
[Fact]
97+
public void WrapText_MixedText_WideCharDoesntSplitAcrossLines()
98+
{
99+
// With width=5, "Hello中文" should wrap:
100+
// "Hello" (5 cols) on line 0, "中文" (4 cols) on line 1
101+
// The wide char 中 should not be split across lines
102+
var buffer = new CharacterBuffer(20, 5);
103+
string mixed = "Hello \u4E2D\u6587"; // "Hello 中文"
104+
105+
buffer.WriteWrappedText(0, 0, 5, mixed, Color.White, Color.Black);
106+
107+
// First line: "Hello"
108+
Assert.Equal(new Rune('H'), buffer.GetCell(0, 0).Character);
109+
Assert.Equal(new Rune('o'), buffer.GetCell(4, 0).Character);
110+
111+
// Second line: "中文" - wide chars should not be split
112+
Assert.Equal(new Rune('\u4E2D'), buffer.GetCell(0, 1).Character);
113+
Assert.Equal(new Rune('\u6587'), buffer.GetCell(2, 1).Character);
114+
}
115+
}
116+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using Xunit;
2+
using SharpConsoleUI.Helpers;
3+
using SharpConsoleUI.Parsing;
4+
5+
namespace SharpConsoleUI.Tests.Helpers
6+
{
7+
public class TextTruncationHelperWideCharTests
8+
{
9+
[Fact]
10+
public void Truncate_CjkText_TruncatesByDisplayWidth()
11+
{
12+
// "中文字" = 3 CJK chars, each width 2 = 6 display columns
13+
// maxWidth=5: ellipsis "..." = 3 cols, available for content = 2
14+
// availableForContent (2) < MinContentCharsBeforeEllipsis (3),
15+
// so falls back to MarkupParser.Truncate(text, 5)
16+
// which fits "中文" (4 cols) but not "中文字" (6 cols), yielding "中文"
17+
string cjk = "\u4E2D\u6587\u5B57"; // 中文字
18+
19+
string result = TextTruncationHelper.Truncate(cjk, 5);
20+
21+
// Should be truncated (original is 6 cols, maxWidth is 5)
22+
int resultWidth = MarkupParser.StripLength(result);
23+
Assert.True(resultWidth <= 5, $"Result width {resultWidth} exceeds maxWidth 5");
24+
Assert.True(result.Length < cjk.Length || result != cjk,
25+
"Result should be truncated");
26+
}
27+
28+
[Fact]
29+
public void Truncate_WideCharAtBoundary_TruncatesBefore()
30+
{
31+
// "A中文" = A(1) + 中(2) + 文(2) = 5 display columns
32+
// maxWidth=4: ellipsis "..." = 3 cols, available for content = 1
33+
// availableForContent (1) < MinContentCharsBeforeEllipsis (3),
34+
// so falls back to MarkupParser.Truncate(text, 4) which gives "A中" (3 cols)
35+
// The wide char 文 should not be partially included
36+
string mixed = "A\u4E2D\u6587"; // A中文
37+
38+
string result = TextTruncationHelper.Truncate(mixed, 4);
39+
40+
int resultWidth = MarkupParser.StripLength(result);
41+
Assert.True(resultWidth <= 4, $"Result width {resultWidth} exceeds maxWidth 4");
42+
// The result should not split a wide character
43+
}
44+
45+
[Fact]
46+
public void Truncate_MixedText_CorrectEllipsisPosition()
47+
{
48+
// "Hello中文世界" = 5 + 8 = 13 display columns
49+
// maxWidth=10: ellipsis "..." = 3, available for content = 7
50+
// Truncated content should be 7 display cols + "..."
51+
string mixed = "Hello\u4E2D\u6587\u4E16\u754C"; // Hello中文世界
52+
53+
string result = TextTruncationHelper.Truncate(mixed, 10);
54+
55+
int resultWidth = MarkupParser.StripLength(result);
56+
Assert.True(resultWidth <= 10, $"Result width {resultWidth} exceeds maxWidth 10");
57+
Assert.Contains("...", result);
58+
}
59+
60+
[Fact]
61+
public void Truncate_ExactFit_NoTruncation()
62+
{
63+
// "中文" = 4 display columns, maxWidth=4 -> exact fit, no truncation
64+
string cjk = "\u4E2D\u6587"; // 中文
65+
66+
string result = TextTruncationHelper.Truncate(cjk, 4);
67+
68+
Assert.Equal(cjk, result);
69+
}
70+
71+
[Fact]
72+
public void Truncate_OneLessThanWide_Truncates()
73+
{
74+
// "中文" = 4 display columns, maxWidth=3 -> must truncate
75+
string cjk = "\u4E2D\u6587"; // 中文
76+
77+
string result = TextTruncationHelper.Truncate(cjk, 3);
78+
79+
int resultWidth = MarkupParser.StripLength(result);
80+
Assert.True(resultWidth <= 3, $"Result width {resultWidth} exceeds maxWidth 3");
81+
Assert.NotEqual(cjk, result);
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)