History

fix(web): blink the terminal cursor again (#5314)

Wout Stiens committed 3 months ago

Commit 11639bf1 parent a261a645 files · +217 −4Browse files

Unified · Split

5 files
  1. apps/web/src/terminal/ghostty/core.ts +18 −0
  2. apps/web/src/terminal/ghostty/renderer.test.ts +54 −0
  3. apps/web/src/terminal/ghostty/runtimeAbi.test.ts +73 −0
  4. apps/web/src/terminal/ghostty/surface.test.ts +24 −0
  5. apps/web/src/terminal/ghostty/surface.ts +48 −4
apps/web/src/terminal/ghostty/core.ts+18−0
@@ -233,6 +233,7 @@ export class GhosttyTerminalCore { · 232 unmodified lines
233233 this.runtime.free(options, optionsSize);
234234 this.assertSuccess("ghostty_terminal_new", terminalResult);
235235 this.terminal = this.runtime.readPointer(this.terminalSlot);
236+ this.applyDefaultCursorBlink();
236237 this.ptyWriter = onPtyData;
237238 this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData);
238239
@@ -302,6 +303,9 @@ export class GhosttyTerminalCore { · 63 unmodified lines
302303 resetAndWrite(data: string): void {
303304 this.ensureActive();
304305 this.runtime.call("ghostty_terminal_reset", this.terminal);
306+ // RIS returns the cursor to Ghostty's built-in steady default, so the
307+ // embedder default has to be applied again before the replay runs.
308+ this.applyDefaultCursorBlink();
305309 this.rows = [];
306310 if (data.length === 0) return;
307311 const writer = this.ptyWriter;
@@ -333,6 +337,20 @@ export class GhosttyTerminalCore { · 25 unmodified lines
333337 );
334338 }
335339
340+ /**
341+ * Ghostty's built-in default cursor is steady, while the xterm.js renderer
342+ * this replaced ran with `cursorBlink: true`. Option 23 is the embedder's
343+ * default blink, which is the state a session starts in and returns to on
344+ * DECSCUSR reset (CSI 0 q), so programs that ask for a specific cursor
345+ * through DECSCUSR or DEC mode 12 still win.
346+ */
347+ private applyDefaultCursorBlink(): void {
348+ const blink = this.runtime.alloc(1);
349+ this.runtime.bytes(blink, 1)[0] = 1;
350+ this.runtime.call("ghostty_terminal_set", this.terminal, 23, blink);
351+ this.runtime.free(blink, 1);
352+ }
353+
336354 setTheme(theme: GhosttyTheme): void {
337355 this.ensureActive();
338356 const color = this.runtime.alloc(3);
apps/web/src/terminal/ghostty/renderer.test.ts+54−0
@@ -120,6 +120,60 @@ describe("renderGhosttySnapshot", () => { · 119 unmodified lines
120120 ]);
121121 });
122122
123+ it("repaints the cell without an overlay during the blink off phase", () => {
124+ const fillTextCalls: unknown[][] = [];
125+ const context = {
126+ canvas: { width: 200, height: 40 },
127+ beginPath: () => {},
128+ clip: () => {},
129+ fillRect: () => {},
130+ fillText: (...args: unknown[]) => fillTextCalls.push(args),
131+ rect: () => {},
132+ resetTransform: () => {},
133+ restore: () => {},
134+ save: () => {},
135+ set fillStyle(_value: string) {},
136+ set font(_value: string) {},
137+ set textBaseline(_value: string) {},
138+ } as unknown as CanvasRenderingContext2D;
139+ const snapshot: GhosttySnapshot = {
140+ cols: 3,
141+ rows: 1,
142+ foreground: { r: 255, g: 255, b: 255 },
143+ background: { r: 0, g: 0, b: 0 },
144+ cursor: { r: 255, g: 255, b: 255 },
145+ cursorX: 2,
146+ cursorY: 0,
147+ cursorVisible: true,
148+ cursorBlinking: true,
149+ cursorStyle: 1,
150+ dirtyRows: new Set(),
151+ rowData: [
152+ {
153+ cells: [cell("a"), cell("b"), cell("x")],
154+ text: "abx",
155+ isWrapContinuation: false,
156+ wrapsToNext: false,
157+ },
158+ ],
159+ };
160+
161+ renderGhosttySnapshot({
162+ context,
163+ snapshot,
164+ metrics: { width: 7.2, height: 16, baseline: 11 },
165+ fontSize: 12,
166+ fontFamily: "monospace",
167+ padding: 4,
168+ forceFull: false,
169+ cursorOn: false,
170+ });
171+
172+ // The cursor row still repaints so the block disappears, but the inverted
173+ // glyph the on phase draws over the cell is gone.
174+ expect(fillTextCalls).toEqual([["abx", 4, 15, 21.6]]);
175+ });
176+
123177 it("repaints the previous cursor row after the cursor moves", () => {
124178 const clearedRows: number[] = [];
125179 const context = {
apps/web/src/terminal/ghostty/runtimeAbi.test.ts+73−0
@@ -83,6 +83,79 @@ describe("vendored libghostty-vt WebAssembly", () => { · 82 unmodified lines
8383 }
8484 });
8585
86+ it("blinks the default cursor until a program asks for a steady one", async () => {
87+ const result = await WebAssembly.instantiate(
88+ decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
89+ { env: { log: () => {} } },
90+ );
91+ const instance = result instanceof WebAssembly.Instance ? result : result.instance;
92+ const memory = instance.exports.memory as WebAssembly.Memory;
93+ const call = (name: string, ...args: number[]) =>
94+ (instance.exports[name] as WasmFunction)(...args);
95+ const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
96+ const options = alloc(8);
97+ const optionsView = new DataView(memory.buffer, options, 8);
98+ optionsView.setUint16(0, 80, true);
99+ optionsView.setUint16(2, 24, true);
100+ const terminalSlot = call("ghostty_wasm_alloc_opaque");
101+ expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
102+ const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
103+ const renderStateSlot = call("ghostty_wasm_alloc_opaque");
104+ expect(call("ghostty_render_state_new", 0, renderStateSlot)).toBe(0);
105+ const renderState = new DataView(memory.buffer).getUint32(renderStateSlot, true);
106+ const scratch = alloc(4);
107+
108+ const blinking = () => {
109+ expect(call("ghostty_render_state_update", renderState, terminal)).toBe(0);
110+ expect(call("ghostty_render_state_get", renderState, 12, scratch)).toBe(0);
111+ return new DataView(memory.buffer, scratch, 4).getUint8(0) !== 0;
112+ };
113+ const write = (data: string) => {
114+ const bytes = new TextEncoder().encode(data);
115+ const pointer = alloc(bytes.length);
116+ new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes);
117+ call("ghostty_terminal_vt_write", terminal, pointer, bytes.length);
118+ call("ghostty_wasm_free_u8_array", pointer, bytes.length);
119+ };
120+ const setDefaultCursorBlink = (blink: boolean) => {
121+ const value = alloc(1);
122+ new Uint8Array(memory.buffer, value, 1)[0] = blink ? 1 : 0;
123+ expect(call("ghostty_terminal_set", terminal, 23, value)).toBe(0);
124+ call("ghostty_wasm_free_u8_array", value, 1);
125+ };
126+
127+ // Ghostty's own default is a steady cursor, so the blink the web terminal
128+ // inherited from xterm.js only exists because option 23 asks for it.
129+ expect(blinking()).toBe(false);
130+ setDefaultCursorBlink(true);
131+ expect(blinking()).toBe(true);
132+
133+ // Programs still own the cursor: DECSCUSR steady block and DEC mode 12 both
134+ // stop the blink, and DECSCUSR reset returns to the embedder default.
135+ write("\u001b[2 q");
136+ expect(blinking()).toBe(false);
137+ write("\u001b[0 q");
138+ expect(blinking()).toBe(true);
139+ write("\u001b[?12l");
140+ expect(blinking()).toBe(false);
141+ write("\u001b[?12h");
142+ expect(blinking()).toBe(true);
143+
144+ // RIS restores Ghostty's built-in steady default rather than the embedder's,
145+ // which is why the core reapplies the option around a session replay.
146+ call("ghostty_terminal_reset", terminal);
147+ expect(blinking()).toBe(false);
148+ setDefaultCursorBlink(true);
149+ expect(blinking()).toBe(true);
150+
151+ call("ghostty_wasm_free_u8_array", scratch, 4);
152+ call("ghostty_render_state_free", renderState);
153+ call("ghostty_wasm_free_opaque", renderStateSlot);
154+ call("ghostty_terminal_free", terminal);
155+ call("ghostty_wasm_free_opaque", terminalSlot);
156+ call("ghostty_wasm_free_u8_array", options, 8);
157+ });
158+
86159 it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => {
87160 const result = await WebAssembly.instantiate(
88161 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
apps/web/src/terminal/ghostty/surface.test.ts+24−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 isTerminalCopyShortcut,
1212 isTerminalLinkPointerGesture,
1313 isTerminalPasteShortcut,
14+ shouldBlinkTerminalCursor,
1415 shouldReportTerminalMouse,
1516 terminalScrollbarGeometry,
1617 terminalScrollbarOffsetAtPointer,
@@ -54,6 +55,29 @@ describe("isTerminalAltGraphText", () => { · 37 unmodified lines
5455 });
5556 });
5657
58+describe("shouldBlinkTerminalCursor", () => {
59+ const blinking = {
60+ focused: true,
61+ cursorBlinking: true,
62+ cursorVisible: true,
63+ reducedMotion: false,
64+ };
65+
66+ it("blinks a focused visible cursor the terminal asked to blink", () => {
67+ expect(shouldBlinkTerminalCursor(blinking)).toBe(true);
68+ });
69+
70+ it("holds the cursor steady when blinking would be unwanted", () => {
71+ // Unfocused surfaces draw a steady hollow cursor, DECSCUSR steady styles and
72+ // DEC mode 12 turn blinking off, a hidden cursor has nothing to toggle, and
73+ // reduced-motion readers get no permanently animating element.
74+ expect(shouldBlinkTerminalCursor({ ...blinking, focused: false })).toBe(false);
75+ expect(shouldBlinkTerminalCursor({ ...blinking, cursorBlinking: false })).toBe(false);
76+ expect(shouldBlinkTerminalCursor({ ...blinking, cursorVisible: false })).toBe(false);
77+ expect(shouldBlinkTerminalCursor({ ...blinking, reducedMotion: true })).toBe(false);
78+ });
79+});
80+
5781 describe("terminalLinkAtColumn", () => {
5882 it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {
5983 const cells = [
apps/web/src/terminal/ghostty/surface.ts+48−4
@@ -31,6 +31,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = · 30 unmodified lines
3131 '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS;
3232 const CONTENT_PADDING = 4;
3333 const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
34+/** Half a blink cycle: the visible and hidden phases are equally long. */
35+const CURSOR_BLINK_INTERVAL_MS = 500;
3436
3537 /** Requested terminal font; omitted fields fall back to the defaults. */
3638 export interface GhosttyTerminalFont {
@@ -71,6 +73,20 @@ export function terminalFontSize(size?: number): number { · 34 unmodified lines
7173 return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));
7274 }
7375
76+/**
77+ * Whether the cursor should keep toggling. An unfocused surface draws a steady
78+ * hollow cursor instead of blinking, and a reduced-motion reader gets a steady
79+ * cursor too rather than a permanently animating element.
80+ */
81+export function shouldBlinkTerminalCursor(state: {
82+ readonly focused: boolean;
83+ readonly cursorBlinking: boolean;
84+ readonly cursorVisible: boolean;
85+ readonly reducedMotion: boolean;
86+}): boolean {
87+ return state.focused && state.cursorBlinking && state.cursorVisible && !state.reducedMotion;
88+}
89+
7490 /**
7591 * Vertical origin of the grid inside the mount. While content is shorter than
7692 * the viewport the grid sits at the top like a fresh terminal. Once scrollback
@@ -380,6 +396,9 @@ export class GhosttyTerminalSurface { · 303 unmodified lines
380396 private pasteShortcutToken = 0;
381397 private wheelRemainder = 0;
382398 private dprMedia: MediaQueryList | null = null;
399+ // Read live on every blink decision, and watched so that dropping the
400+ // preference restarts a blink cycle that has no timer left to notice it.
401+ private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)");
383402 private inputLeft = -1;
384403 private inputTop = -1;
385404
@@ -409,6 +428,7 @@ export class GhosttyTerminalSurface { · 23 unmodified lines
409428 this.resizeObserver = new ResizeObserver(() => this.fit());
410429 this.installEvents();
411430 this.watchDevicePixelRatio();
431+ this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange);
412432 document.fonts.addEventListener("loadingdone", this.onFontsLoaded);
413433 this.resizeObserver.observe(mount);
414434 }
@@ -494,6 +514,9 @@ export class GhosttyTerminalSurface { · 79 unmodified lines
494514 resetAndWrite(data: string): void {
495515 if (this.disposed) return;
496516 this.core.resetAndWrite(data);
517+ // A replayed session starts from the visible phase like any other write:
518+ // reattaching mid-blink must not open on an invisible cursor.
519+ this.cursorOn = true;
497520 this.forceFullRender = true;
498521 this.scrollbarDirty = true;
499522 this.requestRender();
@@ -537,6 +560,14 @@ export class GhosttyTerminalSurface { · 37 unmodified lines
537560 this.requestRender();
538561 }
539562
563+ private readonly onReducedMotionChange = () => {
564+ if (this.disposed) return;
565+ // Nothing else wakes an idle steady cursor: the blink timer only reschedules
566+ // from a render, and reduced motion is exactly the state that stopped it.
567+ this.cursorOn = true;
568+ this.requestRender();
569+ };
570+
540571 private readonly onFontsLoaded = () => {
541572 if (this.disposed) return;
542573 // A face that finished loading after the initial measurement changes glyph
@@ -679,6 +710,7 @@ export class GhosttyTerminalSurface { · 136 unmodified lines
679710 document.fonts.removeEventListener("loadingdone", this.onFontsLoaded);
680711 this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange);
681712 this.dprMedia = null;
713+ this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange);
682714 if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer);
683715 if (this.resizeNotifyTimer !== null) {
684716 window.clearTimeout(this.resizeNotifyTimer);
@@ -1249,7 +1281,9 @@ export class GhosttyTerminalSurface { · 564 unmodified lines
12491281 this.frame = 0;
12501282 }
12511283 this.snapshot = this.core.snapshot();
1252− if (!this.snapshot.cursorBlinking) this.cursorOn = true;
1284+ // A cursor that is not blinking right now must be drawn, never caught in an
1285+ // off phase left behind by a blink that has since been turned off.
1286+ if (!this.blinkEnabled()) this.cursorOn = true;
12531287 // The origin only moves together with a forced full repaint: partial
12541288 // dirty-row redraws must never composite rows at a shifted origin over
12551289 // rows painted at the previous one. Bottom anchoring starts once
@@ -1299,13 +1333,23 @@ export class GhosttyTerminalSurface { · 43 unmodified lines
12991333 private scheduleCursorBlink(): void {
13001334 if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);
13011335 this.cursorTimer = null;
1302− // An unfocused surface shows a steady hollow cursor instead of blinking.
1303− if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return;
1336+ if (!this.blinkEnabled()) return;
13041337 this.cursorTimer = window.setTimeout(() => {
13051338 this.cursorTimer = null;
13061339 this.cursorOn = !this.cursorOn;
13071340 this.requestRender();
1308− }, 500);
1341+ }, CURSOR_BLINK_INTERVAL_MS);
1342+ }
1343+
1344+ private blinkEnabled(): boolean {
1345+ const snapshot = this.snapshot;
1346+ if (!snapshot) return false;
1347+ return shouldBlinkTerminalCursor({
1348+ focused: this.focused,
1349+ cursorBlinking: snapshot.cursorBlinking,
1350+ cursorVisible: snapshot.cursorVisible,
1351+ reducedMotion: this.reducedMotionMedia?.matches ?? false,
1352+ });
13091353 }
13101354
13111355 private positionInput(): void {