Skip to content
Merged
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
18 changes: 18 additions & 0 deletions apps/web/src/terminal/ghostty/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export class GhosttyTerminalCore {
this.runtime.free(options, optionsSize);
this.assertSuccess("ghostty_terminal_new", terminalResult);
this.terminal = this.runtime.readPointer(this.terminalSlot);
this.applyDefaultCursorBlink();
this.ptyWriter = onPtyData;
this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData);

Expand Down Expand Up @@ -302,6 +303,9 @@ export class GhosttyTerminalCore {
resetAndWrite(data: string): void {
this.ensureActive();
this.runtime.call("ghostty_terminal_reset", this.terminal);
// RIS returns the cursor to Ghostty's built-in steady default, so the
// embedder default has to be applied again before the replay runs.
this.applyDefaultCursorBlink();
this.rows = [];
if (data.length === 0) return;
const writer = this.ptyWriter;
Expand Down Expand Up @@ -333,6 +337,20 @@ export class GhosttyTerminalCore {
);
}

/**
* Ghostty's built-in default cursor is steady, while the xterm.js renderer
* this replaced ran with `cursorBlink: true`. Option 23 is the embedder's
* default blink, which is the state a session starts in and returns to on
* DECSCUSR reset (CSI 0 q), so programs that ask for a specific cursor
* through DECSCUSR or DEC mode 12 still win.
*/
private applyDefaultCursorBlink(): void {
const blink = this.runtime.alloc(1);
this.runtime.bytes(blink, 1)[0] = 1;
this.runtime.call("ghostty_terminal_set", this.terminal, 23, blink);
this.runtime.free(blink, 1);
}

setTheme(theme: GhosttyTheme): void {
this.ensureActive();
const color = this.runtime.alloc(3);
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/terminal/ghostty/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,60 @@ describe("renderGhosttySnapshot", () => {
]);
});

it("repaints the cell without an overlay during the blink off phase", () => {
const fillTextCalls: unknown[][] = [];
const context = {
canvas: { width: 200, height: 40 },
beginPath: () => {},
clip: () => {},
fillRect: () => {},
fillText: (...args: unknown[]) => fillTextCalls.push(args),
rect: () => {},
resetTransform: () => {},
restore: () => {},
save: () => {},
set fillStyle(_value: string) {},
set font(_value: string) {},
set textBaseline(_value: string) {},
} as unknown as CanvasRenderingContext2D;
const snapshot: GhosttySnapshot = {
cols: 3,
rows: 1,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
cursor: { r: 255, g: 255, b: 255 },
cursorX: 2,
cursorY: 0,
cursorVisible: true,
cursorBlinking: true,
cursorStyle: 1,
dirtyRows: new Set(),
rowData: [
{
cells: [cell("a"), cell("b"), cell("x")],
text: "abx",
isWrapContinuation: false,
wrapsToNext: false,
},
],
};

renderGhosttySnapshot({
context,
snapshot,
metrics: { width: 7.2, height: 16, baseline: 11 },
fontSize: 12,
fontFamily: "monospace",
padding: 4,
forceFull: false,
cursorOn: false,
});

// The cursor row still repaints so the block disappears, but the inverted
// glyph the on phase draws over the cell is gone.
expect(fillTextCalls).toEqual([["abx", 4, 15, 21.6]]);
});

