Skip to content

Commit 95d1027

Browse files
committed
Fix MultilineEditControl crash and misalignment with Unicode emoji
Rune-aware rendering loop that properly handles surrogate pairs, wide characters, and zero-width combining/variation selectors. VS16 (U+FE0F) now correctly widens emoji from 1 to 2 columns. Guard surrogate chars in SetNarrowCell to prevent Rune constructor crash.
1 parent aee94fa commit 95d1027

2 files changed

Lines changed: 118 additions & 18 deletions

File tree

SharpConsoleUI/Controls/MultilineEdit/MultilineEditControl.Rendering.cs

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// License: MIT
77
// -----------------------------------------------------------------------
88

9+
using System.Text;
910
using SharpConsoleUI.Configuration;
1011
using SharpConsoleUI.Drivers;
1112
using SharpConsoleUI.Helpers;
@@ -291,12 +292,6 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
291292
visibleLine = string.Empty;
292293
}
293294

294-
// Pad or truncate to effective width
295-
if (visibleLine.Length < effectiveWidth)
296-
visibleLine = visibleLine.PadRight(effectiveWidth);
297-
else if (visibleLine.Length > effectiveWidth)
298-
visibleLine = visibleLine.Substring(0, effectiveWidth);
299-
300295
int hScrollForCalc = (_wrapMode == WrapMode.NoWrap) ? _horizontalScrollOffset : 0;
301296

302297
// Determine current line highlight (custom line highlights take precedence)
@@ -310,11 +305,97 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
310305
lineBg = bgColor;
311306

312307
// Paint each character with selection, syntax, and whitespace handling
313-
for (int charPos = 0; charPos < effectiveWidth; charPos++)
308+
// Use Rune-aware iteration to properly handle surrogate pairs,
309+
// wide characters, and zero-width combining/variation selectors
310+
int col = 0; // display column
311+
int srcCharIdx = 0; // char index into visibleLine
312+
Rune? lastRenderedRune = null; // track for VS16 widening
313+
314+
while (col < effectiveWidth)
314315
{
315-
int actualCharPos = charPos + wl.SourceCharOffset + hScrollForCalc;
316-
bool isSelected = false;
316+
Rune rune;
317+
int runeCharLen;
318+
int runeDisplayWidth;
319+
bool isContentChar;
320+
int actualCharPos;
317321

322+
if (srcCharIdx < visibleLine.Length)
323+
{
324+
// Decode a Rune from the string (handles surrogate pairs)
325+
if (char.IsHighSurrogate(visibleLine[srcCharIdx]) &&
326+
srcCharIdx + 1 < visibleLine.Length &&
327+
char.IsLowSurrogate(visibleLine[srcCharIdx + 1]))
328+
{
329+
rune = new Rune(visibleLine[srcCharIdx], visibleLine[srcCharIdx + 1]);
330+
runeCharLen = 2;
331+
}
332+
else if (char.IsSurrogate(visibleLine[srcCharIdx]))
333+
{
334+
rune = new Rune('\uFFFD');
335+
runeCharLen = 1;
336+
}
337+
else
338+
{
339+
rune = new Rune(visibleLine[srcCharIdx]);
340+
runeCharLen = 1;
341+
}
342+
343+
runeDisplayWidth = UnicodeWidth.GetRuneWidth(rune);
344+
isContentChar = true;
345+
actualCharPos = srcCharIdx + wl.SourceCharOffset + hScrollForCalc;
346+
srcCharIdx += runeCharLen;
347+
348+
// Handle zero-width characters (combining marks, variation selectors)
349+
if (runeDisplayWidth == 0)
350+
{
351+
if (col > 0 && UnicodeWidth.IsVS16(rune) &&
352+
lastRenderedRune.HasValue &&
353+
UnicodeWidth.IsVs16Widened(lastRenderedRune.Value) &&
354+
!UnicodeWidth.IsWideRune(lastRenderedRune.Value))
355+
{
356+
// VS16 widens previous character from 1→2 columns
357+
int prevCellX = contentStartX + col - 1;
358+
if (prevCellX >= clipRect.X && prevCellX < clipRect.Right)
359+
{
360+
var prev = buffer.GetCell(prevCellX, paintY);
361+
prev.AppendCombiner(rune);
362+
buffer.SetCell(prevCellX, paintY, prev);
363+
}
364+
// Place continuation cell at current column
365+
int cellX = contentStartX + col;
366+
if (cellX >= clipRect.X && cellX < clipRect.Right)
367+
{
368+
var prev = buffer.GetCell(contentStartX + col - 1, paintY);
369+
buffer.SetCell(cellX, paintY, new Cell(' ', prev.Foreground, prev.Background) { IsWideContinuation = true });
370+
}
371+
lastRenderedRune = null;
372+
col++;
373+
}
374+
else if (col > 0)
375+
{
376+
// Regular combining mark — attach to previous cell
377+
int prevCellX = contentStartX + col - 1;
378+
if (prevCellX >= clipRect.X && prevCellX < clipRect.Right)
379+
{
380+
var prev = buffer.GetCell(prevCellX, paintY);
381+
prev.AppendCombiner(rune);
382+
buffer.SetCell(prevCellX, paintY, prev);
383+
}
384+
}
385+
// Don't advance col for zero-width chars (except VS16 widening above)
386+
continue;
387+
}
388+
}
389+
else
390+
{
391+
rune = new Rune(' ');
392+
runeDisplayWidth = 1;
393+
isContentChar = false;
394+
actualCharPos = col + wl.SourceCharOffset + hScrollForCalc;
395+
}
396+
397+
// Selection check
398+
bool isSelected = false;
318399
if (_hasSelection && wl.SourceLineIndex >= selStartY && wl.SourceLineIndex <= selEndY)
319400
{
320401
if (wl.SourceLineIndex == selStartY && wl.SourceLineIndex == selEndY)
@@ -327,9 +408,6 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
327408
isSelected = true;
328409
}
329410

330-
char c = charPos < visibleLine.Length ? visibleLine[charPos] : ' ';
331-
bool isContentChar = charPos + hScrollForCalc < wl.DisplayText.Length;
332-
333411
// Color priority: Selection > Search Match > Syntax > Visible whitespace > Default
334412
Color charFg;
335413
Color charBg;
@@ -351,9 +429,9 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
351429
{
352430
charBg = lineBg;
353431

354-
if (_showWhitespace && c == ' ' && isContentChar)
432+
if (_showWhitespace && rune.Value == ' ' && isContentChar)
355433
{
356-
c = ControlDefaults.WhitespaceSpaceChar;
434+
rune = new Rune(ControlDefaults.WhitespaceSpaceChar);
357435
charFg = Color.Grey37;
358436
}
359437
else if (_syntaxHighlighter != null)
@@ -367,11 +445,33 @@ public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutR
367445
}
368446
}
369447

