diff --git a/src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts b/src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts new file mode 100644 index 00000000000..be2ffbed67a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +/** + * Issue #12729 — "the cursor lands on the wide character's first cell and clips its right half". + * + * The report pins two claims about the cursor. Both are pinned here against the vendored xterm, + * because neither reproduces and a regression in either would change what the answer to that + * issue is: + * + * 1. the reporter's captured pty stream must leave the cursor on the column the application + * asked for (`CSI 25;11H`), not on the last syllable's starting cell; and + * 2. a block cursor sitting on a full-width glyph must carry the whole syllable, so nothing + * about the cursor can hide half a character. + * + * The occluding box in that report is the IME preedit overlay, not the cursor — see + * `terminal-ime-xterm-trailing-preedit-occlusion.test.ts`. + */ +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Escapes made visible in the issue: hide cursor, home, CUF 2, CUD 24, the text, then two CUPs. +const REPORTED_PTY_STREAM = '\x1b[?25l\x1b[H\x1b[2C\x1b[24B가나다라\x1b[30;1H\x1b[25;11H\x1b[?25h' + +const openTerminals: Terminal[] = [] + +function openTerminal(): Terminal { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal({ cols: 80, rows: 30 }) + terminal.open(container) + openTerminals.push(terminal) + return terminal +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +/** Awaits the repaint the write triggers, so the row spans under assertion are the rendered ones. */ +async function writeAwaitingRender(terminal: Terminal, data: string): Promise { + await write(terminal, data) + await new Promise((resolve) => { + const rendered = terminal.onRender(() => { + rendered.dispose() + resolve() + }) + }) +} + +function describeCells(terminal: Terminal, count: number): string[] { + const buffer = terminal.buffer.active + const line = buffer.getLine(buffer.cursorY) + if (!line) { + throw new Error('cursor row is missing') + } + const cells: string[] = [] + for (let x = 0; x < count; x++) { + const cell = line.getCell(x) + cells.push(`${cell?.getChars() ?? ''}:${cell?.getWidth() ?? -1}`) + } + return cells +} + +describe('#12729 — the cursor is not displaced by full-width text', () => { + beforeEach(() => { + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + while (openTerminals.length > 0) { + openTerminals.pop()?.dispose() + } + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('leaves the reported pty stream with the cursor on the requested column', async () => { + const terminal = openTerminal() + + await write(terminal, REPORTED_PTY_STREAM) + + const buffer = terminal.buffer.active + // `CSI 25;11H` is 1-based; the last syllable's starting cell would be 8. + expect(buffer.cursorX).toBe(10) + expect(buffer.cursorY).toBe(24) + // Each syllable owns its cell plus a zero-width continuation, so the run ends at cell 9. + // Cells 0-1 were never written: the row was reached with CUD, so they stay null. + expect(describeCells(terminal, 11)).toEqual([ + ':1', + ':1', + '가:2', + ':0', + '나:2', + ':0', + '다:2', + ':0', + '라:2', + ':0', + ':1' + ]) + }) + + it('paints the cursor after the run, not on the last syllable', async () => { + const terminal = openTerminal() + terminal.focus() + + await writeAwaitingRender(terminal, '> 가나다라') + + const cursorSpan = document.querySelector('.xterm-rows .xterm-cursor') + expect(cursorSpan?.previousElementSibling?.textContent).toBe('> 가나다라') + expect(cursorSpan?.textContent).toBe(' ') + }) + + it('carries the whole syllable when the cursor does sit on a full-width glyph', async () => { + const terminal = openTerminal() + terminal.focus() + + // CHA 9: back onto 라's starting cell, the placement the report describes. + await writeAwaitingRender(terminal, '> 가나다라\x1b[9G') + + expect(terminal.buffer.active.cursorX).toBe(8) + const cursorSpan = document.querySelector('.xterm-rows .xterm-cursor') + // The whole syllable is inside the cursor span, so a block cursor cannot clip its right half. + expect(cursorSpan?.textContent).toBe('라') + expect(cursorSpan?.classList.contains('xterm-cursor-block')).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts b/src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts new file mode 100644 index 00000000000..4fdda2b009a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +/** + * Issue #12729's second claim — "`terminalCursorStyle` / `terminalCursorOpacity` are ignored". + * + * Neither reproduces as a plumbing defect: an explicit `bar` survives the settings round trip and + * the opacity composes into the theme's cursor colour. What the report was looking at is the IME + * preedit overlay, which no cursor option reaches by construction + * (`terminal-ime-xterm-trailing-preedit-occlusion.test.ts`). + * + * The one thing that does override the preference is a `DECSCUSR` from the running program, which + * every terminal honours and which prompt frameworks emit routinely. That precedence is pinned + * here because it is the answer to "my setting does nothing" whenever the overlay is not involved. + */ +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' +import { composeActiveTerminalTheme } from './terminal-appearance' + +function decPrivateCursorStyle(terminal: Terminal): string | undefined { + return ( + terminal as unknown as { + _core: { coreService: { decPrivateModes: { cursorStyle?: string } } } + } + )._core.coreService.decPrivateModes.cursorStyle +} + +describe('#12729 — cursor style and opacity reach the terminal', () => { + it('keeps an explicitly chosen bar across the settings write and the next load', () => { + // The write path stamps the migration flag alongside the user's choice. + const written = normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: 'bar' }, + { preserveExplicitValue: true } + ) + expect(written).toEqual({ + terminalCursorStyle: 'bar', + terminalCursorStyleDefaultedToBlock: true + }) + + // The load path re-runs the migration over what was persisted and must not re-default it. + expect(normalizeTerminalCursorStyleDefault(written).terminalCursorStyle).toBe('bar') + }) + + it('composes terminalCursorOpacity into the theme cursor colour', () => { + const theme = composeActiveTerminalTheme( + { background: '#112233', foreground: '#aabbcc', cursor: '#ffffff' }, + { terminalCursorOpacity: 0.1 } + ) + + expect(theme?.cursor).toBe('rgba(255, 255, 255, 0.1)') + }) +}) + +describe('#12729 — DECSCUSR from the program outranks the preference', () => { + const openTerminals: Terminal[] = [] + + beforeEach(() => { + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + while (openTerminals.length > 0) { + openTerminals.pop()?.dispose() + } + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('pins the shape a prompt asked for until CSI 0 SP q hands it back', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal({ cols: 40, rows: 6, cursorStyle: 'bar' }) + terminal.open(container) + openTerminals.push(terminal) + + expect(decPrivateCursorStyle(terminal)).toBeUndefined() + + // `CSI 2 SP q` — steady block, what a prompt framework emits on every redraw. + await new Promise((resolve) => terminal.write('\x1b[2 q', resolve)) + expect(decPrivateCursorStyle(terminal)).toBe('block') + + // Changing the preference while that is set cannot win it back; only the reset does. + terminal.options.cursorStyle = 'underline' + expect(decPrivateCursorStyle(terminal)).toBe('block') + + // Orca sends this reset on replay and on the agent-idle path (RESET_TERMINAL_CURSOR_STYLE). + await new Promise((resolve) => terminal.write('\x1b[0 q', resolve)) + expect(decPrivateCursorStyle(terminal)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts new file mode 100644 index 00000000000..41abc4728ce --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom +/** + * Issue #12729 — typing `가나다라` leaves the last syllable inside an opaque block. + * + * The report reads the block as a mis-placed cursor, but no cursor can produce it: the cursor sits + * on the column the application asked for and a block cursor carries the whole wide glyph + * (`terminal-cjk-cursor-cell-placement.test.ts`). What sits there is the preedit overlay. + * + * macOS 2-set Korean keeps the trailing syllable composing until a terminator, so after four + * keystrokes only `가나다` has reached the pty; `라` lives in `.composition-view`, an opaque box + * anchored to the cursor cell. Stock xterm styles that box `#000`/`#FFF` in its own stylesheet, + * which is the black block the report measured, and no cursor setting can reach it — matching the + * report's second claim that `terminalCursorStyle` / `terminalCursorOpacity` do nothing. + * + * #15014 themed the overlay from `options.theme`; this pins the end-of-row arm of that, which is + * the shape #12729 hits. happy-dom performs no layout, so the cell size is supplied and only the + * anchor is asserted, not glyph geometry. + */ +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const CELL_WIDTH_PX = 8 +const CELL_HEIGHT_PX = 16 +const THEME = { background: '#112233', foreground: '#aabbcc' } + +const openTerminals: Terminal[] = [] + +function nextEventLoop(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)) +} + +type Rig = { + commit: (syllable: string) => Promise + compositionView: HTMLElement + composeUpdate: (preedit: string) => void + composeStart: () => void + sent: string[] + terminal: Terminal +} + +function openTerminal(): Rig { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal({ cols: 80, rows: 24, theme: THEME }) + terminal.open(container) + const textarea = terminal.textarea + const compositionView = container.querySelector('.composition-view') + if (!textarea || !compositionView) { + throw new Error('xterm did not create the helper textarea and composition view') + } + openTerminals.push(terminal) + + const cell = ( + terminal as unknown as { + _core: { + _renderService: { dimensions: { css: { cell: { height: number; width: number } } } } + } + } + )._core._renderService.dimensions.css.cell + cell.width = CELL_WIDTH_PX + cell.height = CELL_HEIGHT_PX + + // A local-echo shell: committed syllables come back through the pty and land in the buffer. + const sent: string[] = [] + terminal.onData((data) => { + sent.push(data) + terminal.write(data) + }) + + const composeStart = (): void => { + const start = new CompositionEvent('compositionstart', { bubbles: true }) + Object.defineProperty(start, 'data', { value: '' }) + textarea.dispatchEvent(start) + } + + const composeUpdate = (preedit: string): void => { + const update = new CompositionEvent('compositionupdate', { bubbles: true }) + Object.defineProperty(update, 'data', { value: preedit }) + textarea.value = preedit + textarea.dispatchEvent(update) + } + + const commit = async (syllable: string): Promise => { + const end = new CompositionEvent('compositionend', { bubbles: true }) + Object.defineProperty(end, 'data', { value: syllable }) + textarea.dispatchEvent(end) + textarea.value = '' + await nextEventLoop() + await nextEventLoop() + } + + return { commit, compositionView, composeStart, composeUpdate, sent, terminal } +} + +function stripMarks(text: string | null): string { + return (text ?? '').replaceAll('‎', '') +} + +/** Rolling 2-set Korean: each new consonant commits the previous syllable and opens the next. */ +async function typeHangulRun(rig: Rig, syllables: readonly string[]): Promise { + for (let index = 0; index < syllables.length; index++) { + if (index > 0) { + await rig.commit(syllables[index - 1]!) + } + rig.composeStart() + rig.composeUpdate(syllables[index]!) + await nextEventLoop() + } +} + +describe('#12729 — the block over a trailing Korean syllable is the preedit overlay', () => { + beforeEach(() => { + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(async () => { + // updateCompositionElements re-arms on a timer; let the pending one run before dispose. + await nextEventLoop() + await nextEventLoop() + while (openTerminals.length > 0) { + openTerminals.pop()?.dispose() + } + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('holds the trailing syllable in the overlay while the earlier ones reach the pty', async () => { + const rig = openTerminal() + + await typeHangulRun(rig, ['가', '나', '다', '라']) + + expect(rig.sent.join('')).toBe('가나다') + // Three committed syllables at two cells each: the cursor is at the next free cell. + expect(rig.terminal.buffer.active.cursorX).toBe(6) + expect(rig.compositionView.classList.contains('active')).toBe(true) + expect(stripMarks(rig.compositionView.textContent)).toBe('라') + // Nothing follows the cursor, so the overlay carries the preedit alone. + expect(rig.compositionView.children).toHaveLength(0) + }) + + it('anchors the overlay to the cursor cell so the preedit continues the run', async () => { + const rig = openTerminal() + + await typeHangulRun(rig, ['가', '나', '다', '라']) + + expect(rig.compositionView.style.left).toBe(`${6 * CELL_WIDTH_PX}px`) + expect(rig.compositionView.style.height).toBe(`${CELL_HEIGHT_PX}px`) + // Synced to the grid's font so the preedit keeps the advance the committed syllables have. + expect(rig.compositionView.style.fontFamily).toBe(rig.terminal.options.fontFamily) + expect(rig.compositionView.style.fontSize).toBe(`${rig.terminal.options.fontSize}px`) + }) + + it('themes the trailing overlay instead of the stock opaque #000/#FFF block', async () => { + const rig = openTerminal() + + await typeHangulRun(rig, ['가', '나', '다', '라']) + + const { background, color } = rig.compositionView.style + expect([THEME.background, 'rgb(17, 34, 51)']).toContain(background) + expect([THEME.foreground, 'rgb(170, 187, 204)']).toContain(color) + }) + + it('clears the overlay once the trailing syllable commits', async () => { + const rig = openTerminal() + await typeHangulRun(rig, ['가', '나', '다', '라']) + + await rig.commit('라') + + expect(rig.sent.join('')).toBe('가나다라') + expect(rig.compositionView.classList.contains('active')).toBe(false) + expect(rig.compositionView.textContent).toBe('') + // The whole run is now committed cells, with the cursor after it. + expect(rig.terminal.buffer.active.cursorX).toBe(8) + }) +})