it("repaints the previous cursor row after the cursor moves", () => {
const clearedRows: number[] = [];
const context = {
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/terminal/ghostty/runtimeAbi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,79 @@ describe("vendored libghostty-vt WebAssembly", () => {
}
});

it("blinks the default cursor until a program asks for a steady one", async () => {
const result = await WebAssembly.instantiate(
decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
{ env: { log: () => {} } },
);
const instance = result instanceof WebAssembly.Instance ? result : result.instance;
const memory = instance.exports.memory as WebAssembly.Memory;
const call = (name: string, ...args: number[]) =>
(instance.exports[name] as WasmFunction)(...args);
const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
const options = alloc(8);
const optionsView = new DataView(memory.buffer, options, 8);
optionsView.setUint16(0, 80, true);
optionsView.setUint16(2, 24, true);
const terminalSlot = call("ghostty_wasm_alloc_opaque");
expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
const renderStateSlot = call("ghostty_wasm_alloc_opaque");
expect(call("ghostty_render_state_new", 0, renderStateSlot)).toBe(0);
const renderState = new DataView(memory.buffer).getUint32(renderStateSlot, true);
const scratch = alloc(4);

const blinking = () => {
expect(call("ghostty_render_state_update", renderState, terminal)).toBe(0);
expect(call("ghostty_render_state_get", renderState, 12, scratch)).toBe(0);
return new DataView(memory.buffer, scratch, 4).getUint8(0) !== 0;
};
const write = (data: string) => {
const bytes = new TextEncoder().encode(data);
const pointer = alloc(bytes.length);
new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes);
call("ghostty_terminal_vt_write", terminal, pointer, bytes.length);
call("ghostty_wasm_free_u8_array", pointer, bytes.length);
};
const setDefaultCursorBlink = (blink: boolean) => {
const value = alloc(1);
new Uint8Array(memory.buffer, value, 1)[0] = blink ? 1 : 0;
expect(call("ghostty_terminal_set", terminal, 23, value)).toBe(0);
call("ghostty_wasm_free_u8_array", value, 1);
};

// Ghostty's own default is a steady cursor, so the blink the web terminal
// inherited from xterm.js only exists because option 23 asks for it.
expect(blinking()).toBe(false);
setDefaultCursorBlink(true);
expect(blinking()).toBe(true);

// Programs still own the cursor: DECSCUSR steady block and DEC mode 12 both
// stop the blink, and DECSCUSR reset returns to the embedder default.
write("\u001b[2 q");
expect(blinking()).toBe(false);
write("\u001b[0 q");
expect(blinking()).toBe(true);
write("\u001b[?12l");
expect(blinking()).toBe(false);
write("\u001b[?12h");
expect(blinking()).toBe(true);

// RIS restores Ghostty's built-in steady default rather than the embedder's,
// which is why the core reapplies the option around a session replay.
call("ghostty_terminal_reset", terminal);
expect(blinking()).toBe(false);
setDefaultCursorBlink(true);
expect(blinking()).toBe(true);

call("ghostty_wasm_free_u8_array", scratch, 4);
call("ghostty_render_state_free", renderState);
call("ghostty_wasm_free_opaque", renderStateSlot);
call("ghostty_terminal_free", terminal);
call("ghostty_wasm_free_opaque", terminalSlot);
call("ghostty_wasm_free_u8_array", options, 8);
});

it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => {
const result = await WebAssembly.instantiate(
decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isTerminalCopyShortcut,
isTerminalLinkPointerGesture,
isTerminalPasteShortcut,
shouldBlinkTerminalCursor,
shouldReportTerminalMouse,
terminalScrollbarGeometry,
terminalScrollbarOffsetAtPointer,
Expand Down Expand Up @@ -54,6 +55,29 @@ describe("isTerminalAltGraphText", () => {
});
});

describe("shouldBlinkTerminalCursor", () => {
const blinking = {
focused: true,
cursorBlinking: true,
cursorVisible: true,
reducedMotion: false,
};

it("blinks a focused visible cursor the terminal asked to blink", () => {
expect(shouldBlinkTerminalCursor(blinking)).toBe(true);
});

it("holds the cursor steady when blinking would be unwanted", () => {
// Unfocused surfaces draw a steady hollow cursor, DECSCUSR steady styles and
// DEC mode 12 turn blinking off, a hidden cursor has nothing to toggle, and
// reduced-motion readers get no permanently animating element.
expect(shouldBlinkTerminalCursor({ ...blinking, focused: false })).toBe(false);
expect(shouldBlinkTerminalCursor({ ...blinking, cursorBlinking: false })).toBe(false);
expect(shouldBlinkTerminalCursor({ ...blinking, cursorVisible: false })).toBe(false);
expect(shouldBlinkTerminalCursor({ ...blinking, reducedMotion: true })).toBe(false);
});
});

describe("terminalLinkAtColumn", () => {
it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {
const cells = [
Expand Down
52 changes: 48 additions & 4 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY =
'"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS;
const CONTENT_PADDING = 4;
const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
/** Half a blink cycle: the visible and hidden phases are equally long. */
const CURSOR_BLINK_INTERVAL_MS = 500;

/** Requested terminal font; omitted fields fall back to the defaults. */
export interface GhosttyTerminalFont {
Expand Down Expand Up @@ -71,6 +73,20 @@ export function terminalFontSize(size?: number): number {
return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));
}

/**
* Whether the cursor should keep toggling. An unfocused surface draws a steady
* hollow cursor instead of blinking, and a reduced-motion reader gets a steady
* cursor too rather than a permanently animating element.
*/
export function shouldBlinkTerminalCursor(state: {
readonly focused: boolean;
readonly cursorBlinking: boolean;
readonly cursorVisible: boolean;
readonly reducedMotion: boolean;
}): boolean {
return state.focused && state.cursorBlinking && state.cursorVisible && !state.reducedMotion;
}

/**
* Vertical origin of the grid inside the mount. While content is shorter than
* the viewport the grid sits at the top like a fresh terminal. Once scrollback
Expand Down Expand Up @@ -380,6 +396,9 @@ export class GhosttyTerminalSurface {
private pasteShortcutToken = 0;
private wheelRemainder = 0;
private dprMedia: MediaQueryList | null = null;
// Read live on every blink decision, and watched so that dropping the
// preference restarts a blink cycle that has no timer left to notice it.
private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)");
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
private inputLeft = -1;
private inputTop = -1;

Expand Down Expand Up @@ -409,6 +428,7 @@ export class GhosttyTerminalSurface {
this.resizeObserver = new ResizeObserver(() => this.fit());
this.installEvents();
this.watchDevicePixelRatio();
this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange);
document.fonts.addEventListener("loadingdone", this.onFontsLoaded);
this.resizeObserver.observe(mount);
}
Expand Down Expand Up @@ -494,6 +514,9 @@ export class GhosttyTerminalSurface {
resetAndWrite(data: string): void {
if (this.disposed) return;
this.core.resetAndWrite(data);
// A replayed session starts from the visible phase like any other write:
// reattaching mid-blink must not open on an invisible cursor.
this.cursorOn = true;
this.forceFullRender = true;
this.scrollbarDirty = true;
this.requestRender();
Expand Down Expand Up @@ -537,6 +560,14 @@ export class GhosttyTerminalSurface {
this.requestRender();
}

private readonly onReducedMotionChange = () => {
Comment thread
StiensWout marked this conversation as resolved.
if (this.disposed) return;
// Nothing else wakes an idle steady cursor: the blink timer only reschedules
// from a render, and reduced motion is exactly the state that stopped it.
this.cursorOn = true;
this.requestRender();
};

private readonly onFontsLoaded = () => {
if (this.disposed) return;
// A face that finished loading after the initial measurement changes glyph
Expand Down Expand Up @@ -679,6 +710,7 @@ export class GhosttyTerminalSurface {
document.fonts.removeEventListener("loadingdone", this.onFontsLoaded);
this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange);
this.dprMedia = null;
this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange);
if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer);
if (this.resizeNotifyTimer !== null) {
window.clearTimeout(this.resizeNotifyTimer);
Expand Down Expand Up @@ -1249,7 +1281,9 @@ export class GhosttyTerminalSurface {
this.frame = 0;
}
this.snapshot = this.core.snapshot();
if (!this.snapshot.cursorBlinking) this.cursorOn = true;
// A cursor that is not blinking right now must be drawn, never caught in an
// off phase left behind by a blink that has since been turned off.
if (!this.blinkEnabled()) this.cursorOn = true;
// The origin only moves together with a forced full repaint: partial
// dirty-row redraws must never composite rows at a shifted origin over
// rows painted at the previous one. Bottom anchoring starts once
Expand Down Expand Up @@ -1299,13 +1333,23 @@ export class GhosttyTerminalSurface {
private scheduleCursorBlink(): void {
if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);
this.cursorTimer = null;
// An unfocused surface shows a steady hollow cursor instead of blinking.
if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return;
if (!this.blinkEnabled()) return;
this.cursorTimer = window.setTimeout(() => {
this.cursorTimer = null;
this.cursorOn = !this.cursorOn;
this.requestRender();
}, 500);
}, CURSOR_BLINK_INTERVAL_MS);
}

private blinkEnabled(): boolean {
const snapshot = this.snapshot;
if (!snapshot) return false;
return shouldBlinkTerminalCursor({
focused: this.focused,
cursorBlinking: snapshot.cursorBlinking,
cursorVisible: snapshot.cursorVisible,
reducedMotion: this.reducedMotionMedia?.matches ?? false,
});
}

private positionInput(): void {
Expand Down
Loading