From 4940c9ef392945f010bd435908bd9c0f4c366199 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Wed, 20 May 2026 02:18:10 -0500 Subject: [PATCH 1/3] 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); + } }); }); From 36fe005c1e6491d95c676bec85cb6fde7b541f8e Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 22 May 2026 15:00:22 -0500 Subject: [PATCH 2/3] node example, deno ensure all bytes write --- demo/keyboard-node.ts | 254 ++++++++++++++ demo/keyboard-shared.ts | 640 ++++++++++++++++++++++++++++++++++ demo/keyboard.ts | 753 +++------------------------------------- term.ts | 37 +- 4 files changed, 970 insertions(+), 714 deletions(-) create mode 100644 demo/keyboard-node.ts create mode 100644 demo/keyboard-shared.ts diff --git a/demo/keyboard-node.ts b/demo/keyboard-node.ts new file mode 100644 index 0000000..042df8f --- /dev/null +++ b/demo/keyboard-node.ts @@ -0,0 +1,254 @@ +/// + +import { Buffer } from "node:buffer"; +import { + alternateBuffer, + close, + createInput, + createTerm, + cursor, + fixed, + grow, + mouseTracking, + open, + progressiveInput, + rgba, + settings, + text, +} from "../build/npm/esm/mod.js"; +import type { InputEvent, PointerEvent } from "../mod.ts"; +import { createKeyboardDemo } from "./keyboard-shared.ts"; + +type PointerState = { + x: number; + y: number; + down: boolean; +}; + +type NodeInput = Awaited>; +type NodeScanResult = { + events: InputEvent[]; + pending?: { delay: number }; +}; + +let demo = createKeyboardDemo({ + close, + fixed, + grow, + mouseTracking, + open, + progressiveInput, + rgba, + settings, + text, +} as Parameters[0]); + +let diagnostics = { + disableWindowsFullscreenHandling: process.argv.includes( + "--no-windows-fullscreen-fix", + ), +}; + +(globalThis as typeof globalThis & { + __claytermDiagnostics__?: { + disableWindowsFullscreenHandling?: boolean; + }; +}).__claytermDiagnostics__ = { + disableWindowsFullscreenHandling: diagnostics.disableWindowsFullscreenHandling, +}; + +let term: Awaited> | null = null; +let input: NodeInput | null = null; +let size = getSize(); +let flushTimer: ReturnType | null = null; +let tty = settings(alternateBuffer(), cursor(false)); +let modality = demo.recognizer(); +let context = modality.next().value; +let flags = demo.ttyFlags(context); + +let pointer: { state: PointerState | undefined } = { + state: undefined, +}; + +function getSize() { + return { + width: process.stdout.columns || 80, + height: process.stdout.rows || 24, + }; +} + +function write(bytes: Uint8Array): void { + process.stdout.write(Buffer.from(bytes)); +} + +function scan(chunk?: Uint8Array): NodeScanResult { + let normalized = chunk ? new Uint8Array(chunk) : undefined; + return input!.scan(normalized) as NodeScanResult; +} + +function resetFlushTimer(delay: number): void { + if (flushTimer !== null) { + clearTimeout(flushTimer); + } + flushTimer = setTimeout(() => { + flushTimer = null; + let result = scan(); + for (let event of result.events) { + handleEvent(event); + } + }, delay); +} + +function applyFlags(): void { + write(flags.revert); + flags = demo.ttyFlags(context); + write(flags.apply); +} + +function render(): PointerEvent[] { + if (!term) { + return []; + } + let result = term.render(demo.keyboard(context), { + pointer: pointer.state, + }); + write(result.output); + return result.events as PointerEvent[]; +} + +function dispatchLogged(event: InputEvent | PointerEvent): void { + let previous = context.logged; + context = modality.next(event).value; + if ( + context.event && + context.event.type in context.log && + context.log[context.event.type as keyof typeof context.log] + ) { + context = { ...context, logged: context.event }; + } else { + context = { ...context, logged: previous }; + } +} + +function updatePointer(event: InputEvent | PointerEvent): void { + if (!context["Capture mouse events"]) { + pointer.state = undefined; + return; + } + if (!("x" in event)) { + return; + } + pointer.state = { + x: event.x, + y: event.y, + down: event.type === "mousedown", + }; +} + +function handleEvent(event: InputEvent | PointerEvent): void { + if (event.type === "keydown" && event.ctrl && event.key === "c") { + cleanup(); + process.exit(0); + } + + if (event.type === "pointerenter") { + context.entered.add(event.id); + } + if (event.type === "pointerleave") { + context.entered.delete(event.id); + } + + dispatchLogged(event); + applyFlags(); + updatePointer(event); + + let queue = render(); + while (queue.length > 0) { + let next = queue.shift(); + if (!next) { + continue; + } + if (next.type === "pointerenter") { + context.entered.add(next.id); + } + if (next.type === "pointerleave") { + context.entered.delete(next.id); + } + dispatchLogged(next); + applyFlags(); + let emitted = render(); + queue.push(...emitted); + } +} + +async function resetTerm(): Promise { + size = getSize(); + term = await createTerm(size); +} + +function cleanup(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + try { + write(flags.revert); + write(tty.revert); + } catch { + // ignore cleanup write failures during shutdown + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); +} + +try { + input = await createInput(); + await resetTerm(); + + write(tty.apply); + write(flags.apply); + render(); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.on("data", (chunk: Uint8Array) => { + let result = scan(new Uint8Array(chunk)); + for (let event of result.events) { + handleEvent(event); + } + if (result.pending) { + resetFlushTimer(result.pending.delay); + } + }); + + if (process.stdout.isTTY) { + process.stdout.on("resize", async () => { + try { + await resetTerm(); + render(); + } catch (error) { + cleanup(); + console.error(error); + process.exit(1); + } + }); + } + + process.on("SIGINT", () => { + cleanup(); + process.exit(0); + }); + process.on("SIGTERM", () => { + cleanup(); + process.exit(0); + }); + process.on("exit", cleanup); +} catch (error) { + cleanup(); + console.error(error); + process.exit(1); +} \ No newline at end of file diff --git a/demo/keyboard-shared.ts b/demo/keyboard-shared.ts new file mode 100644 index 0000000..657f49c --- /dev/null +++ b/demo/keyboard-shared.ts @@ -0,0 +1,640 @@ +import type { InputEvent, KeyEvent, Op, PointerEvent } from "../mod.ts"; +import type { SizingAxis } from "../ops.ts"; +import type { Setting } from "../settings.ts"; + +type EventFilter = { + keydown: boolean; + keyrepeat: boolean; + keyup: boolean; + mousedown: boolean; + mouseup: boolean; + mousemove: boolean; + wheel: boolean; + resize: boolean; + pointerenter: boolean; + pointerleave: boolean; + pointerclick: boolean; +}; + +type AppContext = { + mode: "input" | "config"; + event: InputEvent | PointerEvent | null; + logged: InputEvent | PointerEvent | null; + log: EventFilter; + entered: Set; + ["Disambiguate escape codes"]: boolean; + ["Report event types"]: boolean; + ["Report alternate keys"]: boolean; + ["Report all keys as escapes"]: boolean; + ["Report associated text"]: boolean; + ["Capture mouse events"]: boolean; +}; + +type KeyDef = { + label: string; + code: string; + width?: number; +}; + +type KeyboardDemoApi = { + close(): Op; + fixed(value: number): SizingAxis; + grow(min?: number, max?: number): SizingAxis; + mouseTracking(): Setting; + open(id: string, properties: Record): Op; + progressiveInput(bits: number): Setting; + rgba(r: number, g: number, b: number, a?: number): number; + settings(...parts: Setting[]): Setting; + text(value: string, properties?: Record): Op; +}; + +type Mode = Iterable; + +export function createKeyboardDemo(api: KeyboardDemoApi) { + let { + close, + fixed, + grow, + mouseTracking, + open, + progressiveInput, + rgba, + settings, + text, + } = api; + + let active = rgba(60, 120, 220); + let inactive = rgba(50, 50, 60); + let on = rgba(40, 180, 80); + let label = rgba(220, 220, 220); + let dim = rgba(100, 100, 120); + let highlight = rgba(255, 220, 80); + let hovered = rgba(80, 80, 100); + + let KEY_W = 5; + let GAP = 1; + + let flagNames: (keyof Omit< + AppContext, + "mode" | "event" | "logged" | "log" | "entered" + >)[] = [ + "Disambiguate escape codes", + "Report event types", + "Report alternate keys", + "Report all keys as escapes", + "Report associated text", + ]; + + let logEntries: { key: string; name: keyof EventFilter }[] = [ + { key: "a", name: "keydown" }, + { key: "b", name: "keyup" }, + { key: "c", name: "keyrepeat" }, + { key: "d", name: "mousedown" }, + { key: "e", name: "mouseup" }, + { key: "f", name: "mousemove" }, + { key: "g", name: "wheel" }, + { key: "h", name: "resize" }, + { key: "i", name: "pointerenter" }, + { key: "j", name: "pointerleave" }, + { key: "k", name: "pointerclick" }, + ]; + + function isKeyEvent(event: InputEvent | PointerEvent): event is KeyEvent { + return event.type === "keydown" || event.type === "keyrepeat" || + event.type === "keyup"; + } + + function matches(keyDef: KeyDef, event: InputEvent | PointerEvent): boolean { + return isKeyEvent(event) && event.type === "keydown" && + event.code.toUpperCase() === keyDef.code.toUpperCase(); + } + + function key(ops: Op[], keyDef: KeyDef, context: AppContext): void { + let pressed = context.event && matches(keyDef, context.event); + let hover = context.entered.has(`key:${keyDef.code}`); + let bg = pressed ? active : hover ? hovered : inactive; + let width = keyDef.width ?? KEY_W; + ops.push( + open(`key:${keyDef.code}`, { + layout: { + width: fixed(width), + height: grow(), + padding: { left: 1, right: 1 }, + alignX: 2, + alignY: 2, + }, + bg, + border: hover + ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } + : undefined, + }), + text(keyDef.label, { color: hover ? highlight : label }), + close(), + ); + } + + function row(ops: Op[], keys: KeyDef[], context: AppContext): void { + ops.push( + open("", { layout: { direction: "ltr", gap: GAP, height: fixed(3) } }), + ); + for (let keyDef of keys) { + key(ops, keyDef, context); + } + ops.push(close()); + } + + function spacer(ops: Op[], width: number): void { + ops.push( + open("", { layout: { width: fixed(width), height: grow() } }), + close(), + ); + } + + function mainKeys(ops: Op[], context: AppContext): void { + ops.push(open("main-keys", { layout: { direction: "ttb", gap: GAP } })); + + row(ops, [ + { label: "Esc", code: "Escape", width: 11 }, + { label: "F1", code: "F1" }, + { label: "F2", code: "F2" }, + { label: "F3", code: "F3" }, + { label: "F4", code: "F4" }, + { label: "F5", code: "F5" }, + { label: "F6", code: "F6" }, + { label: "F7", code: "F7" }, + { label: "F8", code: "F8" }, + { label: "F9", code: "F9" }, + { label: "F10", code: "F10" }, + { label: "F11", code: "F11" }, + { label: "F12", code: "F12" }, + ], context); + + row(ops, [ + { label: "`", code: "`" }, + { label: "1", code: "1" }, + { label: "2", code: "2" }, + { label: "3", code: "3" }, + { label: "4", code: "4" }, + { label: "5", code: "5" }, + { label: "6", code: "6" }, + { label: "7", code: "7" }, + { label: "8", code: "8" }, + { label: "9", code: "9" }, + { label: "0", code: "0" }, + { label: "-", code: "-" }, + { label: "=", code: "=" }, + { label: "Bksp", code: "Backspace", width: 9 }, + ], context); + + row(ops, [ + { label: "Tab", code: "Tab", width: 7 }, + { label: "Q", code: "q" }, + { label: "W", code: "w" }, + { label: "E", code: "e" }, + { label: "R", code: "r" }, + { label: "T", code: "t" }, + { label: "Y", code: "y" }, + { label: "U", code: "u" }, + { label: "I", code: "i" }, + { label: "O", code: "o" }, + { label: "P", code: "p" }, + { label: "[", code: "[" }, + { label: "]", code: "]" }, + { label: "\\", code: "\\", width: 7 }, + ], context); + + row(ops, [ + { label: "Caps", code: "CapsLock", width: 9 }, + { label: "A", code: "a" }, + { label: "S", code: "s" }, + { label: "D", code: "d" }, + { label: "F", code: "f" }, + { label: "G", code: "g" }, + { label: "H", code: "h" }, + { label: "J", code: "j" }, + { label: "K", code: "k" }, + { label: "L", code: "l" }, + { label: ";", code: ";" }, + { label: "'", code: "'" }, + { label: "Enter", code: "Enter", width: 10 }, + ], context); + + row(ops, [ + { label: "Shift", code: "ShiftLeft", width: 11 }, + { label: "Z", code: "z" }, + { label: "X", code: "x" }, + { label: "C", code: "c" }, + { label: "V", code: "v" }, + { label: "B", code: "b" }, + { label: "N", code: "n" }, + { label: "M", code: "m" }, + { label: ",", code: "," }, + { label: ".", code: "." }, + { label: "/", code: "/" }, + { label: "Shift", code: "ShiftRight", width: 13 }, + ], context); + + row(ops, [ + { label: "Ctrl", code: "ControlLeft", width: 7 }, + { label: "Win", code: "SuperLeft", width: 6 }, + { label: "Alt", code: "AltLeft", width: 6 }, + { label: "", code: " ", width: 33 }, + { label: "Alt", code: "AltRight", width: 6 }, + { label: "Win", code: "SuperRight", width: 6 }, + { label: "Menu", code: "Menu", width: 6 }, + { label: "Ctrl", code: "ControlRight", width: 7 }, + ], context); + + ops.push(close()); + } + + function navKeys(ops: Op[], context: AppContext): void { + ops.push(open("nav-keys", { layout: { direction: "ttb", gap: GAP } })); + + row(ops, [ + { label: "Ins", code: "Insert", width: 6 }, + { label: "Home", code: "Home", width: 6 }, + { label: "PgUp", code: "PageUp", width: 6 }, + ], context); + + row(ops, [ + { label: "Del", code: "Delete", width: 6 }, + { label: "End", code: "End", width: 6 }, + { label: "PgDn", code: "PageDown", width: 6 }, + ], context); + + ops.push(open("", { layout: { height: fixed(3) } }), close()); + + ops.push( + open("", { layout: { direction: "ltr", gap: GAP, height: fixed(3) } }), + ); + spacer(ops, 6); + key(ops, { label: "↑", code: "ArrowUp", width: 6 }, context); + spacer(ops, 6); + ops.push(close()); + + row(ops, [ + { label: "←", code: "ArrowLeft", width: 6 }, + { label: "↓", code: "ArrowDown", width: 6 }, + { label: "→", code: "ArrowRight", width: 6 }, + ], context); + + ops.push(close()); + } + + function numpad(ops: Op[], context: AppContext): void { + ops.push(open("numpad", { layout: { direction: "ttb", gap: GAP } })); + + row(ops, [ + { label: "Num", code: "NumLock", width: 6 }, + { label: "/", code: "NumpadDivide", width: 6 }, + { label: "*", code: "NumpadMultiply", width: 6 }, + { label: "-", code: "NumpadSubtract", width: 6 }, + ], context); + + ops.push(open("", { layout: { direction: "ltr", gap: GAP } })); + ops.push(open("", { layout: { direction: "ttb", gap: GAP } })); + row(ops, [ + { label: "7", code: "Numpad7", width: 6 }, + { label: "8", code: "Numpad8", width: 6 }, + { label: "9", code: "Numpad9", width: 6 }, + ], context); + row(ops, [ + { label: "4", code: "Numpad4", width: 6 }, + { label: "5", code: "Numpad5", width: 6 }, + { label: "6", code: "Numpad6", width: 6 }, + ], context); + ops.push(close()); + + key(ops, { label: "+", code: "NumpadAdd" }, context); + ops.push(close()); + + ops.push(open("", { layout: { direction: "ltr", gap: GAP } })); + ops.push(open("", { layout: { direction: "ttb", gap: GAP } })); + row(ops, [ + { label: "1", code: "Numpad1", width: 6 }, + { label: "2", code: "Numpad2", width: 6 }, + { label: "3", code: "Numpad3", width: 6 }, + ], context); + row(ops, [ + { label: "0", code: "Numpad0", width: 13 }, + { label: ".", code: "NumpadDecimal", width: 6 }, + ], context); + ops.push(close()); + + key(ops, { label: "Ent", code: "NumpadEnter" }, context); + ops.push(close()); + ops.push(close()); + } + + function toggle(ops: Op[], enabled: boolean, name: string): void { + let indicator = enabled ? "●───" : "───○"; + ops.push( + open("", { layout: { direction: "ltr", height: fixed(1), gap: 1 } }), + text(indicator, { color: enabled ? on : dim }), + text(name, { color: enabled ? label : dim }), + close(), + ); + } + + function logToggle( + ops: Op[], + entries: typeof logEntries, + context: AppContext, + ): void { + for (let entry of entries) { + ops.push( + open(`log:${entry.name}`, { + layout: { direction: "ltr", height: fixed(1), gap: 1 }, + }), + ); + ops.push(text(`${entry.key}.`, { color: dim })); + toggle(ops, context.log[entry.name], entry.name); + ops.push(close()); + } + } + + function configPanel(ops: Op[], context: AppContext): void { + let color = context.mode === "config" ? active : rgba(0, 0, 0, 0); + ops.push(open("config", { + layout: { + direction: "ltr", + gap: 3, + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + }, + border: { color, left: 1, right: 1, top: 1, bottom: 1 }, + })); + + ops.push(open("protocol-level", { layout: { direction: "ttb", gap: 1 } })); + ops.push( + open("", { layout: { height: fixed(1) } }), + text("Keyboard Protocol Level", { color: highlight }), + close(), + ); + for (let index = 0; index < flagNames.length; index++) { + let name = flagNames[index]; + ops.push( + open(`flag:${name}`, { + layout: { direction: "ltr", height: fixed(1), gap: 1 }, + }), + ); + ops.push(text(`${index + 1}.`, { color: dim })); + toggle(ops, context[name], name); + ops.push(close()); + } + ops.push(close()); + + let col1 = logEntries.slice(0, 6); + let col2 = logEntries.slice(6); + + ops.push(open("log-events", { layout: { direction: "ttb", gap: 1 } })); + ops.push( + open("", { layout: { height: fixed(1) } }), + text("Log Events", { color: highlight }), + close(), + ); + logToggle(ops, col1, context); + ops.push(close()); + + ops.push(open("log-events-2", { layout: { direction: "ttb", gap: 1 } })); + ops.push(open("", { layout: { height: fixed(1) } }), close()); + logToggle(ops, col2, context); + ops.push(close()); + + ops.push(close()); + } + + function keyboard(context: AppContext): Op[] { + let ops: Op[] = []; + + ops.push(open("root", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + alignX: 2, + alignY: 2, + padding: { left: 2, top: 1 }, + }, + })); + + ops.push(open("", { layout: { direction: "ttb" } })); + + ops.push(open("", { + layout: { + width: grow(), + direction: "ltr", + alignY: 0, + padding: { bottom: 1 }, + }, + })); + + let badgeBg = context.mode === "input" + ? rgba(40, 120, 200) + : rgba(200, 120, 40); + let badgeLabel = context.mode === "input" ? "input" : "config"; + let badgeHint = context.mode === "input" + ? "Ctrl+X Ctrl+X to enter config" + : "Set flags with keys [0-5], Enter to save"; + let mouseBg = context["Capture mouse events"] + ? rgba(40, 180, 80) + : rgba(80, 80, 80); + let mouseLabel = context["Capture mouse events"] ? "capture" : "system"; + + ops.push( + open("badges", { layout: { direction: "ttb", gap: 1, padding: { top: 1 } } }), + open("badge:mode", { + layout: { direction: "ltr", height: fixed(1), padding: { bottom: 1 } }, + }), + open("", { + layout: { padding: { left: 1, right: 1 } }, + bg: rgba(60, 60, 60), + }), + text("mode", { color: rgba(220, 220, 220) }), + close(), + open("", { layout: { padding: { left: 1, right: 1 } }, bg: badgeBg }), + text(badgeLabel, { color: rgba(255, 255, 255) }), + close(), + text(` ${badgeHint}`, { color: dim }), + close(), + open("badge:mouse", { layout: { direction: "ltr", height: fixed(1) } }), + open("", { + layout: { padding: { left: 1, right: 1 } }, + bg: rgba(60, 60, 60), + }), + text("mouse", { color: rgba(220, 220, 220) }), + close(), + open("", { layout: { padding: { left: 1, right: 1 } }, bg: mouseBg }), + text(mouseLabel, { color: rgba(255, 255, 255) }), + close(), + text(" Ctrl+X Ctrl+M to toggle", { color: dim }), + close(), + close(), + ); + + ops.push(open("", { layout: { width: grow(), direction: "ltr", alignX: 1 } })); + configPanel(ops, context); + ops.push(close()); + ops.push(close()); + + let keyboardColor = context.mode === "input" ? active : rgba(0, 0, 0, 0); + ops.push(open("keyboard", { + layout: { + direction: "ltr", + gap: 3, + alignY: 1, + padding: { left: 1, right: 1, top: 1, bottom: 1 }, + }, + border: { color: keyboardColor, left: 1, right: 1, top: 1, bottom: 1 }, + })); + + mainKeys(ops, context); + navKeys(ops, context); + numpad(ops, context); + + ops.push(close()); + ops.push(close()); + + ops.push(open("event-log", { layout: { height: fixed(1), padding: { top: 1 } } })); + ops.push( + text(context.logged ? JSON.stringify(context.logged) : "Press any key...", { + color: highlight, + }), + ); + ops.push(close()); + ops.push(close()); + + return ops; + } + + function ttyFlags(context: AppContext): Setting { + let parts: Setting[] = []; + let bits = 0; + if (context["Disambiguate escape codes"]) bits |= 1; + if (context["Report event types"]) bits |= 2; + if (context["Report alternate keys"]) bits |= 4; + if (context["Report all keys as escapes"]) bits |= 8; + if (context["Report associated text"]) bits |= 16; + parts.push(progressiveInput(bits)); + if (context["Capture mouse events"]) { + parts.push(mouseTracking()); + } + return settings(...parts); + } + + function createInitialContext(): AppContext { + return { + mode: "input", + "Disambiguate escape codes": true, + "Report event types": true, + "Report alternate keys": true, + "Report all keys as escapes": true, + "Report associated text": true, + "Capture mouse events": true, + log: { + keydown: true, + keyrepeat: false, + keyup: false, + mousedown: false, + mouseup: false, + mousemove: false, + wheel: true, + resize: true, + pointerenter: false, + pointerleave: false, + pointerclick: true, + }, + entered: new Set(), + event: null, + logged: null, + }; + } + + function* recognizer(): Iterator { + let current = createInitialContext(); + let event = yield current; + let mode = inputmode({ ...current, event }); + + while (true) { + mode = yield* mode; + } + } + + function* inputmode(context: AppContext): Mode { + context = { ...context, mode: "input" }; + let event = context.event ? context.event : yield context; + while (true) { + context = { ...context, event }; + if (event.type === "keydown" && event.key === "x" && event.ctrl) { + let next = yield context; + while (next.type !== "keydown") { + context = { ...context, event: next }; + next = yield context; + } + context = { ...context, event: next }; + if (next.key === "x" && next.ctrl) { + return configmode({ ...context, event: null }); + } else if (next.key === "m" && next.ctrl) { + context = { + ...context, + "Capture mouse events": !context["Capture mouse events"], + event: null, + }; + event = yield context; + continue; + } + } + event = yield context; + } + } + + function* configmode(context: AppContext): Mode { + context = { ...context, mode: "config" }; + let event = yield context; + while (true) { + if (event.type === "keydown" && event.key === "Enter") { + return inputmode({ ...context, event: null }); + } + if (event.type === "keydown") { + let keyEvent = event as KeyEvent; + let entry = logEntries.find((candidate) => candidate.key === keyEvent.key); + if (entry) { + context = { + ...context, + log: { ...context.log, [entry.name]: !context.log[entry.name] }, + }; + } + if ("012345".indexOf(keyEvent.key) >= 0) { + context = { ...context }; + context["Report associated text"] = false; + context["Report all keys as escapes"] = false; + context["Report alternate keys"] = false; + context["Report event types"] = false; + context["Disambiguate escape codes"] = false; + switch (keyEvent.key) { + case "5": + context["Report associated text"] = true; + case "4": + context["Report all keys as escapes"] = true; + case "3": + context["Report alternate keys"] = true; + case "2": + context["Report event types"] = true; + case "1": + context["Disambiguate escape codes"] = true; + break; + case "0": + break; + } + } + } + event = yield context; + } + } + + return { + keyboard, + recognizer, + ttyFlags, + }; +} \ No newline at end of file diff --git a/demo/keyboard.ts b/demo/keyboard.ts index 955361b..adc10f1 100644 --- a/demo/keyboard.ts +++ b/demo/keyboard.ts @@ -1,4 +1,3 @@ -// deno-lint-ignore-file no-fallthrough import { createChannel, each, @@ -10,586 +9,59 @@ import { until, } from "effection"; import { - close, createTerm, - fixed, - grow, - type InputEvent, - type KeyEvent, - type Op, - open, type PointerEvent, - rgba, - text, } from "../mod.ts"; import { alternateBuffer, - cursor, - mouseTracking, - progressiveInput, - type Setting, settings, } from "../settings.ts"; +import { close, fixed, grow, open, rgba, text } from "../mod.ts"; +import { cursor, mouseTracking, progressiveInput } from "../settings.ts"; +import { createKeyboardDemo } from "./keyboard-shared.ts"; import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; -const active = rgba(60, 120, 220); -const inactive = rgba(50, 50, 60); -const on = rgba(40, 180, 80); -const label = rgba(220, 220, 220); -const dim = rgba(100, 100, 120); -const highlight = rgba(255, 220, 80); - -const KEY_W = 5; -const GAP = 1; -const WRITE_CHUNK_SIZE = 1024; - -interface KeyDef { - label: string; - code: string; - width?: number; -} - -function isKeyEvent(e: InputEvent | PointerEvent): e is KeyEvent { - return e.type === "keydown" || e.type === "keyrepeat" || e.type === "keyup"; -} - -function matches(k: KeyDef, event: InputEvent | PointerEvent): boolean { - return isKeyEvent(event) && event.type === "keydown" && - event.code.toUpperCase() === k.code.toUpperCase(); -} - -const hovered = rgba(80, 80, 100); - -function writeOutput(output: Uint8Array): void { - if (Deno.build.os !== "windows") { - Deno.stdout.writeSync(output); - return; - } +const demo = createKeyboardDemo({ + close, + fixed, + grow, + mouseTracking, + open, + progressiveInput, + rgba, + settings, + text, +}); - // 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; +let diagnostics = { + disableWindowsFullscreenHandling: Deno.args.includes( + "--no-windows-fullscreen-fix", + ), +}; - for (let i = 0; i < output.length; i++) { - if (output[i] === 0x0a) { - lastBreak = i + 1; - } +(globalThis as typeof globalThis & { + __claytermDiagnostics__?: { + disableWindowsFullscreenHandling?: boolean; + }; +}).__claytermDiagnostics__ = { + disableWindowsFullscreenHandling: diagnostics.disableWindowsFullscreenHandling, +}; - if (i - start + 1 >= WRITE_CHUNK_SIZE && lastBreak > start) { - Deno.stdout.writeSync(output.subarray(start, lastBreak)); - start = lastBreak; +function writeAllSync(output: Uint8Array): void { + // Deno's stdout writes are not guaranteed to drain the full buffer in one + // call, so always loop until the entire frame chunk is written. + let offset = 0; + while (offset < output.length) { + let written = Deno.stdout.writeSync(output.subarray(offset)); + if (written <= 0) { + // should never happen with stdout, but guard against infinite loop if it does + throw new Error("stdout write returned without making progress"); } - } - - if (start < output.length) { - Deno.stdout.writeSync(output.subarray(start)); + offset += written; } } -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}`); - let bg = pressed ? active : hover ? hovered : inactive; - let w = k.width ?? KEY_W; - ops.push( - open(`key:${k.code}`, { - layout: { - width: fixed(w), - height: grow(), - padding: { left: 1, right: 1 }, - alignX: 2, - alignY: 2, - }, - bg, - border: hover - ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } - : undefined, - }), - text(k.label, { color: hover ? highlight : label }), - close(), - ); -} - -function row(ops: Op[], keys: KeyDef[], ctx: AppContext): void { - ops.push( - open("", { layout: { direction: "ltr", gap: GAP, height: fixed(3) } }), - ); - for (let k of keys) { - key(ops, k, ctx); - } - ops.push(close()); -} - -function spacer(ops: Op[], width: number): void { - ops.push( - open("", { layout: { width: fixed(width), height: grow() } }), - close(), - ); -} - -function mainKeys(ops: Op[], ctx: AppContext): void { - ops.push( - open("main-keys", { layout: { direction: "ttb", gap: GAP } }), - ); - - row(ops, [ - { label: "Esc", code: "Escape", width: 11 }, - { label: "F1", code: "F1" }, - { label: "F2", code: "F2" }, - { label: "F3", code: "F3" }, - { label: "F4", code: "F4" }, - { label: "F5", code: "F5" }, - { label: "F6", code: "F6" }, - { label: "F7", code: "F7" }, - { label: "F8", code: "F8" }, - { label: "F9", code: "F9" }, - { label: "F10", code: "F10" }, - { label: "F11", code: "F11" }, - { label: "F12", code: "F12" }, - ], ctx); - - row(ops, [ - { label: "`", code: "`" }, - { label: "1", code: "1" }, - { label: "2", code: "2" }, - { label: "3", code: "3" }, - { label: "4", code: "4" }, - { label: "5", code: "5" }, - { label: "6", code: "6" }, - { label: "7", code: "7" }, - { label: "8", code: "8" }, - { label: "9", code: "9" }, - { label: "0", code: "0" }, - { label: "-", code: "-" }, - { label: "=", code: "=" }, - { label: "Bksp", code: "Backspace", width: 9 }, - ], ctx); - - row(ops, [ - { label: "Tab", code: "Tab", width: 7 }, - { label: "Q", code: "q" }, - { label: "W", code: "w" }, - { label: "E", code: "e" }, - { label: "R", code: "r" }, - { label: "T", code: "t" }, - { label: "Y", code: "y" }, - { label: "U", code: "u" }, - { label: "I", code: "i" }, - { label: "O", code: "o" }, - { label: "P", code: "p" }, - { label: "[", code: "[" }, - { label: "]", code: "]" }, - { label: "\\", code: "\\", width: 7 }, - ], ctx); - - row(ops, [ - { label: "Caps", code: "CapsLock", width: 9 }, - { label: "A", code: "a" }, - { label: "S", code: "s" }, - { label: "D", code: "d" }, - { label: "F", code: "f" }, - { label: "G", code: "g" }, - { label: "H", code: "h" }, - { label: "J", code: "j" }, - { label: "K", code: "k" }, - { label: "L", code: "l" }, - { label: ";", code: ";" }, - { label: "'", code: "'" }, - { label: "Enter", code: "Enter", width: 10 }, - ], ctx); - - row(ops, [ - { label: "Shift", code: "ShiftLeft", width: 11 }, - { label: "Z", code: "z" }, - { label: "X", code: "x" }, - { label: "C", code: "c" }, - { label: "V", code: "v" }, - { label: "B", code: "b" }, - { label: "N", code: "n" }, - { label: "M", code: "m" }, - { label: ",", code: "," }, - { label: ".", code: "." }, - { label: "/", code: "/" }, - { label: "Shift", code: "ShiftRight", width: 13 }, - ], ctx); - - row(ops, [ - { label: "Ctrl", code: "ControlLeft", width: 7 }, - { label: "Win", code: "SuperLeft", width: 6 }, - { label: "Alt", code: "AltLeft", width: 6 }, - { label: "", code: " ", width: 33 }, - { label: "Alt", code: "AltRight", width: 6 }, - { label: "Win", code: "SuperRight", width: 6 }, - { label: "Menu", code: "Menu", width: 6 }, - { label: "Ctrl", code: "ControlRight", width: 7 }, - ], ctx); - - ops.push(close()); -} - -function navKeys(ops: Op[], ctx: AppContext): void { - ops.push( - open("nav-keys", { layout: { direction: "ttb", gap: GAP } }), - ); - - // top section: Ins/Home/PgUp, Del/End/PgDn - row(ops, [ - { label: "Ins", code: "Insert", width: 6 }, - { label: "Home", code: "Home", width: 6 }, - { label: "PgUp", code: "PageUp", width: 6 }, - ], ctx); - - row(ops, [ - { label: "Del", code: "Delete", width: 6 }, - { label: "End", code: "End", width: 6 }, - { label: "PgDn", code: "PageDown", width: 6 }, - ], ctx); - - // gap before arrows - ops.push( - open("", { layout: { height: fixed(3) } }), - close(), - ); - - // arrow up - ops.push( - open("", { layout: { direction: "ltr", gap: GAP, height: fixed(3) } }), - ); - spacer(ops, 6); - key(ops, { label: "\u2191", code: "ArrowUp", width: 6 }, ctx); - spacer(ops, 6); - ops.push(close()); - - // arrow left/down/right - row(ops, [ - { label: "\u2190", code: "ArrowLeft", width: 6 }, - { label: "\u2193", code: "ArrowDown", width: 6 }, - { label: "\u2192", code: "ArrowRight", width: 6 }, - ], ctx); - - ops.push(close()); -} - -function numpad(ops: Op[], ctx: AppContext): void { - ops.push( - open("numpad", { layout: { direction: "ttb", gap: GAP } }), - ); - - row(ops, [ - { label: "Num", code: "NumLock", width: 6 }, - { label: "/", code: "NumpadDivide", width: 6 }, - { label: "*", code: "NumpadMultiply", width: 6 }, - { label: "-", code: "NumpadSubtract", width: 6 }, - ], ctx); - - // rows 2-3 grouped horizontally so + spans both - ops.push( - open("", { layout: { direction: "ltr", gap: GAP } }), - ); - - // left side: 7-8-9 and 4-5-6 stacked - ops.push( - open("", { layout: { direction: "ttb", gap: GAP } }), - ); - row(ops, [ - { label: "7", code: "Numpad7", width: 6 }, - { label: "8", code: "Numpad8", width: 6 }, - { label: "9", code: "Numpad9", width: 6 }, - ], ctx); - row(ops, [ - { label: "4", code: "Numpad4", width: 6 }, - { label: "5", code: "Numpad5", width: 6 }, - { label: "6", code: "Numpad6", width: 6 }, - ], ctx); - ops.push(close()); - - // + spanning both rows - key(ops, { label: "+", code: "NumpadAdd" }, ctx); - - ops.push(close()); - - // rows 4-5 grouped horizontally so Enter spans both - ops.push( - open("", { layout: { direction: "ltr", gap: GAP } }), - ); - - // left side: 1-2-3 and 0-. stacked - ops.push( - open("", { layout: { direction: "ttb", gap: GAP } }), - ); - row(ops, [ - { label: "1", code: "Numpad1", width: 6 }, - { label: "2", code: "Numpad2", width: 6 }, - { label: "3", code: "Numpad3", width: 6 }, - ], ctx); - row(ops, [ - { label: "0", code: "Numpad0", width: 13 }, - { label: ".", code: "NumpadDecimal", width: 6 }, - ], ctx); - ops.push(close()); - - // Enter spanning both rows - key(ops, { label: "Ent", code: "NumpadEnter" }, ctx); - - ops.push(close()); - - ops.push(close()); -} - -function toggle(ops: Op[], enabled: boolean, name: string): void { - let indicator = enabled - ? "\u25cf\u2500\u2500\u2500" - : "\u2500\u2500\u2500\u25cb"; - ops.push( - open("", { - layout: { - direction: "ltr", - height: fixed(1), - gap: 1, - }, - }), - text(indicator, { color: enabled ? on : dim }), - text(name, { color: enabled ? label : dim }), - close(), - ); -} - -const flagNames: - (keyof Omit)[] = - [ - "Disambiguate escape codes", - "Report event types", - "Report alternate keys", - "Report all keys as escapes", - "Report associated text", - ]; - -const logEntries: { key: string; name: keyof EventFilter }[] = [ - { key: "a", name: "keydown" }, - { key: "b", name: "keyup" }, - { key: "c", name: "keyrepeat" }, - { key: "d", name: "mousedown" }, - { key: "e", name: "mouseup" }, - { key: "f", name: "mousemove" }, - { key: "g", name: "wheel" }, - { key: "h", name: "resize" }, - { key: "i", name: "pointerenter" }, - { key: "j", name: "pointerleave" }, - { key: "k", name: "pointerclick" }, -]; - -function logToggle( - ops: Op[], - entries: typeof logEntries, - ctx: AppContext, -): void { - for (let entry of entries) { - ops.push( - open(`log:${entry.name}`, { - layout: { direction: "ltr", height: fixed(1), gap: 1 }, - }), - ); - ops.push(text(`${entry.key}.`, { color: dim })); - toggle(ops, ctx.log[entry.name], entry.name); - ops.push(close()); - } -} - -function configPanel(ops: Op[], ctx: AppContext): void { - let color = ctx.mode === "config" ? active : rgba(0, 0, 0, 0); - ops.push(open("config", { - layout: { - direction: "ltr", - gap: 3, - padding: { left: 1, right: 1, top: 1, bottom: 1 }, - }, - border: { color, left: 1, right: 1, top: 1, bottom: 1 }, - })); - - // keyboard protocol level column - ops.push(open("protocol-level", { layout: { direction: "ttb", gap: 1 } })); - ops.push( - open("", { layout: { height: fixed(1) } }), - text("Keyboard Protocol Level", { color: highlight }), - close(), - ); - for (let i = 0; i < flagNames.length; i++) { - let name = flagNames[i]; - ops.push( - open(`flag:${name}`, { - layout: { direction: "ltr", height: fixed(1), gap: 1 }, - }), - ); - ops.push(text(`${i + 1}.`, { color: dim })); - toggle(ops, ctx[name], name); - ops.push(close()); - } - ops.push(close()); - - // log events column 1 - let col1 = logEntries.slice(0, 6); - let col2 = logEntries.slice(6); - - ops.push(open("log-events", { layout: { direction: "ttb", gap: 1 } })); - ops.push( - open("", { layout: { height: fixed(1) } }), - text("Log Events", { color: highlight }), - close(), - ); - logToggle(ops, col1, ctx); - ops.push(close()); - - // log events column 2 - ops.push(open("log-events-2", { layout: { direction: "ttb", gap: 1 } })); - ops.push( - open("", { layout: { height: fixed(1) } }), - close(), - ); - logToggle(ops, col2, ctx); - ops.push(close()); - - ops.push(close()); -} - -function keyboard(ctx: AppContext): Op[] { - let ops: Op[] = []; - - // root - ops.push( - open("root", { - layout: { - width: grow(), - height: grow(), - direction: "ttb", - alignX: 2, - alignY: 2, - padding: { left: 2, top: 1 }, - }, - }), - ); - - // keyboard + toggles wrapper - ops.push( - open("", { layout: { direction: "ttb" } }), - ); - - // badges + config row - ops.push( - open("", { - layout: { - width: grow(), - direction: "ltr", - alignY: 0, - padding: { bottom: 1 }, - }, - }), - ); - - // badges column (left, bottom-aligned) - let badgeBg = ctx.mode === "input" ? rgba(40, 120, 200) : rgba(200, 120, 40); - let badgeLabel = ctx.mode === "input" ? "input" : "config"; - let badgeHint = ctx.mode === "input" - ? "Ctrl+X Ctrl+X to enter config" - : "Set flags with keys [0-5], Enter to save"; - let mouseBg = ctx["Capture mouse events"] - ? rgba(40, 180, 80) - : rgba(80, 80, 80); - let mouseLabel = ctx["Capture mouse events"] ? "capture" : "system"; - ops.push( - open("badges", { - layout: { direction: "ttb", gap: 1, padding: { top: 1 } }, - }), - open("badge:mode", { - layout: { direction: "ltr", height: fixed(1), padding: { bottom: 1 } }, - }), - open("", { - layout: { padding: { left: 1, right: 1 } }, - bg: rgba(60, 60, 60), - }), - text("mode", { color: rgba(220, 220, 220) }), - close(), - open("", { layout: { padding: { left: 1, right: 1 } }, bg: badgeBg }), - text(badgeLabel, { color: rgba(255, 255, 255) }), - close(), - text(` ${badgeHint}`, { color: dim }), - close(), - open("badge:mouse", { layout: { direction: "ltr", height: fixed(1) } }), - open("", { - layout: { padding: { left: 1, right: 1 } }, - bg: rgba(60, 60, 60), - }), - text("mouse", { color: rgba(220, 220, 220) }), - close(), - open("", { layout: { padding: { left: 1, right: 1 } }, bg: mouseBg }), - text(mouseLabel, { color: rgba(255, 255, 255) }), - close(), - text(" Ctrl+X Ctrl+M to toggle", { color: dim }), - close(), - close(), - ); - - // config panel (right) - ops.push( - open("", { layout: { width: grow(), direction: "ltr", alignX: 1 } }), - ); - configPanel(ops, ctx); - ops.push(close()); - - ops.push(close()); // badges + config row - - // three keyboard groups side by side, bottom-aligned - let kbColor = ctx.mode === "input" ? active : rgba(0, 0, 0, 0); - ops.push( - open("keyboard", { - layout: { - direction: "ltr", - gap: 3, - alignY: 1, - padding: { left: 1, right: 1, top: 1, bottom: 1 }, - }, - border: { color: kbColor, left: 1, right: 1, top: 1, bottom: 1 }, - }), - ); - - mainKeys(ops, ctx); - navKeys(ops, ctx); - numpad(ops, ctx); - - ops.push(close()); - - ops.push(close()); // keyboard + toggles wrapper - - // raw event display - ops.push( - open("event-log", { layout: { height: fixed(1), padding: { top: 1 } } }), - text(ctx.logged ? JSON.stringify(ctx.logged) : "Press any key...", { - color: highlight, - }), - close(), - ); - - ops.push(close()); - - return ops; -} - -function ttyFlags(ctx: AppContext): Setting { - let parts: Setting[] = []; - let bits = 0; - if (ctx["Disambiguate escape codes"]) bits |= 1; - if (ctx["Report event types"]) bits |= 2; - if (ctx["Report alternate keys"]) bits |= 4; - if (ctx["Report all keys as escapes"]) bits |= 8; - if (ctx["Report associated text"]) bits |= 16; - parts.push(progressiveInput(bits)); - if (ctx["Capture mouse events"]) { - parts.push(mouseTracking()); - } - return settings(...parts); -} - await main(function* () { let { columns, rows } = Deno.stdout.isTerminal() ? Deno.consoleSize() @@ -604,26 +76,26 @@ await main(function* () { let term = yield* until(createTerm({ width: columns, height: rows })); let tty = settings(alternateBuffer(), cursor(false)); - Deno.stdout.writeSync(tty.apply); + writeAllSync(tty.apply); - let modality = recognizer(); + let modality = demo.recognizer(); let context = modality.next().value; - let flags = ttyFlags(context); - Deno.stdout.writeSync(flags.apply); + let flags = demo.ttyFlags(context); + writeAllSync(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); + writeAllSync(flags.revert); + writeAllSync(tty.revert); }); - let { output } = term.render(keyboard(context)); + let { output } = term.render(demo.keyboard(context)); - writeOutput(output); + writeAllSync(output); let pointer = { events: createChannel(), @@ -649,9 +121,9 @@ await main(function* () { context = { ...context, logged: prev }; } - Deno.stdout.writeSync(flags.revert); - flags = ttyFlags(context); - Deno.stdout.writeSync(flags.apply); + writeAllSync(flags.revert); + flags = demo.ttyFlags(context); + writeAllSync(flags.apply); if (context["Capture mouse events"]) { if ("x" in event) { @@ -665,7 +137,7 @@ await main(function* () { pointer.state = undefined; } - let { output, events } = term.render(keyboard(context), { + let { output, events } = term.render(demo.keyboard(context), { pointer: pointer.state, }); @@ -673,125 +145,12 @@ await main(function* () { yield* pointer.events.send(event); } - writeOutput(output); + writeAllSync(output); yield* each.next(); } }); -function* recognizer(): Iterator { - let current: AppContext = { - mode: "input", - "Disambiguate escape codes": true, - "Report event types": true, - "Report alternate keys": true, - "Report all keys as escapes": true, - "Report associated text": true, - "Capture mouse events": true, - log: { - keydown: true, - keyrepeat: false, - keyup: false, - mousedown: false, - mouseup: false, - mousemove: false, - wheel: true, - resize: true, - pointerenter: false, - pointerleave: false, - pointerclick: true, - }, - entered: new Set(), - event: null, - logged: null, - }; - - let event = yield current; - - let mode = inputmode({ ...current, event }); - - while (true) { - mode = yield* mode; - } -} - -type Mode = Iterable; - -function* inputmode(context: AppContext): Mode { - context = { ...context, mode: "input" }; - let event = context.event ? context.event : yield context; - while (true) { - context = { ...context, event }; - if (event.type === "keydown" && event.key === "x" && event.ctrl) { - let next = yield context; - while (next.type !== "keydown") { - context = { ...context, event: next }; - next = yield context; - } - context = { ...context, event: next }; - if (next.key === "x" && next.ctrl) { - return configmode({ - ...context, - event: null, - }); - } else if (next.key === "m" && next.ctrl) { - context = { - ...context, - "Capture mouse events": !context["Capture mouse events"], - event: null, - }; - event = yield context; - continue; - } - } - event = yield context; - } -} - -function* configmode(context: AppContext): Mode { - context = { ...context, mode: "config" }; - let event = yield context; - while (true) { - if (event.type === "keydown" && event.key === "Enter") { - return inputmode({ ...context, event: null }); - } - if (event.type === "keydown") { - let k = (event as KeyEvent).key; - let entry = logEntries.find((e) => e.key === k); - if (entry) { - context = { - ...context, - log: { ...context.log, [entry.name]: !context.log[entry.name] }, - }; - } - if ("012345".indexOf(event.key) >= 0) { - context = { ...context }; - context["Report associated text"] = false; - context["Report all keys as escapes"] = false; - context["Report alternate keys"] = false; - context["Report event types"] = false; - context["Disambiguate escape codes"] = false; - switch (event.key) { - case "5": - context["Report associated text"] = true; - case "4": - context["Report all keys as escapes"] = true; - case "3": - context["Report alternate keys"] = true; - case "2": - context["Report event types"] = true; - case "1": - context["Disambiguate escape codes"] = true; - break; - case "0": - break; - } - } - } - event = yield context; - } -} - function merge( a: Stream, b: Stream, @@ -823,17 +182,3 @@ type EventFilter = { pointerleave: boolean; pointerclick: boolean; }; - -type AppContext = { - mode: "input" | "config"; - event: InputEvent | PointerEvent | null; - logged: InputEvent | PointerEvent | null; - log: EventFilter; - entered: Set; - ["Disambiguate escape codes"]: boolean; - ["Report event types"]: boolean; - ["Report alternate keys"]: boolean; - ["Report all keys as escapes"]: boolean; - ["Report associated text"]: boolean; - ["Capture mouse events"]: boolean; -}; diff --git a/term.ts b/term.ts index 6853d94..f397f8a 100644 --- a/term.ts +++ b/term.ts @@ -41,10 +41,21 @@ export interface ElementInfo { const WINDOWS_WRAP_DISABLE = new Uint8Array([0x1b, 0x5b, 0x3f, 0x37, 0x6c]); const WINDOWS_WRAP_ENABLE = new Uint8Array([0x1b, 0x5b, 0x3f, 0x37, 0x68]); +function windowsFullscreenHandlingDisabled(): boolean { + let diagnostics = (globalThis as typeof globalThis & { + __claytermDiagnostics__?: { + disableWindowsFullscreenHandling?: boolean; + }; + }).__claytermDiagnostics__; + + return diagnostics?.disableWindowsFullscreenHandling === true; +} + 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. + // Empirically, Deno-on-Windows fullscreen redraws were more reliable when + // line-mode output began with a home cursor move and used CRLF row + // separators. Bare LF left later rows visually clipped in the tested + // terminal stack, while localized redraws could still reveal the content. let extra = 0; for (let i = 0; i < output.length; i++) { if (output[i] === 0x0a && (i === 0 || output[i - 1] !== 0x0d)) { @@ -79,8 +90,9 @@ function normalizeWindowsLineOutput(output: Uint8Array): Uint8Array { } function wrapWindowsFullscreenOutput(output: Uint8Array): Uint8Array { - // Disabling autowrap around a fullscreen frame avoids Windows terminal - // redraw quirks observed at the right edge. + // Preserve an explicit wrap-state boundary around fullscreen frames on + // Windows. In the tested Deno path this avoided redraw glitches at the right + // edge even though the same scene rendered cleanly in Node. // 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, @@ -123,6 +135,10 @@ export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; } +let runtimeIsWindows = (globalThis as typeof globalThis & { + Deno?: { build?: { os?: string } }; +}).Deno?.build?.os === "windows"; + export async function createTerm(options: TermOptions): Promise { let { width, height } = options; let native = await createTermNative(width, height); @@ -138,12 +154,13 @@ export async function createTerm(options: TermOptions): Promise { let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; let autoLineMode = false; - let windowsFullscreen = row === 1 && Deno.build.os === "windows"; + let windowsFullscreen = row === 1 && runtimeIsWindows && + !windowsFullscreenHandlingDisabled(); - // 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. + // Use the line-oriented render path by default for Windows fullscreen + // renders. This was required for Deno to avoid + // clipped rows during fullscreen redraw, while the equivalent Node path + // did not show the same failure. if (mode === 0 && options?.mode === undefined && windowsFullscreen) { mode = 1; autoLineMode = true; From 4e2fb431ad347e92b1cf5d1ce99599f6d1f447f6 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 22 May 2026 16:05:42 -0500 Subject: [PATCH 3/3] fullscreen wrap not required --- demo/keyboard-node.ts | 40 +++++++------------ demo/keyboard-shared.ts | 34 +++++++++++----- demo/keyboard.ts | 24 +----------- deno.lock | 10 +++++ term.ts | 87 ----------------------------------------- test/term.test.ts | 13 +----- 6 files changed, 52 insertions(+), 156 deletions(-) diff --git a/demo/keyboard-node.ts b/demo/keyboard-node.ts index 042df8f..ac9e503 100644 --- a/demo/keyboard-node.ts +++ b/demo/keyboard-node.ts @@ -31,31 +31,19 @@ type NodeScanResult = { pending?: { delay: number }; }; -let demo = createKeyboardDemo({ - close, - fixed, - grow, - mouseTracking, - open, - progressiveInput, - rgba, - settings, - text, -} as Parameters[0]); - -let diagnostics = { - disableWindowsFullscreenHandling: process.argv.includes( - "--no-windows-fullscreen-fix", - ), -}; - -(globalThis as typeof globalThis & { - __claytermDiagnostics__?: { - disableWindowsFullscreenHandling?: boolean; - }; -}).__claytermDiagnostics__ = { - disableWindowsFullscreenHandling: diagnostics.disableWindowsFullscreenHandling, -}; +let demo = createKeyboardDemo( + { + close, + fixed, + grow, + mouseTracking, + open, + progressiveInput, + rgba, + settings, + text, + } as Parameters[0], +); let term: Awaited> | null = null; let input: NodeInput | null = null; @@ -251,4 +239,4 @@ try { cleanup(); console.error(error); process.exit(1); -} \ No newline at end of file +} diff --git a/demo/keyboard-shared.ts b/demo/keyboard-shared.ts index 657f49c..b6f89de 100644 --- a/demo/keyboard-shared.ts +++ b/demo/keyboard-shared.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-fallthrough import type { InputEvent, KeyEvent, Op, PointerEvent } from "../mod.ts"; import type { SizingAxis } from "../ops.ts"; import type { Setting } from "../settings.ts"; @@ -442,7 +443,9 @@ export function createKeyboardDemo(api: KeyboardDemoApi) { let mouseLabel = context["Capture mouse events"] ? "capture" : "system"; ops.push( - open("badges", { layout: { direction: "ttb", gap: 1, padding: { top: 1 } } }), + open("badges", { + layout: { direction: "ttb", gap: 1, padding: { top: 1 } }, + }), open("badge:mode", { layout: { direction: "ltr", height: fixed(1), padding: { bottom: 1 } }, }), @@ -472,7 +475,9 @@ export function createKeyboardDemo(api: KeyboardDemoApi) { close(), ); - ops.push(open("", { layout: { width: grow(), direction: "ltr", alignX: 1 } })); + ops.push( + open("", { layout: { width: grow(), direction: "ltr", alignX: 1 } }), + ); configPanel(ops, context); ops.push(close()); ops.push(close()); @@ -495,11 +500,16 @@ export function createKeyboardDemo(api: KeyboardDemoApi) { ops.push(close()); ops.push(close()); - ops.push(open("event-log", { layout: { height: fixed(1), padding: { top: 1 } } })); ops.push( - text(context.logged ? JSON.stringify(context.logged) : "Press any key...", { - color: highlight, - }), + open("event-log", { layout: { height: fixed(1), padding: { top: 1 } } }), + ); + ops.push( + text( + context.logged ? JSON.stringify(context.logged) : "Press any key...", + { + color: highlight, + }, + ), ); ops.push(close()); ops.push(close()); @@ -550,7 +560,11 @@ export function createKeyboardDemo(api: KeyboardDemoApi) { }; } - function* recognizer(): Iterator { + function* recognizer(): Iterator< + AppContext, + never, + InputEvent | PointerEvent + > { let current = createInitialContext(); let event = yield current; let mode = inputmode({ ...current, event }); @@ -597,7 +611,9 @@ export function createKeyboardDemo(api: KeyboardDemoApi) { } if (event.type === "keydown") { let keyEvent = event as KeyEvent; - let entry = logEntries.find((candidate) => candidate.key === keyEvent.key); + let entry = logEntries.find((candidate) => + candidate.key === keyEvent.key + ); if (entry) { context = { ...context, @@ -637,4 +653,4 @@ export function createKeyboardDemo(api: KeyboardDemoApi) { recognizer, ttyFlags, }; -} \ No newline at end of file +} diff --git a/demo/keyboard.ts b/demo/keyboard.ts index adc10f1..ddd6fa5 100644 --- a/demo/keyboard.ts +++ b/demo/keyboard.ts @@ -8,14 +8,8 @@ import { type Stream, until, } from "effection"; -import { - createTerm, - type PointerEvent, -} from "../mod.ts"; -import { - alternateBuffer, - settings, -} from "../settings.ts"; +import { createTerm, type PointerEvent } from "../mod.ts"; +import { alternateBuffer, settings } from "../settings.ts"; import { close, fixed, grow, open, rgba, text } from "../mod.ts"; import { cursor, mouseTracking, progressiveInput } from "../settings.ts"; import { createKeyboardDemo } from "./keyboard-shared.ts"; @@ -34,20 +28,6 @@ const demo = createKeyboardDemo({ text, }); -let diagnostics = { - disableWindowsFullscreenHandling: Deno.args.includes( - "--no-windows-fullscreen-fix", - ), -}; - -(globalThis as typeof globalThis & { - __claytermDiagnostics__?: { - disableWindowsFullscreenHandling?: boolean; - }; -}).__claytermDiagnostics__ = { - disableWindowsFullscreenHandling: diagnostics.disableWindowsFullscreenHandling, -}; - function writeAllSync(output: Uint8Array): void { // Deno's stdout writes are not guaranteed to drain the full buffer in one // call, so always loop until the entire frame chunk is written. diff --git a/deno.lock b/deno.lock index 7947a80..a25aa0d 100644 --- a/deno.lock +++ b/deno.lock @@ -22,6 +22,7 @@ "jsr:@ts-morph/common@0.27": "0.27.0", "npm:@sinclair/typebox@*": "0.34.48", "npm:@sinclair/typebox@0.34": "0.34.48", + "npm:@types/node@*": "25.9.1", "npm:effection@^4.0.2": "4.0.2", "npm:valrs@*": "0.1.0" }, @@ -112,9 +113,18 @@ "@sinclair/typebox@0.34.48": { "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==" }, + "@types/node@25.9.1": { + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dependencies": [ + "undici-types" + ] + }, "effection@4.0.2": { "integrity": "sha512-O8WMGP10nPuJDwbNGILcaCNWS+CvDYjcdsUSD79nWZ+WtUQ8h1MEV7JJwCSZCSeKx8+TdEaZ/8r6qPTR2o/o8w==" }, + "undici-types@7.24.6": { + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" + }, "valrs@0.1.0": { "integrity": "sha512-BqVkjx3qhsRLHerblLDoqEx0OEx7ms0DB6LPv40oWkMfFKUVKrqVuklaGdrPrHyubC5hSHYfEtUiQXrCkC6xHQ==" } diff --git a/term.ts b/term.ts index f397f8a..478c099 100644 --- a/term.ts +++ b/term.ts @@ -38,71 +38,6 @@ 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 windowsFullscreenHandlingDisabled(): boolean { - let diagnostics = (globalThis as typeof globalThis & { - __claytermDiagnostics__?: { - disableWindowsFullscreenHandling?: boolean; - }; - }).__claytermDiagnostics__; - - return diagnostics?.disableWindowsFullscreenHandling === true; -} - -function normalizeWindowsLineOutput(output: Uint8Array): Uint8Array { - // Empirically, Deno-on-Windows fullscreen redraws were more reliable when - // line-mode output began with a home cursor move and used CRLF row - // separators. Bare LF left later rows visually clipped in the tested - // terminal stack, while localized redraws could still reveal the content. - 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 { - // Preserve an explicit wrap-state boundary around fullscreen frames on - // Windows. In the tested Deno path this avoided redraw glitches at the right - // edge even though the same scene rendered cleanly in Node. - // 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", @@ -135,10 +70,6 @@ export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; } -let runtimeIsWindows = (globalThis as typeof globalThis & { - Deno?: { build?: { os?: string } }; -}).Deno?.build?.os === "windows"; - export async function createTerm(options: TermOptions): Promise { let { width, height } = options; let native = await createTermNative(width, height); @@ -153,18 +84,6 @@ 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 && runtimeIsWindows && - !windowsFullscreenHandlingDisabled(); - - // Use the line-oriented render path by default for Windows fullscreen - // renders. This was required for Deno to avoid - // clipped rows during fullscreen redraw, while the equivalent Node path - // did not show the same failure. - if (mode === 0 && options?.mode === undefined && windowsFullscreen) { - mode = 1; - autoLineMode = true; - } native.reduce(statePtr, opsBuf, len, mode, row); @@ -179,12 +98,6 @@ export async function createTerm(options: TermOptions): Promise { 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 270caf4..47d8c94 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -130,11 +130,6 @@ 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 () => { @@ -152,13 +147,7 @@ describe("term", () => { │ │ └──────────────────┘`.trim()); - 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); - } + expect(second.length).toBeLessThan(first.length); }); });