Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions lib/buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,59 @@ describe('Buffer API', () => {
}
});

for (const text of ['ASCII', 'e\u0301', '👩‍💻', '👋🏽', '🇺🇸']) {
test(`preserves ${JSON.stringify(text)} in buffer exports and snapshots`, () => {
term.write(text);
const line = term.buffer.active.getLine(0)!;
expect(line.translateToString(true)).toBe(text);
expect(line.getCell(0)!.getChars()).toBe(text === 'ASCII' ? 'A' : text);
if (text !== 'ASCII' && text !== 'e\u0301') {
expect(line.getCell(1)!.getChars()).toBe('');
}
term.write('\rchanged');
expect(line.translateToString(true)).toBe(text);
});

test(`preserves ${JSON.stringify(text)} in scrollback exports`, () => {
term.write(text);
term.write('\r\n'.repeat(24));
const line = term.buffer.normal.getLine(0)!;
expect(line.translateToString(true)).toBe(text);
expect(line.getCell(0)!.getChars()).toBe(text === 'ASCII' ? 'A' : text);
});
}

test('keeps the scalar when a grapheme lookup fails', () => {
term.write('e\u0301\r\n'.repeat(30));
const wasmTerm = term.wasmTerm!;
wasmTerm.getGrapheme = () => null;
wasmTerm.getScrollbackGrapheme = () => [];
const buffer = term.buffer.normal;
expect(buffer.getLine(0)!.getCell(0)!.getChars()).toBe('e');
expect(
buffer
.getLine(buffer.length - 2)!
.getCell(0)!
.getChars()
).toBe('e');
});

test('makes no grapheme lookup for single-codepoint cells', () => {
term.write('ASCII\r\n'.repeat(30));
const wasmTerm = term.wasmTerm!;
let lookups = 0;
const countLookup = () => {
lookups++;
return null;
};
wasmTerm.getGrapheme = countLookup;
wasmTerm.getScrollbackGrapheme = countLookup;
const buffer = term.buffer.normal;
expect(buffer.getLine(0)!.translateToString(true)).toBe('ASCII');
expect(buffer.getLine(buffer.length - 2)!.translateToString(true)).toBe('ASCII');
expect(lookups).toBe(0);
});

describe('BufferNamespace', () => {
test('should have buffer property', () => {
expect(term.buffer).toBeDefined();
Expand Down
36 changes: 31 additions & 5 deletions lib/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export class Buffer implements IBuffer {
// Determine if accessing scrollback or visible screen
const scrollbackLength = wasmTerm.getScrollbackLength();
let cells: GhosttyCell[] | null;
let lineNumber: number;
let lineNumber: number = y;
let isWrapped: boolean;

if (this.bufferType === 'normal' && y < scrollbackLength) {
Expand All @@ -197,7 +197,22 @@ export class Buffer implements IBuffer {
return undefined;
}

return new BufferLine(cells, isWrapped, wasmTerm.cols);
// Snapshot graphemes alongside scalar cells, before the terminal can change.
// grapheme_len counts codepoints beyond the first, so 0 means the scalar is exact.
// A failed lookup leaves the slot empty so getChars() falls back to the scalar.
const inScrollback = this.bufferType === 'normal' && y < scrollbackLength;
let graphemes: string[] | undefined;
for (let x = 0; x < cells.length; x++) {
if (cells[x].codepoint === 0 || cells[x].grapheme_len === 0) continue;
const codepoints = inScrollback
? wasmTerm.getScrollbackGrapheme(y, x)
: wasmTerm.getGrapheme(lineNumber, x);
if (codepoints && codepoints.length > 0) {
graphemes ??= [];
graphemes[x] = String.fromCodePoint(...codepoints);
}
}
return new BufferLine(cells, isWrapped, wasmTerm.cols, graphemes);
}

getNullCell(): IBufferCell {
Expand All @@ -221,7 +236,12 @@ export class BufferLine implements IBufferLine {
private _isWrapped: boolean;
private _length: number;

constructor(cells: GhosttyCell[], isWrapped: boolean, length: number) {
constructor(
cells: GhosttyCell[],
isWrapped: boolean,
length: number,
private graphemes?: string[]
) {
this.cells = cells;
this._isWrapped = isWrapped;
this._length = length;
Expand Down Expand Up @@ -260,7 +280,7 @@ export class BufferLine implements IBufferLine {
);
}

return new BufferCell(this.cells[x], x);
return new BufferCell(this.cells[x], x, this.graphemes?.[x]);
}

translateToString(trimRight = false, startColumn = 0, endColumn = this._length): string {
Expand Down Expand Up @@ -296,12 +316,18 @@ export class BufferCell implements IBufferCell {
private cell: GhosttyCell;
private x: number;

constructor(cell: GhosttyCell, x: number) {
constructor(
cell: GhosttyCell,
x: number,
private grapheme?: string
) {
this.cell = cell;
this.x = x;
}

getChars(): string {
if (this.grapheme !== undefined) return this.grapheme;

const codepoint = this.cell.codepoint;

// Return empty string for null character or invalid codepoints
Expand Down