Skip to content

Commit 8331f42

Browse files
authored
fix: support unicode grapheme cluster rendering for complex scripts #85
1 parent 3dd4aef commit 8331f42

File tree

7 files changed

+462
-35
lines changed

7 files changed

+462
-35
lines changed

lib/buffer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export class Buffer implements IBuffer {
114114
flags: 0,
115115
width: 1,
116116
hyperlink_id: 0,
117+
grapheme_len: 0,
117118
};
118119
this.nullCell = new BufferCell(nullCellData, 0);
119120
}
@@ -253,6 +254,7 @@ export class BufferLine implements IBufferLine {
253254
flags: 0,
254255
width: 1,
255256
hyperlink_id: 0,
257+
grapheme_len: 0,
256258
},
257259
x
258260
);

lib/ghostty.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ export class GhosttyTerminal {
593593
flags: u8[cellOffset + 10],
594594
width: u8[cellOffset + 11],
595595
hyperlink_id: view.getUint16(cellOffset + 12, true),
596+
grapheme_len: u8[cellOffset + 14],
596597
});
597598
}
598599

@@ -671,6 +672,7 @@ export class GhosttyTerminal {
671672
flags: 0,
672673
width: 1,
673674
hyperlink_id: 0,
675+
grapheme_len: 0,
674676
});
675677
}
676678
}
@@ -694,9 +696,89 @@ export class GhosttyTerminal {
694696
cell.flags = u8[offset + 10];
695697
cell.width = u8[offset + 11];
696698
cell.hyperlink_id = view.getUint16(offset + 12, true);
699+
cell.grapheme_len = u8[offset + 14]; // grapheme_len is at byte 14
697700
}
698701
}
699702

703+
/** Small buffer for grapheme lookups (reused to avoid allocation) */
704+
private graphemeBuffer: Uint32Array | null = null;
705+
private graphemeBufferPtr: number = 0;
706+
707+
/**
708+
* Get all codepoints for a grapheme cluster at the given position.
709+
* For most cells this returns a single codepoint, but for complex scripts
710+
* (Hindi, emoji with ZWJ, etc.) it returns multiple codepoints.
711+
* @returns Array of codepoints, or null on error
712+
*/
713+
getGrapheme(row: number, col: number): number[] | null {
714+
// Allocate buffer on first use (16 codepoints should be enough for any grapheme)
715+
if (!this.graphemeBuffer) {
716+
this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4);
717+
this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16);
718+
}
719+
720+
const count = this.exports.ghostty_render_state_get_grapheme(
721+
this.handle,
722+
row,
723+
col,
724+
this.graphemeBufferPtr,
725+
16
726+
);
727+
728+
if (count < 0) return null;
729+
730+
// Re-create view in case memory grew
731+
const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count);
732+
return Array.from(view);
733+
}
734+
735+
/**
736+
* Get a string representation of the grapheme at the given position.
737+
* This properly handles complex scripts like Hindi, emoji with ZWJ, etc.
738+
*/
739+
getGraphemeString(row: number, col: number): string {
740+
const codepoints = this.getGrapheme(row, col);
741+
if (!codepoints || codepoints.length === 0) return ' ';
742+
return String.fromCodePoint(...codepoints);
743+
}
744+
745+
/**
746+
* Get all codepoints for a grapheme cluster in the scrollback buffer.
747+
* @param offset Scrollback line offset (0 = oldest)
748+
* @param col Column index
749+
* @returns Array of codepoints, or null on error
750+
*/
751+
getScrollbackGrapheme(offset: number, col: number): number[] | null {
752+
// Reuse the same buffer as getGrapheme
753+
if (!this.graphemeBuffer) {
754+
this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4);
755+
this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16);
756+
}
757+
758+
const count = this.exports.ghostty_terminal_get_scrollback_grapheme(
759+
this.handle,
760+
offset,
761+
col,
762+
this.graphemeBufferPtr,
763+
16
764+
);
765+
766+
if (count < 0) return null;
767+
768+
// Re-create view in case memory grew
769+
const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count);
770+
return Array.from(view);
771+
}
772+
773+
/**
774+
* Get a string representation of a grapheme in the scrollback buffer.
775+
*/
776+
getScrollbackGraphemeString(offset: number, col: number): string {
777+
const codepoints = this.getScrollbackGrapheme(offset, col);
778+
if (!codepoints || codepoints.length === 0) return ' ';
779+
return String.fromCodePoint(...codepoints);
780+
}
781+
700782
private invalidateBuffers(): void {
701783
if (this.viewportBufferPtr) {
702784
this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize);

lib/renderer.ts

Lines changed: 92 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

2935
export 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

lib/selection-manager.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,19 @@ export class SelectionManager {
163163
for (let col = colStart; col <= colEnd; col++) {
164164
const cell = line[col];
165165
if (cell && cell.codepoint !== 0) {
166-
const char = String.fromCodePoint(cell.codepoint);
166+
// Use grapheme lookup for cells with multi-codepoint characters
167+
let char: string;
168+
if (cell.grapheme_len > 0) {
169+
// Row is in scrollback or screen - determine which and use appropriate method
170+
if (absRow < scrollbackLength) {
171+
char = this.wasmTerm.getScrollbackGraphemeString(absRow, col);
172+
} else {
173+
const screenRow = absRow - scrollbackLength;
174+
char = this.wasmTerm.getGraphemeString(screenRow, col);
175+
}
176+
} else {
177+
char = String.fromCodePoint(cell.codepoint);
178+
}
167179
lineText += char;
168180
if (char.trim()) {
169181
lastNonEmpty = lineText.length;

0 commit comments

Comments
 (0)