From 573fa05c11c98861342525dc90e77cf0e3e9e7ec Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 20 May 2026 02:18:10 -0500 Subject: [PATCH] fix windows fullscreen rendering keyboard demo --- demo/keyboard.ts | 41 +++++++++++++++++++++++--- input-native.ts | 7 ++++- term-native.ts | 7 ++++- term.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++++- test/term.test.ts | 13 ++++++++- 5 files changed, 133 insertions(+), 8 deletions(-) diff --git a/demo/keyboard.ts b/demo/keyboard.ts index 0dce0c3..955361b 100644 --- a/demo/keyboard.ts +++ b/demo/keyboard.ts @@ -42,6 +42,7 @@ const highlight = rgba(255, 220, 80); const KEY_W = 5; const GAP = 1; +const WRITE_CHUNK_SIZE = 1024; interface KeyDef { label: string; @@ -60,6 +61,33 @@ function matches(k: KeyDef, event: InputEvent | PointerEvent): boolean { const hovered = rgba(80, 80, 100); +function writeOutput(output: Uint8Array): void { + if (Deno.build.os !== "windows") { + Deno.stdout.writeSync(output); + return; + } + + // VS Code's Windows terminal path can corrupt large fullscreen writes from + // Deno, so flush complete rendered rows instead of one large write. + let start = 0; + let lastBreak = -1; + + for (let i = 0; i < output.length; i++) { + if (output[i] === 0x0a) { + lastBreak = i + 1; + } + + if (i - start + 1 >= WRITE_CHUNK_SIZE && lastBreak > start) { + Deno.stdout.writeSync(output.subarray(start, lastBreak)); + start = lastBreak; + } + } + + if (start < output.length) { + Deno.stdout.writeSync(output.subarray(start)); + } +} + function key(ops: Op[], k: KeyDef, ctx: AppContext): void { let pressed = ctx.event && matches(k, ctx.event); let hover = ctx.entered.has(`key:${k.code}`); @@ -567,11 +595,12 @@ await main(function* () { ? Deno.consoleSize() : { columns: 80, rows: 24 }; - Deno.stdin.setRaw(true); + if (Deno.stdin.isTerminal()) { + Deno.stdin.setRaw(true); + } let stdin = yield* useStdin(); let input = useInput(stdin); - let term = yield* until(createTerm({ width: columns, height: rows })); let tty = settings(alternateBuffer(), cursor(false)); @@ -584,13 +613,17 @@ await main(function* () { Deno.stdout.writeSync(flags.apply); yield* ensure(() => { + // Restore so Backspace and normal shell editing work after exit. + if (Deno.stdin.isTerminal()) { + Deno.stdin.setRaw(false); + } Deno.stdout.writeSync(flags.revert); Deno.stdout.writeSync(tty.revert); }); let { output } = term.render(keyboard(context)); - Deno.stdout.writeSync(output); + writeOutput(output); let pointer = { events: createChannel(), @@ -640,7 +673,7 @@ await main(function* () { yield* pointer.events.send(event); } - Deno.stdout.writeSync(output); + writeOutput(output); yield* each.next(); } diff --git a/input-native.ts b/input-native.ts index 7ed6d1b..e66e405 100644 --- a/input-native.ts +++ b/input-native.ts @@ -177,7 +177,12 @@ export async function createInputNative( let memory = new WebAssembly.Memory({ initial: 4 }); let instance = await WebAssembly.instantiate(compiled, { - env: { memory }, + env: { + memory, + debugLog(_ptr: number, _len: number) { + // no-op debug logger for wasm imports + }, + }, clay: { measureTextFunction() {}, queryScrollOffsetFunction(ret: number) { diff --git a/term-native.ts b/term-native.ts index 40e646d..5579680 100644 --- a/term-native.ts +++ b/term-native.ts @@ -41,7 +41,12 @@ export async function createTermNative( let exports: Record = {}; let instance = await WebAssembly.instantiate(compiled, { - env: { memory }, + env: { + memory, + debugLog(_ptr: number, _len: number) { + // no-op debug logger for wasm imports + }, + }, clay: { measureTextFunction( ret: number, diff --git a/term.ts b/term.ts index 12517d0..6853d94 100644 --- a/term.ts +++ b/term.ts @@ -38,6 +38,59 @@ export interface ElementInfo { bounds: BoundingBox; } +const WINDOWS_WRAP_DISABLE = new Uint8Array([0x1b, 0x5b, 0x3f, 0x37, 0x6c]); +const WINDOWS_WRAP_ENABLE = new Uint8Array([0x1b, 0x5b, 0x3f, 0x37, 0x68]); + +function normalizeWindowsLineOutput(output: Uint8Array): Uint8Array { + // Windows fullscreen line-mode output needs an explicit home cursor move and + // CRLF row separators; bare LF can leave the cursor in the wrong column and + // visually clip later rows in some terminal stacks. + let extra = 0; + for (let i = 0; i < output.length; i++) { + if (output[i] === 0x0a && (i === 0 || output[i - 1] !== 0x0d)) { + extra++; + } + } + + let prefix = new Uint8Array([ + ...WINDOWS_WRAP_DISABLE, + 0x1b, + 0x5b, + 0x48, + ]); + let suffix = WINDOWS_WRAP_ENABLE; + let normalized = new Uint8Array( + prefix.length + output.length + extra + suffix.length, + ); + normalized.set(prefix, 0); + + let offset = prefix.length; + for (let i = 0; i < output.length; i++) { + let byte = output[i]; + if (byte === 0x0a && (i === 0 || output[i - 1] !== 0x0d)) { + normalized[offset++] = 0x0d; + } + normalized[offset++] = byte; + } + + normalized.set(suffix, offset); + + return normalized as Uint8Array; +} + +function wrapWindowsFullscreenOutput(output: Uint8Array): Uint8Array { + // Disabling autowrap around a fullscreen frame avoids Windows terminal + // redraw quirks observed at the right edge. + // xterm defines CSI ? 7 h / CSI ? 7 l as auto-wrap on/off. + let wrapped = new Uint8Array( + WINDOWS_WRAP_DISABLE.length + output.length + WINDOWS_WRAP_ENABLE.length, + ); + wrapped.set(WINDOWS_WRAP_DISABLE, 0); + wrapped.set(output, WINDOWS_WRAP_DISABLE.length); + wrapped.set(WINDOWS_WRAP_ENABLE, WINDOWS_WRAP_DISABLE.length + output.length); + return wrapped; +} + const ERROR_TYPES = [ "TEXT_MEASUREMENT_FUNCTION_NOT_PROVIDED", "ARENA_CAPACITY_EXCEEDED", @@ -84,6 +137,18 @@ export async function createTerm(options: TermOptions): Promise { let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; + let autoLineMode = false; + let windowsFullscreen = row === 1 && Deno.build.os === "windows"; + + // Windows terminals have historically been less reliable with many + // absolute cursor CUP updates in full-screen diff mode. Use the + // line-oriented render path by default on Windows for fullscreen + // layouts to improve redraw reliability. + if (mode === 0 && options?.mode === undefined && windowsFullscreen) { + mode = 1; + autoLineMode = true; + } + native.reduce(statePtr, opsBuf, len, mode, row); if (options?.pointer) { @@ -91,12 +156,18 @@ export async function createTerm(options: TermOptions): Promise { native.setPointer(x, y, down); } - let output = new Uint8Array( + let output: Uint8Array = new Uint8Array( memory.buffer, native.output(statePtr), native.length(statePtr), ); + if (autoLineMode) { + output = normalizeWindowsLineOutput(output); + } else if (windowsFullscreen) { + output = wrapWindowsFullscreenOutput(output); + } + let current = new Set( options?.pointer ? native.getPointerOverIds() : [], ); diff --git a/test/term.test.ts b/test/term.test.ts index 47d8c94..270caf4 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -130,6 +130,11 @@ describe("term", () => { │ │ │ │ └──────────────────┘`.trim()); + + if (Deno.build.os === "windows") { + expect(out.startsWith("\x1b[?7l")).toBe(true); + expect(out.endsWith("\x1b[?7h")).toBe(true); + } }); it("primes front buffer for subsequent diff render", async () => { @@ -147,7 +152,13 @@ describe("term", () => { │ │ └──────────────────┘`.trim()); - expect(second.length).toBeLessThan(first.length); + if (Deno.build.os === "windows") { + expect(second.startsWith("\x1b[?7l\x1b[H")).toBe(true); + expect(second).toContain("\r\n"); + expect(second.endsWith("\x1b[?7h")).toBe(true); + } else { + expect(second.length).toBeLessThan(first.length); + } }); });