370-
int cellX = contentStartX + charPos;
371-
if (cellX >= clipRect.X && cellX < clipRect.Right)
448+
int cellX2 = contentStartX + col;
449+
450+
if (runeDisplayWidth == 2 && col + 1 < effectiveWidth)
451+
{
452+
// Wide character — write base cell + continuation cell
453+
if (cellX2 >= clipRect.X && cellX2 < clipRect.Right)
454+
buffer.SetCell(cellX2, paintY, new Cell(rune, charFg, charBg));
455+
if (cellX2 + 1 >= clipRect.X && cellX2 + 1 < clipRect.Right)
456+
buffer.SetCell(cellX2 + 1, paintY, new Cell(' ', charFg, charBg) { IsWideContinuation = true });
457+
col += 2;
458+
}
459+
else if (runeDisplayWidth == 2)
372460
{
373-
buffer.SetNarrowCell(cellX, paintY, c, charFg, charBg);
461+
// Wide character at right edge — can't fit, show space
462+
if (cellX2 >= clipRect.X && cellX2 < clipRect.Right)
463+
buffer.SetNarrowCell(cellX2, paintY, ' ', charFg, charBg);
464+
col++;
374465
}
466+
else
467+
{
468+
// Narrow character (BMP or non-BMP single-width)
469+
if (cellX2 >= clipRect.X && cellX2 < clipRect.Right)
470+
buffer.SetNarrowCell(cellX2, paintY, rune, charFg, charBg);
471+
col++;
472+
}
473+
474+
lastRenderedRune = rune;
375475
}
376476
}
377477
else

SharpConsoleUI/Layout/CharacterBuffer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ private void ExpandDirtyRegion(int x, int y)
194194
/// Do NOT use for cells from MarkupParser.Parse — use SetCell(Cell) instead to preserve flags.
195195
/// </summary>
196196
public void SetNarrowCell(int x, int y, char character, Color foreground, Color background)
197-
=> SetNarrowCell(x, y, new Rune(character), foreground, background);
197+
=> SetNarrowCell(x, y, char.IsSurrogate(character) ? new Rune('\uFFFD') : new Rune(character), foreground, background);
198198

199199
/// <summary>
200200
/// Sets a narrow (width-1) cell at the specified position with a Rune character.

0 commit comments

Comments
 (0)