66// License: MIT
77// -----------------------------------------------------------------------
88
9+ using System . Text ;
910using SharpConsoleUI . Configuration ;
1011using SharpConsoleUI . Drivers ;
1112using 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
0 commit comments