@@ -24,6 +24,12 @@ export interface IRenderable {
2424 /** Returns true if a full redraw is needed (e.g., screen change) */
2525 needsFullRedraw ?( ) : boolean ;
2626 clearDirty ( ) : void ;
27+ /**
28+ * Get the full grapheme string for a cell at (row, col).
29+ * For cells with grapheme_len > 0, this returns all codepoints combined.
30+ * For simple cells, returns the single character.
31+ */
32+ getGraphemeString ?( row : number , col : number ) : string ;
2733}
2834
2935export interface IScrollbackProvider {
@@ -103,6 +109,9 @@ export class CanvasRenderer {
103109 // Viewport tracking (for scrolling)
104110 private lastViewportY : number = 0 ;
105111
112+ // Current buffer being rendered (for grapheme lookups)
113+ private currentBuffer : IRenderable | null = null ;
114+
106115 // Selection manager (for rendering selection overlay)
107116 private selectionManager ?: SelectionManager ;
108117
@@ -253,6 +262,9 @@ export class CanvasRenderer {
253262 scrollbackProvider ?: IScrollbackProvider ,
254263 scrollbarOpacity : number = 1
255264 ) : void {
265+ // Store buffer reference for grapheme lookups in renderCell
266+ this . currentBuffer = buffer ;
267+
256268 // getCursor() calls update() internally to ensure fresh state.
257269 // Multiple update() calls are safe - dirty state persists until clearDirty().
258270 const cursor = buffer . getCursor ( ) ;
@@ -398,15 +410,29 @@ export class CanvasRenderer {
398410 // Track if anything was actually rendered
399411 let anyLinesRendered = false ;
400412
401- // Render each line
413+ // Determine which rows need rendering.
414+ // We also include adjacent rows (above and below) for each dirty row to handle
415+ // glyph overflow - tall glyphs like Devanagari vowel signs can extend into
416+ // adjacent rows' visual space.
417+ const rowsToRender = new Set < number > ( ) ;
402418 for ( let y = 0 ; y < dims . rows ; y ++ ) {
403419 // When scrolled, always force render all lines since we're showing scrollback
404420 const needsRender =
405421 viewportY > 0
406422 ? true
407423 : forceAll || buffer . isRowDirty ( y ) || selectionRows . has ( y ) || hyperlinkRows . has ( y ) ;
408424
409- if ( ! needsRender ) {
425+ if ( needsRender ) {
426+ rowsToRender . add ( y ) ;
427+ // Include adjacent rows to handle glyph overflow
428+ if ( y > 0 ) rowsToRender . add ( y - 1 ) ;
429+ if ( y < dims . rows - 1 ) rowsToRender . add ( y + 1 ) ;
430+ }
431+ }
432+
433+ // Render each line
434+ for ( let y = 0 ; y < dims . rows ; y ++ ) {
435+ if ( ! rowsToRender . has ( y ) ) {
410436 continue ;
411437 }
412438
@@ -470,61 +496,97 @@ export class CanvasRenderer {
470496 }
471497
472498 /**
473- * Render a single line
499+ * Render a single line using two-pass approach:
500+ * 1. First pass: Draw all cell backgrounds
501+ * 2. Second pass: Draw all cell text and decorations
502+ *
503+ * This two-pass approach is necessary for proper rendering of complex scripts
504+ * like Devanagari where diacritics (like vowel sign ि) can extend LEFT of the
505+ * base character into the previous cell's visual area. If we draw backgrounds
506+ * and text in a single pass (cell by cell), the background of cell N would
507+ * cover any left-extending portions of graphemes from cell N-1.
474508 */
475509 private renderLine ( line : GhosttyCell [ ] , y : number , cols : number ) : void {
476510 const lineY = y * this . metrics . height ;
477511
478- // Clear line background
512+ // Clear line background with theme color.
513+ // We clear just the cell area - glyph overflow is handled by also
514+ // redrawing adjacent rows (see render() method).
479515 this . ctx . fillStyle = this . theme . background ;
480516 this . ctx . fillRect ( 0 , lineY , cols * this . metrics . width , this . metrics . height ) ;
481517
482- // Render each cell
518+ // PASS 1: Draw all cell backgrounds first
519+ // This ensures all backgrounds are painted before any text, allowing text
520+ // to "bleed" across cell boundaries without being covered by adjacent backgrounds
483521 for ( let x = 0 ; x < line . length ; x ++ ) {
484522 const cell = line [ x ] ;
523+ if ( cell . width === 0 ) continue ; // Skip spacer cells for wide characters
524+ this . renderCellBackground ( cell , x , y ) ;
525+ }
485526
486- // Skip padding cells for wide characters
487- if ( cell . width === 0 ) {
488- continue ;
489- }
490-
491- this . renderCell ( cell , x , y ) ;
527+ // PASS 2: Draw all cell text and decorations
528+ // Now text can safely extend beyond cell boundaries (for complex scripts)
529+ for ( let x = 0 ; x < line . length ; x ++ ) {
530+ const cell = line [ x ] ;
531+ if ( cell . width === 0 ) continue ; // Skip spacer cells for wide characters
532+ this . renderCellText ( cell , x , y ) ;
492533 }
493534 }
494535
495536 /**
496- * Render a single cell
537+ * Render a cell's background only (Pass 1 of two-pass rendering)
497538 */
498- private renderCell ( cell : GhosttyCell , x : number , y : number ) : void {
539+ private renderCellBackground ( cell : GhosttyCell , x : number , y : number ) : void {
499540 const cellX = x * this . metrics . width ;
500541 const cellY = y * this . metrics . height ;
501- const cellWidth = this . metrics . width * cell . width ; // Handle wide chars (width=2)
542+ const cellWidth = this . metrics . width * cell . width ;
502543
503- // Extract colors and handle inverse
504- let fg_r = cell . fg_r ,
505- fg_g = cell . fg_g ,
506- fg_b = cell . fg_b ;
544+ // Extract background color and handle inverse
507545 let bg_r = cell . bg_r ,
508546 bg_g = cell . bg_g ,
509547 bg_b = cell . bg_b ;
510548
511549 if ( cell . flags & CellFlags . INVERSE ) {
512- [ fg_r , fg_g , fg_b , bg_r , bg_g , bg_b ] = [ bg_r , bg_g , bg_b , fg_r , fg_g , fg_b ] ;
550+ // When inverted, background becomes foreground
551+ bg_r = cell . fg_r ;
552+ bg_g = cell . fg_g ;
553+ bg_b = cell . fg_b ;
513554 }
514555
515556 // Only draw cell background if it's different from the default (black)
516- // This lets the theme background (drawn in renderLine ) show through for default cells
557+ // This lets the theme background (drawn earlier ) show through for default cells
517558 const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0 ;
518559 if ( ! isDefaultBg ) {
519560 this . ctx . fillStyle = this . rgbToCSS ( bg_r , bg_g , bg_b ) ;
520561 this . ctx . fillRect ( cellX , cellY , cellWidth , this . metrics . height ) ;
521562 }
563+ }
564+
565+ /**
566+ * Render a cell's text and decorations (Pass 2 of two-pass rendering)
567+ */
568+ private renderCellText ( cell : GhosttyCell , x : number , y : number ) : void {
569+ const cellX = x * this . metrics . width ;
570+ const cellY = y * this . metrics . height ;
571+ const cellWidth = this . metrics . width * cell . width ;
522572
523573 // Skip rendering if invisible
524574 if ( cell . flags & CellFlags . INVISIBLE ) {
525575 return ;
526576 }
527577
578+ // Extract colors and handle inverse
579+ let fg_r = cell . fg_r ,
580+ fg_g = cell . fg_g ,
581+ fg_b = cell . fg_b ;
582+
583+ if ( cell . flags & CellFlags . INVERSE ) {
584+ // When inverted, foreground becomes background
585+ fg_r = cell . bg_r ;
586+ fg_g = cell . bg_g ;
587+ fg_b = cell . bg_b ;
588+ }
589+
528590 // Set text style
529591 let fontStyle = '' ;
530592 if ( cell . flags & CellFlags . ITALIC ) fontStyle += 'italic ' ;
@@ -542,7 +604,16 @@ export class CanvasRenderer {
542604 // Draw text
543605 const textX = cellX ;
544606 const textY = cellY + this . metrics . baseline ;
545- const char = String . fromCodePoint ( cell . codepoint || 32 ) ; // Default to space if null
607+
608+ // Get the character to render - use grapheme lookup for complex scripts
609+ let char : string ;
610+ if ( cell . grapheme_len > 0 && this . currentBuffer ?. getGraphemeString ) {
611+ // Cell has additional codepoints - get full grapheme cluster
612+ char = this . currentBuffer . getGraphemeString ( y , x ) ;
613+ } else {
614+ // Simple cell - single codepoint
615+ char = String . fromCodePoint ( cell . codepoint || 32 ) ; // Default to space if null
616+ }
546617 this . ctx . fillText ( char , textX , textY ) ;
547618
548619 // Reset alpha
0 commit comments