From 2348a7bef11e6f582bc85af7e718f5c9fea38432 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 4 Dec 2025 03:45:45 -0600 Subject: [PATCH] fix: Unicode grapheme cluster rendering for complex scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add two-pass rendering in renderLine (backgrounds first, then text) to fix left-extending Devanagari diacritics like ि that extend into the previous cell's visual area - Add adjacent row redraw when a row is dirty to handle tall glyphs that extend beyond cell boundaries (fixes artifacts when clearing lines with Devanagari text) - Add grapheme cluster support via WASM API: - ghostty_render_state_get_grapheme() for viewport cells - ghostty_terminal_get_scrollback_grapheme() for scrollback - grapheme_len field in GhosttyCell struct - Enable mode 2027 (grapheme clustering) by default - Update selection-manager to extract full graphemes for copy/paste - Add tests for grapheme cluster support --- lib/buffer.ts | 2 + lib/ghostty.ts | 82 +++++++++++++++ lib/renderer.ts | 113 ++++++++++++++++---- lib/selection-manager.ts | 14 ++- lib/terminal.test.ts | 84 +++++++++++++++ lib/types.ts | 17 ++- patches/ghostty-wasm-api.patch | 185 ++++++++++++++++++++++++++++++--- 7 files changed, 462 insertions(+), 35 deletions(-) diff --git a/lib/buffer.ts b/lib/buffer.ts index 1a6fd83..031a493 100644 --- a/lib/buffer.ts +++ b/lib/buffer.ts @@ -114,6 +114,7 @@ export class Buffer implements IBuffer { flags: 0, width: 1, hyperlink_id: 0, + grapheme_len: 0, }; this.nullCell = new BufferCell(nullCellData, 0); } @@ -253,6 +254,7 @@ export class BufferLine implements IBufferLine { flags: 0, width: 1, hyperlink_id: 0, + grapheme_len: 0, }, x ); diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 6ee32bc..7449185 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -593,6 +593,7 @@ export class GhosttyTerminal { flags: u8[cellOffset + 10], width: u8[cellOffset + 11], hyperlink_id: view.getUint16(cellOffset + 12, true), + grapheme_len: u8[cellOffset + 14], }); } @@ -671,6 +672,7 @@ export class GhosttyTerminal { flags: 0, width: 1, hyperlink_id: 0, + grapheme_len: 0, }); } } @@ -694,9 +696,89 @@ export class GhosttyTerminal { cell.flags = u8[offset + 10]; cell.width = u8[offset + 11]; cell.hyperlink_id = view.getUint16(offset + 12, true); + cell.grapheme_len = u8[offset + 14]; // grapheme_len is at byte 14 } } + /** Small buffer for grapheme lookups (reused to avoid allocation) */ + private graphemeBuffer: Uint32Array | null = null; + private graphemeBufferPtr: number = 0; + + /** + * Get all codepoints for a grapheme cluster at the given position. + * For most cells this returns a single codepoint, but for complex scripts + * (Hindi, emoji with ZWJ, etc.) it returns multiple codepoints. + * @returns Array of codepoints, or null on error + */ + getGrapheme(row: number, col: number): number[] | null { + // Allocate buffer on first use (16 codepoints should be enough for any grapheme) + if (!this.graphemeBuffer) { + this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); + this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); + } + + const count = this.exports.ghostty_render_state_get_grapheme( + this.handle, + row, + col, + this.graphemeBufferPtr, + 16 + ); + + if (count < 0) return null; + + // Re-create view in case memory grew + const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); + return Array.from(view); + } + + /** + * Get a string representation of the grapheme at the given position. + * This properly handles complex scripts like Hindi, emoji with ZWJ, etc. + */ + getGraphemeString(row: number, col: number): string { + const codepoints = this.getGrapheme(row, col); + if (!codepoints || codepoints.length === 0) return ' '; + return String.fromCodePoint(...codepoints); + } + + /** + * Get all codepoints for a grapheme cluster in the scrollback buffer. + * @param offset Scrollback line offset (0 = oldest) + * @param col Column index + * @returns Array of codepoints, or null on error + */ + getScrollbackGrapheme(offset: number, col: number): number[] | null { + // Reuse the same buffer as getGrapheme + if (!this.graphemeBuffer) { + this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); + this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); + } + + const count = this.exports.ghostty_terminal_get_scrollback_grapheme( + this.handle, + offset, + col, + this.graphemeBufferPtr, + 16 + ); + + if (count < 0) return null; + + // Re-create view in case memory grew + const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); + return Array.from(view); + } + + /** + * Get a string representation of a grapheme in the scrollback buffer. + */ + getScrollbackGraphemeString(offset: number, col: number): string { + const codepoints = this.getScrollbackGrapheme(offset, col); + if (!codepoints || codepoints.length === 0) return ' '; + return String.fromCodePoint(...codepoints); + } + private invalidateBuffers(): void { if (this.viewportBufferPtr) { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); diff --git a/lib/renderer.ts b/lib/renderer.ts index 046665e..13b2d6b 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -24,6 +24,12 @@ export interface IRenderable { /** Returns true if a full redraw is needed (e.g., screen change) */ needsFullRedraw?(): boolean; clearDirty(): void; + /** + * Get the full grapheme string for a cell at (row, col). + * For cells with grapheme_len > 0, this returns all codepoints combined. + * For simple cells, returns the single character. + */ + getGraphemeString?(row: number, col: number): string; } export interface IScrollbackProvider { @@ -103,6 +109,9 @@ export class CanvasRenderer { // Viewport tracking (for scrolling) private lastViewportY: number = 0; + // Current buffer being rendered (for grapheme lookups) + private currentBuffer: IRenderable | null = null; + // Selection manager (for rendering selection overlay) private selectionManager?: SelectionManager; @@ -253,6 +262,9 @@ export class CanvasRenderer { scrollbackProvider?: IScrollbackProvider, scrollbarOpacity: number = 1 ): void { + // Store buffer reference for grapheme lookups in renderCell + this.currentBuffer = buffer; + // getCursor() calls update() internally to ensure fresh state. // Multiple update() calls are safe - dirty state persists until clearDirty(). const cursor = buffer.getCursor(); @@ -398,7 +410,11 @@ export class CanvasRenderer { // Track if anything was actually rendered let anyLinesRendered = false; - // Render each line + // Determine which rows need rendering. + // We also include adjacent rows (above and below) for each dirty row to handle + // glyph overflow - tall glyphs like Devanagari vowel signs can extend into + // adjacent rows' visual space. + const rowsToRender = new Set(); for (let y = 0; y < dims.rows; y++) { // When scrolled, always force render all lines since we're showing scrollback const needsRender = @@ -406,7 +422,17 @@ export class CanvasRenderer { ? true : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y); - if (!needsRender) { + if (needsRender) { + rowsToRender.add(y); + // Include adjacent rows to handle glyph overflow + if (y > 0) rowsToRender.add(y - 1); + if (y < dims.rows - 1) rowsToRender.add(y + 1); + } + } + + // Render each line + for (let y = 0; y < dims.rows; y++) { + if (!rowsToRender.has(y)) { continue; } @@ -470,61 +496,97 @@ export class CanvasRenderer { } /** - * Render a single line + * Render a single line using two-pass approach: + * 1. First pass: Draw all cell backgrounds + * 2. Second pass: Draw all cell text and decorations + * + * This two-pass approach is necessary for proper rendering of complex scripts + * like Devanagari where diacritics (like vowel sign ि) can extend LEFT of the + * base character into the previous cell's visual area. If we draw backgrounds + * and text in a single pass (cell by cell), the background of cell N would + * cover any left-extending portions of graphemes from cell N-1. */ private renderLine(line: GhosttyCell[], y: number, cols: number): void { const lineY = y * this.metrics.height; - // Clear line background + // Clear line background with theme color. + // We clear just the cell area - glyph overflow is handled by also + // redrawing adjacent rows (see render() method). this.ctx.fillStyle = this.theme.background; this.ctx.fillRect(0, lineY, cols * this.metrics.width, this.metrics.height); - // Render each cell + // PASS 1: Draw all cell backgrounds first + // This ensures all backgrounds are painted before any text, allowing text + // to "bleed" across cell boundaries without being covered by adjacent backgrounds for (let x = 0; x < line.length; x++) { const cell = line[x]; + if (cell.width === 0) continue; // Skip spacer cells for wide characters + this.renderCellBackground(cell, x, y); + } - // Skip padding cells for wide characters - if (cell.width === 0) { - continue; - } - - this.renderCell(cell, x, y); + // PASS 2: Draw all cell text and decorations + // Now text can safely extend beyond cell boundaries (for complex scripts) + for (let x = 0; x < line.length; x++) { + const cell = line[x]; + if (cell.width === 0) continue; // Skip spacer cells for wide characters + this.renderCellText(cell, x, y); } } /** - * Render a single cell + * Render a cell's background only (Pass 1 of two-pass rendering) */ - private renderCell(cell: GhosttyCell, x: number, y: number): void { + private renderCellBackground(cell: GhosttyCell, x: number, y: number): void { const cellX = x * this.metrics.width; const cellY = y * this.metrics.height; - const cellWidth = this.metrics.width * cell.width; // Handle wide chars (width=2) + const cellWidth = this.metrics.width * cell.width; - // Extract colors and handle inverse - let fg_r = cell.fg_r, - fg_g = cell.fg_g, - fg_b = cell.fg_b; + // Extract background color and handle inverse let bg_r = cell.bg_r, bg_g = cell.bg_g, bg_b = cell.bg_b; if (cell.flags & CellFlags.INVERSE) { - [fg_r, fg_g, fg_b, bg_r, bg_g, bg_b] = [bg_r, bg_g, bg_b, fg_r, fg_g, fg_b]; + // When inverted, background becomes foreground + bg_r = cell.fg_r; + bg_g = cell.fg_g; + bg_b = cell.fg_b; } // Only draw cell background if it's different from the default (black) - // This lets the theme background (drawn in renderLine) show through for default cells + // This lets the theme background (drawn earlier) show through for default cells const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; if (!isDefaultBg) { this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); } + } + + /** + * Render a cell's text and decorations (Pass 2 of two-pass rendering) + */ + private renderCellText(cell: GhosttyCell, x: number, y: number): void { + const cellX = x * this.metrics.width; + const cellY = y * this.metrics.height; + const cellWidth = this.metrics.width * cell.width; // Skip rendering if invisible if (cell.flags & CellFlags.INVISIBLE) { return; } + // Extract colors and handle inverse + let fg_r = cell.fg_r, + fg_g = cell.fg_g, + fg_b = cell.fg_b; + + if (cell.flags & CellFlags.INVERSE) { + // When inverted, foreground becomes background + fg_r = cell.bg_r; + fg_g = cell.bg_g; + fg_b = cell.bg_b; + } + // Set text style let fontStyle = ''; if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic '; @@ -542,7 +604,16 @@ export class CanvasRenderer { // Draw text const textX = cellX; const textY = cellY + this.metrics.baseline; - const char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null + + // Get the character to render - use grapheme lookup for complex scripts + let char: string; + if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) { + // Cell has additional codepoints - get full grapheme cluster + char = this.currentBuffer.getGraphemeString(y, x); + } else { + // Simple cell - single codepoint + char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null + } this.ctx.fillText(char, textX, textY); // Reset alpha diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 5b2af73..8c9f1d2 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -163,7 +163,19 @@ export class SelectionManager { for (let col = colStart; col <= colEnd; col++) { const cell = line[col]; if (cell && cell.codepoint !== 0) { - const char = String.fromCodePoint(cell.codepoint); + // Use grapheme lookup for cells with multi-codepoint characters + let char: string; + if (cell.grapheme_len > 0) { + // Row is in scrollback or screen - determine which and use appropriate method + if (absRow < scrollbackLength) { + char = this.wasmTerm.getScrollbackGraphemeString(absRow, col); + } else { + const screenRow = absRow - scrollbackLength; + char = this.wasmTerm.getGraphemeString(screenRow, col); + } + } else { + char = String.fromCodePoint(cell.codepoint); + } lineText += char; if (char.trim()) { lastNonEmpty = lineText.length; diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index adb0f0a..d56011e 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -2763,6 +2763,90 @@ describe('unicode API', () => { }); }); +// ========================================================================== +// Grapheme Cluster Support (Unicode complex scripts) +// ========================================================================== + +describe('Grapheme Cluster Support', () => { + let container: HTMLElement | null = null; + + beforeEach(async () => { + if (typeof document !== 'undefined') { + container = document.createElement('div'); + document.body.appendChild(container); + } + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + container = null; + } + }); + + test('cell grapheme_len is 0 for simple ASCII characters', async () => { + const term = await createIsolatedTerminal(); + term.open(container!); + term.write('Hello'); + + // Get the viewport and check the first cell + const viewport = term.wasmTerm!.getViewport(); + expect(viewport[0].codepoint).toBe(0x48); // 'H' + expect(viewport[0].grapheme_len).toBe(0); + + term.dispose(); + }); + + test('getGraphemeString returns simple characters correctly', async () => { + const term = await createIsolatedTerminal(); + term.open(container!); + term.write('Test'); + + // Test basic ASCII + const grapheme = term.wasmTerm!.getGraphemeString(0, 0); + expect(grapheme).toBe('T'); + + term.dispose(); + }); + + test('getGrapheme returns null for invalid coordinates', async () => { + const term = await createIsolatedTerminal(); + term.open(container!); + term.write('Test'); + + // Test out of bounds + const result = term.wasmTerm!.getGrapheme(100, 100); + expect(result).toBeNull(); + + term.dispose(); + }); + + test('getGrapheme returns array of codepoints', async () => { + const term = await createIsolatedTerminal(); + term.open(container!); + term.write('A'); + + const codepoints = term.wasmTerm!.getGrapheme(0, 0); + expect(codepoints).not.toBeNull(); + expect(codepoints!.length).toBeGreaterThanOrEqual(1); + expect(codepoints![0]).toBe(0x41); // 'A' + + term.dispose(); + }); + + test('grapheme cluster mode 2027 is enabled by default', async () => { + const term = await createIsolatedTerminal(); + term.open(container!); + + // Mode 2027 should be enabled by default for proper Unicode handling + // This is a DEC private mode, not ANSI + const graphemeClusterEnabled = term.wasmTerm!.getMode(2027, false); + expect(graphemeClusterEnabled).toBe(true); + + term.dispose(); + }); +}); + // ========================================================================== // xterm.js Compatibility: Write Behavior // ========================================================================== diff --git a/lib/types.ts b/lib/types.ts index 930334d..e9182f2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -430,6 +430,13 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { bufPtr: number, bufLen: number ): number; // Returns total cells written or -1 on error + ghostty_render_state_get_grapheme( + terminal: TerminalHandle, + row: number, + col: number, + bufPtr: number, + bufLen: number + ): number; // Returns count of codepoints or -1 on error // Terminal modes ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; @@ -444,6 +451,13 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { bufPtr: number, bufLen: number ): number; // Returns cells written or -1 on error + ghostty_terminal_get_scrollback_grapheme( + terminal: TerminalHandle, + offset: number, + col: number, + bufPtr: number, + bufLen: number + ): number; // Returns codepoint count or -1 on error ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; // Response API (for DSR and other terminal queries) @@ -525,7 +539,7 @@ export type TerminalHandle = number; * Cell structure matching ghostty_cell_t in C (16 bytes) */ export interface GhosttyCell { - codepoint: number; // u32 (Unicode codepoint) + codepoint: number; // u32 (Unicode codepoint - first codepoint of grapheme) fg_r: number; // u8 (foreground red) fg_g: number; // u8 (foreground green) fg_b: number; // u8 (foreground blue) @@ -535,6 +549,7 @@ export interface GhosttyCell { flags: number; // u8 (style flags bitfield) width: number; // u8 (character width: 1=normal, 2=wide, etc.) hyperlink_id: number; // u16 (0 = no link, >0 = hyperlink ID in set) + grapheme_len: number; // u8 (number of extra codepoints beyond first) } /** diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index f764358..03cad70 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -29,10 +29,10 @@ index 4f8fef88e..ca9fb1d4d 100644 #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 -index 000000000..e371164b6 +index 000000000..298ad36c1 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,214 @@ +@@ -0,0 +1,249 @@ +/** + * @file terminal.h + * @@ -92,7 +92,8 @@ index 000000000..e371164b6 + uint8_t flags; + uint8_t width; + uint16_t hyperlink_id; -+ uint16_t _pad; ++ uint8_t grapheme_len; /* Number of extra codepoints beyond first (0 = no grapheme) */ ++ uint8_t _pad; +} GhosttyCell; + +/** Cell flags */ @@ -178,6 +179,24 @@ index 000000000..e371164b6 + size_t buffer_size +); + ++/** ++ * Get grapheme codepoints for a cell at (row, col). ++ * For cells with grapheme_len > 0, this returns all codepoints that make up ++ * the grapheme cluster. The buffer receives u32 codepoints. ++ * @param row Row index (0-based) ++ * @param col Column index (0-based) ++ * @param out_buffer Buffer to receive codepoints ++ * @param buffer_size Size of buffer in u32 elements ++ * @return Number of codepoints written (including the first), or -1 on error ++ */ ++int ghostty_render_state_get_grapheme( ++ GhosttyTerminal term, ++ int row, ++ int col, ++ uint32_t* out_buffer, ++ size_t buffer_size ++); ++ +/* ============================================================================ + * Terminal Modes + * ========================================================================= */ @@ -217,6 +236,22 @@ index 000000000..e371164b6 + size_t buffer_size +); + ++/** ++ * Get grapheme codepoints for a cell in the scrollback buffer. ++ * @param offset Scrollback line offset (0 = oldest) ++ * @param col Column index (0-based) ++ * @param out_buffer Buffer to receive codepoints ++ * @param buffer_size Size of buffer in u32 elements ++ * @return Number of codepoints written, or -1 on error ++ */ ++int ghostty_terminal_get_scrollback_grapheme( ++ GhosttyTerminal term, ++ int offset, ++ int col, ++ uint32_t* out_buffer, ++ size_t buffer_size ++); ++ +/** Check if a row is a continuation from previous row (soft-wrapped) */ +bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int y); + @@ -248,10 +283,10 @@ index 000000000..e371164b6 + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index 03a883e20..35f6b787f 100644 +index 03a883e20..f07bbd759 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig -@@ -140,6 +140,39 @@ comptime { +@@ -140,6 +140,41 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @@ -274,6 +309,7 @@ index 03a883e20..35f6b787f 100644 + @export(&c.render_state_is_row_dirty, .{ .name = "ghostty_render_state_is_row_dirty" }); + @export(&c.render_state_mark_clean, .{ .name = "ghostty_render_state_mark_clean" }); + @export(&c.render_state_get_viewport, .{ .name = "ghostty_render_state_get_viewport" }); ++ @export(&c.render_state_get_grapheme, .{ .name = "ghostty_render_state_get_grapheme" }); + + // Terminal modes + @export(&c.terminal_is_alternate_screen, .{ .name = "ghostty_terminal_is_alternate_screen" }); @@ -283,6 +319,7 @@ index 03a883e20..35f6b787f 100644 + // Scrollback API + @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); + @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); ++ @export(&c.terminal_get_scrollback_grapheme, .{ .name = "ghostty_terminal_get_scrollback_grapheme" }); + @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); + + // Response API (for DSR and other queries) @@ -292,7 +329,7 @@ index 03a883e20..35f6b787f 100644 // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig -index bc92597f5..6a97183fe 100644 +index bc92597f5..18503933f 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); @@ -303,7 +340,7 @@ index bc92597f5..6a97183fe 100644 // The full C API, unexported. pub const osc_new = osc.new; -@@ -52,6 +53,40 @@ pub const key_encoder_encode = key_encode.encode; +@@ -52,6 +53,42 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; @@ -326,6 +363,7 @@ index bc92597f5..6a97183fe 100644 +pub const render_state_is_row_dirty = terminal.renderStateIsRowDirty; +pub const render_state_mark_clean = terminal.renderStateMarkClean; +pub const render_state_get_viewport = terminal.renderStateGetViewport; ++pub const render_state_get_grapheme = terminal.renderStateGetGrapheme; + +// Terminal modes +pub const terminal_is_alternate_screen = terminal.isAlternateScreen; @@ -335,6 +373,7 @@ index bc92597f5..6a97183fe 100644 +// Scrollback API +pub const terminal_get_scrollback_length = terminal.getScrollbackLength; +pub const terminal_get_scrollback_line = terminal.getScrollbackLine; ++pub const terminal_get_scrollback_grapheme = terminal.getScrollbackGrapheme; +pub const terminal_is_row_wrapped = terminal.isRowWrapped; + +// Response API (for DSR and other queries) @@ -344,7 +383,7 @@ index bc92597f5..6a97183fe 100644 test { _ = color; _ = osc; -@@ -59,6 +93,7 @@ test { +@@ -59,6 +96,7 @@ test { _ = key_encode; _ = paste; _ = sgr; @@ -354,10 +393,10 @@ index bc92597f5..6a97183fe 100644 _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 -index 000000000..3868eb17b +index 000000000..d57b4e405 --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,882 @@ +@@ -0,0 +1,1004 @@ +//! C API wrapper for Terminal +//! +//! This provides a minimal, high-performance interface to Ghostty's Terminal @@ -677,7 +716,8 @@ index 000000000..3868eb17b + flags: u8, + width: u8, + hyperlink_id: u16, -+ _pad: u16 = 0, ++ grapheme_len: u8 = 0, // Number of extra codepoints beyond first ++ _pad: u8 = 0, +}; + +/// Dirty state @@ -793,6 +833,12 @@ index 000000000..3868eb17b + // NOTE: linefeed mode must be FALSE to match native terminal behavior + // When true, LF does automatic CR which breaks apps like nvim + wrapper.terminal.modes.set(.linefeed, false); ++ ++ // Enable grapheme clustering (mode 2027) by default for proper Unicode support. ++ // This makes Hindi, Arabic, emoji sequences, etc. render correctly by treating ++ // multi-codepoint grapheme clusters as single visual units. ++ wrapper.terminal.modes.set(.grapheme_cluster, true); ++ + return @ptrCast(wrapper); +} + @@ -1004,6 +1050,12 @@ index 000000000..3868eb17b + if (sty.flags.blink) flags |= 1 << 6; + if (sty.flags.faint) flags |= 1 << 7; + ++ // Get grapheme length if cell has grapheme data ++ const grapheme_len: u8 = if (cell.hasGrapheme()) ++ if (page.lookupGrapheme(cell)) |cps| @min(@as(u8, @intCast(cps.len)), 255) else 0 ++ else ++ 0; ++ + out[idx] = .{ + .codepoint = cell.codepoint(), + .fg_r = fg.r, @@ -1019,6 +1071,7 @@ index 000000000..3868eb17b + .spacer_tail, .spacer_head => 0, + }, + .hyperlink_id = if (cell.hyperlink) 1 else 0, ++ .grapheme_len = grapheme_len, + }; + idx += 1; + } @@ -1027,6 +1080,56 @@ index 000000000..3868eb17b + return @intCast(total); +} + ++/// Get grapheme codepoints for a cell at (row, col). ++/// Returns all codepoints (including the first one) as u32 values. ++/// Returns the number of codepoints written, or -1 on error. ++pub fn renderStateGetGrapheme( ++ ptr: ?*anyopaque, ++ row: c_int, ++ col: c_int, ++ out: [*]u32, ++ buf_size: usize, ++) callconv(.c) c_int { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); ++ const rs = &wrapper.render_state; ++ const t = &wrapper.terminal; ++ const cols: usize = @intCast(rs.cols); ++ ++ if (row < 0 or col < 0) return -1; ++ if (@as(usize, @intCast(row)) >= rs.rows) return -1; ++ if (@as(usize, @intCast(col)) >= cols) return -1; ++ if (buf_size < 1) return -1; ++ ++ // Get the pin for this row from the terminal's active screen ++ const pages = &t.screens.active.pages; ++ const pin = pages.pin(.{ .active = .{ .y = @intCast(row) } }) orelse return -1; ++ ++ const cells = pin.cells(.all); ++ const page = pin.node.data; ++ const x: usize = @intCast(col); ++ ++ if (x >= cells.len) return -1; ++ ++ const cell = &cells[x]; ++ ++ // First codepoint is always from the cell ++ out[0] = cell.codepoint(); ++ var count: usize = 1; ++ ++ // Add extra codepoints from grapheme map if present ++ if (cell.hasGrapheme()) { ++ if (page.lookupGrapheme(cell)) |cps| { ++ for (cps) |cp| { ++ if (count >= buf_size) break; ++ out[count] = cp; ++ count += 1; ++ } ++ } ++ } ++ ++ return @intCast(count); ++} ++ +// ============================================================================ +// Terminal Modes (minimal set for compatibility) +// ============================================================================ @@ -1138,7 +1241,13 @@ index 000000000..3868eb17b + if (sty.flags.invisible) flags |= 1 << 5; + if (sty.flags.blink) flags |= 1 << 6; + if (sty.flags.faint) flags |= 1 << 7; -+ ++ ++ // Get grapheme length if cell has grapheme data ++ const grapheme_len: u8 = if (cell.hasGrapheme()) ++ if (page.lookupGrapheme(cell)) |cps| @min(@as(u8, @intCast(cps.len)), 255) else 0 ++ else ++ 0; ++ + out[x] = .{ + .codepoint = cell.codepoint(), + .fg_r = fg.r, @@ -1154,11 +1263,63 @@ index 000000000..3868eb17b + .spacer_tail, .spacer_head => 0, + }, + .hyperlink_id = if (cell.hyperlink) 1 else 0, ++ .grapheme_len = grapheme_len, + }; + } + return @intCast(cols); +} + ++/// Get grapheme codepoints for a cell in the scrollback buffer. ++/// Returns all codepoints (including the first one) as u32 values. ++/// Returns the number of codepoints written, or -1 on error. ++pub fn getScrollbackGrapheme( ++ ptr: ?*anyopaque, ++ offset: c_int, ++ col: c_int, ++ out: [*]u32, ++ buf_size: usize, ++) callconv(.c) c_int { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); ++ const rs = &wrapper.render_state; ++ const cols: usize = @intCast(rs.cols); ++ ++ if (offset < 0 or col < 0) return -1; ++ if (@as(usize, @intCast(col)) >= cols) return -1; ++ if (buf_size < 1) return -1; ++ ++ const scrollback_len = getScrollbackLength(ptr); ++ if (offset >= scrollback_len) return -1; ++ ++ // Get the pin for this scrollback row ++ const pages = &wrapper.terminal.screens.active.pages; ++ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; ++ ++ const cells = pin.cells(.all); ++ const page = pin.node.data; ++ const x: usize = @intCast(col); ++ ++ if (x >= cells.len) return -1; ++ ++ const cell = &cells[x]; ++ ++ // First codepoint is always from the cell ++ out[0] = cell.codepoint(); ++ var count: usize = 1; ++ ++ // Add extra codepoints from grapheme map if present ++ if (cell.hasGrapheme()) { ++ if (page.lookupGrapheme(cell)) |cps| { ++ for (cps) |cp| { ++ if (count >= buf_size) break; ++ out[count] = cp; ++ count += 1; ++ } ++ } ++ } ++ ++ return @intCast(count); ++} ++ +/// Check if a row is a continuation from the previous row (soft-wrapped) +/// This matches xterm.js semantics where isWrapped indicates the row continues +/// from the previous row, not that it wraps to the next row.