Unified · Split
| @@ -233,6 +233,7 @@ export class GhosttyTerminalCore { · 232 unmodified lines | ||
| 233 | 233 | this.runtime.free(options, optionsSize); |
| 234 | 234 | this.assertSuccess("ghostty_terminal_new", terminalResult); |
| 235 | 235 | this.terminal = this.runtime.readPointer(this.terminalSlot); |
| 236 | + this.applyDefaultCursorBlink(); | |
| 236 | 237 | this.ptyWriter = onPtyData; |
| 237 | 238 | this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); |
| 238 | 239 | |
| @@ -302,6 +303,9 @@ export class GhosttyTerminalCore { · 63 unmodified lines | ||
| 302 | 303 | resetAndWrite(data: string): void { |
| 303 | 304 | this.ensureActive(); |
| 304 | 305 | 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(); | |
| 305 | 309 | this.rows = []; |
| 306 | 310 | if (data.length === 0) return; |
| 307 | 311 | const writer = this.ptyWriter; |
| @@ -333,6 +337,20 @@ export class GhosttyTerminalCore { · 25 unmodified lines | ||
| 333 | 337 | ); |
| 334 | 338 | } |
| 335 | 339 | |
| 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 | + | |
| 336 | 354 | setTheme(theme: GhosttyTheme): void { |
| 337 | 355 | this.ensureActive(); |
| 338 | 356 | const color = this.runtime.alloc(3); |
| @@ -120,6 +120,60 @@ describe("renderGhosttySnapshot", () => { · 119 unmodified lines | ||
| 120 | 120 | ]); |
| 121 | 121 | }); |
| 122 | 122 | |
| 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 | + | |
| 123 | 177 | it("repaints the previous cursor row after the cursor moves", () => { |
| 124 | 178 | const clearedRows: number[] = []; |
| 125 | 179 | const context = { |
| @@ -83,6 +83,79 @@ describe("vendored libghostty-vt WebAssembly", () => { · 82 unmodified lines | ||
| 83 | 83 | } |
| 84 | 84 | }); |
| 85 | 85 | |
| 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 | + | |
| 86 | 159 | it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => { |
| 87 | 160 | const result = await WebAssembly.instantiate( |
| 88 | 161 | decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | isTerminalCopyShortcut, |
| 12 | 12 | isTerminalLinkPointerGesture, |
| 13 | 13 | isTerminalPasteShortcut, |
| 14 | + shouldBlinkTerminalCursor, | |
| 14 | 15 | shouldReportTerminalMouse, |
| 15 | 16 | terminalScrollbarGeometry, |
| 16 | 17 | terminalScrollbarOffsetAtPointer, |
| @@ -54,6 +55,29 @@ describe("isTerminalAltGraphText", () => { · 37 unmodified lines | ||
| 54 | 55 | }); |
| 55 | 56 | }); |
| 56 | 57 | |
| 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 | + | |
| 57 | 81 | describe("terminalLinkAtColumn", () => { |
| 58 | 82 | it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { |
| 59 | 83 | const cells = [ |
| @@ -31,6 +31,8 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = · 30 unmodified lines | ||
| 31 | 31 | '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; |
| 32 | 32 | const CONTENT_PADDING = 4; |
| 33 | 33 | 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; | |
| 34 | 36 | |
| 35 | 37 | /** Requested terminal font; omitted fields fall back to the defaults. */ |
| 36 | 38 | export interface GhosttyTerminalFont { |
| @@ -71,6 +73,20 @@ export function terminalFontSize(size?: number): number { · 34 unmodified lines | ||
| 71 | 73 | return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); |
| 72 | 74 | } |
| 73 | 75 | |
| 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 | + | |
| 74 | 90 | /** |
| 75 | 91 | * Vertical origin of the grid inside the mount. While content is shorter than |
| 76 | 92 | * the viewport the grid sits at the top like a fresh terminal. Once scrollback |
| @@ -380,6 +396,9 @@ export class GhosttyTerminalSurface { · 303 unmodified lines | ||
| 380 | 396 | private pasteShortcutToken = 0; |
| 381 | 397 | private wheelRemainder = 0; |
| 382 | 398 | 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)"); | |
| 383 | 402 | private inputLeft = -1; |
| 384 | 403 | private inputTop = -1; |
| 385 | 404 | |
| @@ -409,6 +428,7 @@ export class GhosttyTerminalSurface { · 23 unmodified lines | ||
| 409 | 428 | this.resizeObserver = new ResizeObserver(() => this.fit()); |
| 410 | 429 | this.installEvents(); |
| 411 | 430 | this.watchDevicePixelRatio(); |
| 431 | + this.reducedMotionMedia?.addEventListener("change", this.onReducedMotionChange); | |
| 412 | 432 | document.fonts.addEventListener("loadingdone", this.onFontsLoaded); |
| 413 | 433 | this.resizeObserver.observe(mount); |
| 414 | 434 | } |
| @@ -494,6 +514,9 @@ export class GhosttyTerminalSurface { · 79 unmodified lines | ||
| 494 | 514 | resetAndWrite(data: string): void { |
| 495 | 515 | if (this.disposed) return; |
| 496 | 516 | 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; | |
| 497 | 520 | this.forceFullRender = true; |
| 498 | 521 | this.scrollbarDirty = true; |
| 499 | 522 | this.requestRender(); |
| @@ -537,6 +560,14 @@ export class GhosttyTerminalSurface { · 37 unmodified lines | ||
| 537 | 560 | this.requestRender(); |
| 538 | 561 | } |
| 539 | 562 | |
| 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 | + | |
| 540 | 571 | private readonly onFontsLoaded = () => { |
| 541 | 572 | if (this.disposed) return; |
| 542 | 573 | // A face that finished loading after the initial measurement changes glyph |
| @@ -679,6 +710,7 @@ export class GhosttyTerminalSurface { · 136 unmodified lines | ||
| 679 | 710 | document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); |
| 680 | 711 | this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); |
| 681 | 712 | this.dprMedia = null; |
| 713 | + this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); | |
| 682 | 714 | if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); |
| 683 | 715 | if (this.resizeNotifyTimer !== null) { |
| 684 | 716 | window.clearTimeout(this.resizeNotifyTimer); |
| @@ -1249,7 +1281,9 @@ export class GhosttyTerminalSurface { · 564 unmodified lines | ||
| 1249 | 1281 | this.frame = 0; |
| 1250 | 1282 | } |
| 1251 | 1283 | 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; | |
| 1253 | 1287 | // The origin only moves together with a forced full repaint: partial |
| 1254 | 1288 | // dirty-row redraws must never composite rows at a shifted origin over |
| 1255 | 1289 | // rows painted at the previous one. Bottom anchoring starts once |
| @@ -1299,13 +1333,23 @@ export class GhosttyTerminalSurface { · 43 unmodified lines | ||
| 1299 | 1333 | private scheduleCursorBlink(): void { |
| 1300 | 1334 | if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); |
| 1301 | 1335 | 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; | |
| 1304 | 1337 | this.cursorTimer = window.setTimeout(() => { |
| 1305 | 1338 | this.cursorTimer = null; |
| 1306 | 1339 | this.cursorOn = !this.cursorOn; |
| 1307 | 1340 | 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 | + }); | |
| 1309 | 1353 | } |
| 1310 | 1354 | |
| 1311 | 1355 | private positionInput(): void { |