From b87793b67e6aad74e40557abc45640fedbaa6366 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 2 Feb 2026 06:44:29 -0800 Subject: [PATCH] TUI --- build.zig | 23 +- bun.lock | 12 +- cmake/Sources.json | 6 +- cmake/targets/CloneGhostty.cmake | 8 + package.json | 3 + packages/bun-types/package.json | 5 +- src/async/posix_event_loop.zig | 8 + src/bun.js/api.zig | 6 + src/bun.js/api/BunObject.zig | 24 + src/bun.js/api/tui/TUIBufferWriter.classes.ts | 44 + src/bun.js/api/tui/TUIKeyReader.classes.ts | 38 + src/bun.js/api/tui/TUIScreen.classes.ts | 73 + .../api/tui/TUITerminalWriter.classes.ts | 82 + src/bun.js/api/tui/buffer_writer.zig | 177 ++ src/bun.js/api/tui/key_reader.zig | 631 +++++++ src/bun.js/api/tui/renderer.zig | 407 +++++ src/bun.js/api/tui/screen.zig | 794 +++++++++ src/bun.js/api/tui/terminal_writer.zig | 543 ++++++ src/bun.js/api/tui/tui.zig | 5 + src/bun.js/bindings/BunObject+exports.h | 4 + src/bun.js/bindings/BunObject.cpp | 4 + .../bindings/generated_classes_list.zig | 4 + src/crash_handler.zig | 6 +- src/sys.zig | 54 + test/js/bun/tui/bench.test.ts | 286 ++++ test/js/bun/tui/e2e.test.ts | 605 +++++++ test/js/bun/tui/key-reader.test.ts | 1039 +++++++++++ test/js/bun/tui/screen.test.ts | 1027 +++++++++++ test/js/bun/tui/writer.test.ts | 1512 +++++++++++++++++ 29 files changed, 7424 insertions(+), 6 deletions(-) create mode 100644 cmake/targets/CloneGhostty.cmake create mode 100644 src/bun.js/api/tui/TUIBufferWriter.classes.ts create mode 100644 src/bun.js/api/tui/TUIKeyReader.classes.ts create mode 100644 src/bun.js/api/tui/TUIScreen.classes.ts create mode 100644 src/bun.js/api/tui/TUITerminalWriter.classes.ts create mode 100644 src/bun.js/api/tui/buffer_writer.zig create mode 100644 src/bun.js/api/tui/key_reader.zig create mode 100644 src/bun.js/api/tui/renderer.zig create mode 100644 src/bun.js/api/tui/screen.zig create mode 100644 src/bun.js/api/tui/terminal_writer.zig create mode 100644 src/bun.js/api/tui/tui.zig create mode 100644 test/js/bun/tui/bench.test.ts create mode 100644 test/js/bun/tui/e2e.test.ts create mode 100644 test/js/bun/tui/key-reader.test.ts create mode 100644 test/js/bun/tui/screen.test.ts create mode 100644 test/js/bun/tui/writer.test.ts diff --git a/build.zig b/build.zig index 9f63a72030..e57378fe23 100644 --- a/build.zig +++ b/build.zig @@ -759,7 +759,6 @@ fn configureObj(b: *Build, opts: *BunBuildOptions, obj: *Compile) void { obj.no_link_obj = opts.os != .windows and !opts.no_llvm; - if (opts.enable_asan and !enableFastBuild(b)) { if (@hasField(Build.Module, "sanitize_address")) { if (opts.enable_fuzzilli) { @@ -869,6 +868,28 @@ fn addInternalImports(b: *Build, mod: *Module, opts: *BunBuildOptions) void { .root_source_file = b.path(async_path), }); + // Ghostty terminal module — used by Bun's TUI primitives (Screen/Writer). + // We provide terminal_options matching Ghostty's build_options.zig.Options. + { + // Must match ghostty's terminal/build_options.zig Artifact enum + const GhosttyArtifact = enum { ghostty, lib }; + + const ghostty_terminal_opts = b.addOptions(); + ghostty_terminal_opts.addOption(GhosttyArtifact, "artifact", .lib); + ghostty_terminal_opts.addOption(bool, "c_abi", false); + ghostty_terminal_opts.addOption(bool, "oniguruma", false); + ghostty_terminal_opts.addOption(bool, "simd", true); + ghostty_terminal_opts.addOption(bool, "slow_runtime_safety", false); + ghostty_terminal_opts.addOption(bool, "kitty_graphics", false); + ghostty_terminal_opts.addOption(bool, "tmux_control_mode", false); + + const ghostty_mod = b.createModule(.{ + .root_source_file = b.path("vendor/ghostty/src/ghostty_terminal.zig"), + }); + ghostty_mod.addOptions("terminal_options", ghostty_terminal_opts); + mod.addImport("ghostty", ghostty_mod); + } + // Generated code exposed as individual modules. inline for (.{ .{ .file = "ZigGeneratedClasses.zig", .import = "ZigGeneratedClasses" }, diff --git a/bun.lock b/bun.lock index 121ee86803..68065e11d1 100644 --- a/bun.lock +++ b/bun.lock @@ -8,8 +8,11 @@ "@lezer/common": "^1.2.3", "@lezer/cpp": "^1.1.3", "@types/bun": "workspace:*", + "@xterm/headless": "^6.0.0", + "@xterm/xterm": "^6.0.0", "bun-tracestrings": "github:oven-sh/bun.report#912ca63e26c51429d3e6799aa2a6ab079b188fd8", "esbuild": "^0.21.5", + "marked": "^17.0.1", "mitata": "^0.1.14", "peechy": "0.4.34", "prettier": "^3.6.2", @@ -29,6 +32,7 @@ }, "packages/bun-types": { "name": "bun-types", + "version": "1.3.8", "dependencies": { "@types/node": "*", }, @@ -158,6 +162,10 @@ "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], @@ -256,7 +264,7 @@ "lru-cache": ["@wolfy1339/lru-cache@11.0.2-patch.1", "", {}, "sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA=="], - "marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "mitata": ["mitata@0.1.14", "", {}, "sha512-8kRs0l636eT4jj68PFXOR2D5xl4m56T478g16SzUPOYgkzQU+xaw62guAQxzBPm+SXb15GQi1cCpDxJfkr4CSA=="], @@ -328,6 +336,8 @@ "@octokit/webhooks/@octokit/webhooks-methods": ["@octokit/webhooks-methods@4.1.0", "", {}, "sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ=="], + "bun-tracestrings/marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], + "camel-case/no-case": ["no-case@2.3.2", "", { "dependencies": { "lower-case": "^1.1.1" } }, "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ=="], "change-case/camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], diff --git a/cmake/Sources.json b/cmake/Sources.json index 2f5339f047..5ea0cee655 100644 --- a/cmake/Sources.json +++ b/cmake/Sources.json @@ -9,7 +9,11 @@ }, { "output": "ZigGeneratedClassesSources.txt", - "paths": ["src/bun.js/*.classes.ts", "src/bun.js/{api,node,test,webcore}/*.classes.ts"] + "paths": [ + "src/bun.js/*.classes.ts", + "src/bun.js/{api,node,test,webcore}/*.classes.ts", + "src/bun.js/api/tui/*.classes.ts" + ] }, { "output": "JavaScriptSources.txt", diff --git a/cmake/targets/CloneGhostty.cmake b/cmake/targets/CloneGhostty.cmake new file mode 100644 index 0000000000..8aad6c2eba --- /dev/null +++ b/cmake/targets/CloneGhostty.cmake @@ -0,0 +1,8 @@ +register_repository( + NAME + ghostty + REPOSITORY + ghostty-org/ghostty + COMMIT + 1b7a15899ad40fba4ce020f537055d30eaf99ee8 +) diff --git a/package.json b/package.json index 090fa9a87e..4a9d09f7ef 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "@lezer/common": "^1.2.3", "@lezer/cpp": "^1.1.3", "@types/bun": "workspace:*", + "@xterm/headless": "^6.0.0", + "@xterm/xterm": "^6.0.0", "bun-tracestrings": "github:oven-sh/bun.report#912ca63e26c51429d3e6799aa2a6ab079b188fd8", "esbuild": "^0.21.5", + "marked": "^17.0.1", "mitata": "^0.1.14", "peechy": "0.4.34", "prettier": "^3.6.2", diff --git a/packages/bun-types/package.json b/packages/bun-types/package.json index 7e1e879efd..52a98d09cf 100644 --- a/packages/bun-types/package.json +++ b/packages/bun-types/package.json @@ -32,5 +32,6 @@ "bun", "bun.js", "types" - ] -} + ], + "version": "1.3.8" +} \ No newline at end of file diff --git a/src/async/posix_event_loop.zig b/src/async/posix_event_loop.zig index dc10c2386a..b9923ca625 100644 --- a/src/async/posix_event_loop.zig +++ b/src/async/posix_event_loop.zig @@ -151,6 +151,7 @@ pub const FilePoll = struct { const ShellStaticPipeWriter = bun.shell.ShellSubprocess.StaticPipeWriter.Poll; const FileSink = jsc.WebCore.FileSink.Poll; const TerminalPoll = bun.api.Terminal.Poll; + const TuiWriterPoll = bun.api.TuiTerminalWriter.IOWriter; const DNSResolver = bun.api.dns.Resolver; const GetAddrInfoRequest = bun.api.dns.GetAddrInfoRequest; const Request = bun.api.dns.internal.Request; @@ -183,6 +184,7 @@ pub const FilePoll = struct { Process, ShellBufferedWriter, // i do not know why, but this has to be here otherwise compiler will complain about dependency loop TerminalPoll, + TuiWriterPoll, }); pub const AllocatorType = enum { @@ -422,6 +424,12 @@ pub const FilePoll = struct { handler.onPoll(size_or_offset, poll.flags.contains(.hup)); }, + @field(Owner.Tag, @typeName(TuiWriterPoll)) => { + log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) TuiWriter", .{poll.fd}); + var handler: *TuiWriterPoll = ptr.as(TuiWriterPoll); + handler.onPoll(size_or_offset, poll.flags.contains(.hup)); + }, + else => { const possible_name = Owner.typeNameFromTag(@intFromEnum(ptr.tag())); log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) disconnected? (maybe: {s})", .{ poll.fd, possible_name orelse "" }); diff --git a/src/bun.js/api.zig b/src/bun.js/api.zig index 058db33e86..02aa442b30 100644 --- a/src/bun.js/api.zig +++ b/src/bun.js/api.zig @@ -25,6 +25,12 @@ pub const SocketHandlers = @import("./api/bun/socket.zig").Handlers; pub const Subprocess = @import("./api/bun/subprocess.zig"); pub const Terminal = @import("./api/bun/Terminal.zig"); +pub const tui = @import("./api/tui/tui.zig"); +pub const TuiRenderer = tui.TuiRenderer; +pub const TuiScreen = tui.TuiScreen; +pub const TuiTerminalWriter = tui.TuiTerminalWriter; +pub const TuiBufferWriter = tui.TuiBufferWriter; +pub const TuiKeyReader = tui.TuiKeyReader; pub const HashObject = @import("./api/HashObject.zig"); pub const JSONCObject = @import("./api/JSONCObject.zig"); pub const MarkdownObject = @import("./api/MarkdownObject.zig"); diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index 14c6772fa0..abc4ae4ace 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -82,6 +82,10 @@ pub const BunObject = struct { pub const ValkeyClient = toJSLazyPropertyCallback(Bun.getValkeyClientConstructor); pub const valkey = toJSLazyPropertyCallback(Bun.getValkeyDefaultClient); pub const Terminal = toJSLazyPropertyCallback(Bun.getTerminalConstructor); + pub const TUIScreen = toJSLazyPropertyCallback(Bun.getScreenConstructor); + pub const TUITerminalWriter = toJSLazyPropertyCallback(Bun.getTerminalWriterConstructor); + pub const TUIBufferWriter = toJSLazyPropertyCallback(Bun.getBufferWriterConstructor); + pub const TUIKeyReader = toJSLazyPropertyCallback(Bun.getKeyReaderConstructor); // --- Lazy property callbacks --- // --- Getters --- @@ -152,6 +156,10 @@ pub const BunObject = struct { @export(&BunObject.ValkeyClient, .{ .name = lazyPropertyCallbackName("ValkeyClient") }); @export(&BunObject.valkey, .{ .name = lazyPropertyCallbackName("valkey") }); @export(&BunObject.Terminal, .{ .name = lazyPropertyCallbackName("Terminal") }); + @export(&BunObject.TUIScreen, .{ .name = lazyPropertyCallbackName("TUIScreen") }); + @export(&BunObject.TUITerminalWriter, .{ .name = lazyPropertyCallbackName("TUITerminalWriter") }); + @export(&BunObject.TUIBufferWriter, .{ .name = lazyPropertyCallbackName("TUIBufferWriter") }); + @export(&BunObject.TUIKeyReader, .{ .name = lazyPropertyCallbackName("TUIKeyReader") }); // --- Lazy property callbacks --- // --- Callbacks --- @@ -1342,6 +1350,22 @@ pub fn getTerminalConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) return api.Terminal.js.getConstructor(globalThis); } +pub fn getScreenConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { + return api.TuiScreen.js.getConstructor(globalThis); +} + +pub fn getTerminalWriterConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { + return api.TuiTerminalWriter.js.getConstructor(globalThis); +} + +pub fn getBufferWriterConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { + return api.TuiBufferWriter.js.getConstructor(globalThis); +} + +pub fn getKeyReaderConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { + return api.TuiKeyReader.js.getConstructor(globalThis); +} + pub fn getEmbeddedFiles(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) bun.JSError!jsc.JSValue { const vm = globalThis.bunVM(); const graph = vm.standalone_module_graph orelse return try jsc.JSValue.createEmptyArray(globalThis, 0); diff --git a/src/bun.js/api/tui/TUIBufferWriter.classes.ts b/src/bun.js/api/tui/TUIBufferWriter.classes.ts new file mode 100644 index 0000000000..860ea71eb8 --- /dev/null +++ b/src/bun.js/api/tui/TUIBufferWriter.classes.ts @@ -0,0 +1,44 @@ +import { define } from "../../../codegen/class-definitions"; + +export default [ + define({ + name: "TuiBufferWriter", + construct: true, + constructNeedsThis: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + values: ["buffer"], + proto: { + render: { + fn: "render", + length: 2, + }, + clear: { + fn: "clear", + length: 0, + }, + close: { + fn: "close", + length: 0, + }, + end: { + fn: "end", + length: 0, + }, + cursorX: { + getter: "getCursorX", + }, + cursorY: { + getter: "getCursorY", + }, + byteOffset: { + getter: "getByteOffset", + }, + byteLength: { + getter: "getByteLength", + }, + }, + }), +]; diff --git a/src/bun.js/api/tui/TUIKeyReader.classes.ts b/src/bun.js/api/tui/TUIKeyReader.classes.ts new file mode 100644 index 0000000000..3c84d9a289 --- /dev/null +++ b/src/bun.js/api/tui/TUIKeyReader.classes.ts @@ -0,0 +1,38 @@ +import { define } from "../../../codegen/class-definitions"; + +export default [ + define({ + name: "TuiKeyReader", + construct: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + proto: { + close: { + fn: "close", + length: 0, + }, + onkeypress: { + setter: "setOnKeypress", + getter: "getOnKeypress", + }, + onpaste: { + setter: "setOnPaste", + getter: "getOnPaste", + }, + onmouse: { + setter: "setOnMouse", + getter: "getOnMouse", + }, + onfocus: { + setter: "setOnFocus", + getter: "getOnFocus", + }, + onblur: { + setter: "setOnBlur", + getter: "getOnBlur", + }, + }, + }), +]; diff --git a/src/bun.js/api/tui/TUIScreen.classes.ts b/src/bun.js/api/tui/TUIScreen.classes.ts new file mode 100644 index 0000000000..8d27cfeafa --- /dev/null +++ b/src/bun.js/api/tui/TUIScreen.classes.ts @@ -0,0 +1,73 @@ +import { define } from "../../../codegen/class-definitions"; + +export default [ + define({ + name: "TuiScreen", + construct: true, + finalize: true, + configurable: false, + estimatedSize: true, + klass: {}, + JSType: "0b11101110", + proto: { + setText: { + fn: "setText", + length: 4, + }, + style: { + fn: "style", + length: 1, + }, + clearRect: { + fn: "clearRect", + length: 4, + }, + fill: { + fn: "fill", + length: 6, + }, + copy: { + fn: "copy", + length: 7, + }, + resize: { + fn: "resize", + length: 2, + }, + clear: { + fn: "clear", + length: 0, + }, + width: { + getter: "getWidth", + }, + height: { + getter: "getHeight", + }, + getCell: { + fn: "getCell", + length: 2, + }, + hyperlink: { + fn: "hyperlink", + length: 1, + }, + setHyperlink: { + fn: "setHyperlink", + length: 3, + }, + clip: { + fn: "clip", + length: 4, + }, + unclip: { + fn: "unclip", + length: 0, + }, + drawBox: { + fn: "drawBox", + length: 5, + }, + }, + }), +]; diff --git a/src/bun.js/api/tui/TUITerminalWriter.classes.ts b/src/bun.js/api/tui/TUITerminalWriter.classes.ts new file mode 100644 index 0000000000..43643fcdb6 --- /dev/null +++ b/src/bun.js/api/tui/TUITerminalWriter.classes.ts @@ -0,0 +1,82 @@ +import { define } from "../../../codegen/class-definitions"; + +export default [ + define({ + name: "TuiTerminalWriter", + construct: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + proto: { + render: { + fn: "render", + length: 2, + }, + clear: { + fn: "clear", + length: 0, + }, + close: { + fn: "close", + length: 0, + }, + end: { + fn: "end", + length: 0, + }, + enterAltScreen: { + fn: "enterAltScreen", + length: 0, + }, + exitAltScreen: { + fn: "exitAltScreen", + length: 0, + }, + enableMouseTracking: { + fn: "enableMouseTracking", + length: 0, + }, + disableMouseTracking: { + fn: "disableMouseTracking", + length: 0, + }, + enableFocusTracking: { + fn: "enableFocusTracking", + length: 0, + }, + disableFocusTracking: { + fn: "disableFocusTracking", + length: 0, + }, + enableBracketedPaste: { + fn: "enableBracketedPaste", + length: 0, + }, + disableBracketedPaste: { + fn: "disableBracketedPaste", + length: 0, + }, + write: { + fn: "write", + length: 1, + }, + cursorX: { + getter: "getCursorX", + }, + cursorY: { + getter: "getCursorY", + }, + columns: { + getter: "getColumns", + }, + rows: { + getter: "getRows", + }, + onresize: { + setter: "setOnResize", + getter: "getOnResize", + }, + }, + }), +]; diff --git a/src/bun.js/api/tui/buffer_writer.zig b/src/bun.js/api/tui/buffer_writer.zig new file mode 100644 index 0000000000..e13068aff3 --- /dev/null +++ b/src/bun.js/api/tui/buffer_writer.zig @@ -0,0 +1,177 @@ +//! TuiBufferWriter — JS-visible renderer that writes ANSI into a caller-owned ArrayBuffer. +//! +//! `render()` writes from byte 0, truncates if frame > capacity, returns total +//! rendered byte count. +//! +//! Read-only getters: +//! - `byteOffset` = min(rendered_len, capacity) — position after last write +//! - `byteLength` = total rendered bytes (may exceed capacity, signals truncation) +//! +//! `close()` / `end()` — clear renderer, release buffer ref, throw on future `render()`. + +const TuiBufferWriter = @This(); + +const TuiRenderer = @import("./renderer.zig"); +const TuiScreen = @import("./screen.zig"); +const CursorStyle = TuiRenderer.CursorStyle; + +const ghostty = @import("ghostty").terminal; +const size = ghostty.size; + +pub const js = jsc.Codegen.JSTuiBufferWriter; +pub const toJS = js.toJS; +pub const fromJS = js.fromJS; +pub const fromJSDirect = js.fromJSDirect; + +renderer: TuiRenderer = .{}, +/// Internal buffer used by TuiRenderer, then copied into the ArrayBuffer. +output: std.ArrayList(u8) = .{}, +/// True after close() / end() has been called. +closed: bool = false, +/// Number of bytes actually copied into the ArrayBuffer (min of rendered, capacity). +byte_offset: usize = 0, +/// Total number of rendered bytes (may exceed capacity). +byte_length: usize = 0, + +pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame, this_value: jsc.JSValue) bun.JSError!*TuiBufferWriter { + const arguments = callframe.arguments(); + if (arguments.len < 1) + return globalThis.throw("TUIBufferWriter requires an ArrayBuffer or TypedArray argument", .{}); + + const arg = arguments[0]; + + // Buffer mode: ArrayBuffer or TypedArray + if (arg.asArrayBuffer(globalThis) == null) + return globalThis.throw("TUIBufferWriter requires an ArrayBuffer or TypedArray argument", .{}); + + const this = bun.new(TuiBufferWriter, .{}); + // Store the ArrayBuffer on the JS object via GC-traced write barrier. + js.gc.set(.buffer, this_value, globalThis, arg); + return this; +} + +fn deinit(this: *TuiBufferWriter) void { + this.renderer.deinit(); + this.output.deinit(bun.default_allocator); + bun.destroy(this); +} + +pub fn finalize(this: *TuiBufferWriter) callconv(.c) void { + deinit(this); +} + +// --- render --- + +/// render(screen, options?) +/// Copies ANSI frame into the ArrayBuffer, returns total rendered byte count. +pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUIBufferWriter is closed", .{}); + + const arguments = callframe.arguments(); + if (arguments.len < 1) return globalThis.throw("render requires a Screen argument", .{}); + const screen = TuiScreen.fromJS(arguments[0]) orelse + return globalThis.throw("render: argument must be a TUIScreen", .{}); + + // Parse optional cursor options + var cursor_x: ?size.CellCountInt = null; + var cursor_y: ?size.CellCountInt = null; + var cursor_visible: ?bool = null; + var cursor_style: ?CursorStyle = null; + var cursor_blinking: ?bool = null; + if (arguments.len > 1 and arguments[1].isObject()) { + const opts = arguments[1]; + if (try opts.getTruthy(globalThis, "cursorX")) |v| { + const val = try v.coerce(i32, globalThis); + cursor_x = @intCast(@max(0, @min(val, screen.page.size.cols -| 1))); + } + if (try opts.getTruthy(globalThis, "cursorY")) |v| { + const val = try v.coerce(i32, globalThis); + cursor_y = @intCast(@max(0, @min(val, screen.page.size.rows -| 1))); + } + if (try opts.getTruthy(globalThis, "cursorVisible")) |v| { + if (v.isBoolean()) cursor_visible = v.asBoolean(); + } + if (try opts.getTruthy(globalThis, "cursorStyle")) |v| { + cursor_style = parseCursorStyle(v, globalThis); + } + if (try opts.getTruthy(globalThis, "cursorBlinking")) |v| { + if (v.isBoolean()) cursor_blinking = v.asBoolean(); + } + } + + const ab_val = js.gc.get(.buffer, callframe.this()) orelse + return globalThis.throw("render: ArrayBuffer has been detached", .{}); + const ab = ab_val.asArrayBuffer(globalThis) orelse + return globalThis.throw("render: ArrayBuffer has been detached", .{}); + const dest = ab.byteSlice(); + if (dest.len == 0) + return globalThis.throw("render: ArrayBuffer is empty", .{}); + + this.output.clearRetainingCapacity(); + this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking); + + const total_len = this.output.items.len; + const copy_len = @min(total_len, dest.len); + @memcpy(dest[0..copy_len], this.output.items[0..copy_len]); + + this.byte_offset = copy_len; + this.byte_length = total_len; + + return jsc.JSValue.jsNumber(copy_len); +} + +/// clear() +pub fn clear(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUIBufferWriter is closed", .{}); + this.renderer.clear(); + this.byte_offset = 0; + this.byte_length = 0; + return .js_undefined; +} + +/// close() +pub fn close(this: *TuiBufferWriter, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return .js_undefined; + this.closed = true; + this.renderer.clear(); + this.byte_offset = 0; + this.byte_length = 0; + return .js_undefined; +} + +/// end() — alias for close() +pub fn end(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + return this.close(globalThis, callframe); +} + +// --- Getters --- + +pub fn getCursorX(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_x))); +} + +pub fn getCursorY(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_y))); +} + +pub fn getByteOffset(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.byte_offset))); +} + +pub fn getByteLength(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.byte_length))); +} + +// --- Helpers --- + +fn parseCursorStyle(value: jsc.JSValue, globalThis: *jsc.JSGlobalObject) ?CursorStyle { + const str = value.toSliceClone(globalThis) catch return null; + defer str.deinit(); + return cursor_style_map.get(str.slice()); +} + +const cursor_style_map = bun.ComptimeEnumMap(CursorStyle); + +const bun = @import("bun"); +const std = @import("std"); +const jsc = bun.jsc; diff --git a/src/bun.js/api/tui/key_reader.zig b/src/bun.js/api/tui/key_reader.zig new file mode 100644 index 0000000000..e4e9ba9552 --- /dev/null +++ b/src/bun.js/api/tui/key_reader.zig @@ -0,0 +1,631 @@ +//! TuiKeyReader — reads stdin in raw mode, parses escape sequences via +//! Ghostty's VT parser, and delivers structured key events to JS callbacks. + +const TuiKeyReader = @This(); + +const ghostty = @import("ghostty").terminal; +const Parser = ghostty.Parser; +const Action = Parser.Action; + +const RefCount = bun.ptr.RefCount(TuiKeyReader, "ref_count", deinit, .{}); +pub const ref = RefCount.ref; +pub const deref = RefCount.deref; + +pub const js = jsc.Codegen.JSTuiKeyReader; +pub const toJS = js.toJS; +pub const fromJS = js.fromJS; +pub const fromJSDirect = js.fromJSDirect; + +pub const IOReader = bun.io.BufferedReader; + +ref_count: RefCount, +reader: IOReader = IOReader.init(TuiKeyReader), +parser: Parser = Parser.init(), +event_loop_handle: jsc.EventLoopHandle = undefined, +globalThis: *jsc.JSGlobalObject = undefined, +stdin_fd: bun.FileDescriptor = undefined, + +/// JS callbacks stored as Strong.Optional refs. +onkeypress_callback: jsc.Strong.Optional = .empty, +onpaste_callback: jsc.Strong.Optional = .empty, +onmouse_callback: jsc.Strong.Optional = .empty, +onfocus_callback: jsc.Strong.Optional = .empty, +onblur_callback: jsc.Strong.Optional = .empty, + +/// Bracketed paste accumulation buffer. +paste_buf: std.ArrayList(u8) = .{}, + +/// SGR mouse sequence accumulation buffer. +mouse_buf: [32]u8 = undefined, +mouse_len: u8 = 0, + +flags: Flags = .{}, + +const Flags = packed struct { + closed: bool = false, + reader_done: bool = false, + in_paste: bool = false, + is_tty: bool = false, + /// Set when ESC O (SS3) was received; the next char is the SS3 payload. + ss3_pending: bool = false, + /// Set when bare ESC was received; the next char is alt+char. + esc_pending: bool = false, + /// Set when we're accumulating an SGR mouse event sequence. + in_mouse: bool = false, + _padding: u1 = 0, +}; + +pub fn constructor(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!*TuiKeyReader { + if (comptime bun.Environment.isWindows) { + return globalThis.throw("TUIKeyReader is not supported on Windows", .{}); + } + + const stdin_fd = bun.FD.fromNative(0); + + // Set raw mode if stdin is a TTY. + const is_tty = std.posix.isatty(0); + if (is_tty) { + if (Bun__ttySetMode(0, 1) != 0) + return globalThis.throw("Failed to set raw mode on stdin", .{}); + } + + const this = bun.new(TuiKeyReader, .{ + .ref_count = .init(), + .event_loop_handle = jsc.EventLoopHandle.init(globalThis.bunVM().eventLoop()), + .globalThis = globalThis, + .stdin_fd = stdin_fd, + .flags = .{ .is_tty = is_tty }, + }); + this.reader.setParent(this); + + switch (this.reader.start(stdin_fd, true)) { + .result => {}, + .err => { + _ = Bun__ttySetMode(0, 0); + bun.destroy(this); + return globalThis.throw("Failed to start reading stdin", .{}); + }, + } + this.reader.flags.close_handle = false; // Do NOT close stdin. + // Don't call reader.read() here — defer until onkeypress callback is set, + // otherwise the initial read may consume data before JS has a chance to + // set up its callback handler. + + return this; +} + +fn deinit(this: *TuiKeyReader) void { + this.onkeypress_callback.deinit(); + this.onpaste_callback.deinit(); + this.onmouse_callback.deinit(); + this.onfocus_callback.deinit(); + this.onblur_callback.deinit(); + if (!this.flags.closed) { + if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0); + this.flags.closed = true; + this.reader.deinit(); + } + this.parser.deinit(); + this.paste_buf.deinit(bun.default_allocator); + bun.destroy(this); +} + +pub fn finalize(this: *TuiKeyReader) callconv(.c) void { + if (!this.flags.closed) { + if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0); + this.flags.closed = true; + this.reader.close(); + } + this.deref(); +} + +pub fn eventLoop(this: *TuiKeyReader) jsc.EventLoopHandle { + return this.event_loop_handle; +} + +pub fn loop(this: *TuiKeyReader) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.event_loop_handle.loop().uv_loop; + } else { + return this.event_loop_handle.loop(); + } +} + +// --- BufferedReader callbacks --- + +pub fn onReadChunk(this: *TuiKeyReader, chunk: []const u8, _: bun.io.ReadState) bool { + this.processInput(chunk); + return true; +} + +pub fn onReaderDone(this: *TuiKeyReader) void { + if (!this.flags.reader_done) { + this.flags.reader_done = true; + } +} + +pub fn onReaderError(this: *TuiKeyReader, _: bun.sys.Error) void { + if (!this.flags.reader_done) { + this.flags.reader_done = true; + } +} + +// --- JS methods --- + +pub fn close(this: *TuiKeyReader, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (!this.flags.closed) { + if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0); + this.flags.closed = true; + this.reader.close(); + } + return .js_undefined; +} + +pub fn setOnKeypress(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void { + const was_empty = this.onkeypress_callback.get() == null; + if (value.isCallable()) { + this.onkeypress_callback = jsc.Strong.Optional.create(value, globalThis); + // Start reading on first callback set. + if (was_empty and !this.flags.closed) { + this.reader.read(); + } + } else if (value.isUndefinedOrNull()) { + this.onkeypress_callback.deinit(); + } +} + +pub fn getOnKeypress(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return this.onkeypress_callback.get() orelse .js_undefined; +} + +pub fn setOnPaste(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void { + if (value.isCallable()) { + this.onpaste_callback = jsc.Strong.Optional.create(value, globalThis); + } else if (value.isUndefinedOrNull()) { + this.onpaste_callback.deinit(); + } +} + +pub fn getOnPaste(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return this.onpaste_callback.get() orelse .js_undefined; +} + +pub fn setOnMouse(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void { + if (value.isCallable()) { + this.onmouse_callback = jsc.Strong.Optional.create(value, globalThis); + // Start reading on first callback set. + if (!this.flags.closed) { + this.reader.read(); + } + } else if (value.isUndefinedOrNull()) { + this.onmouse_callback.deinit(); + } +} + +pub fn getOnMouse(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return this.onmouse_callback.get() orelse .js_undefined; +} + +pub fn setOnFocus(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void { + if (value.isCallable()) { + this.onfocus_callback = jsc.Strong.Optional.create(value, globalThis); + } else if (value.isUndefinedOrNull()) { + this.onfocus_callback.deinit(); + } +} + +pub fn getOnFocus(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return this.onfocus_callback.get() orelse .js_undefined; +} + +pub fn setOnBlur(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void { + if (value.isCallable()) { + this.onblur_callback = jsc.Strong.Optional.create(value, globalThis); + } else if (value.isUndefinedOrNull()) { + this.onblur_callback.deinit(); + } +} + +pub fn getOnBlur(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return this.onblur_callback.get() orelse .js_undefined; +} + +// --- Input processing via Ghostty parser --- + +fn processInput(this: *TuiKeyReader, data: []const u8) void { + var i: usize = 0; + while (i < data.len) { + const byte = data[i]; + + // 0x7F (DEL) is ignored by the VT parser — handle it directly as backspace. + if (byte == 0x7f) { + if (this.flags.in_paste) { + this.paste_buf.append(bun.default_allocator, byte) catch {}; + } else { + this.emitKeypress("backspace", &.{0x7f}, false, false, false); + } + i += 1; + continue; + } + + // UTF-8 multi-byte sequences: the raw VT parser doesn't handle these. + // Decode them and emit as print events directly. + if (byte >= 0xC0) { + const seq_len = bun.strings.utf8ByteSequenceLength(byte); + if (i + seq_len <= data.len) { + const seq = data[i .. i + seq_len]; + var seq_bytes = [4]u8{ seq[0], 0, 0, 0 }; + if (seq_len > 1) seq_bytes[1] = seq[1]; + if (seq_len > 2) seq_bytes[2] = seq[2]; + if (seq_len > 3) seq_bytes[3] = seq[3]; + const decoded = bun.strings.decodeWTF8RuneT(&seq_bytes, seq_len, u21, 0xFFFD); + if (decoded == 0xFFFD) { + i += 1; + continue; + } + if (this.flags.in_paste) { + this.paste_buf.appendSlice(bun.default_allocator, seq) catch {}; + } else if (this.flags.esc_pending) { + this.flags.esc_pending = false; + this.emitPrintChar(decoded, true); + } else { + this.emitPrintChar(decoded, false); + } + i += seq_len; + continue; + } + i += 1; // Incomplete UTF-8 at end. + continue; + } + + // Continuation bytes (0x80-0xBF) — skip stray ones. + if (byte >= 0x80) { + i += 1; + continue; + } + + // ASCII: feed through the Ghostty VT parser. + const actions = this.parser.next(byte); + for (&actions) |maybe_action| { + const action = maybe_action orelse continue; + this.handleAction(action, byte); + } + i += 1; + } +} + +fn emitPrintChar(this: *TuiKeyReader, cp: u21, alt: bool) void { + var buf: [4]u8 = undefined; + const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp); + if (len == 0) return; + this.emitKeypress(buf[0..len], buf[0..len], false, false, alt); +} + +fn handleAction(this: *TuiKeyReader, action: Action, raw_byte: u8) void { + _ = raw_byte; + + switch (action) { + .print => |cp| { + // Check if we're in SS3 mode (previous was ESC O). + if (this.flags.ss3_pending) { + this.flags.ss3_pending = false; + const name: []const u8 = switch (cp) { + 'P' => "f1", + 'Q' => "f2", + 'R' => "f3", + 'S' => "f4", + 'A' => "up", + 'B' => "down", + 'C' => "right", + 'D' => "left", + 'H' => "home", + 'F' => "end", + else => return, + }; + this.emitKeypress(name, name, false, false, false); + return; + } + + // Check if previous was a bare ESC (alt prefix). + if (this.flags.esc_pending) { + this.flags.esc_pending = false; + var buf: [4]u8 = undefined; + const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp); + if (len == 0) return; + this.emitKeypress(buf[0..len], buf[0..len], false, false, true); + return; + } + + // Printable character. + if (this.flags.in_paste) { + var buf: [4]u8 = undefined; + const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp); + if (len == 0) return; + this.paste_buf.appendSlice(bun.default_allocator, buf[0..len]) catch {}; + return; + } + var buf: [4]u8 = undefined; + const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp); + if (len == 0) return; + this.emitKeypress(buf[0..len], buf[0..len], false, false, false); + }, + + .execute => |c| { + // If bare ESC was pending, consume it — this is just a control char after ESC. + if (this.flags.esc_pending) { + this.flags.esc_pending = false; + const result = executeToKey(c); + this.emitKeypress(result.name, &.{c}, result.ctrl, false, true); + return; + } + + // C0 control character. + if (this.flags.in_paste) { + this.paste_buf.append(bun.default_allocator, c) catch {}; + return; + } + const result = executeToKey(c); + this.emitKeypress(result.name, &.{c}, result.ctrl, false, false); + }, + + .csi_dispatch => |csi| { + // Clear any pending ESC state (CSI follows ESC [). + this.flags.esc_pending = false; + this.flags.ss3_pending = false; + this.handleCSI(csi); + }, + + .esc_dispatch => |esc| { + this.handleESC(esc); + }, + + else => {}, + } +} + +fn handleCSI(this: *TuiKeyReader, csi: Action.CSI) void { + // Check for bracketed paste start/end. + if (csi.final == '~' and csi.params.len >= 1) { + if (csi.params[0] == 200) { + this.flags.in_paste = true; + this.paste_buf.clearRetainingCapacity(); + return; + } + if (csi.params[0] == 201) { + this.flags.in_paste = false; + this.emitPaste(); + return; + } + } + + if (this.flags.in_paste) return; + + // Focus events: CSI I (focus in), CSI O (focus out). + if (csi.final == 'I' and csi.params.len == 0 and csi.intermediates.len == 0) { + this.emitFocus(true); + return; + } + if (csi.final == 'O' and csi.params.len == 0 and csi.intermediates.len == 0) { + this.emitFocus(false); + return; + } + + // SGR mouse events: CSI < Pb ; Px ; Py M/m + const has_lt = for (csi.intermediates) |c| { + if (c == '<') break true; + } else false; + if (has_lt and (csi.final == 'M' or csi.final == 'm') and csi.params.len >= 3) { + this.emitMouse(csi.params[0], csi.params[1], csi.params[2], csi.final == 'm'); + return; + } + + // Extract modifier from parameter 2 (if present). + const modifier: u16 = if (csi.params.len >= 2) csi.params[1] else 0; + const ctrl = if (modifier > 0) (modifier -| 1) & 4 != 0 else false; + const alt = if (modifier > 0) (modifier -| 1) & 2 != 0 else false; + const shift = if (modifier > 0) (modifier -| 1) & 1 != 0 else false; + + // Check for intermediates — '>' means xterm-style, '?' means DECRPM, etc. + const has_gt = for (csi.intermediates) |c| { + if (c == '>') break true; + } else false; + + // Kitty protocol: CSI codepoint u / CSI codepoint;modifier u + if (csi.final == 'u' and !has_gt) { + if (csi.params.len >= 1 and csi.params[0] > 0 and csi.params[0] < 128) { + const name = ctrlName(@intCast(csi.params[0])); + this.emitKeypress(name, name, ctrl, shift, alt); + } + return; + } + + const name: []const u8 = switch (csi.final) { + 'A' => "up", + 'B' => "down", + 'C' => "right", + 'D' => "left", + 'H' => "home", + 'F' => "end", + 'P' => "f1", + 'Q' => "f2", + 'R' => "f3", + 'S' => "f4", + 'Z' => "tab", // shift+tab + '~' => tildeKey(csi.params), + else => return, + }; + + const is_shift_tab = csi.final == 'Z'; + this.emitKeypress(name, name, ctrl, if (is_shift_tab) true else shift, alt); +} + +fn handleESC(this: *TuiKeyReader, esc: Action.ESC) void { + if (this.flags.in_paste) return; + + // SS3: ESC O — the final byte 'O' means the NEXT char is the SS3 payload. + if (esc.intermediates.len == 0 and esc.final == 'O') { + this.flags.ss3_pending = true; + return; + } + + // Bare ESC dispatch with no intermediates — this is alt+char. + if (esc.intermediates.len == 0) { + const name = ctrlName(esc.final); + this.emitKeypress(name, name, false, false, true); + } +} + +fn tildeKey(params: []const u16) []const u8 { + if (params.len == 0) return "~"; + return switch (params[0]) { + 1 => "home", + 2 => "insert", + 3 => "delete", + 4 => "end", + 5 => "pageup", + 6 => "pagedown", + 15 => "f5", + 17 => "f6", + 18 => "f7", + 19 => "f8", + 20 => "f9", + 21 => "f10", + 23 => "f11", + 24 => "f12", + else => "~", + }; +} + +const KeyResult = struct { + name: []const u8, + ctrl: bool, +}; + +fn executeToKey(c: u8) KeyResult { + return switch (c) { + '\r', '\n' => .{ .name = "enter", .ctrl = false }, + '\t' => .{ .name = "tab", .ctrl = false }, + 0x08 => .{ .name = "backspace", .ctrl = true }, + 0x7f => .{ .name = "backspace", .ctrl = false }, + 0x1b => .{ .name = "escape", .ctrl = false }, + 0x00 => .{ .name = "space", .ctrl = true }, + // ctrl+a through ctrl+z, excluding \t (0x09), \n (0x0a), \r (0x0d) + 0x01...0x07, 0x0b, 0x0c, 0x0e...0x1a => .{ + .name = @as(*const [1]u8, @ptrCast(&ascii_table['a' + c - 1]))[0..1], + .ctrl = true, + }, + // C1 control codes (0x80-0x9F) and other high bytes — ignore. + 0x80...0xff => .{ .name = "", .ctrl = false }, + else => .{ .name = @as(*const [1]u8, @ptrCast(&ascii_table[c]))[0..1], .ctrl = false }, + }; +} + +fn ctrlName(c: u8) []const u8 { + if (c < 0x20 or c == 0x7f) return executeToKey(c).name; + if (c < 128) return @as(*const [1]u8, @ptrCast(&ascii_table[c]))[0..1]; + return ""; +} + +// --- Event emission --- + +/// Emit a mouse event to the JS callback. +fn emitMouse(this: *TuiKeyReader, button_code: u16, px: u16, py: u16, is_release: bool) void { + const callback = this.onmouse_callback.get() orelse return; + const globalThis = this.globalThis; + + const event = jsc.JSValue.createEmptyObject(globalThis, 8); + + // Decode SGR button code: + // bits 0-1: button (0=left, 1=middle, 2=right) + // bit 5: motion flag (32) + // bit 6: scroll flag (64) + // bits 2-4: modifiers (4=shift, 8=alt/meta, 16=ctrl) + const base_button = button_code & 0x03; + const is_motion = button_code & 32 != 0; + const is_scroll = button_code & 64 != 0; + const mod_shift = button_code & 4 != 0; + const mod_alt = button_code & 8 != 0; + const mod_ctrl = button_code & 16 != 0; + + const event_type: []const u8 = if (is_scroll) + (if (base_button == 0) "scrollUp" else "scrollDown") + else if (is_release) + "up" + else if (is_motion) + (if (base_button == 3) "move" else "drag") + else + "down"; + + const button: i32 = if (is_scroll) + (if (base_button == 0) @as(i32, 4) else 5) // wheel up/down + else + @as(i32, @intCast(base_button)); + + event.put(globalThis, bun.String.static("type"), bun.String.createUTF8ForJS(globalThis, event_type) catch return); + event.put(globalThis, bun.String.static("button"), jsc.JSValue.jsNumber(button)); + event.put(globalThis, bun.String.static("x"), jsc.JSValue.jsNumber(@as(i32, @intCast(px)) - 1)); // 1-based → 0-based + event.put(globalThis, bun.String.static("y"), jsc.JSValue.jsNumber(@as(i32, @intCast(py)) - 1)); // 1-based → 0-based + event.put(globalThis, bun.String.static("shift"), jsc.JSValue.jsBoolean(mod_shift)); + event.put(globalThis, bun.String.static("alt"), jsc.JSValue.jsBoolean(mod_alt)); + event.put(globalThis, bun.String.static("ctrl"), jsc.JSValue.jsBoolean(mod_ctrl)); + + globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{event}); +} + +/// Emit a focus/blur event. +fn emitFocus(this: *TuiKeyReader, focused: bool) void { + const callback = if (focused) + this.onfocus_callback.get() + else + this.onblur_callback.get(); + if (callback == null) return; + const globalThis = this.globalThis; + globalThis.bunVM().eventLoop().runCallback(callback.?, globalThis, .js_undefined, &.{}); +} + +fn emitKeypress(this: *TuiKeyReader, name: []const u8, sequence: []const u8, ctrl: bool, shift: bool, alt: bool) void { + if (name.len == 0) return; + const callback = this.onkeypress_callback.get() orelse return; + const globalThis = this.globalThis; + + const event = jsc.JSValue.createEmptyObject(globalThis, 5); + const name_js = bun.String.createUTF8ForJS(globalThis, name) catch return; + const seq_js = bun.String.createUTF8ForJS(globalThis, sequence) catch return; + event.put(globalThis, bun.String.static("name"), name_js); + event.put(globalThis, bun.String.static("sequence"), seq_js); + event.put(globalThis, bun.String.static("ctrl"), jsc.JSValue.jsBoolean(ctrl)); + event.put(globalThis, bun.String.static("shift"), jsc.JSValue.jsBoolean(shift)); + event.put(globalThis, bun.String.static("alt"), jsc.JSValue.jsBoolean(alt)); + + globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{event}); +} + +fn emitPaste(this: *TuiKeyReader) void { + const callback = this.onpaste_callback.get() orelse { + this.paste_buf.clearRetainingCapacity(); + return; + }; + const globalThis = this.globalThis; + const text = bun.String.createUTF8ForJS(globalThis, this.paste_buf.items) catch { + this.paste_buf.clearRetainingCapacity(); + return; + }; + this.paste_buf.clearRetainingCapacity(); + + globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{text}); +} + +/// Static table for printable ASCII character names. +const ascii_table: [128]u8 = blk: { + var table: [128]u8 = undefined; + for (0..128) |i| { + table[i] = @intCast(i); + } + break :blk table; +}; + +extern fn Bun__ttySetMode(fd: i32, mode: i32) i32; + +const bun = @import("bun"); +const std = @import("std"); +const jsc = bun.jsc; diff --git a/src/bun.js/api/tui/renderer.zig b/src/bun.js/api/tui/renderer.zig new file mode 100644 index 0000000000..afe06f9c8e --- /dev/null +++ b/src/bun.js/api/tui/renderer.zig @@ -0,0 +1,407 @@ +//! TuiRenderer — pure ANSI frame builder. +//! Diffs a TuiScreen into ANSI escape sequences using Ghostty's Page/Cell +//! for previous-frame buffer and u64 cell comparison. +//! No fd, no event loop, no ref-counting — just appends bytes to a caller-provided buffer. + +const TuiRenderer = @This(); + +const ghostty = @import("ghostty").terminal; +const Page = ghostty.page.Page; +const Cell = ghostty.Cell; +const Style = ghostty.Style; +const size = ghostty.size; +const TuiScreen = @import("./screen.zig"); + +pub const CursorStyle = enum { + default, + block, + underline, + line, + + /// Return the DECSCUSR parameter for this cursor style. + fn decscusr(this: CursorStyle, blinking: bool) u8 { + return switch (this) { + .default => 0, + .block => if (blinking) @as(u8, 1) else 2, + .underline => if (blinking) @as(u8, 3) else 4, + .line => if (blinking) @as(u8, 5) else 6, + }; + } +}; + +prev_page: ?Page = null, +cursor_x: size.CellCountInt = 0, +cursor_y: size.CellCountInt = 0, +current_style_id: size.StyleCountInt = 0, +current_hyperlink_id: u16 = 0, +has_rendered: bool = false, +prev_rows: size.CellCountInt = 0, +prev_hyperlink_ids: ?[]u16 = null, +current_cursor_style: CursorStyle = .default, +current_cursor_blinking: bool = false, +/// Set during render() to the target buffer. Not valid outside render(). +buf: *std.ArrayList(u8) = undefined, + +pub fn render( + this: *TuiRenderer, + buf: *std.ArrayList(u8), + screen: *const TuiScreen, + cursor_x: ?size.CellCountInt, + cursor_y: ?size.CellCountInt, + cursor_visible: ?bool, + cursor_style: ?CursorStyle, + cursor_blinking: ?bool, +) void { + this.buf = buf; + + this.emit(BSU); + + const need_full = !this.has_rendered or this.prev_page == null or + (if (this.prev_page) |p| p.size.cols != screen.page.size.cols or p.size.rows != screen.page.size.rows else true); + + if (need_full) this.renderFull(screen) else this.renderDiff(screen); + + if (cursor_x != null or cursor_y != null) { + this.moveTo(cursor_x orelse 0, cursor_y orelse 0); + } + if (cursor_visible) |visible| { + if (visible) { + this.emit("\x1b[?25h"); + } else { + this.emit("\x1b[?25l"); + } + } + + // Emit DECSCUSR if cursor style or blinking changed. + const new_style = cursor_style orelse this.current_cursor_style; + const new_blink = cursor_blinking orelse this.current_cursor_blinking; + if (new_style != this.current_cursor_style or new_blink != this.current_cursor_blinking) { + const param = new_style.decscusr(new_blink); + var local_buf: [8]u8 = undefined; + const seq = std.fmt.bufPrint(&local_buf, "\x1b[{d} q", .{@as(u32, param)}) catch unreachable; + this.emit(seq); + this.current_cursor_style = new_style; + this.current_cursor_blinking = new_blink; + } + + this.emit(ESU); + + this.swapScreens(screen); + this.prev_rows = screen.page.size.rows; + this.has_rendered = true; +} + +pub fn clear(this: *TuiRenderer) void { + if (this.prev_page) |*p| { + p.deinit(); + this.prev_page = null; + } + this.cursor_x = 0; + this.cursor_y = 0; + this.current_style_id = 0; + this.current_hyperlink_id = 0; + this.has_rendered = false; + this.prev_rows = 0; + if (this.prev_hyperlink_ids) |ids| { + bun.default_allocator.free(ids); + this.prev_hyperlink_ids = null; + } +} + +pub fn deinit(this: *TuiRenderer) void { + if (this.prev_page) |*p| p.deinit(); + if (this.prev_hyperlink_ids) |ids| bun.default_allocator.free(ids); +} + +// --- Rendering internals --- + +fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void { + this.emit("\x1b[?25l"); + if (this.has_rendered) { + if (this.cursor_y > 0) { + this.emitCSI(this.cursor_y, 'A'); + } + this.emit("\r"); + this.cursor_x = 0; + this.cursor_y = 0; + } + this.current_style_id = 0; + + var y: size.CellCountInt = 0; + while (y < screen.page.size.rows) : (y += 1) { + if (y > 0) this.emit("\r\n"); + const cells = screen.page.getRow(y).cells.ptr(screen.page.memory)[0..screen.page.size.cols]; + + var blank_cells: usize = 0; + var x: size.CellCountInt = 0; + while (x < screen.page.size.cols) : (x += 1) { + const cell = cells[x]; + if (cell.wide == .spacer_tail) continue; + + if (cell.isEmpty() and !cell.hasStyling()) { + blank_cells += 1; + continue; + } + + if (cell.codepoint() == ' ' and !cell.hasStyling()) { + blank_cells += 1; + continue; + } + + if (blank_cells > 0) { + var i: usize = 0; + while (i < blank_cells) : (i += 1) this.emit(" "); + blank_cells = 0; + } + + this.transitionHyperlink(screen.hyperlinks.getId(x, y, screen.page.size.cols), screen); + this.transitionStyle(cell.style_id, &screen.page); + this.writeCell(cell, &screen.page); + } + if (this.current_hyperlink_id != 0) { + this.emit("\x1b]8;;\x1b\\"); + this.current_hyperlink_id = 0; + } + this.emit("\x1b[K"); + } + + if (this.prev_rows > screen.page.size.rows) { + var extra = this.prev_rows - screen.page.size.rows; + while (extra > 0) : (extra -= 1) { + this.emit("\r\n\x1b[2K"); + } + this.emitCSI(this.prev_rows - screen.page.size.rows, 'A'); + } + + this.emit("\x1b[0m\x1b[?25h"); + this.current_style_id = 0; + this.cursor_x = screen.page.size.cols; + this.cursor_y = screen.page.size.rows -| 1; +} + +fn renderDiff(this: *TuiRenderer, screen: *const TuiScreen) void { + const prev = &(this.prev_page orelse return); + + var y: size.CellCountInt = 0; + while (y < screen.page.size.rows) : (y += 1) { + const row = screen.page.getRow(y); + if (!row.dirty) continue; + + const cells = row.cells.ptr(screen.page.memory)[0..screen.page.size.cols]; + const prev_cells = prev.getRow(y).cells.ptr(prev.memory)[0..prev.size.cols]; + + var x: size.CellCountInt = 0; + while (x < screen.page.size.cols) : (x += 1) { + const cell = cells[x]; + if (cell.wide == .spacer_tail) continue; + + const cur_hid = screen.hyperlinks.getId(x, y, screen.page.size.cols); + const prev_hid = if (this.prev_hyperlink_ids) |pids| blk: { + const idx = @as(usize, y) * @as(usize, screen.page.size.cols) + @as(usize, x); + break :blk if (idx < pids.len) pids[idx] else @as(u16, 0); + } else @as(u16, 0); + + if (@as(u64, @bitCast(cell)) == @as(u64, @bitCast(prev_cells[x])) and cur_hid == prev_hid) continue; + + if (x != this.cursor_x or y != this.cursor_y) this.moveTo(x, y); + this.transitionHyperlink(cur_hid, screen); + this.transitionStyle(cell.style_id, &screen.page); + this.writeCell(cell, &screen.page); + this.cursor_x = x + if (cell.wide == .wide) @as(size.CellCountInt, 2) else @as(size.CellCountInt, 1); + this.cursor_y = y; + } + } +} + +fn swapScreens(this: *TuiRenderer, screen: *const TuiScreen) void { + if (this.prev_page) |*p| { + if (p.size.cols != screen.page.size.cols or p.size.rows != screen.page.size.rows) { + p.deinit(); + this.prev_page = null; + } + } + if (this.prev_page == null) { + this.prev_page = Page.init(.{ .cols = screen.page.size.cols, .rows = screen.page.size.rows, .styles = 256 }) catch return; + } + var prev = &(this.prev_page orelse return); + + var y: size.CellCountInt = 0; + while (y < screen.page.size.rows) : (y += 1) { + const src_row = screen.page.getRow(y); + const src_cells = src_row.cells.ptr(screen.page.memory)[0..screen.page.size.cols]; + const dst_row = prev.getRow(y); + const dst_cells = dst_row.cells.ptr(prev.memory)[0..prev.size.cols]; + @memcpy(dst_cells[0..screen.page.size.cols], src_cells); + src_row.dirty = false; + dst_row.dirty = false; + } + + if (screen.hyperlinks.ids) |src_ids| { + const count = @as(usize, screen.page.size.cols) * @as(usize, screen.page.size.rows); + if (this.prev_hyperlink_ids) |prev_ids| { + if (prev_ids.len != count) { + bun.default_allocator.free(prev_ids); + this.prev_hyperlink_ids = bun.default_allocator.alloc(u16, count) catch null; + } + } else { + this.prev_hyperlink_ids = bun.default_allocator.alloc(u16, count) catch null; + } + if (this.prev_hyperlink_ids) |prev_ids| { + @memcpy(prev_ids[0..count], src_ids[0..count]); + } + } else { + if (this.prev_hyperlink_ids) |prev_ids| { + @memset(prev_ids, 0); + } + } +} + +// --- ANSI emission helpers --- + +fn moveTo(this: *TuiRenderer, x: size.CellCountInt, y: size.CellCountInt) void { + if (y > this.cursor_y) { + this.emitCSI(y - this.cursor_y, 'B'); + } else if (y < this.cursor_y) { + this.emitCSI(this.cursor_y - y, 'A'); + } + + if (x != this.cursor_x or y != this.cursor_y) { + this.emit("\r"); + if (x > 0) this.emitCSI(x, 'C'); + } + + this.cursor_x = x; + this.cursor_y = y; +} + +fn emitCSI(this: *TuiRenderer, n: anytype, code: u8) void { + var local_buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&local_buf, "\x1b[{d}{c}", .{ @as(u32, @intCast(n)), code }) catch return; + this.emit(seq); +} + +fn transitionHyperlink(this: *TuiRenderer, new_id: u16, screen: *const TuiScreen) void { + if (new_id == this.current_hyperlink_id) return; + if (new_id == 0) { + this.emit("\x1b]8;;\x1b\\"); + } else { + if (screen.hyperlinks.getUrl(new_id)) |url| { + this.emit("\x1b]8;;"); + this.emit(url); + this.emit("\x1b\\"); + } + } + this.current_hyperlink_id = new_id; +} + +fn transitionStyle(this: *TuiRenderer, new_id: size.StyleCountInt, page: *const Page) void { + if (new_id == this.current_style_id) return; + if (new_id == 0) { + this.emit("\x1b[0m"); + this.current_style_id = 0; + return; + } + const s = page.styles.get(page.memory, new_id); + this.emit("\x1b[0m"); + this.emitStyleSGR(s); + this.current_style_id = new_id; +} + +fn emitStyleSGR(this: *TuiRenderer, s: *const Style) void { + if (s.flags.bold) this.emit("\x1b[1m"); + if (s.flags.faint) this.emit("\x1b[2m"); + if (s.flags.italic) this.emit("\x1b[3m"); + switch (s.flags.underline) { + .none => {}, + .single => this.emit("\x1b[4m"), + .double => this.emit("\x1b[4:2m"), + .curly => this.emit("\x1b[4:3m"), + .dotted => this.emit("\x1b[4:4m"), + .dashed => this.emit("\x1b[4:5m"), + } + if (s.flags.blink) this.emit("\x1b[5m"); + if (s.flags.inverse) this.emit("\x1b[7m"); + if (s.flags.invisible) this.emit("\x1b[8m"); + if (s.flags.strikethrough) this.emit("\x1b[9m"); + if (s.flags.overline) this.emit("\x1b[53m"); + this.emitColor(s.fg_color, false); + this.emitColor(s.bg_color, true); + this.emitUnderlineColor(s.underline_color); +} + +fn emitColor(this: *TuiRenderer, color: Style.Color, is_bg: bool) void { + const base: u8 = if (is_bg) 48 else 38; + switch (color) { + .none => {}, + .palette => |idx| { + var local_buf: [16]u8 = undefined; + this.emit(std.fmt.bufPrint(&local_buf, "\x1b[{d};5;{d}m", .{ base, idx }) catch return); + }, + .rgb => |rgb| { + var local_buf: [24]u8 = undefined; + this.emit(std.fmt.bufPrint(&local_buf, "\x1b[{d};2;{d};{d};{d}m", .{ base, rgb.r, rgb.g, rgb.b }) catch return); + }, + } +} + +fn emitUnderlineColor(this: *TuiRenderer, color: Style.Color) void { + switch (color) { + .none => {}, + .palette => |idx| { + var local_buf: [16]u8 = undefined; + this.emit(std.fmt.bufPrint(&local_buf, "\x1b[58;5;{d}m", .{idx}) catch return); + }, + .rgb => |rgb| { + var local_buf: [24]u8 = undefined; + this.emit(std.fmt.bufPrint(&local_buf, "\x1b[58;2;{d};{d};{d}m", .{ rgb.r, rgb.g, rgb.b }) catch return); + }, + } +} + +fn writeCell(this: *TuiRenderer, cell: Cell, page: *const Page) void { + switch (cell.content_tag) { + .codepoint => { + if (!cell.hasText()) { + this.emit(" "); + return; + } + var local_buf: [4]u8 = undefined; + const len = bun.strings.encodeWTF8RuneT(&local_buf, u21, cell.content.codepoint); + if (len == 0) { + this.emit(" "); + return; + } + this.emit(local_buf[0..len]); + }, + .codepoint_grapheme => { + if (!cell.hasText()) { + this.emit(" "); + return; + } + var local_buf: [4]u8 = undefined; + const len = bun.strings.encodeWTF8RuneT(&local_buf, u21, cell.content.codepoint); + if (len == 0) { + this.emit(" "); + return; + } + this.emit(local_buf[0..len]); + if (page.lookupGrapheme(&cell)) |graphemes| { + for (graphemes) |gcp| { + const glen = bun.strings.encodeWTF8RuneT(&local_buf, u21, gcp); + if (glen > 0) this.emit(local_buf[0..glen]); + } + } + }, + .bg_color_palette, .bg_color_rgb => this.emit(" "), + } +} + +fn emit(this: *TuiRenderer, data: []const u8) void { + this.buf.appendSlice(bun.default_allocator, data) catch {}; +} + +const BSU = "\x1b[?2026h"; +const ESU = "\x1b[?2026l"; + +const bun = @import("bun"); +const std = @import("std"); diff --git a/src/bun.js/api/tui/screen.zig b/src/bun.js/api/tui/screen.zig new file mode 100644 index 0000000000..f1329a4b16 --- /dev/null +++ b/src/bun.js/api/tui/screen.zig @@ -0,0 +1,794 @@ +//! TuiScreen — a grid of styled cells for TUI rendering. +//! Wraps Ghostty's Page/Row/Cell/StyleSet directly. + +const TuiScreen = @This(); + +const ghostty = @import("ghostty").terminal; +const Page = ghostty.page.Page; +const Cell = ghostty.Cell; +const Style = ghostty.Style; +const size = ghostty.size; +const sgr = ghostty.sgr; + +pub const js = jsc.Codegen.JSTuiScreen; +pub const toJS = js.toJS; +pub const fromJS = js.fromJS; +pub const fromJSDirect = js.fromJSDirect; + +const ClipRect = struct { x1: size.CellCountInt, y1: size.CellCountInt, x2: size.CellCountInt, y2: size.CellCountInt }; + +/// Manages hyperlink URLs and a per-cell ID mapping. +pub const HyperlinkPool = struct { + /// Parallel array indexed by (y * cols + x), lazily allocated. 0 = no hyperlink. + ids: ?[]u16 = null, + /// Interned URL strings, indexed by (id - 1). + urls: std.ArrayListUnmanaged([]const u8) = .{}, + /// URL string → ID for deduplication. + map: std.StringHashMapUnmanaged(u16) = .{}, + /// Next ID to assign (starts at 1; 0 is reserved for "no hyperlink"). + next_id: u16 = 1, + + /// Intern a URL string. Returns an existing ID if the URL was already interned. + pub fn intern(self: *HyperlinkPool, url: []const u8) error{OutOfMemory}!u16 { + if (self.map.get(url)) |existing_id| return existing_id; + + const id = self.next_id; + self.next_id +%= 1; + + const owned_url = try bun.default_allocator.dupe(u8, url); + errdefer bun.default_allocator.free(owned_url); + + try self.urls.append(bun.default_allocator, owned_url); + errdefer _ = self.urls.pop(); + + try self.map.put(bun.default_allocator, owned_url, id); + + return id; + } + + /// Look up the URL for a given hyperlink ID (1-based). Returns null for ID 0. + pub fn getUrl(self: *const HyperlinkPool, id: u16) ?[]const u8 { + if (id == 0) return null; + const idx = id - 1; + if (idx >= self.urls.items.len) return null; + return self.urls.items[idx]; + } + + /// Get the hyperlink ID for a cell at (x, y) given grid width `cols`. + pub fn getId(self: *const HyperlinkPool, x: size.CellCountInt, y: size.CellCountInt, cols: size.CellCountInt) u16 { + const cell_ids = self.ids orelse return 0; + const idx = @as(usize, y) * @as(usize, cols) + @as(usize, x); + if (idx >= cell_ids.len) return 0; + return cell_ids[idx]; + } + + /// Set the hyperlink ID for a cell at (x, y) given grid width `cols`. + /// Lazily allocates the ID array on first use. + pub fn setId(self: *HyperlinkPool, x: size.CellCountInt, y: size.CellCountInt, cols: size.CellCountInt, rows: size.CellCountInt, hid: u16) error{OutOfMemory}!void { + const cell_ids = try self.ensureIds(cols, rows); + const idx = @as(usize, y) * @as(usize, cols) + @as(usize, x); + if (idx < cell_ids.len) cell_ids[idx] = hid; + } + + /// Ensure the per-cell ID array is allocated for the given grid dimensions. + fn ensureIds(self: *HyperlinkPool, cols: size.CellCountInt, rows: size.CellCountInt) error{OutOfMemory}![]u16 { + if (self.ids) |existing| return existing; + const count = @as(usize, cols) * @as(usize, rows); + const cell_ids = try bun.default_allocator.alloc(u16, count); + @memset(cell_ids, 0); + self.ids = cell_ids; + return cell_ids; + } + + /// Zero all per-cell IDs (e.g. on screen clear). Does not free the array. + pub fn clearIds(self: *HyperlinkPool) void { + if (self.ids) |cell_ids| @memset(cell_ids, 0); + } + + /// Free the per-cell ID array (e.g. on resize where dimensions change). + pub fn freeIds(self: *HyperlinkPool) void { + if (self.ids) |cell_ids| bun.default_allocator.free(cell_ids); + self.ids = null; + } + + /// Release all resources. + pub fn deinit(self: *HyperlinkPool) void { + self.freeIds(); + for (self.urls.items) |url| bun.default_allocator.free(url); + self.urls.deinit(bun.default_allocator); + self.map.deinit(bun.default_allocator); + self.* = .{}; + } +}; + +page: Page, +clip_stack: [8]ClipRect = undefined, +clip_depth: u8 = 0, +hyperlinks: HyperlinkPool = .{}, + +pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*TuiScreen { + const arguments = callframe.arguments(); + if (arguments.len < 2) return globalThis.throw("Screen requires (cols, rows) arguments", .{}); + if (!arguments[0].isNumber() or !arguments[1].isNumber()) + return globalThis.throw("Screen requires numeric cols and rows", .{}); + + const cols: size.CellCountInt = @intCast(@max(1, @min((try arguments[0].coerce(i32, globalThis)), 4096))); + const rows: size.CellCountInt = @intCast(@max(1, @min((try arguments[1].coerce(i32, globalThis)), 4096))); + + const page = Page.init(.{ .cols = cols, .rows = rows, .styles = 256 }) catch { + return globalThis.throw("Failed to allocate Screen", .{}); + }; + + return bun.new(TuiScreen, .{ .page = page }); +} + +pub fn finalize(this: *TuiScreen) callconv(.c) void { + this.hyperlinks.deinit(); + this.page.deinit(); + bun.destroy(this); +} + +pub fn estimatedSize(this: *const TuiScreen) usize { + return this.page.memory.len; +} + +fn getCols(self: *const TuiScreen) size.CellCountInt { + return self.page.size.cols; +} + +fn getRows(self: *const TuiScreen) size.CellCountInt { + return self.page.size.rows; +} + +fn getRowCells(self: *const TuiScreen, y: usize) struct { row: *ghostty.page.Row, cells: []Cell } { + const row = self.page.getRow(y); + const cells = row.cells.ptr(self.page.memory)[0..self.page.size.cols]; + return .{ .row = row, .cells = cells }; +} + +/// setText(x, y, text, styleId) +pub fn setText(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 3) return globalThis.throw("setText requires (x, y, text[, styleId])", .{}); + + const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols() -| 1))); + const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows() -| 1))); + if (!arguments[2].isString()) return globalThis.throw("setText: text must be a string", .{}); + + const sid: size.StyleCountInt = if (arguments.len > 3 and arguments[3].isNumber()) + @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), std.math.maxInt(size.StyleCountInt)))) + else + 0; + + // Apply clipping: for setText, clip restricts which row+col range is writable + const start_x = raw_x; + const y = raw_y; + var clip_max_col: size.CellCountInt = this.getCols(); + if (this.clip_depth > 0) { + const cr = this.clip_stack[this.clip_depth - 1]; + if (y < cr.y1 or y >= cr.y2 or start_x >= cr.x2) return jsc.JSValue.jsNumber(@as(i32, 0)); + clip_max_col = cr.x2; + } + + const str = try arguments[2].toSliceClone(globalThis); + defer str.deinit(); + const text = str.slice(); + + const rc = this.getRowCells(y); + const row = rc.row; + const cells = rc.cells; + var col = start_x; + var i: usize = 0; + const cols = clip_max_col; + + // Fast path: blast ASCII directly into cells — no per-codepoint decode + const first_non_ascii = bun.strings.firstNonASCII(text) orelse @as(u32, @intCast(text.len)); + const ascii_end: usize = @min(first_non_ascii, cols -| col); + if (ascii_end > 0) { + for (text[0..ascii_end]) |byte| { + if (col >= cols) break; + cells[col] = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = byte }, + .style_id = sid, + .wide = .narrow, + }; + col += 1; + } + i = ascii_end; + row.dirty = true; + if (sid != 0) row.styled = true; + } + + // Slow path: non-ASCII — decode codepoints, handle width/graphemes + var after_zwj = false; + while (i < text.len and col < cols) { + const cp_len = bun.strings.utf8ByteSequenceLength(text[i]); + if (cp_len == 0) { + i += 1; + continue; + } + if (i + cp_len > text.len) break; + + var bytes = [4]u8{ text[i], 0, 0, 0 }; + if (cp_len > 1) bytes[1] = text[i + 1]; + if (cp_len > 2) bytes[2] = text[i + 2]; + if (cp_len > 3) bytes[3] = text[i + 3]; + const cp = bun.strings.decodeWTF8RuneT(&bytes, cp_len, u21, 0xFFFD); + if (cp == 0xFFFD and cp_len > 1) { + i += cp_len; + continue; + } + + const width: u2 = @intCast(@min(bun.strings.visibleCodepointWidth(@intCast(cp), false), 2)); + + if (width == 0) { + // Zero-width codepoint: append as grapheme extension to the + // preceding content cell (walk back past any spacer_tails). + if (col > start_x) { + const target = graphemeTarget(cells, col, start_x); + this.page.appendGrapheme(row, &cells[target], @intCast(cp)) catch {}; + row.dirty = true; + } + after_zwj = (cp == 0x200D); + i += cp_len; + continue; + } + + // After a ZWJ, the next codepoint is a grapheme continuation + // regardless of its own width (e.g. family sequence). + if (after_zwj) { + after_zwj = false; + if (col > start_x) { + const target = graphemeTarget(cells, col, start_x); + this.page.appendGrapheme(row, &cells[target], @intCast(cp)) catch {}; + row.dirty = true; + } + i += cp_len; + continue; + } + + if (width == 2 and col + 1 >= cols) break; + + cells[col] = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(cp) }, + .style_id = sid, + .wide = if (width == 2) .wide else .narrow, + }; + row.dirty = true; + if (sid != 0) row.styled = true; + col += 1; + + if (width == 2 and col < cols) { + cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = ' ' }, .style_id = sid, .wide = .spacer_tail }; + col += 1; + } + i += cp_len; + } + + return jsc.JSValue.jsNumber(@as(i32, @intCast(col - start_x))); +} + +/// style({ fg, bg, bold, italic, underline, ... }) → styleId +pub fn style(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 1 or !arguments[0].isObject()) return globalThis.throw("style requires an options object", .{}); + + const opts = arguments[0]; + var s = Style{}; + + if (try opts.getTruthy(globalThis, "fg")) |v| s.fg_color = try parseColor(globalThis, v); + if (try opts.getTruthy(globalThis, "bg")) |v| s.bg_color = try parseColor(globalThis, v); + if (try opts.getTruthy(globalThis, "underlineColor")) |v| s.underline_color = try parseColor(globalThis, v); + + // Build flags by setting individual fields + if (try opts.getTruthy(globalThis, "bold")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.bold = true; + }; + if (try opts.getTruthy(globalThis, "italic")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.italic = true; + }; + if (try opts.getTruthy(globalThis, "faint")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.faint = true; + }; + if (try opts.getTruthy(globalThis, "blink")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.blink = true; + }; + if (try opts.getTruthy(globalThis, "inverse")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.inverse = true; + }; + if (try opts.getTruthy(globalThis, "invisible")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.invisible = true; + }; + if (try opts.getTruthy(globalThis, "strikethrough")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.strikethrough = true; + }; + if (try opts.getTruthy(globalThis, "overline")) |v| if (v.isBoolean() and v.asBoolean()) { + s.flags.overline = true; + }; + if (try opts.getTruthy(globalThis, "underline")) |v| { + if (v.isString()) { + const ul_str = try v.toSliceClone(globalThis); + defer ul_str.deinit(); + const UnderlineStyle = @TypeOf(s.flags.underline); + s.flags.underline = bun.ComptimeEnumMap(UnderlineStyle).get(ul_str.slice()) orelse .none; + } else if (v.isBoolean() and v.asBoolean()) { + s.flags.underline = .single; + } + } + + // Default style (no flags, no colors) is always ID 0. + if (s.default()) return jsc.JSValue.jsNumber(@as(i32, 0)); + + const id = this.page.styles.add(this.page.memory, s) catch { + return globalThis.throw("Failed to intern style: style set full", .{}); + }; + + return jsc.JSValue.jsNumber(@as(i32, @intCast(id))); +} + +/// clearRect(x, y, w, h) +pub fn clearRect(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 4) return globalThis.throw("clearRect requires (x, y, w, h)", .{}); + + const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols()))); + const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows()))); + const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), this.getCols() -| raw_x))); + const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getRows() -| raw_y))); + + const clipped = this.applyClip(raw_x, raw_y, raw_w, raw_h) orelse return .js_undefined; + + var row_idx = clipped.y; + while (row_idx < clipped.y +| clipped.h) : (row_idx += 1) { + const row = this.page.getRow(row_idx); + this.page.clearCells(row, clipped.x, clipped.x +| clipped.w); + row.dirty = true; + } + return .js_undefined; +} + +/// fill(x, y, w, h, char, styleId) +pub fn fill(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 5) return globalThis.throw("fill requires (x, y, w, h, char[, styleId])", .{}); + + const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols()))); + const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows()))); + const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), this.getCols() -| raw_x))); + const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getRows() -| raw_y))); + + var fill_cp: u21 = ' '; + if (arguments[4].isString()) { + const cs = try arguments[4].toSliceClone(globalThis); + defer cs.deinit(); + const s = cs.slice(); + if (s.len > 0) { + const cl = bun.strings.utf8ByteSequenceLength(s[0]); + if (cl == 0) { + fill_cp = ' '; + } else { + var bytes = [4]u8{ s[0], 0, 0, 0 }; + if (cl > 1 and s.len > 1) bytes[1] = s[1]; + if (cl > 2 and s.len > 2) bytes[2] = s[2]; + if (cl > 3 and s.len > 3) bytes[3] = s[3]; + fill_cp = bun.strings.decodeWTF8RuneT(&bytes, cl, u21, ' '); + } + } + } else if (arguments[4].isNumber()) { + fill_cp = @intCast(@max(0, @min((try arguments[4].coerce(i32, globalThis)), 0x10FFFF))); + } + + const sid: size.StyleCountInt = if (arguments.len > 5 and arguments[5].isNumber()) + @intCast(@max(0, @min((try arguments[5].coerce(i32, globalThis)), std.math.maxInt(size.StyleCountInt)))) + else + 0; + + const clipped = this.applyClip(raw_x, raw_y, raw_w, raw_h) orelse return .js_undefined; + + const fill_width: u2 = @intCast(@min(bun.strings.visibleCodepointWidth(@intCast(fill_cp), false), 2)); + const end_x = clipped.x +| clipped.w; + + var row_idx = clipped.y; + while (row_idx < clipped.y +| clipped.h) : (row_idx += 1) { + const rc = this.getRowCells(row_idx); + var col = clipped.x; + while (col < end_x) { + if (fill_width == 2) { + if (col + 1 >= end_x) break; // wide char doesn't fit in remaining space + rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = fill_cp }, .style_id = sid, .wide = .wide }; + rc.cells[col + 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = ' ' }, .style_id = sid, .wide = .spacer_tail }; + col += 2; + } else { + rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = fill_cp }, .style_id = sid, .wide = .narrow }; + col += 1; + } + } + rc.row.dirty = true; + if (sid != 0) rc.row.styled = true; + } + return .js_undefined; +} + +/// copy(srcScreen, sx, sy, dx, dy, w, h) +pub fn copy(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 7) return globalThis.throw("copy requires (srcScreen, sx, sy, dx, dy, w, h)", .{}); + + const src = TuiScreen.fromJS(arguments[0]) orelse return globalThis.throw("copy: first argument must be a Screen", .{}); + const sx: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), src.getCols()))); + const sy: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), src.getRows()))); + const raw_dx: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getCols()))); + const raw_dy: size.CellCountInt = @intCast(@max(0, @min((try arguments[4].coerce(i32, globalThis)), this.getRows()))); + const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[5].coerce(i32, globalThis)), @min(src.getCols() -| sx, this.getCols() -| raw_dx)))); + const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[6].coerce(i32, globalThis)), @min(src.getRows() -| sy, this.getRows() -| raw_dy)))); + + const clipped = this.applyClip(raw_dx, raw_dy, raw_w, raw_h) orelse return .js_undefined; + // Adjust source offset based on how much the destination was shifted by clipping + const src_x_off = clipped.x -| raw_dx; + const src_y_off = clipped.y -| raw_dy; + + var off: size.CellCountInt = 0; + while (off < clipped.h) : (off += 1) { + const src_cells = src.getRowCells(sy +| src_y_off +| off).cells; + const dst = this.getRowCells(clipped.y +| off); + @memcpy(dst.cells[clipped.x..][0..clipped.w], src_cells[sx +| src_x_off..][0..clipped.w]); + dst.row.dirty = true; + } + return .js_undefined; +} + +/// resize(cols, rows) +pub fn resize(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 2) return globalThis.throw("resize requires (cols, rows)", .{}); + + const nc: size.CellCountInt = @intCast(@max(1, @min((try arguments[0].coerce(i32, globalThis)), 4096))); + const nr: size.CellCountInt = @intCast(@max(1, @min((try arguments[1].coerce(i32, globalThis)), 4096))); + if (nc == this.getCols() and nr == this.getRows()) return .js_undefined; + + var new_page = Page.init(.{ .cols = nc, .rows = nr, .styles = 256 }) catch { + return globalThis.throw("Failed to resize Screen", .{}); + }; + + const cc = @min(this.getCols(), nc); + const cr = @min(this.getRows(), nr); + var ri: size.CellCountInt = 0; + while (ri < cr) : (ri += 1) { + const src_cells = this.getRowCells(ri).cells; + const dst_row = new_page.getRow(ri); + const dst_cells = dst_row.cells.ptr(new_page.memory)[0..new_page.size.cols]; + @memcpy(dst_cells[0..cc], src_cells[0..cc]); + dst_row.dirty = true; + } + + this.page.deinit(); + this.page = new_page; + // Free hyperlink ID array (it's sized to old dimensions) + this.hyperlinks.freeIds(); + return .js_undefined; +} + +/// clear() +pub fn clear(this: *TuiScreen, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + var ri: size.CellCountInt = 0; + while (ri < this.getRows()) : (ri += 1) { + const row = this.page.getRow(ri); + this.page.clearCells(row, 0, this.getCols()); + row.dirty = true; + } + this.hyperlinks.clearIds(); + return .js_undefined; +} + +/// hyperlink(url) → hyperlinkId — interns a URL and returns its ID +pub fn hyperlink(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 1 or !arguments[0].isString()) return globalThis.throw("hyperlink requires a URL string", .{}); + + const url_str = try arguments[0].toSliceClone(globalThis); + defer url_str.deinit(); + + const id = try this.hyperlinks.intern(url_str.slice()); + return jsc.JSValue.jsNumber(@as(i32, @intCast(id))); +} + +/// setHyperlink(x, y, hyperlinkId) — set the hyperlink ID for a cell +pub fn setHyperlink(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 3) return globalThis.throw("setHyperlink requires (x, y, hyperlinkId)", .{}); + + const x = (try arguments[0].coerce(i32, globalThis)); + const y = (try arguments[1].coerce(i32, globalThis)); + if (x < 0 or x >= this.getCols() or y < 0 or y >= this.getRows()) return .js_undefined; + + const hid: u16 = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), std.math.maxInt(u16)))); + + try this.hyperlinks.setId(@intCast(x), @intCast(y), this.getCols(), this.getRows(), hid); + this.page.getRow(@intCast(y)).dirty = true; + return .js_undefined; +} + +/// clip(x1, y1, x2, y2) — push a clipping rectangle onto the stack +pub fn clip(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 4) return globalThis.throw("clip requires (x1, y1, x2, y2)", .{}); + + if (this.clip_depth >= 8) return globalThis.throw("clip stack overflow (max 8)", .{}); + + const cols = this.getCols(); + const rows = this.getRows(); + const x1: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), cols))); + const y1: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), rows))); + const x2: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), cols))); + const y2: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), rows))); + + this.clip_stack[this.clip_depth] = .{ .x1 = x1, .y1 = y1, .x2 = x2, .y2 = y2 }; + this.clip_depth += 1; + return .js_undefined; +} + +/// unclip() — pop the clipping stack +pub fn unclip(this: *TuiScreen, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.clip_depth > 0) this.clip_depth -= 1; + return .js_undefined; +} + +/// Apply the active clip rect to a region, returning the clamped bounds. +/// Returns null if the region is entirely outside the clip rect. +fn applyClip(this: *const TuiScreen, x: size.CellCountInt, y: size.CellCountInt, w: size.CellCountInt, h: size.CellCountInt) ?struct { x: size.CellCountInt, y: size.CellCountInt, w: size.CellCountInt, h: size.CellCountInt } { + if (this.clip_depth == 0) return .{ .x = x, .y = y, .w = w, .h = h }; + const cr = this.clip_stack[this.clip_depth - 1]; + const cx1 = @max(x, cr.x1); + const cy1 = @max(y, cr.y1); + const cx2 = @min(x +| w, cr.x2); + const cy2 = @min(y +| h, cr.y2); + if (cx1 >= cx2 or cy1 >= cy2) return null; + return .{ .x = cx1, .y = cy1, .w = cx2 -| cx1, .h = cy2 -| cy1 }; +} + +pub fn getWidth(this: *const TuiScreen, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.getCols()))); +} + +pub fn getHeight(this: *const TuiScreen, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.getRows()))); +} + +/// getCell(x, y) → { char, styleId, wide } +pub fn getCell(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 2) return globalThis.throw("getCell requires (x, y)", .{}); + const x = (try arguments[0].coerce(i32, globalThis)); + const y = (try arguments[1].coerce(i32, globalThis)); + if (x < 0 or x >= this.getCols() or y < 0 or y >= this.getRows()) return .null; + + const cell = this.getRowCells(@intCast(y)).cells[@intCast(x)]; + const result = jsc.JSValue.createEmptyObject(globalThis, 3); + + if (cell.content_tag == .codepoint or cell.content_tag == .codepoint_grapheme) { + const cp: u21 = @intCast(cell.content.codepoint); + if (cp == 0) { + result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, " ")); + } else { + var buf: [4]u8 = undefined; + const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp); + if (len > 0) { + result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, buf[0..len])); + } else { + result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, " ")); + } + } + } else { + result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, " ")); + } + + result.put(globalThis, bun.String.static("styleId"), jsc.JSValue.jsNumber(@as(i32, @intCast(cell.style_id)))); + result.put(globalThis, bun.String.static("wide"), jsc.JSValue.jsNumber(@as(i32, @intFromEnum(cell.wide)))); + return result; +} + +/// drawBox(x, y, w, h, options?) — draw a bordered box. +/// Options: { style: "single"|"double"|"rounded"|"heavy", styleId, fill, fillChar } +pub fn drawBox(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + if (arguments.len < 4) return globalThis.throw("drawBox requires (x, y, w, h[, options])", .{}); + + const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols()))); + const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows()))); + const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), this.getCols() -| raw_x))); + const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getRows() -| raw_y))); + + if (raw_w < 2 or raw_h < 2) return .js_undefined; + + var border_chars = BoxChars.single; + var sid: size.StyleCountInt = 0; + var do_fill = false; + var fill_char: u21 = ' '; + + if (arguments.len > 4 and arguments[4].isObject()) { + const opts = arguments[4]; + if (try opts.getTruthy(globalThis, "style")) |v| { + if (v.isString()) { + const s = try v.toSliceClone(globalThis); + defer s.deinit(); + border_chars = BoxChars.fromName(s.slice()); + } + } + if (try opts.getTruthy(globalThis, "styleId")) |v| { + if (v.isNumber()) sid = @intCast(@max(0, @min((try v.coerce(i32, globalThis)), std.math.maxInt(size.StyleCountInt)))); + } + if (try opts.getTruthy(globalThis, "fill")) |v| { + if (v.isBoolean()) do_fill = v.asBoolean(); + } + if (try opts.getTruthy(globalThis, "fillChar")) |v| { + if (v.isString()) { + const cs = try v.toSliceClone(globalThis); + defer cs.deinit(); + const fc = cs.slice(); + if (fc.len > 0) { + const cl = bun.strings.utf8ByteSequenceLength(fc[0]); + if (cl > 0) { + var bytes = [4]u8{ fc[0], 0, 0, 0 }; + if (cl > 1 and fc.len > 1) bytes[1] = fc[1]; + if (cl > 2 and fc.len > 2) bytes[2] = fc[2]; + if (cl > 3 and fc.len > 3) bytes[3] = fc[3]; + fill_char = bun.strings.decodeWTF8RuneT(&bytes, cl, u21, ' '); + } + } + } + } + } + + const clipped = this.applyClip(raw_x, raw_y, raw_w, raw_h) orelse return .js_undefined; + const x = clipped.x; + const y = clipped.y; + const w = clipped.w; + const h = clipped.h; + const x2 = x +| w; + const y2 = y +| h; + const orig_x2 = raw_x +| raw_w; + const orig_y2 = raw_y +| raw_h; + + // Top border + if (y == raw_y) { + const rc = this.getRowCells(y); + if (x == raw_x) { + rc.cells[x] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.tl }, .style_id = sid, .wide = .narrow }; + } + var col = @max(x, raw_x + 1); + while (col < @min(x2, orig_x2 -| 1)) : (col += 1) { + rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.h }, .style_id = sid, .wide = .narrow }; + } + if (x2 == orig_x2 and x2 > x) { + rc.cells[x2 - 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.tr }, .style_id = sid, .wide = .narrow }; + } + rc.row.dirty = true; + if (sid != 0) rc.row.styled = true; + } + + // Bottom border + if (y2 == orig_y2 and y2 > y) { + const rc = this.getRowCells(y2 - 1); + if (x == raw_x) { + rc.cells[x] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.bl }, .style_id = sid, .wide = .narrow }; + } + var col = @max(x, raw_x + 1); + while (col < @min(x2, orig_x2 -| 1)) : (col += 1) { + rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.h }, .style_id = sid, .wide = .narrow }; + } + if (x2 == orig_x2 and x2 > x) { + rc.cells[x2 - 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.br }, .style_id = sid, .wide = .narrow }; + } + rc.row.dirty = true; + if (sid != 0) rc.row.styled = true; + } + + // Side borders and optional fill + const row_start = if (y == raw_y) y + 1 else y; + const row_end = if (y2 == orig_y2 and y2 > y) y2 - 1 else y2; + var row_idx = row_start; + while (row_idx < row_end) : (row_idx += 1) { + const rc = this.getRowCells(row_idx); + if (x == raw_x) { + rc.cells[x] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.v }, .style_id = sid, .wide = .narrow }; + } + if (x2 == orig_x2 and x2 > x) { + rc.cells[x2 - 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.v }, .style_id = sid, .wide = .narrow }; + } + if (do_fill) { + const fill_start = @max(x, raw_x + 1); + const fill_end = @min(x2, orig_x2 -| 1); + var col = fill_start; + while (col < fill_end) : (col += 1) { + rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = fill_char }, .style_id = sid, .wide = .narrow }; + } + } + rc.row.dirty = true; + if (sid != 0) rc.row.styled = true; + } + + return .js_undefined; +} + +/// Box drawing character sets. +const BoxChars = struct { + tl: u21, + tr: u21, + bl: u21, + br: u21, + h: u21, + v: u21, + + const single = BoxChars{ .tl = '┌', .tr = '┐', .bl = '└', .br = '┘', .h = '─', .v = '│' }; + const double = BoxChars{ .tl = '╔', .tr = '╗', .bl = '╚', .br = '╝', .h = '═', .v = '║' }; + const rounded = BoxChars{ .tl = '╭', .tr = '╮', .bl = '╰', .br = '╯', .h = '─', .v = '│' }; + const heavy = BoxChars{ .tl = '┏', .tr = '┓', .bl = '┗', .br = '┛', .h = '━', .v = '┃' }; + const ascii = BoxChars{ .tl = '+', .tr = '+', .bl = '+', .br = '+', .h = '-', .v = '|' }; + + fn fromName(name: []const u8) BoxChars { + if (bun.strings.eqlComptime(name, "double")) return double; + if (bun.strings.eqlComptime(name, "rounded")) return rounded; + if (bun.strings.eqlComptime(name, "heavy")) return heavy; + if (bun.strings.eqlComptime(name, "ascii")) return ascii; + return single; + } +}; + +/// Walk back from `col` past spacer_tail cells to find the content cell +/// that should receive grapheme extensions. +fn graphemeTarget(cells: []Cell, col: size.CellCountInt, start_x: size.CellCountInt) size.CellCountInt { + var target = col - 1; + while (target > start_x and cells[target].wide == .spacer_tail) { + target -= 1; + } + return target; +} + +fn parseColor(globalThis: *jsc.JSGlobalObject, val: jsc.JSValue) bun.JSError!Style.Color { + if (val.isNumber()) { + const v: u32 = @bitCast(val.toInt32()); + return .{ .rgb = .{ + .r = @intCast((v >> 16) & 0xFF), + .g = @intCast((v >> 8) & 0xFF), + .b = @intCast(v & 0xFF), + } }; + } + if (val.isString()) { + const str = try val.toSliceClone(globalThis); + defer str.deinit(); + const s = str.slice(); + const hex = if (s.len > 0 and s[0] == '#') s[1..] else s; + if (hex.len == 6) { + const r = std.fmt.parseInt(u8, hex[0..2], 16) catch 0; + const g = std.fmt.parseInt(u8, hex[2..4], 16) catch 0; + const b = std.fmt.parseInt(u8, hex[4..6], 16) catch 0; + return .{ .rgb = .{ .r = r, .g = g, .b = b } }; + } + } + // Object form: { palette: 196 } for 256-color palette, or { r, g, b } for RGB + if (val.isObject()) { + if (try val.getTruthy(globalThis, "palette")) |p| { + if (p.isNumber()) { + const idx: u8 = @intCast(@max(0, @min((try p.coerce(i32, globalThis)), 255))); + return .{ .palette = idx }; + } + } + // Object RGB: { r: 255, g: 0, b: 0 } + const r_val = try val.getTruthy(globalThis, "r"); + const g_val = try val.getTruthy(globalThis, "g"); + const b_val = try val.getTruthy(globalThis, "b"); + if (r_val != null and g_val != null and b_val != null) { + const r: u8 = @intCast(@max(0, @min((try r_val.?.coerce(i32, globalThis)), 255))); + const g: u8 = @intCast(@max(0, @min((try g_val.?.coerce(i32, globalThis)), 255))); + const b: u8 = @intCast(@max(0, @min((try b_val.?.coerce(i32, globalThis)), 255))); + return .{ .rgb = .{ .r = r, .g = g, .b = b } }; + } + } + return .none; +} + +const bun = @import("bun"); +const std = @import("std"); +const jsc = bun.jsc; diff --git a/src/bun.js/api/tui/terminal_writer.zig b/src/bun.js/api/tui/terminal_writer.zig new file mode 100644 index 0000000000..9b80ec2e8b --- /dev/null +++ b/src/bun.js/api/tui/terminal_writer.zig @@ -0,0 +1,543 @@ +//! TuiTerminalWriter — JS-visible renderer that outputs ANSI to a file descriptor. +//! +//! Accepts `Bun.file()` (a file-backed Blob) as its argument. +//! If the Blob wraps a raw fd, uses it directly (owns_fd = false). +//! If the Blob wraps a path, opens the file (owns_fd = true). +//! +//! Lifetime: ref-counted so that in-flight async writes keep the object alive +//! even if JS drops all references. Refs are held by: +//! 1. JS side (released in finalize) +//! 2. In-flight write (ref in render, deref in onWriterWrite/onWriterError) +//! +//! Double-buffered: while the IOWriter drains `output`, new frames render into +//! `next_output`. On drain, if `next_output` has data, the buffers are swapped +//! and the next async write starts immediately. + +const TuiTerminalWriter = @This(); + +const TuiRenderer = @import("./renderer.zig"); +const TuiScreen = @import("./screen.zig"); +const CursorStyle = TuiRenderer.CursorStyle; + +const ghostty = @import("ghostty").terminal; +const size = ghostty.size; + +pub const IOWriter = bun.io.BufferedWriter(TuiTerminalWriter, struct { + pub const onWritable = TuiTerminalWriter.onWritable; + pub const getBuffer = TuiTerminalWriter.getWriterBuffer; + pub const onClose = TuiTerminalWriter.onWriterClose; + pub const onError = TuiTerminalWriter.onWriterError; + pub const onWrite = TuiTerminalWriter.onWriterWrite; +}); + +const RefCount = bun.ptr.RefCount(TuiTerminalWriter, "ref_count", deinit, .{}); +pub const ref = RefCount.ref; +pub const deref = RefCount.deref; + +pub const js = jsc.Codegen.JSTuiTerminalWriter; +pub const toJS = js.toJS; +pub const fromJS = js.fromJS; +pub const fromJSDirect = js.fromJSDirect; + +ref_count: RefCount, +renderer: TuiRenderer = .{}, + +fd: bun.FileDescriptor, +owns_fd: bool, +globalThis: *jsc.JSGlobalObject = undefined, +io_writer: IOWriter = .{}, +event_loop: jsc.EventLoopHandle = undefined, +/// Buffer currently being drained by the IOWriter. +output: std.ArrayList(u8) = .{}, +/// Receives the next rendered frame while `output` is in-flight. +next_output: std.ArrayList(u8) = .{}, +write_offset: usize = 0, +/// True while the IOWriter is asynchronously draining `output`. +write_pending: bool = false, +/// True after close() / end() has been called. +closed: bool = false, +/// True if close() was called while a write is still pending. +closing: bool = false, +/// True while alternate screen mode is active. +alt_screen: bool = false, +/// JS callback for resize events. +onresize_callback: jsc.Strong.Optional = .empty, +/// Cached terminal dimensions for change detection. +cached_cols: u16 = 0, +cached_rows: u16 = 0, +/// True while mouse tracking is active. +mouse_tracking: bool = false, +/// True while focus tracking is active. +focus_tracking: bool = false, + +pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*TuiTerminalWriter { + const arguments = callframe.arguments(); + if (arguments.len < 1) + return globalThis.throw("TUITerminalWriter requires a Bun.file() argument", .{}); + + const arg = arguments[0]; + + const blob = arg.as(jsc.WebCore.Blob) orelse + return globalThis.throw("TUITerminalWriter requires a Bun.file() argument", .{}); + if (!blob.needsToReadFile()) + return globalThis.throw("TUITerminalWriter requires a file-backed Blob (use Bun.file())", .{}); + + const store = blob.store orelse + return globalThis.throw("TUITerminalWriter: Blob has no backing store", .{}); + const pathlike = store.data.file.pathlike; + + var fd: bun.FileDescriptor = undefined; + var owns_fd: bool = undefined; + + switch (pathlike) { + .fd => |raw_fd| { + fd = raw_fd; + owns_fd = false; + }, + .path => |path| { + var path_buf: bun.PathBuffer = undefined; + const result = bun.sys.open(path.sliceZ(&path_buf), bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o644); + switch (result) { + .result => |opened_fd| { + fd = opened_fd; + owns_fd = true; + }, + .err => |err| { + return globalThis.throw("TUITerminalWriter: failed to open file: {f}", .{err}); + }, + } + }, + } + + const this = bun.new(TuiTerminalWriter, .{ + .ref_count = .init(), + .fd = fd, + .owns_fd = owns_fd, + .globalThis = globalThis, + .event_loop = jsc.EventLoopHandle.init(globalThis.bunVM().eventLoop()), + }); + this.io_writer.setParent(this); + _ = this.io_writer.start(fd, true); + this.io_writer.close_fd = false; + this.io_writer.updateRef(this.event_loop, false); + return this; +} + +fn deinit(this: *TuiTerminalWriter) void { + this.onresize_callback.deinit(); + this.renderer.deinit(); + this.io_writer.end(); + if (this.owns_fd) { + _ = this.fd.close(); + } + this.output.deinit(bun.default_allocator); + this.next_output.deinit(bun.default_allocator); + bun.destroy(this); +} + +pub fn finalize(this: *TuiTerminalWriter) callconv(.c) void { + this.deref(); +} + +pub fn eventLoop(this: *TuiTerminalWriter) jsc.EventLoopHandle { + return this.event_loop; +} + +pub fn loop(this: *TuiTerminalWriter) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.event_loop.loop().uv_loop; + } else { + return this.event_loop.loop(); + } +} + +// --- BufferedWriter callbacks --- + +pub fn getWriterBuffer(this: *TuiTerminalWriter) []const u8 { + if (this.write_offset >= this.output.items.len) return &.{}; + return this.output.items[this.write_offset..]; +} + +pub fn onWriterWrite(this: *TuiTerminalWriter, amount: usize, status: bun.io.WriteStatus) void { + this.write_offset += amount; + + const drained = status == .end_of_file or status == .drained or + this.write_offset >= this.output.items.len; + + if (drained) { + if (this.flushNextOutput()) return; + this.io_writer.updateRef(this.event_loop, false); + this.write_pending = false; + if (this.closing) { + this.closed = true; + this.closing = false; + } + this.deref(); + } +} + +pub fn onWriterError(this: *TuiTerminalWriter, _: bun.sys.Error) void { + this.io_writer.updateRef(this.event_loop, false); + this.write_offset = 0; + this.output.clearRetainingCapacity(); + this.next_output.clearRetainingCapacity(); + this.write_pending = false; + if (this.closing) { + this.closed = true; + this.closing = false; + } + this.deref(); +} + +pub fn onWriterClose(_: *TuiTerminalWriter) void {} + +pub fn onWritable(this: *TuiTerminalWriter) void { + _ = this.flushNextOutput(); +} + +/// If `next_output` has queued data, swap it into `output` and start the next +/// async write. Returns true if a new write was kicked off. +fn flushNextOutput(this: *TuiTerminalWriter) bool { + if (this.next_output.items.len == 0) return false; + + this.output.clearRetainingCapacity(); + const tmp = this.output; + this.output = this.next_output; + this.next_output = tmp; + + this.write_offset = 0; + this.io_writer.write(); + return true; +} + +// --- render --- + +/// render(screen, options?) +pub fn render(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + + // Check for resize before rendering. + this.checkResize(); + + const arguments = callframe.arguments(); + if (arguments.len < 1) return globalThis.throw("render requires a Screen argument", .{}); + const screen = TuiScreen.fromJS(arguments[0]) orelse + return globalThis.throw("render: argument must be a TUIScreen", .{}); + + // Parse optional cursor options + var cursor_x: ?size.CellCountInt = null; + var cursor_y: ?size.CellCountInt = null; + var cursor_visible: ?bool = null; + var cursor_style: ?CursorStyle = null; + var cursor_blinking: ?bool = null; + if (arguments.len > 1 and arguments[1].isObject()) { + const opts = arguments[1]; + if (try opts.getTruthy(globalThis, "cursorX")) |v| { + const val = try v.coerce(i32, globalThis); + cursor_x = @intCast(@max(0, @min(val, screen.page.size.cols -| 1))); + } + if (try opts.getTruthy(globalThis, "cursorY")) |v| { + const val = try v.coerce(i32, globalThis); + cursor_y = @intCast(@max(0, @min(val, screen.page.size.rows -| 1))); + } + if (try opts.getTruthy(globalThis, "cursorVisible")) |v| { + if (v.isBoolean()) cursor_visible = v.asBoolean(); + } + if (try opts.getTruthy(globalThis, "cursorStyle")) |v| { + cursor_style = parseCursorStyle(v, globalThis); + } + if (try opts.getTruthy(globalThis, "cursorBlinking")) |v| { + if (v.isBoolean()) cursor_blinking = v.asBoolean(); + } + } + + // Async double-buffered write. + if (this.write_pending) { + this.next_output.clearRetainingCapacity(); + this.renderer.render(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking); + } else { + this.output.clearRetainingCapacity(); + this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking); + if (this.output.items.len > 0) { + this.write_offset = 0; + this.write_pending = true; + this.ref(); + this.io_writer.updateRef(this.event_loop, true); + this.io_writer.write(); + } + } + + return .js_undefined; +} + +/// clear() +pub fn clear(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + this.renderer.clear(); + return .js_undefined; +} + +/// close() +pub fn close(this: *TuiTerminalWriter, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return .js_undefined; + // Auto-disable tracking modes on close. + if (this.mouse_tracking) { + this.writeRaw("\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l"); + this.mouse_tracking = false; + } + if (this.focus_tracking) { + this.writeRaw("\x1b[?1004l"); + this.focus_tracking = false; + } + // Auto-exit alternate screen on close. + if (this.alt_screen) { + this.writeRaw("\x1b[?1049l"); + this.alt_screen = false; + } + if (this.write_pending) { + this.closing = true; + } else { + this.closed = true; + } + this.renderer.clear(); + return .js_undefined; +} + +/// end() — alias for close() +pub fn end(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + return this.close(globalThis, callframe); +} + +// --- Alternate Screen --- + +/// enterAltScreen() +pub fn enterAltScreen(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + if (this.alt_screen) return .js_undefined; + this.alt_screen = true; + this.writeRaw("\x1b[?1049h"); + this.renderer.clear(); // Reset diff state for alt screen. + return .js_undefined; +} + +/// exitAltScreen() +pub fn exitAltScreen(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + if (this.alt_screen) { + this.writeRaw("\x1b[?1049l"); + this.alt_screen = false; + this.renderer.clear(); + } + return .js_undefined; +} + +// --- Mouse Tracking --- + +/// enableMouseTracking() — enable SGR mouse mode +pub fn enableMouseTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + if (this.mouse_tracking) return .js_undefined; + this.mouse_tracking = true; + // Enable: button events + SGR encoding + any-event tracking + this.writeRaw("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"); + return .js_undefined; +} + +/// disableMouseTracking() +pub fn disableMouseTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + if (!this.mouse_tracking) return .js_undefined; + this.mouse_tracking = false; + this.writeRaw("\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l"); + return .js_undefined; +} + +// --- Focus Tracking --- + +/// enableFocusTracking() +pub fn enableFocusTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + if (this.focus_tracking) return .js_undefined; + this.focus_tracking = true; + this.writeRaw("\x1b[?1004h"); + return .js_undefined; +} + +/// disableFocusTracking() +pub fn disableFocusTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + if (!this.focus_tracking) return .js_undefined; + this.focus_tracking = false; + this.writeRaw("\x1b[?1004l"); + return .js_undefined; +} + +// --- Bracketed Paste --- + +/// enableBracketedPaste() +pub fn enableBracketedPaste(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + this.writeRaw("\x1b[?2004h"); + return .js_undefined; +} + +/// disableBracketedPaste() +pub fn disableBracketedPaste(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + this.writeRaw("\x1b[?2004l"); + return .js_undefined; +} + +/// write(string) — write raw string bytes to the terminal +pub fn write(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{}); + const arguments = callframe.arguments(); + if (arguments.len < 1 or !arguments[0].isString()) + return globalThis.throw("write requires a string argument", .{}); + + const str = try arguments[0].toSliceClone(globalThis); + defer str.deinit(); + this.writeRaw(str.slice()); + return .js_undefined; +} + +// --- Resize --- + +pub fn setOnResize(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void { + if (value.isCallable()) { + this.onresize_callback = jsc.Strong.Optional.create(value, globalThis); + // Initialize cached dimensions on first set. + if (this.cached_cols == 0) { + switch (bun.sys.getWinsize(this.fd)) { + .result => |ws| { + this.cached_cols = ws.col; + this.cached_rows = ws.row; + }, + .err => { + this.cached_cols = 80; + this.cached_rows = 24; + }, + } + } + // Install the global SIGWINCH handler if not already installed. + SigwinchHandler.install(); + } else if (value.isUndefinedOrNull()) { + this.onresize_callback.deinit(); + } +} + +pub fn getOnResize(this: *TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return this.onresize_callback.get() orelse .js_undefined; +} + +/// Check if terminal size changed (called from render path). +fn checkResize(this: *TuiTerminalWriter) void { + if (this.onresize_callback.get() == null) return; + if (!SigwinchHandler.consume()) return; + + switch (bun.sys.getWinsize(this.fd)) { + .result => |ws| { + if (ws.col != this.cached_cols or ws.row != this.cached_rows) { + this.cached_cols = ws.col; + this.cached_rows = ws.row; + this.dispatchResize(ws.col, ws.row); + } + }, + .err => {}, + } +} + +fn dispatchResize(this: *TuiTerminalWriter, cols: u16, rows: u16) void { + const callback = this.onresize_callback.get() orelse return; + const globalThis = this.globalThis; + globalThis.bunVM().eventLoop().runCallback( + callback, + globalThis, + .js_undefined, + &.{ + jsc.JSValue.jsNumber(@as(i32, @intCast(cols))), + jsc.JSValue.jsNumber(@as(i32, @intCast(rows))), + }, + ); +} + +/// Global SIGWINCH handler — uses a static atomic flag. +const SigwinchHandler = struct { + var flag: std.atomic.Value(bool) = .init(false); + var installed: bool = false; + + fn install() void { + if (installed) return; + installed = true; + + if (comptime !bun.Environment.isWindows) { + const act = std.posix.Sigaction{ + .handler = .{ .handler = handler }, + .mask = std.posix.sigemptyset(), + .flags = std.posix.SA.RESTART, + }; + std.posix.sigaction(std.posix.SIG.WINCH, &act, null); + } + } + + fn handler(_: c_int) callconv(.c) void { + flag.store(true, .release); + } + + fn consume() bool { + return flag.swap(false, .acq_rel); + } +}; + +// --- Getters --- + +pub fn getCursorX(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_x))); +} + +pub fn getCursorY(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_y))); +} + +pub fn getColumns(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return switch (bun.sys.getWinsize(this.fd)) { + .result => |ws| jsc.JSValue.jsNumber(@as(i32, @intCast(ws.col))), + .err => jsc.JSValue.jsNumber(@as(i32, 80)), + }; +} + +pub fn getRows(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue { + return switch (bun.sys.getWinsize(this.fd)) { + .result => |ws| jsc.JSValue.jsNumber(@as(i32, @intCast(ws.row))), + .err => jsc.JSValue.jsNumber(@as(i32, 24)), + }; +} + +// --- Helpers --- + +fn parseCursorStyle(value: jsc.JSValue, globalThis: *jsc.JSGlobalObject) ?CursorStyle { + const str = value.toSliceClone(globalThis) catch return null; + defer str.deinit(); + return cursor_style_map.get(str.slice()); +} + +const cursor_style_map = bun.ComptimeEnumMap(CursorStyle); + +/// Write raw bytes directly to the IOWriter (for alt screen, etc.). +fn writeRaw(this: *TuiTerminalWriter, data: []const u8) void { + if (this.write_pending) { + this.next_output.appendSlice(bun.default_allocator, data) catch {}; + } else { + this.output.clearRetainingCapacity(); + this.output.appendSlice(bun.default_allocator, data) catch {}; + this.write_offset = 0; + this.write_pending = true; + this.ref(); + this.io_writer.updateRef(this.event_loop, true); + this.io_writer.write(); + } +} + +const bun = @import("bun"); +const std = @import("std"); +const jsc = bun.jsc; diff --git a/src/bun.js/api/tui/tui.zig b/src/bun.js/api/tui/tui.zig new file mode 100644 index 0000000000..bf300fac2f --- /dev/null +++ b/src/bun.js/api/tui/tui.zig @@ -0,0 +1,5 @@ +pub const TuiScreen = @import("./screen.zig"); +pub const TuiTerminalWriter = @import("./terminal_writer.zig"); +pub const TuiBufferWriter = @import("./buffer_writer.zig"); +pub const TuiRenderer = @import("./renderer.zig"); +pub const TuiKeyReader = @import("./key_reader.zig"); diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index d0290ad258..86f64b2759 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -24,6 +24,10 @@ macro(TOML) \ macro(YAML) \ macro(Terminal) \ + macro(TUIScreen) \ + macro(TUITerminalWriter) \ + macro(TUIBufferWriter) \ + macro(TUIKeyReader) \ macro(Transpiler) \ macro(ValkeyClient) \ macro(argv) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index ce181ea109..81b77c5778 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -998,6 +998,10 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2 stripANSI jsFunctionBunStripANSI DontDelete|Function 1 wrapAnsi jsFunctionBunWrapAnsi DontDelete|Function 3 + TUIScreen BunObject_lazyPropCb_wrap_TUIScreen DontDelete|PropertyCallback + TUITerminalWriter BunObject_lazyPropCb_wrap_TUITerminalWriter DontDelete|PropertyCallback + TUIBufferWriter BunObject_lazyPropCb_wrap_TUIBufferWriter DontDelete|PropertyCallback + TUIKeyReader BunObject_lazyPropCb_wrap_TUIKeyReader DontDelete|PropertyCallback Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback version constructBunVersion ReadOnly|DontDelete|PropertyCallback diff --git a/src/bun.js/bindings/generated_classes_list.zig b/src/bun.js/bindings/generated_classes_list.zig index 84e1839b67..5a726e3973 100644 --- a/src/bun.js/bindings/generated_classes_list.zig +++ b/src/bun.js/bindings/generated_classes_list.zig @@ -50,6 +50,10 @@ pub const Classes = struct { pub const Subprocess = api.Subprocess; pub const ResourceUsage = api.Subprocess.ResourceUsage; pub const Terminal = api.Terminal; + pub const TuiScreen = api.TuiScreen; + pub const TuiTerminalWriter = api.TuiTerminalWriter; + pub const TuiBufferWriter = api.TuiBufferWriter; + pub const TuiKeyReader = api.TuiKeyReader; pub const TCPSocket = api.TCPSocket; pub const TLSSocket = api.TLSSocket; pub const UDPSocket = api.UDPSocket; diff --git a/src/crash_handler.zig b/src/crash_handler.zig index c719549cc1..f2c93a05c0 100644 --- a/src/crash_handler.zig +++ b/src/crash_handler.zig @@ -242,6 +242,8 @@ pub fn crashHandler( // To make the release-mode behavior easier to demo, debug mode // checks for this CLI flag. const debug_trace = bun.Environment.show_crash_trace and check_flag: { + if (Bun__forceDumpCrashTrace) break :check_flag true; + for (bun.argv) |arg| { if (bun.strings.eqlComptime(arg, "--debug-crash-handler-use-trace-string")) { break :check_flag false; @@ -1647,13 +1649,15 @@ pub inline fn handleErrorReturnTrace(err: anyerror, maybe_trace: ?*std.builtin.S } extern "c" fn WTF__DumpStackTrace(ptr: [*]usize, count: usize) void; +pub export var Bun__forceDumpCrashTrace: bool = false; + /// Version of the standard library dumpStackTrace that has some fallbacks for /// cases where such logic fails to run. pub fn dumpStackTrace(trace: std.builtin.StackTrace, limits: WriteStackTraceLimits) void { Output.flush(); var stderr_w = std.fs.File.stderr().writerStreaming(&.{}); const stderr = &stderr_w.interface; - if (!bun.Environment.show_crash_trace) { + if (!bun.Environment.show_crash_trace and !Bun__forceDumpCrashTrace) { // debug symbols aren't available, lets print a tracestring stderr.print("View Debug Trace: {f}\n", .{TraceString{ .action = .view_trace, diff --git a/src/sys.zig b/src/sys.zig index 285878683d..cb71103b5f 100644 --- a/src/sys.zig +++ b/src/sys.zig @@ -265,6 +265,10 @@ pub const Tag = enum(u8) { readv, preadv, ioctl_ficlone, + tcgetattr, + tcsetattr, + ioctl_TIOCGWINSZ, + ioctl_TIOCSWINSZ, accept, bind2, connect2, @@ -4344,6 +4348,56 @@ pub const umask = switch (Environment.os) { .windows => @extern(*const fn (mode: u16) callconv(.c) u16, .{ .name = "_umask" }), }; +// --- Terminal I/O --- + +pub fn tcgetattr(fd: bun.FileDescriptor) Maybe(std.posix.termios) { + if (comptime Environment.isWindows) { + return .{ .err = Error.fromCode(.NOTTY, .tcgetattr) }; + } + const native = fd.native(); + var termios: std.posix.termios = undefined; + const rc = std.posix.system.tcgetattr(native, &termios); + if (rc != 0) { + return .{ .err = Error.fromCode(std.posix.errno(rc), .tcgetattr) }; + } + return .{ .result = termios }; +} + +pub fn tcsetattr(fd: bun.FileDescriptor, action: std.posix.TCSA, termios_p: std.posix.termios) Maybe(void) { + if (comptime Environment.isWindows) { + return .{ .err = Error.fromCode(.NOTTY, .tcsetattr) }; + } + const native = fd.native(); + const rc = std.posix.system.tcsetattr(native, action, &termios_p); + if (rc != 0) { + return .{ .err = Error.fromCode(std.posix.errno(rc), .tcsetattr) }; + } + return .{ .result = {} }; +} + +pub fn getWinsize(fd: bun.FileDescriptor) Maybe(std.posix.winsize) { + if (comptime Environment.isWindows) { + return .{ .err = Error.fromCode(.NOTTY, .ioctl_TIOCGWINSZ) }; + } + var ws: std.posix.winsize = undefined; + const rc = std.posix.system.ioctl(fd.native(), std.posix.T.IOCGWINSZ, @intFromPtr(&ws)); + if (rc != 0) { + return .{ .err = Error.fromCode(std.posix.errno(rc), .ioctl_TIOCGWINSZ) }; + } + return .{ .result = ws }; +} + +pub fn setWinsize(fd: bun.FileDescriptor, ws: std.posix.winsize) Maybe(void) { + if (comptime Environment.isWindows) { + return .{ .err = Error.fromCode(.NOTTY, .ioctl_TIOCSWINSZ) }; + } + const rc = std.posix.system.ioctl(fd.native(), std.posix.T.IOCSWINSZ, @intFromPtr(&ws)); + if (rc != 0) { + return .{ .err = Error.fromCode(std.posix.errno(rc), .ioctl_TIOCSWINSZ) }; + } + return .{ .result = {} }; +} + pub const File = @import("./sys/File.zig"); const builtin = @import("builtin"); diff --git a/test/js/bun/tui/bench.test.ts b/test/js/bun/tui/bench.test.ts new file mode 100644 index 0000000000..e880ef753b --- /dev/null +++ b/test/js/bun/tui/bench.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from "bun:test"; +import { closeSync, openSync } from "fs"; +import { tempDir } from "harness"; +import { join } from "path"; + +/** + * Performance benchmarks for TUI Screen/Writer. + * These tests establish baselines for performance regression detection. + * They verify that operations complete within reasonable time bounds. + */ + +describe("TUI Performance", () => { + test("setText ASCII throughput: 80x24 fills in < 50ms", () => { + const screen = new Bun.TUIScreen(80, 24); + const row = Buffer.alloc(80, "A").toString(); + const iterations = 100; + + const start = Bun.nanoseconds(); + for (let iter = 0; iter < iterations; iter++) { + for (let y = 0; y < 24; y++) { + screen.setText(0, y, row); + } + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; // ms + + // 100 iterations of 24 rows × 80 chars = 192,000 setText calls worth of chars + // Should complete in well under 50ms + expect(elapsed).toBeLessThan(50); + }); + + test("setText CJK throughput: 80x24 fills in < 100ms", () => { + const screen = new Bun.TUIScreen(80, 24); + // 40 CJK chars = 80 columns + const row = Buffer.alloc(40 * 3, 0) + .fill("\xe4\xb8\x96") // 世 in UTF-8 + .toString("utf8"); + const iterations = 100; + + const start = Bun.nanoseconds(); + for (let iter = 0; iter < iterations; iter++) { + for (let y = 0; y < 24; y++) { + screen.setText(0, y, row); + } + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + // CJK is slower due to width computation, but should still be fast + expect(elapsed).toBeLessThan(100); + }); + + test("style interning: 1000 calls for same style < 50ms", () => { + const screen = new Bun.TUIScreen(80, 24); + const iterations = 1000; + + const start = Bun.nanoseconds(); + for (let i = 0; i < iterations; i++) { + screen.style({ bold: true, fg: 0xff0000 }); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + // Relaxed for debug builds — release builds should be < 5ms + expect(elapsed).toBeLessThan(50); + }); + + test("style interning: 200 unique styles < 10ms", () => { + const screen = new Bun.TUIScreen(80, 24); + + const start = Bun.nanoseconds(); + for (let i = 0; i < 200; i++) { + screen.style({ fg: i + 1 }); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(10); + }); + + test("full render 80x24 ASCII < 10ms", () => { + using dir = tempDir("tui-bench-full", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(80, 24); + const row = Buffer.alloc(80, "X").toString(); + for (let y = 0; y < 24; y++) { + screen.setText(0, y, row); + } + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + + const start = Bun.nanoseconds(); + writer.render(screen); + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(10); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("full render 200x50 ASCII < 20ms", () => { + using dir = tempDir("tui-bench-full-large", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(200, 50); + const row = Buffer.alloc(200, "X").toString(); + for (let y = 0; y < 50; y++) { + screen.setText(0, y, row); + } + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + + const start = Bun.nanoseconds(); + writer.render(screen); + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(20); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("diff render with 0 dirty rows < 1ms", () => { + using dir = tempDir("tui-bench-noop", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(200, 50); + const row = Buffer.alloc(200, "X").toString(); + for (let y = 0; y < 50; y++) { + screen.setText(0, y, row); + } + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen); // first render (full) + + // No changes — second render should be a no-op diff + const start = Bun.nanoseconds(); + writer.render(screen); + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(1); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("diff render with 3 dirty rows on 200x50 < 5ms", () => { + using dir = tempDir("tui-bench-diff", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(200, 50); + const row = Buffer.alloc(200, "X").toString(); + for (let y = 0; y < 50; y++) { + screen.setText(0, y, row); + } + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen); // first render (full) + + // Change 3 rows + screen.setText(0, 10, Buffer.alloc(200, "A").toString()); + screen.setText(0, 25, Buffer.alloc(200, "B").toString()); + screen.setText(0, 40, Buffer.alloc(200, "C").toString()); + + const start = Bun.nanoseconds(); + writer.render(screen); + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(5); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("clearRect performance: 1000 clears < 10ms", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.fill(0, 0, 80, 24, "X"); + + const start = Bun.nanoseconds(); + for (let i = 0; i < 1000; i++) { + screen.clearRect(0, 0, 40, 12); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(10); + }); + + test("fill performance: 1000 fills < 50ms", () => { + const screen = new Bun.TUIScreen(80, 24); + + const start = Bun.nanoseconds(); + for (let i = 0; i < 1000; i++) { + screen.fill(0, 0, 80, 24, "#"); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + // Relaxed for debug builds — release builds should be < 10ms + expect(elapsed).toBeLessThan(50); + }); + + test("copy performance: 1000 copies < 20ms", () => { + const src = new Bun.TUIScreen(80, 24); + const dst = new Bun.TUIScreen(80, 24); + src.fill(0, 0, 80, 24, "X"); + + const start = Bun.nanoseconds(); + for (let i = 0; i < 1000; i++) { + dst.copy(src, 0, 0, 0, 0, 80, 24); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(20); + }); + + test("resize cycle: 100 resizes < 50ms", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.fill(0, 0, 80, 24, "X"); + + const start = Bun.nanoseconds(); + for (let i = 0; i < 100; i++) { + screen.resize(160, 48); + screen.resize(80, 24); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(50); + }); + + test("getCell performance: 10000 reads < 200ms", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.fill(0, 0, 80, 24, "X"); + + const start = Bun.nanoseconds(); + for (let i = 0; i < 10000; i++) { + screen.getCell(i % 80, i % 24); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + // Relaxed for debug builds — getCell allocates a JS object per call + expect(elapsed).toBeLessThan(200); + }); + + test("multiple render frames: 100 renders < 100ms", () => { + using dir = tempDir("tui-bench-multiframe", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(80, 24); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + + // First render + screen.fill(0, 0, 80, 24, " "); + writer.render(screen); + + const start = Bun.nanoseconds(); + for (let i = 0; i < 100; i++) { + // Change 1-2 rows per frame (typical Claude Code usage) + screen.setText(0, i % 24, `Frame ${i} content here`); + writer.render(screen); + } + const elapsed = (Bun.nanoseconds() - start) / 1e6; + + expect(elapsed).toBeLessThan(100); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); +}); diff --git a/test/js/bun/tui/e2e.test.ts b/test/js/bun/tui/e2e.test.ts new file mode 100644 index 0000000000..b19f79c4fe --- /dev/null +++ b/test/js/bun/tui/e2e.test.ts @@ -0,0 +1,605 @@ +import { Terminal } from "@xterm/headless"; +import { describe, expect, test } from "bun:test"; +import { closeSync, openSync, readFileSync } from "fs"; +import { tempDir } from "harness"; +import { join } from "path"; + +/** + * End-to-end tests: write to Screen → render via Writer → parse with xterm.js → verify. + * Validates the full pipeline: Ghostty cell storage → ANSI diff output → terminal state. + */ + +function renderToAnsi(cols: number, rows: number, setup: (screen: InstanceType) => void): string { + const screen = new Bun.TUIScreen(cols, rows); + setup(screen); + + using dir = tempDir("tui-e2e", {}); + const path = join(String(dir), "output.bin"); + const fd = openSync(path, "w"); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen); + closeSync(fd); + + return readFileSync(path, "utf8"); +} + +function feedXterm(ansi: string, cols: number, rows: number): Terminal { + const term = new Terminal({ cols, rows, allowProposedApi: true }); + term.write(ansi); + return term; +} + +function xtermLine(term: Terminal, y: number): string { + return term.buffer.active.getLine(y)?.translateToString(true) ?? ""; +} + +function xtermCell(term: Terminal, x: number, y: number) { + const cell = term.buffer.active.getLine(y)?.getCell(x); + if (!cell) return null; + return { + char: cell.getChars(), + width: cell.getWidth(), + fg: cell.getFgColor(), + bg: cell.getBgColor(), + isFgRGB: cell.isFgRGB(), + isBgRGB: cell.isBgRGB(), + bold: cell.isBold(), + italic: cell.isItalic(), + underline: cell.isUnderline(), + strikethrough: cell.isStrikethrough(), + inverse: cell.isInverse(), + dim: cell.isDim(), + overline: cell.isOverline(), + }; +} + +/** Flush xterm.js write queue */ +async function flush(term: Terminal): Promise { + await new Promise(resolve => term.write("", resolve)); +} + +/** + * Render a screen twice through the same writer, feed the combined output + * to xterm.js. Returns the terminal after both renders are applied. + */ +async function renderTwoFrames( + cols: number, + rows: number, + setup1: (screen: InstanceType) => void, + setup2: (screen: InstanceType) => void, +): Promise { + using dir = tempDir("tui-e2e-multi", {}); + const path = join(String(dir), "output.bin"); + const fd = openSync(path, "w"); + + const screen = new Bun.TUIScreen(cols, rows); + setup1(screen); + + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen); // frame 1 (full) + + setup2(screen); + writer.render(screen); // frame 2 (diff) + + closeSync(fd); + const ansi = readFileSync(path, "utf8"); + const term = feedXterm(ansi, cols, rows); + await flush(term); + return term; +} + +describe("TUI E2E: Screen → Writer → xterm.js", () => { + // ─── Basic rendering ───────────────────────────────────────────── + + test("ASCII text renders correctly", async () => { + const ansi = renderToAnsi(40, 5, screen => { + screen.setText(0, 0, "Hello, World!"); + screen.setText(0, 1, "Line two"); + }); + + const term = feedXterm(ansi, 40, 5); + await flush(term); + + expect(xtermLine(term, 0).trimEnd()).toBe("Hello, World!"); + expect(xtermLine(term, 1).trimEnd()).toBe("Line two"); + expect(xtermLine(term, 2).trim()).toBe(""); + + term.dispose(); + }); + + test("CJK wide characters take 2 columns", async () => { + const ansi = renderToAnsi(20, 3, screen => { + screen.setText(0, 0, "A世界B"); + }); + + const term = feedXterm(ansi, 20, 3); + await flush(term); + + expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "A", width: 1 })); + expect(xtermCell(term, 1, 0)).toEqual(expect.objectContaining({ char: "世", width: 2 })); + expect(xtermCell(term, 2, 0)).toEqual(expect.objectContaining({ width: 0 })); + expect(xtermCell(term, 3, 0)).toEqual(expect.objectContaining({ char: "界", width: 2 })); + expect(xtermCell(term, 5, 0)).toEqual(expect.objectContaining({ char: "B" })); + + term.dispose(); + }); + + // ─── Style rendering ───────────────────────────────────────────── + + test("bold style produces bold cells", async () => { + const ansi = renderToAnsi(20, 3, screen => { + const bold = screen.style({ bold: true }); + screen.setText(0, 0, "Bold", bold); + screen.setText(5, 0, "Normal"); + }); + + const term = feedXterm(ansi, 20, 3); + await flush(term); + + const boldCell = xtermCell(term, 0, 0)!; + expect(boldCell.char).toBe("B"); + expect(boldCell.bold).toBeTruthy(); + + const normalCell = xtermCell(term, 5, 0)!; + expect(normalCell.char).toBe("N"); + expect(normalCell.bold).toBeFalsy(); + + term.dispose(); + }); + + test("italic style produces italic cells", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const italic = screen.style({ italic: true }); + screen.setText(0, 0, "Ital", italic); + screen.setText(5, 0, "Norm"); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + expect(xtermCell(term, 0, 0)!.italic).toBeTruthy(); + expect(xtermCell(term, 5, 0)!.italic).toBeFalsy(); + + term.dispose(); + }); + + test("dim (faint) style", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const dim = screen.style({ faint: true }); + screen.setText(0, 0, "Dim", dim); + screen.setText(5, 0, "Norm"); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + expect(xtermCell(term, 0, 0)!.dim).toBeTruthy(); + expect(xtermCell(term, 5, 0)!.dim).toBeFalsy(); + + term.dispose(); + }); + + test("strikethrough style", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const strike = screen.style({ strikethrough: true }); + screen.setText(0, 0, "Strike", strike); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + expect(xtermCell(term, 0, 0)!.strikethrough).toBeTruthy(); + + term.dispose(); + }); + + test("inverse style", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const inv = screen.style({ inverse: true }); + screen.setText(0, 0, "Inv", inv); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + expect(xtermCell(term, 0, 0)!.inverse).toBeTruthy(); + + term.dispose(); + }); + + test("overline style", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const over = screen.style({ overline: true }); + screen.setText(0, 0, "Over", over); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + expect(xtermCell(term, 0, 0)!.overline).toBeTruthy(); + + term.dispose(); + }); + + test("underline style", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const ul = screen.style({ underline: "single" }); + screen.setText(0, 0, "UL", ul); + screen.setText(5, 0, "Norm"); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + expect(xtermCell(term, 0, 0)!.underline).toBeTruthy(); + expect(xtermCell(term, 5, 0)!.underline).toBeFalsy(); + + term.dispose(); + }); + + test("combined bold+italic+strikethrough", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const s = screen.style({ bold: true, italic: true, strikethrough: true }); + screen.setText(0, 0, "Combo", s); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + const cell = xtermCell(term, 0, 0)!; + expect(cell.bold).toBeTruthy(); + expect(cell.italic).toBeTruthy(); + expect(cell.strikethrough).toBeTruthy(); + + term.dispose(); + }); + + test("RGB foreground color", async () => { + const ansi = renderToAnsi(20, 3, screen => { + const red = screen.style({ fg: 0xff0000 }); + screen.setText(0, 0, "Red", red); + }); + + const term = feedXterm(ansi, 20, 3); + await flush(term); + + expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "R", isFgRGB: true, fg: 0xff0000 })); + + term.dispose(); + }); + + test("RGB background color", async () => { + const ansi = renderToAnsi(20, 3, screen => { + const blue = screen.style({ bg: 0x0000ff }); + screen.setText(0, 0, "Blue", blue); + }); + + const term = feedXterm(ansi, 20, 3); + await flush(term); + + expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "B", isBgRGB: true, bg: 0x0000ff })); + + term.dispose(); + }); + + test("combined fg + bg colors", async () => { + const ansi = renderToAnsi(20, 1, screen => { + const s = screen.style({ fg: 0xff0000, bg: 0x00ff00 }); + screen.setText(0, 0, "Both", s); + }); + + const term = feedXterm(ansi, 20, 1); + await flush(term); + + const cell = xtermCell(term, 0, 0)!; + expect(cell.isFgRGB).toBeTruthy(); + expect(cell.fg).toBe(0xff0000); + expect(cell.isBgRGB).toBeTruthy(); + expect(cell.bg).toBe(0x00ff00); + + term.dispose(); + }); + + test("multiple styles on same line", async () => { + const ansi = renderToAnsi(30, 3, screen => { + const bold = screen.style({ bold: true }); + const italic = screen.style({ italic: true }); + screen.setText(0, 0, "Bold", bold); + screen.setText(5, 0, "Italic", italic); + screen.setText(12, 0, "Plain"); + }); + + const term = feedXterm(ansi, 30, 3); + await flush(term); + + const c0 = xtermCell(term, 0, 0)!; + expect(c0.bold).toBeTruthy(); + expect(c0.italic).toBeFalsy(); + + const c5 = xtermCell(term, 5, 0)!; + expect(c5.italic).toBeTruthy(); + expect(c5.bold).toBeFalsy(); + + const c12 = xtermCell(term, 12, 0)!; + expect(c12.bold).toBeFalsy(); + expect(c12.italic).toBeFalsy(); + + term.dispose(); + }); + + test("style reset between rows", async () => { + const ansi = renderToAnsi(20, 3, screen => { + const bold = screen.style({ bold: true }); + screen.setText(0, 0, "BoldRow", bold); + screen.setText(0, 1, "PlainRow"); + }); + + const term = feedXterm(ansi, 20, 3); + await flush(term); + + expect(xtermCell(term, 0, 0)!.bold).toBeTruthy(); + expect(xtermCell(term, 0, 1)!.bold).toBeFalsy(); + + term.dispose(); + }); + + // ─── Fill / Clear ───────────────────────────────────────────────── + + test("fill fills region visible to xterm", async () => { + const ansi = renderToAnsi(10, 3, screen => { + screen.fill(0, 0, 10, 3, "#"); + }); + + const term = feedXterm(ansi, 10, 3); + await flush(term); + + expect(xtermLine(term, 0)).toBe("##########"); + expect(xtermLine(term, 1)).toBe("##########"); + expect(xtermLine(term, 2)).toBe("##########"); + + term.dispose(); + }); + + test("clearRect clears cells visible to xterm", async () => { + const ansi = renderToAnsi(20, 3, screen => { + screen.fill(0, 0, 20, 3, "X"); + screen.clearRect(5, 0, 10, 1); + }); + + const term = feedXterm(ansi, 20, 3); + await flush(term); + + const line0 = xtermLine(term, 0); + expect(line0.substring(0, 5)).toBe("XXXXX"); + expect(line0.substring(15, 20)).toBe("XXXXX"); + expect(xtermLine(term, 1)).toBe(Buffer.alloc(20, "X").toString()); + + term.dispose(); + }); + + test("background color fills visible in xterm", async () => { + const ansi = renderToAnsi(10, 1, screen => { + const bg = screen.style({ bg: 0x0000ff }); + screen.fill(0, 0, 5, 1, " ", bg); + screen.setText(5, 0, "X"); + }); + + const term = feedXterm(ansi, 10, 1); + await flush(term); + + // First 5 cells should have blue background + const bgCell = xtermCell(term, 0, 0)!; + expect(bgCell.isBgRGB).toBeTruthy(); + expect(bgCell.bg).toBe(0x0000ff); + + // Cell at 5 should have X + expect(xtermCell(term, 5, 0)!.char).toBe("X"); + + term.dispose(); + }); + + // ─── Synchronized update ────────────────────────────────────────── + + test("synchronized update markers are present", () => { + const ansi = renderToAnsi(10, 3, screen => { + screen.setText(0, 0, "Hi"); + }); + + expect(ansi).toContain("\x1b[?2026h"); + expect(ansi).toContain("\x1b[?2026l"); + + const bsuIdx = ansi.indexOf("\x1b[?2026h"); + const esuIdx = ansi.indexOf("\x1b[?2026l"); + const contentIdx = ansi.indexOf("Hi"); + expect(bsuIdx).toBeLessThan(contentIdx); + expect(esuIdx).toBeGreaterThan(contentIdx); + }); + + // ─── Multi-frame / Diff rendering ──────────────────────────────── + + test("overwrite produces correct result after diff", async () => { + const term = await renderTwoFrames( + 20, + 3, + screen => { + screen.setText(0, 0, "Hello"); + }, + screen => { + screen.setText(0, 0, "AB"); // overwrite first 2 chars + }, + ); + + expect(xtermLine(term, 0).trimEnd()).toBe("ABllo"); + + term.dispose(); + }); + + test("clear then write across frames", async () => { + const term = await renderTwoFrames( + 10, + 3, + screen => { + screen.fill(0, 0, 10, 3, "X"); + }, + screen => { + screen.clearRect(0, 0, 10, 1); // clear row 0 + screen.setText(0, 0, "Y"); // write Y on row 0 + }, + ); + + // Row 0 should start with Y, rest cleared + const line0 = xtermLine(term, 0); + expect(line0.charAt(0)).toBe("Y"); + // Row 1 should still be X's + expect(xtermLine(term, 1)).toBe(Buffer.alloc(10, "X").toString()); + + term.dispose(); + }); + + test("multiple renders accumulate correctly", async () => { + using dir = tempDir("tui-e2e-multi3", {}); + const path = join(String(dir), "output.bin"); + const fd = openSync(path, "w"); + + const screen = new Bun.TUIScreen(20, 3); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + + // Frame 1: write "AAAA" at positions 0-3 + screen.setText(0, 0, "AAAA"); + writer.render(screen); + + // Frame 2: overwrite first 2 with "BB" → cells: B B A A + screen.setText(0, 0, "BB"); + writer.render(screen); + + // Frame 3: write "C" at position 4 → cells: B B A A C + screen.setText(4, 0, "C"); + writer.render(screen); + + closeSync(fd); + + const ansi = readFileSync(path, "utf8"); + const term = feedXterm(ansi, 20, 3); + await flush(term); + + expect(xtermLine(term, 0).trimEnd()).toBe("BBAAC"); + + term.dispose(); + }); + + test("style changes across renders", async () => { + const term = await renderTwoFrames( + 20, + 1, + screen => { + const bold = screen.style({ bold: true }); + screen.setText(0, 0, "Text", bold); + }, + screen => { + // Overwrite with plain text (no bold) + screen.setText(0, 0, "Text"); + }, + ); + + // After second frame, text should NOT be bold + expect(xtermCell(term, 0, 0)!.bold).toBeFalsy(); + expect(xtermCell(term, 0, 0)!.char).toBe("T"); + + term.dispose(); + }); + + test("adding bold in second frame", async () => { + const term = await renderTwoFrames( + 20, + 1, + screen => { + screen.setText(0, 0, "Plain"); + }, + screen => { + const bold = screen.style({ bold: true }); + screen.setText(0, 0, "Plain", bold); + }, + ); + + expect(xtermCell(term, 0, 0)!.bold).toBeTruthy(); + + term.dispose(); + }); + + // ─── Large screen ───────────────────────────────────────────────── + + test("large screen render (200x50)", async () => { + const cols = 200; + const rows = 50; + const ansi = renderToAnsi(cols, rows, screen => { + // Fill with a pattern + for (let y = 0; y < rows; y++) { + const ch = String.fromCharCode(65 + (y % 26)); // A-Z + screen.setText(0, y, Buffer.alloc(cols, ch).toString()); + } + }); + + const term = feedXterm(ansi, cols, rows); + await flush(term); + + // Verify a few rows + expect(xtermLine(term, 0)).toBe(Buffer.alloc(cols, "A").toString()); + expect(xtermLine(term, 1)).toBe(Buffer.alloc(cols, "B").toString()); + expect(xtermLine(term, 25)).toBe(Buffer.alloc(cols, "Z").toString()); + expect(xtermLine(term, 26)).toBe(Buffer.alloc(cols, "A").toString()); + expect(xtermLine(term, 49)).toBe(Buffer.alloc(cols, "X").toString()); + + term.dispose(); + }); + + // ─── Mixed content ─────────────────────────────────────────────── + + test("mixed ASCII and CJK across rows", async () => { + const ansi = renderToAnsi(20, 3, screen => { + screen.setText(0, 0, "Hello"); + screen.setText(0, 1, "世界ABC"); + screen.setText(0, 2, "A世B界C"); + }); + + const term = feedXterm(ansi, 20, 3); + await flush(term); + + expect(xtermLine(term, 0).trimEnd()).toBe("Hello"); + // Row 1: 世(2) 界(2) A B C = 7 cols + expect(xtermCell(term, 0, 1)!.char).toBe("世"); + expect(xtermCell(term, 2, 1)!.char).toBe("界"); + expect(xtermCell(term, 4, 1)!.char).toBe("A"); + + // Row 2: A(1) 世(2) B(1) 界(2) C(1) = 7 cols + expect(xtermCell(term, 0, 2)!.char).toBe("A"); + expect(xtermCell(term, 1, 2)!.char).toBe("世"); + expect(xtermCell(term, 3, 2)!.char).toBe("B"); + expect(xtermCell(term, 4, 2)!.char).toBe("界"); + expect(xtermCell(term, 6, 2)!.char).toBe("C"); + + term.dispose(); + }); + + test("styled fill then overwrite with different style", async () => { + const ansi = renderToAnsi(10, 1, screen => { + const red = screen.style({ fg: 0xff0000 }); + screen.fill(0, 0, 10, 1, "X", red); + + const blue = screen.style({ fg: 0x0000ff }); + screen.setText(3, 0, "HI", blue); + }); + + const term = feedXterm(ansi, 10, 1); + await flush(term); + + // Cells 0-2 should be red X + expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "X", fg: 0xff0000, isFgRGB: true })); + // Cells 3-4 should be blue HI + expect(xtermCell(term, 3, 0)).toEqual(expect.objectContaining({ char: "H", fg: 0x0000ff, isFgRGB: true })); + expect(xtermCell(term, 4, 0)).toEqual(expect.objectContaining({ char: "I", fg: 0x0000ff, isFgRGB: true })); + // Cells 5-9 should be red X again + expect(xtermCell(term, 5, 0)).toEqual(expect.objectContaining({ char: "X", fg: 0xff0000, isFgRGB: true })); + + term.dispose(); + }); +}); diff --git a/test/js/bun/tui/key-reader.test.ts b/test/js/bun/tui/key-reader.test.ts new file mode 100644 index 0000000000..8a71ad734e --- /dev/null +++ b/test/js/bun/tui/key-reader.test.ts @@ -0,0 +1,1039 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +describe("Bun.TUIKeyReader", () => { + test("constructor exists", () => { + expect(Bun.TUIKeyReader).toBeDefined(); + expect(typeof Bun.TUIKeyReader).toBe("function"); + }); + + test("parses printable ASCII characters", async () => { + using dir = tempDir("tui-keyreader-ascii", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: any[] = []; + reader.onkeypress = (event: any) => { + events.push({ name: event.name, ctrl: event.ctrl, shift: event.shift, alt: event.alt }); + if (events.length >= 3) { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send "abc" to stdin + proc.stdin.write("abc"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual([ + { name: "a", ctrl: false, shift: false, alt: false }, + { name: "b", ctrl: false, shift: false, alt: false }, + { name: "c", ctrl: false, shift: false, alt: false }, + ]); + expect(exitCode).toBe(0); + }); + + test("parses ctrl+c as ctrl key event", async () => { + using dir = tempDir("tui-keyreader-ctrl", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onkeypress = (event: any) => { + reader.close(); + console.log(JSON.stringify({ name: event.name, ctrl: event.ctrl })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send ctrl+c (0x03) + proc.stdin.write(new Uint8Array([0x03])); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event.name).toBe("c"); + expect(event.ctrl).toBe(true); + expect(exitCode).toBe(0); + }); + + test("parses arrow keys (CSI sequences)", async () => { + using dir = tempDir("tui-keyreader-arrows", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: string[] = []; + reader.onkeypress = (event: any) => { + events.push(event.name); + if (events.length >= 4) { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send arrow key sequences: up, down, right, left + proc.stdin.write("\x1b[A\x1b[B\x1b[C\x1b[D"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual(["up", "down", "right", "left"]); + expect(exitCode).toBe(0); + }); + + test("parses enter, tab, backspace", async () => { + using dir = tempDir("tui-keyreader-special", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: string[] = []; + reader.onkeypress = (event: any) => { + events.push(event.name); + if (events.length >= 3) { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send enter (\r), tab (\t), backspace (0x7f) + proc.stdin.write(new Uint8Array([0x0d, 0x09, 0x7f])); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual(["enter", "tab", "backspace"]); + expect(exitCode).toBe(0); + }); + + test("parses SS3 function keys (f1-f4)", async () => { + using dir = tempDir("tui-keyreader-ss3", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: string[] = []; + reader.onkeypress = (event: any) => { + events.push(event.name); + if (events.length >= 4) { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send SS3 f1-f4: \x1bOP, \x1bOQ, \x1bOR, \x1bOS + proc.stdin.write("\x1bOP\x1bOQ\x1bOR\x1bOS"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual(["f1", "f2", "f3", "f4"]); + expect(exitCode).toBe(0); + }); + + test("parses alt+letter (meta prefix)", async () => { + using dir = tempDir("tui-keyreader-alt", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onkeypress = (event: any) => { + reader.close(); + console.log(JSON.stringify({ name: event.name, alt: event.alt })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send alt+a: \x1ba + proc.stdin.write("\x1ba"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event.name).toBe("a"); + expect(event.alt).toBe(true); + expect(exitCode).toBe(0); + }); + + test("parses bracketed paste", async () => { + using dir = tempDir("tui-keyreader-paste", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onpaste = (text: string) => { + reader.close(); + console.log(JSON.stringify({ text })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send bracketed paste: \x1b[200~hello world\x1b[201~ + proc.stdin.write("\x1b[200~hello world\x1b[201~"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const result = JSON.parse(stdout.trim()); + expect(result.text).toBe("hello world"); + expect(exitCode).toBe(0); + }); + + test("close() is idempotent", async () => { + using dir = tempDir("tui-keyreader-close", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.close(); + reader.close(); // should not throw + console.log("ok"); + process.exit(0); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("ok"); + expect(exitCode).toBe(0); + }); + + test("CSI ~ sequences (delete, pageup, pagedown, f5-f12)", async () => { + using dir = tempDir("tui-keyreader-tilde", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: string[] = []; + reader.onkeypress = (event: any) => { + events.push(event.name); + if (events.length >= 4) { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send delete (\x1b[3~), pageup (\x1b[5~), pagedown (\x1b[6~), f5 (\x1b[15~) + proc.stdin.write("\x1b[3~\x1b[5~\x1b[6~\x1b[15~"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual(["delete", "pageup", "pagedown", "f5"]); + expect(exitCode).toBe(0); + }); + + test("parses UTF-8 characters", async () => { + using dir = tempDir("tui-keyreader-utf8", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: string[] = []; + reader.onkeypress = (event: any) => { + events.push(event.name); + if (events.length >= 3) { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + proc.stdin.write("a世b"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual(["a", "世", "b"]); + expect(exitCode).toBe(0); + }); + + test("parses CSI modifier keys (shift+up, ctrl+right)", async () => { + using dir = tempDir("tui-keyreader-mods", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: any[] = []; + reader.onkeypress = (event: any) => { + events.push({ name: event.name, shift: event.shift, ctrl: event.ctrl, alt: event.alt }); + if (events.length >= 2) { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // shift+up = \x1b[1;2A, ctrl+right = \x1b[1;5C + proc.stdin.write("\x1b[1;2A\x1b[1;5C"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual([ + { name: "up", shift: true, ctrl: false, alt: false }, + { name: "right", shift: false, ctrl: true, alt: false }, + ]); + expect(exitCode).toBe(0); + }); + + test("parses kitty protocol CSI u", async () => { + using dir = tempDir("tui-keyreader-kitty", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onkeypress = (event: any) => { + reader.close(); + console.log(JSON.stringify({ name: event.name, ctrl: event.ctrl })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // kitty: ctrl+a = \x1b[97;5u (codepoint 97='a', modifier 5=ctrl) + proc.stdin.write("\x1b[97;5u"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event.name).toBe("a"); + expect(event.ctrl).toBe(true); + expect(exitCode).toBe(0); + }); + + test("onkeypress/onpaste setter/getter", async () => { + using dir = tempDir("tui-keyreader-props", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const results: boolean[] = []; + + // Initially undefined + results.push(reader.onkeypress === undefined); + results.push(reader.onpaste === undefined); + + // Set callback + const cb = () => {}; + reader.onkeypress = cb; + results.push(reader.onkeypress === cb); + + // Set to undefined clears it + reader.onkeypress = undefined; + results.push(reader.onkeypress === undefined); + + reader.close(); + console.log(JSON.stringify(results)); + process.exit(0); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const results = JSON.parse(stdout.trim()); + expect(results).toEqual([true, true, true, true]); + expect(exitCode).toBe(0); + }); + + // ─── onmouse callback ────────────────────────────────────────── + + describe("onmouse", () => { + test("parses SGR mouse left button press", async () => { + using dir = tempDir("tui-keyreader-mouse-down", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onmouse = (event: any) => { + reader.close(); + console.log(JSON.stringify({ + type: event.type, + button: event.button, + x: event.x, + y: event.y, + shift: event.shift, + alt: event.alt, + ctrl: event.ctrl, + })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // SGR mouse: CSI < 0 ; 10 ; 5 M = left button press at (10,5) + proc.stdin.write("\x1b[<0;10;5M"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event).toEqual({ + type: "down", + button: 0, + x: 9, // 1-based to 0-based + y: 4, // 1-based to 0-based + shift: false, + alt: false, + ctrl: false, + }); + expect(exitCode).toBe(0); + }); + + test("parses SGR mouse right button release", async () => { + using dir = tempDir("tui-keyreader-mouse-up", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onmouse = (event: any) => { + reader.close(); + console.log(JSON.stringify({ + type: event.type, + button: event.button, + x: event.x, + y: event.y, + })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // SGR mouse: CSI < 2 ; 20 ; 15 m = right button (2) release at (20,15) + proc.stdin.write("\x1b[<2;20;15m"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event).toEqual({ + type: "up", + button: 2, + x: 19, + y: 14, + }); + expect(exitCode).toBe(0); + }); + + test("parses SGR mouse scroll up", async () => { + using dir = tempDir("tui-keyreader-mouse-scroll", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onmouse = (event: any) => { + reader.close(); + console.log(JSON.stringify({ + type: event.type, + button: event.button, + x: event.x, + y: event.y, + })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // SGR mouse: CSI < 64 ; 5 ; 3 M = scroll up (64 = scroll flag + button 0) at (5,3) + proc.stdin.write("\x1b[<64;5;3M"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event).toEqual({ + type: "scrollUp", + button: 4, // wheel up + x: 4, + y: 2, + }); + expect(exitCode).toBe(0); + }); + + test("parses SGR mouse scroll down", async () => { + using dir = tempDir("tui-keyreader-mouse-scrolldn", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onmouse = (event: any) => { + reader.close(); + console.log(JSON.stringify({ + type: event.type, + button: event.button, + })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // SGR mouse: CSI < 65 ; 5 ; 3 M = scroll down (64 + 1 = scroll flag + button 1) + proc.stdin.write("\x1b[<65;5;3M"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event).toEqual({ + type: "scrollDown", + button: 5, // wheel down + }); + expect(exitCode).toBe(0); + }); + + test("parses SGR mouse motion event (drag)", async () => { + using dir = tempDir("tui-keyreader-mouse-drag", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onmouse = (event: any) => { + reader.close(); + console.log(JSON.stringify({ + type: event.type, + button: event.button, + x: event.x, + y: event.y, + })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // SGR mouse: CSI < 32 ; 10 ; 5 M = motion flag (32) + left button (0) = drag + proc.stdin.write("\x1b[<32;10;5M"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event).toEqual({ + type: "drag", + button: 0, + x: 9, + y: 4, + }); + expect(exitCode).toBe(0); + }); + + test("parses SGR mouse move (motion + no button)", async () => { + using dir = tempDir("tui-keyreader-mouse-move", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onmouse = (event: any) => { + reader.close(); + console.log(JSON.stringify({ + type: event.type, + button: event.button, + x: event.x, + y: event.y, + })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // SGR mouse: CSI < 35 ; 10 ; 5 M = motion flag (32) + button 3 = move (no button) + proc.stdin.write("\x1b[<35;10;5M"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event).toEqual({ + type: "move", + button: 3, + x: 9, + y: 4, + }); + expect(exitCode).toBe(0); + }); + + test("parses SGR mouse with modifier keys", async () => { + using dir = tempDir("tui-keyreader-mouse-mods", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onmouse = (event: any) => { + reader.close(); + console.log(JSON.stringify({ + type: event.type, + button: event.button, + shift: event.shift, + alt: event.alt, + ctrl: event.ctrl, + })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // SGR mouse: CSI < 28 ; 10 ; 5 M = button 0 + shift(4) + alt(8) + ctrl(16) = 0+4+8+16=28 + proc.stdin.write("\x1b[<28;10;5M"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const event = JSON.parse(stdout.trim()); + expect(event).toEqual({ + type: "down", + button: 0, + shift: true, + alt: true, + ctrl: true, + }); + expect(exitCode).toBe(0); + }); + + test("onmouse setter/getter", async () => { + using dir = tempDir("tui-keyreader-mouse-sg", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const results: boolean[] = []; + + results.push(reader.onmouse === undefined); + + const cb = () => {}; + reader.onmouse = cb; + results.push(reader.onmouse === cb); + + reader.onmouse = undefined; + results.push(reader.onmouse === undefined); + + reader.close(); + console.log(JSON.stringify(results)); + process.exit(0); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const results = JSON.parse(stdout.trim()); + expect(results).toEqual([true, true, true]); + expect(exitCode).toBe(0); + }); + + test("mouse event without onmouse callback is ignored", async () => { + using dir = tempDir("tui-keyreader-mouse-nocb", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + let gotKeypress = false; + reader.onkeypress = (event: any) => { + if (event.name === "x") { + gotKeypress = true; + reader.close(); + console.log(JSON.stringify({ gotKeypress })); + process.exit(0); + } + }; + // No onmouse set — mouse events should be silently dropped + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send mouse event followed by a regular keypress + proc.stdin.write("\x1b[<0;10;5Mx"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const result = JSON.parse(stdout.trim()); + expect(result.gotKeypress).toBe(true); + expect(exitCode).toBe(0); + }); + }); + + // ─── onfocus / onblur callbacks ───────────────────────────────── + + describe("onfocus / onblur", () => { + test("parses focus in event (CSI I)", async () => { + using dir = tempDir("tui-keyreader-focus-in", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onfocus = () => { + reader.close(); + console.log(JSON.stringify({ focused: true })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Focus in: CSI I = \x1b[I + proc.stdin.write("\x1b[I"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const result = JSON.parse(stdout.trim()); + expect(result.focused).toBe(true); + expect(exitCode).toBe(0); + }); + + test("parses focus out event (CSI O)", async () => { + using dir = tempDir("tui-keyreader-focus-out", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onblur = () => { + reader.close(); + console.log(JSON.stringify({ blurred: true })); + process.exit(0); + }; + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Focus out: CSI O = \x1b[O + proc.stdin.write("\x1b[O"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const result = JSON.parse(stdout.trim()); + expect(result.blurred).toBe(true); + expect(exitCode).toBe(0); + }); + + test("focus and blur events interleaved with keypresses", async () => { + using dir = tempDir("tui-keyreader-focus-mixed", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const events: string[] = []; + reader.onfocus = () => { + events.push("focus"); + if (events.length >= 4) finish(); + }; + reader.onblur = () => { + events.push("blur"); + if (events.length >= 4) finish(); + }; + reader.onkeypress = (event: any) => { + events.push("key:" + event.name); + if (events.length >= 4) finish(); + }; + function finish() { + reader.close(); + console.log(JSON.stringify(events)); + process.exit(0); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send: focus-in, key 'a', focus-out, key 'b' + proc.stdin.write("\x1b[Ia\x1b[Ob"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const events = JSON.parse(stdout.trim()); + expect(events).toEqual(["focus", "key:a", "blur", "key:b"]); + expect(exitCode).toBe(0); + }); + + test("onfocus/onblur setter/getter", async () => { + using dir = tempDir("tui-keyreader-focus-sg", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + const results: boolean[] = []; + + results.push(reader.onfocus === undefined); + results.push(reader.onblur === undefined); + + const focusCb = () => {}; + const blurCb = () => {}; + reader.onfocus = focusCb; + reader.onblur = blurCb; + results.push(reader.onfocus === focusCb); + results.push(reader.onblur === blurCb); + + reader.onfocus = undefined; + reader.onblur = undefined; + results.push(reader.onfocus === undefined); + results.push(reader.onblur === undefined); + + reader.close(); + console.log(JSON.stringify(results)); + process.exit(0); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const results = JSON.parse(stdout.trim()); + expect(results).toEqual([true, true, true, true, true, true]); + expect(exitCode).toBe(0); + }); + + test("focus event without onfocus callback is ignored", async () => { + using dir = tempDir("tui-keyreader-focus-nocb", { + "test.ts": ` + const reader = new Bun.TUIKeyReader(); + reader.onkeypress = (event: any) => { + if (event.name === "z") { + reader.close(); + console.log("ok"); + process.exit(0); + } + }; + // No onfocus/onblur set — focus events should be silently dropped + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "test.ts")], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // Send focus-in, focus-out, then 'z' + proc.stdin.write("\x1b[I\x1b[Oz"); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("ok"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/tui/screen.test.ts b/test/js/bun/tui/screen.test.ts new file mode 100644 index 0000000000..a6766cfd6e --- /dev/null +++ b/test/js/bun/tui/screen.test.ts @@ -0,0 +1,1027 @@ +import { describe, expect, test } from "bun:test"; + +describe("Bun.TUIScreen", () => { + // ─── Constructor ────────────────────────────────────────────────── + + test("constructor creates a screen with given dimensions", () => { + const screen = new Bun.TUIScreen(80, 24); + expect(screen.width).toBe(80); + expect(screen.height).toBe(24); + }); + + test("constructor throws with invalid arguments", () => { + expect(() => new (Bun.TUIScreen as any)()).toThrow(); + expect(() => new (Bun.TUIScreen as any)(80)).toThrow(); + expect(() => new (Bun.TUIScreen as any)("a", "b")).toThrow(); + }); + + test("constructor clamps dimensions to [1, 4096]", () => { + const small = new Bun.TUIScreen(0, 0); + expect(small.width).toBe(1); + expect(small.height).toBe(1); + + const neg = new Bun.TUIScreen(-5, -10); + expect(neg.width).toBe(1); + expect(neg.height).toBe(1); + + const big = new Bun.TUIScreen(9999, 9999); + expect(big.width).toBe(4096); + expect(big.height).toBe(4096); + }); + + test("constructor creates 1x1 screen", () => { + const screen = new Bun.TUIScreen(1, 1); + expect(screen.width).toBe(1); + expect(screen.height).toBe(1); + const cell = screen.getCell(0, 0); + expect(cell).not.toBeNull(); + expect(cell.char).toBe(" "); + }); + + // ─── setText: ASCII ─────────────────────────────────────────────── + + test("setText writes ASCII text", () => { + const screen = new Bun.TUIScreen(80, 24); + const cols = screen.setText(0, 0, "Hello"); + expect(cols).toBe(5); + + expect(screen.getCell(0, 0).char).toBe("H"); + expect(screen.getCell(4, 0).char).toBe("o"); + }); + + test("setText with empty string returns 0", () => { + const screen = new Bun.TUIScreen(80, 24); + const cols = screen.setText(0, 0, ""); + expect(cols).toBe(0); + }); + + test("setText clips at screen boundary", () => { + const screen = new Bun.TUIScreen(5, 1); + const cols = screen.setText(0, 0, "Hello World!"); + expect(cols).toBe(5); + expect(screen.getCell(0, 0).char).toBe("H"); + expect(screen.getCell(4, 0).char).toBe("o"); + }); + + test("setText at last column writes exactly one char", () => { + const screen = new Bun.TUIScreen(10, 1); + const cols = screen.setText(9, 0, "ABCDEF"); + expect(cols).toBe(1); + expect(screen.getCell(9, 0).char).toBe("A"); + }); + + test("setText at last row", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 4, "Bottom"); + expect(screen.getCell(0, 4).char).toBe("B"); + expect(screen.getCell(5, 4).char).toBe("m"); + }); + + test("setText overwrites existing content", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.setText(0, 0, "AAAA"); + screen.setText(0, 0, "BB"); + expect(screen.getCell(0, 0).char).toBe("B"); + expect(screen.getCell(1, 0).char).toBe("B"); + expect(screen.getCell(2, 0).char).toBe("A"); + expect(screen.getCell(3, 0).char).toBe("A"); + }); + + test("setText very long string clips correctly", () => { + const screen = new Bun.TUIScreen(80, 1); + const longStr = Buffer.alloc(10000, "X").toString(); + const cols = screen.setText(0, 0, longStr); + expect(cols).toBe(80); + expect(screen.getCell(79, 0).char).toBe("X"); + }); + + test("setText with style ID", () => { + const screen = new Bun.TUIScreen(80, 24); + const styleId = screen.style({ fg: 0xff0000, bold: true }); + expect(styleId).toBeGreaterThan(0); + + screen.setText(0, 0, "Red", styleId); + expect(screen.getCell(0, 0).styleId).toBe(styleId); + }); + + // ─── setText: CJK wide characters ───────────────────────────────── + + test("setText handles CJK wide characters", () => { + const screen = new Bun.TUIScreen(80, 24); + const cols = screen.setText(0, 0, "世界"); + expect(cols).toBe(4); + + expect(screen.getCell(0, 0)).toEqual(expect.objectContaining({ char: "世", wide: 1 })); + expect(screen.getCell(1, 0).wide).toBe(2); // spacer_tail + expect(screen.getCell(2, 0)).toEqual(expect.objectContaining({ char: "界", wide: 1 })); + expect(screen.getCell(3, 0).wide).toBe(2); + }); + + test("wide char at last column doesn't fit", () => { + const screen = new Bun.TUIScreen(5, 1); + screen.setText(0, 0, "ABCDE"); // fill all 5 cells + const cols = screen.setText(4, 0, "世"); // col 4, needs 2 cols, only 1 available + expect(cols).toBe(0); // shouldn't fit + expect(screen.getCell(4, 0).char).toBe("E"); // original preserved + }); + + test("wide char at col width-2 fits exactly", () => { + const screen = new Bun.TUIScreen(5, 1); + const cols = screen.setText(3, 0, "世"); + expect(cols).toBe(2); + expect(screen.getCell(3, 0)).toEqual(expect.objectContaining({ char: "世", wide: 1 })); + expect(screen.getCell(4, 0).wide).toBe(2); + }); + + test("overwrite wide char partially clears spacer", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.setText(5, 0, "世"); // occupies 5,6 + expect(screen.getCell(5, 0).wide).toBe(1); // wide + expect(screen.getCell(6, 0).wide).toBe(2); // spacer_tail + + // Overwrite just col 5 with a narrow char + screen.setText(5, 0, "A"); + expect(screen.getCell(5, 0).char).toBe("A"); + expect(screen.getCell(5, 0).wide).toBe(0); // narrow + // The spacer at col 6 is stale — it's up to the renderer to handle + }); + + // ─── setText: Unicode edge cases ────────────────────────────────── + + test("setText handles real emoji (non-BMP)", () => { + const screen = new Bun.TUIScreen(80, 24); + const cols = screen.setText(0, 0, "😀"); + expect(cols).toBe(2); // emoji is width 2 + expect(screen.getCell(0, 0).char).toBe("😀"); + expect(screen.getCell(0, 0).wide).toBe(1); + expect(screen.getCell(1, 0).wide).toBe(2); // spacer + }); + + test("setText handles combining marks (e + combining acute)", () => { + const screen = new Bun.TUIScreen(80, 24); + // e (U+0065) + combining acute accent (U+0301) = é + const cols = screen.setText(0, 0, "e\u0301"); + // Should be 1 column — combining mark attaches to 'e' + expect(cols).toBe(1); + // The base character should be 'e' + expect(screen.getCell(0, 0).char).toBe("e"); + }); + + test("setText handles ZWJ emoji sequences (family)", () => { + const screen = new Bun.TUIScreen(80, 24); + // 👨‍👩‍👧 = man + ZWJ + woman + ZWJ + girl + const family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; + const cols = screen.setText(0, 0, family); + // With ZWJ grapheme clustering: 👨 takes 2 cols, ZWJ + subsequent + // emoji codepoints are appended as grapheme extensions → 2 cols total. + expect(cols).toBe(2); + // The base character cell should contain 👨 + expect(screen.getCell(0, 0).char).toBe("\u{1F468}"); + expect(screen.getCell(0, 0).wide).toBe(1); // wide + expect(screen.getCell(1, 0).wide).toBe(2); // spacer_tail + }); + + test("setText handles regional indicators (flag)", () => { + const screen = new Bun.TUIScreen(80, 24); + // 🇺🇸 = U+1F1FA U+1F1F8 + const flag = "\u{1F1FA}\u{1F1F8}"; + const cols = screen.setText(0, 0, flag); + // Regional indicators: first is width 1, second may be zero-width + // The exact behavior depends on visibleCodepointWidth for each RI + expect(cols).toBeGreaterThanOrEqual(1); + }); + + test("setText handles variation selectors", () => { + const screen = new Bun.TUIScreen(80, 24); + // ☺ (U+263A) + VS16 (U+FE0F) = emoji presentation + const cols = screen.setText(0, 0, "\u263A\uFE0F"); + // VS16 is zero-width, should attach to preceding char + expect(cols).toBeGreaterThanOrEqual(1); + }); + + test("setText handles null codepoint in middle", () => { + const screen = new Bun.TUIScreen(80, 24); + // Should not crash + const cols = screen.setText(0, 0, "A\0B"); + expect(cols).toBeGreaterThanOrEqual(2); + }); + + test("setText handles mixed ASCII and CJK", () => { + const screen = new Bun.TUIScreen(80, 24); + // This tests the fast-path to slow-path transition + const cols = screen.setText(0, 0, "Hello世界World"); + // Hello=5, 世=2, 界=2, World=5 → 14 + expect(cols).toBe(14); + expect(screen.getCell(0, 0).char).toBe("H"); + expect(screen.getCell(5, 0).char).toBe("世"); + expect(screen.getCell(9, 0).char).toBe("W"); + }); + + test("setText handles Devanagari conjuncts", () => { + const screen = new Bun.TUIScreen(80, 24); + // क (ka) + ् (virama) + ष (ssa) = क्ष + const cols = screen.setText(0, 0, "\u0915\u094D\u0937"); + expect(cols).toBeGreaterThanOrEqual(1); // at least 1 visible cell + }); + + // ─── setText: error handling ────────────────────────────────────── + + test("setText throws with too few arguments", () => { + const screen = new Bun.TUIScreen(80, 24); + expect(() => (screen as any).setText(0, 0)).toThrow(); + expect(() => (screen as any).setText(0)).toThrow(); + expect(() => (screen as any).setText()).toThrow(); + }); + + test("setText throws with non-string text", () => { + const screen = new Bun.TUIScreen(80, 24); + expect(() => (screen as any).setText(0, 0, 42)).toThrow(); + }); + + // ─── style ──────────────────────────────────────────────────────── + + test("style interns identical styles", () => { + const screen = new Bun.TUIScreen(80, 24); + const id1 = screen.style({ fg: 0xff0000, bold: true }); + const id2 = screen.style({ fg: 0xff0000, bold: true }); + expect(id1).toBe(id2); + }); + + test("style interns same style 1000 times — same ID", () => { + const screen = new Bun.TUIScreen(80, 24); + const first = screen.style({ bold: true, fg: 0x112233 }); + for (let i = 0; i < 1000; i++) { + expect(screen.style({ bold: true, fg: 0x112233 })).toBe(first); + } + }); + + test("style supports all individual flags", () => { + const screen = new Bun.TUIScreen(80, 24); + + const bold = screen.style({ bold: true }); + const italic = screen.style({ italic: true }); + const faint = screen.style({ faint: true }); + const blink = screen.style({ blink: true }); + const inverse = screen.style({ inverse: true }); + const invisible = screen.style({ invisible: true }); + const strikethrough = screen.style({ strikethrough: true }); + const overline = screen.style({ overline: true }); + + // All should be unique non-zero IDs + const ids = [bold, italic, faint, blink, inverse, invisible, strikethrough, overline]; + for (const id of ids) expect(id).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("style with all attributes combined", () => { + const screen = new Bun.TUIScreen(80, 24); + const id = screen.style({ + bold: true, + italic: true, + faint: true, + blink: true, + inverse: true, + invisible: true, + strikethrough: true, + overline: true, + underline: "curly", + fg: 0xff0000, + bg: 0x00ff00, + underlineColor: 0x0000ff, + }); + expect(id).toBeGreaterThan(0); + }); + + test("style supports all underline variants", () => { + const screen = new Bun.TUIScreen(80, 24); + const single = screen.style({ underline: "single" }); + const double = screen.style({ underline: "double" }); + const curly = screen.style({ underline: "curly" }); + const dotted = screen.style({ underline: "dotted" }); + const dashed = screen.style({ underline: "dashed" }); + + const ids = [single, double, curly, dotted, dashed]; + for (const id of ids) expect(id).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(5); + }); + + test("underline: true maps to single", () => { + const screen = new Bun.TUIScreen(80, 24); + const fromBool = screen.style({ underline: true }); + const fromStr = screen.style({ underline: "single" }); + expect(fromBool).toBe(fromStr); + }); + + test("style supports hex string colors", () => { + const screen = new Bun.TUIScreen(80, 24); + const s1 = screen.style({ fg: "#ff0000" }); + const s2 = screen.style({ fg: 0xff0000 }); + // Both should produce the same RGB, so same style ID + expect(s1).toBe(s2); + }); + + test("style supports underlineColor", () => { + const screen = new Bun.TUIScreen(80, 24); + const id = screen.style({ underline: "curly", underlineColor: 0xff0000 }); + expect(id).toBeGreaterThan(0); + }); + + test("style with empty object returns 0 (default style)", () => { + const screen = new Bun.TUIScreen(80, 24); + const id = screen.style({}); + expect(id).toBe(0); + }); + + test("style with non-boolean flags ignores them", () => { + const screen = new Bun.TUIScreen(80, 24); + // Non-boolean values for boolean flags should be ignored (treated as no flag) + const id = screen.style({ bold: "yes" as any }); + // Since bold: "yes" is not a boolean, it should be ignored → default style + expect(id).toBe(0); + }); + + test("style with invalid hex color produces none color", () => { + const screen = new Bun.TUIScreen(80, 24); + // 3-char hex is not supported (only 6-char) + const id = screen.style({ fg: "#FFF" }); + // Invalid hex → .none color → default style + expect(id).toBe(0); + }); + + test("style throws with non-object argument", () => { + const screen = new Bun.TUIScreen(80, 24); + expect(() => (screen as any).style()).toThrow(); + expect(() => (screen as any).style(42)).toThrow(); + expect(() => (screen as any).style("bold")).toThrow(); + }); + + test("many unique styles up to capacity", () => { + const screen = new Bun.TUIScreen(80, 24); + const ids = new Set(); + // Create styles with unique colors — capacity is 256 + // We leave some headroom since style 0 is reserved + for (let i = 1; i < 200; i++) { + const id = screen.style({ fg: i }); + ids.add(id); + } + // Should all be unique + expect(ids.size).toBe(199); + }); + + // ─── clearRect ──────────────────────────────────────────────────── + + test("clearRect clears cells", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.setText(0, 0, "Hello World"); + screen.clearRect(0, 0, 5, 1); + expect(screen.getCell(0, 0).char).toBe(" "); + // "Hello World": H(0) e(1) l(2) l(3) o(4) ' '(5) W(6) — cell 6 is 'W' + expect(screen.getCell(6, 0).char).toBe("W"); + }); + + test("clearRect with zero width is no-op", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hello"); + screen.clearRect(0, 0, 0, 5); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("clearRect with zero height is no-op", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hello"); + screen.clearRect(0, 0, 10, 0); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("clearRect exceeding bounds clips correctly", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.fill(0, 0, 10, 5, "X"); + // Clear from (7,3) with w=100, h=100 — should clip to actual bounds + screen.clearRect(7, 3, 100, 100); + // Cells before the rect should be untouched + expect(screen.getCell(6, 3).char).toBe("X"); + expect(screen.getCell(0, 2).char).toBe("X"); + // Cells in the cleared area should be blank + expect(screen.getCell(7, 3).char).toBe(" "); + expect(screen.getCell(9, 4).char).toBe(" "); + }); + + test("clearRect throws with too few arguments", () => { + const screen = new Bun.TUIScreen(10, 5); + expect(() => (screen as any).clearRect(0, 0, 10)).toThrow(); + expect(() => (screen as any).clearRect(0, 0)).toThrow(); + }); + + // ─── fill ───────────────────────────────────────────────────────── + + test("fill fills region with character", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.fill(0, 0, 10, 5, "#"); + expect(screen.getCell(0, 0).char).toBe("#"); + expect(screen.getCell(9, 4).char).toBe("#"); + }); + + test("fill with style", () => { + const screen = new Bun.TUIScreen(10, 5); + const sid = screen.style({ bg: 0x0000ff }); + screen.fill(0, 0, 10, 5, " ", sid); + expect(screen.getCell(5, 2).styleId).toBe(sid); + }); + + test("fill with numeric codepoint", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.fill(0, 0, 5, 1, 0x41); // 'A' + expect(screen.getCell(0, 0).char).toBe("A"); + expect(screen.getCell(4, 0).char).toBe("A"); + }); + + test("fill with empty string fills spaces", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.fill(0, 0, 10, 1, "X"); + screen.fill(0, 0, 5, 1, ""); + // Empty string → space fill + expect(screen.getCell(0, 0).char).toBe(" "); + expect(screen.getCell(5, 0).char).toBe("X"); + }); + + test("fill with multi-char string uses first codepoint", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.fill(0, 0, 10, 1, "ABC"); + // Should fill with 'A' only + expect(screen.getCell(0, 0).char).toBe("A"); + expect(screen.getCell(9, 0).char).toBe("A"); + }); + + test("fill with wide character creates wide+spacer pairs", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.fill(0, 0, 10, 1, "世"); + // 10 cols / 2 per wide char = 5 wide chars + for (let i = 0; i < 10; i += 2) { + expect(screen.getCell(i, 0)).toEqual(expect.objectContaining({ char: "世", wide: 1 })); + expect(screen.getCell(i + 1, 0).wide).toBe(2); // spacer_tail + } + }); + + test("fill with wide char on odd-width region leaves last col empty", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.fill(0, 0, 10, 1, "X"); // pre-fill + screen.fill(0, 0, 5, 1, "世"); // 5 cols, fits 2 wide chars (4 cols), last col untouched + expect(screen.getCell(0, 0).char).toBe("世"); + expect(screen.getCell(2, 0).char).toBe("世"); + // Col 4 was not filled (would need 2 cols but only 1 left) + expect(screen.getCell(4, 0).char).toBe("X"); + }); + + test("fill with zero width is no-op", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hello"); + screen.fill(0, 0, 0, 5, "X"); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("fill with zero height is no-op", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hello"); + screen.fill(0, 0, 10, 0, "X"); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("fill throws with too few arguments", () => { + const screen = new Bun.TUIScreen(10, 5); + expect(() => (screen as any).fill(0, 0, 10, 5)).toThrow(); + expect(() => (screen as any).fill(0, 0)).toThrow(); + }); + + // ─── copy ───────────────────────────────────────────────────────── + + test("copy blits from another screen", () => { + const src = new Bun.TUIScreen(20, 10); + const dst = new Bun.TUIScreen(20, 10); + + src.setText(0, 0, "Source"); + dst.copy(src, 0, 0, 5, 5, 6, 1); + + expect(dst.getCell(5, 5).char).toBe("S"); + expect(dst.getCell(10, 5).char).toBe("e"); + }); + + test("copy with zero width is no-op", () => { + const src = new Bun.TUIScreen(10, 5); + const dst = new Bun.TUIScreen(10, 5); + dst.setText(0, 0, "Hello"); + dst.copy(src, 0, 0, 0, 0, 0, 5); + expect(dst.getCell(0, 0).char).toBe("H"); + }); + + test("copy with zero height is no-op", () => { + const src = new Bun.TUIScreen(10, 5); + const dst = new Bun.TUIScreen(10, 5); + dst.setText(0, 0, "Hello"); + dst.copy(src, 0, 0, 0, 0, 10, 0); + expect(dst.getCell(0, 0).char).toBe("H"); + }); + + test("copy clips when destination is too small", () => { + const src = new Bun.TUIScreen(20, 10); + const dst = new Bun.TUIScreen(10, 5); + src.fill(0, 0, 20, 10, "X"); + // Copy 20-wide row into dst starting at col 5 — should clip to 5 cols + dst.copy(src, 0, 0, 5, 0, 20, 1); + expect(dst.getCell(5, 0).char).toBe("X"); + expect(dst.getCell(9, 0).char).toBe("X"); + }); + + test("copy from self (non-overlapping) works", () => { + const screen = new Bun.TUIScreen(20, 5); + screen.setText(0, 0, "Hello"); + // Copy row 0 to row 2 — non-overlapping + screen.copy(screen, 0, 0, 0, 2, 5, 1); + expect(screen.getCell(0, 2).char).toBe("H"); + expect(screen.getCell(4, 2).char).toBe("o"); + // Original should be preserved + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("copy throws with non-Screen first argument", () => { + const screen = new Bun.TUIScreen(10, 5); + expect(() => (screen as any).copy({}, 0, 0, 0, 0, 10, 5)).toThrow(); + expect(() => (screen as any).copy(42, 0, 0, 0, 0, 10, 5)).toThrow(); + }); + + test("copy throws with too few arguments", () => { + const src = new Bun.TUIScreen(10, 5); + const dst = new Bun.TUIScreen(10, 5); + expect(() => (dst as any).copy(src, 0, 0, 0, 0, 10)).toThrow(); + }); + + // ─── resize ─────────────────────────────────────────────────────── + + test("resize preserves content", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.setText(0, 0, "Hello"); + screen.resize(40, 12); + expect(screen.width).toBe(40); + expect(screen.height).toBe(12); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("resize to larger adds empty space", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hi"); + screen.resize(20, 10); + expect(screen.width).toBe(20); + expect(screen.height).toBe(10); + expect(screen.getCell(0, 0).char).toBe("H"); + expect(screen.getCell(15, 8).char).toBe(" "); + }); + + test("resize to same size is no-op", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hello"); + screen.resize(10, 5); + expect(screen.width).toBe(10); + expect(screen.height).toBe(5); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("resize to 1x1 preserves top-left cell", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hello"); + screen.resize(1, 1); + expect(screen.width).toBe(1); + expect(screen.height).toBe(1); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("resize larger then smaller round-trips content", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.setText(0, 0, "Hello"); + screen.resize(20, 10); + screen.resize(10, 5); + expect(screen.getCell(0, 0).char).toBe("H"); + expect(screen.getCell(4, 0).char).toBe("o"); + }); + + test("rapid resize cycle doesn't crash", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.setText(0, 0, "Test"); + for (let i = 0; i < 100; i++) { + const w = ((i * 7 + 3) % 200) + 1; + const h = ((i * 11 + 5) % 100) + 1; + screen.resize(w, h); + } + // Should not crash, dimensions should be valid + expect(screen.width).toBeGreaterThanOrEqual(1); + expect(screen.height).toBeGreaterThanOrEqual(1); + }); + + test("resize clamps to [1, 4096]", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.resize(0, 0); + expect(screen.width).toBe(1); + expect(screen.height).toBe(1); + }); + + test("resize throws with too few arguments", () => { + const screen = new Bun.TUIScreen(10, 5); + expect(() => (screen as any).resize(10)).toThrow(); + expect(() => (screen as any).resize()).toThrow(); + }); + + // ─── clear ──────────────────────────────────────────────────────── + + test("clear resets all cells", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.setText(0, 0, "Hello"); + screen.clear(); + expect(screen.getCell(0, 0).char).toBe(" "); + expect(screen.getCell(4, 0).char).toBe(" "); + }); + + // ─── getCell ────────────────────────────────────────────────────── + + test("getCell returns null for out-of-bounds", () => { + const screen = new Bun.TUIScreen(80, 24); + expect(screen.getCell(-1, 0)).toBeNull(); + expect(screen.getCell(80, 0)).toBeNull(); + expect(screen.getCell(0, 24)).toBeNull(); + expect(screen.getCell(0, -1)).toBeNull(); + }); + + test("getCell returns null for large coordinates", () => { + const screen = new Bun.TUIScreen(10, 5); + expect(screen.getCell(999999, 0)).toBeNull(); + expect(screen.getCell(0, 999999)).toBeNull(); + }); + + test("getCell on spacer_tail returns spacer wide flag", () => { + const screen = new Bun.TUIScreen(10, 1); + screen.setText(0, 0, "世"); + const spacer = screen.getCell(1, 0); + expect(spacer.wide).toBe(2); // spacer_tail + }); + + test("getCell throws with too few arguments", () => { + const screen = new Bun.TUIScreen(10, 5); + expect(() => (screen as any).getCell(0)).toThrow(); + expect(() => (screen as any).getCell()).toThrow(); + }); + + // ─── 1x1 screen stress ─────────────────────────────────────────── + + test("1x1 screen: write single char", () => { + const screen = new Bun.TUIScreen(1, 1); + const cols = screen.setText(0, 0, "X"); + expect(cols).toBe(1); + expect(screen.getCell(0, 0).char).toBe("X"); + }); + + test("1x1 screen: wide char doesn't fit", () => { + const screen = new Bun.TUIScreen(1, 1); + const cols = screen.setText(0, 0, "世"); + expect(cols).toBe(0); // needs 2 cols, only 1 available + }); + + test("1x1 screen: fill", () => { + const screen = new Bun.TUIScreen(1, 1); + screen.fill(0, 0, 1, 1, "Z"); + expect(screen.getCell(0, 0).char).toBe("Z"); + }); + + test("1x1 screen: clearRect", () => { + const screen = new Bun.TUIScreen(1, 1); + screen.setText(0, 0, "X"); + screen.clearRect(0, 0, 1, 1); + expect(screen.getCell(0, 0).char).toBe(" "); + }); + + // ─── Clipping ─────────────────────────────────────────────────── + + test("clip restricts setText writes", () => { + const screen = new Bun.TUIScreen(20, 5); + screen.clip(5, 1, 15, 4); + // Write outside clip region — should be skipped + screen.setText(0, 0, "Outside"); + expect(screen.getCell(0, 0).char).toBe(" "); + // Write inside clip region + screen.setText(5, 1, "Inside"); + expect(screen.getCell(5, 1).char).toBe("I"); + screen.unclip(); + }); + + test("clip restricts fill", () => { + const screen = new Bun.TUIScreen(20, 5); + screen.clip(2, 1, 8, 3); + screen.fill(0, 0, 20, 5, "X"); + // Inside clip: filled + expect(screen.getCell(2, 1).char).toBe("X"); + expect(screen.getCell(7, 2).char).toBe("X"); + // Outside clip: untouched + expect(screen.getCell(0, 0).char).toBe(" "); + expect(screen.getCell(8, 1).char).toBe(" "); + expect(screen.getCell(2, 0).char).toBe(" "); + screen.unclip(); + }); + + test("clip restricts clearRect", () => { + const screen = new Bun.TUIScreen(20, 5); + screen.fill(0, 0, 20, 5, "X"); + screen.clip(5, 1, 15, 4); + screen.clearRect(0, 0, 20, 5); + // Inside clip: cleared + expect(screen.getCell(5, 1).char).toBe(" "); + // Outside clip: still X + expect(screen.getCell(0, 0).char).toBe("X"); + expect(screen.getCell(4, 1).char).toBe("X"); + screen.unclip(); + }); + + test("unclip restores full access", () => { + const screen = new Bun.TUIScreen(10, 3); + screen.clip(3, 1, 7, 2); + screen.unclip(); + screen.setText(0, 0, "Hello"); + expect(screen.getCell(0, 0).char).toBe("H"); + }); + + test("clip stack allows nesting", () => { + const screen = new Bun.TUIScreen(20, 5); + screen.clip(0, 0, 20, 5); + screen.clip(5, 1, 15, 4); + screen.fill(0, 0, 20, 5, "X"); + // Only inner clip applies + expect(screen.getCell(0, 0).char).toBe(" "); + expect(screen.getCell(5, 1).char).toBe("X"); + screen.unclip(); + // Outer clip now applies + screen.fill(0, 0, 20, 5, "Y"); + expect(screen.getCell(0, 0).char).toBe("Y"); + screen.unclip(); + }); + + test("setText clips at right edge of clip rect", () => { + const screen = new Bun.TUIScreen(20, 3); + screen.clip(5, 0, 10, 3); + // Start at col 5, clip end at col 10 — only 5 chars fit + const cols = screen.setText(5, 0, "Hello World"); + expect(cols).toBe(5); + expect(screen.getCell(5, 0).char).toBe("H"); + expect(screen.getCell(9, 0).char).toBe("o"); + screen.unclip(); + }); + + // ─── Hyperlinks ───────────────────────────────────────────────── + + test("hyperlink interns URLs and returns IDs", () => { + const screen = new Bun.TUIScreen(80, 24); + const id1 = screen.hyperlink("https://example.com"); + const id2 = screen.hyperlink("https://other.com"); + const id3 = screen.hyperlink("https://example.com"); + expect(id1).toBeGreaterThan(0); + expect(id2).toBeGreaterThan(0); + expect(id1).not.toBe(id2); + expect(id3).toBe(id1); // same URL → same ID + }); + + test("setHyperlink sets and getCell works", () => { + const screen = new Bun.TUIScreen(80, 24); + const id = screen.hyperlink("https://example.com"); + screen.setText(0, 0, "Click"); + screen.setHyperlink(0, 0, id); + screen.setHyperlink(1, 0, id); + // setHyperlink doesn't crash + expect(screen.getCell(0, 0).char).toBe("C"); + }); + + test("setHyperlink with out-of-bounds is no-op", () => { + const screen = new Bun.TUIScreen(10, 5); + const id = screen.hyperlink("https://example.com"); + // Should not crash + screen.setHyperlink(100, 100, id); + screen.setHyperlink(-1, -1, id); + }); + + test("hyperlink throws with non-string argument", () => { + const screen = new Bun.TUIScreen(80, 24); + expect(() => (screen as any).hyperlink()).toThrow(); + expect(() => (screen as any).hyperlink(42)).toThrow(); + }); + + // ─── drawBox ────────────────────────────────────────────────────── + + describe("drawBox", () => { + test("draws a box with default single border", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 5, 3); + // Top-left corner + expect(screen.getCell(0, 0).char).toBe("\u250C"); // ┌ + // Top-right corner + expect(screen.getCell(4, 0).char).toBe("\u2510"); // ┐ + // Bottom-left corner + expect(screen.getCell(0, 2).char).toBe("\u2514"); // └ + // Bottom-right corner + expect(screen.getCell(4, 2).char).toBe("\u2518"); // ┘ + // Top horizontal border + expect(screen.getCell(1, 0).char).toBe("\u2500"); // ─ + expect(screen.getCell(2, 0).char).toBe("\u2500"); // ─ + expect(screen.getCell(3, 0).char).toBe("\u2500"); // ─ + // Left vertical border + expect(screen.getCell(0, 1).char).toBe("\u2502"); // │ + // Right vertical border + expect(screen.getCell(4, 1).char).toBe("\u2502"); // │ + }); + + test("draws a box with double border style", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 5, 3, { style: "double" }); + expect(screen.getCell(0, 0).char).toBe("\u2554"); // ╔ + expect(screen.getCell(4, 0).char).toBe("\u2557"); // ╗ + expect(screen.getCell(0, 2).char).toBe("\u255A"); // ╚ + expect(screen.getCell(4, 2).char).toBe("\u255D"); // ╝ + expect(screen.getCell(1, 0).char).toBe("\u2550"); // ═ + expect(screen.getCell(0, 1).char).toBe("\u2551"); // ║ + }); + + test("draws a box with rounded border style", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 5, 3, { style: "rounded" }); + expect(screen.getCell(0, 0).char).toBe("\u256D"); // ╭ + expect(screen.getCell(4, 0).char).toBe("\u256E"); // ╮ + expect(screen.getCell(0, 2).char).toBe("\u2570"); // ╰ + expect(screen.getCell(4, 2).char).toBe("\u256F"); // ╯ + // Horizontal and vertical are same as single + expect(screen.getCell(1, 0).char).toBe("\u2500"); // ─ + expect(screen.getCell(0, 1).char).toBe("\u2502"); // │ + }); + + test("draws a box with heavy border style", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 5, 3, { style: "heavy" }); + expect(screen.getCell(0, 0).char).toBe("\u250F"); // ┏ + expect(screen.getCell(4, 0).char).toBe("\u2513"); // ┓ + expect(screen.getCell(0, 2).char).toBe("\u2517"); // ┗ + expect(screen.getCell(4, 2).char).toBe("\u251B"); // ┛ + expect(screen.getCell(1, 0).char).toBe("\u2501"); // ━ + expect(screen.getCell(0, 1).char).toBe("\u2503"); // ┃ + }); + + test("draws a box with ascii border style", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 5, 3, { style: "ascii" }); + expect(screen.getCell(0, 0).char).toBe("+"); + expect(screen.getCell(4, 0).char).toBe("+"); + expect(screen.getCell(0, 2).char).toBe("+"); + expect(screen.getCell(4, 2).char).toBe("+"); + expect(screen.getCell(1, 0).char).toBe("-"); + expect(screen.getCell(0, 1).char).toBe("|"); + }); + + test("box smaller than 2x2 is no-op", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.fill(0, 0, 10, 5, "X"); + // 1x1 box + screen.drawBox(0, 0, 1, 1); + expect(screen.getCell(0, 0).char).toBe("X"); + // 1x3 box + screen.drawBox(0, 0, 1, 3); + expect(screen.getCell(0, 0).char).toBe("X"); + // 3x1 box + screen.drawBox(0, 0, 3, 1); + expect(screen.getCell(0, 0).char).toBe("X"); + // 0x0 box + screen.drawBox(0, 0, 0, 0); + expect(screen.getCell(0, 0).char).toBe("X"); + }); + + test("box with fill: true fills interior with spaces", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.fill(0, 0, 10, 5, "X"); + screen.drawBox(0, 0, 5, 4, { fill: true }); + // Border should be drawn + expect(screen.getCell(0, 0).char).toBe("\u250C"); // ┌ + // Interior should be spaces + expect(screen.getCell(1, 1).char).toBe(" "); + expect(screen.getCell(2, 1).char).toBe(" "); + expect(screen.getCell(3, 1).char).toBe(" "); + expect(screen.getCell(1, 2).char).toBe(" "); + expect(screen.getCell(2, 2).char).toBe(" "); + expect(screen.getCell(3, 2).char).toBe(" "); + // Outside box should be untouched + expect(screen.getCell(5, 0).char).toBe("X"); + expect(screen.getCell(0, 4).char).toBe("X"); + }); + + test("box with fill: true and custom fillChar", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 6, 4, { fill: true, fillChar: "." }); + // Interior filled with dots + expect(screen.getCell(1, 1).char).toBe("."); + expect(screen.getCell(4, 2).char).toBe("."); + // Border should still be border chars + expect(screen.getCell(0, 0).char).toBe("\u250C"); // ┌ + }); + + test("box with styleId applies style to border", () => { + const screen = new Bun.TUIScreen(10, 5); + const sid = screen.style({ fg: 0xff0000, bold: true }); + screen.drawBox(0, 0, 5, 3, { styleId: sid }); + expect(screen.getCell(0, 0).styleId).toBe(sid); + expect(screen.getCell(1, 0).styleId).toBe(sid); + expect(screen.getCell(0, 1).styleId).toBe(sid); + }); + + test("minimum 2x2 box draws just corners", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 2, 2); + expect(screen.getCell(0, 0).char).toBe("\u250C"); // ┌ + expect(screen.getCell(1, 0).char).toBe("\u2510"); // ┐ + expect(screen.getCell(0, 1).char).toBe("\u2514"); // └ + expect(screen.getCell(1, 1).char).toBe("\u2518"); // ┘ + }); + + test("box at edge of screen clips dimensions", () => { + const screen = new Bun.TUIScreen(10, 5); + // Draw box at bottom-right that extends beyond screen + // raw_w clamped to 10-7=3, raw_h clamped to 5-3=2 => 3x2 box + screen.drawBox(7, 3, 10, 10); + expect(screen.getCell(7, 3).char).toBe("\u250C"); // ┌ top-left + expect(screen.getCell(9, 3).char).toBe("\u2510"); // ┐ top-right (col 9 = x+w-1) + expect(screen.getCell(7, 4).char).toBe("\u2514"); // └ bottom-left + expect(screen.getCell(9, 4).char).toBe("\u2518"); // ┘ bottom-right + // Horizontal border between corners + expect(screen.getCell(8, 3).char).toBe("\u2500"); // ─ top horizontal + }); + + test("box respects clip rect", () => { + const screen = new Bun.TUIScreen(20, 10); + screen.fill(0, 0, 20, 10, "X"); + // Set clip rect that contains the entire box + screen.clip(1, 1, 12, 8); + screen.drawBox(1, 1, 10, 6); + // Inside clip: border chars drawn + expect(screen.getCell(1, 1).char).toBe("\u250C"); // ┌ top-left corner + expect(screen.getCell(10, 1).char).toBe("\u2510"); // ┐ top-right corner + // Outside clip: untouched + expect(screen.getCell(0, 0).char).toBe("X"); + expect(screen.getCell(11, 0).char).toBe("X"); + screen.unclip(); + }); + + test("drawBox with unknown style name defaults to single", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.drawBox(0, 0, 5, 3, { style: "nonexistent" }); + expect(screen.getCell(0, 0).char).toBe("\u250C"); // ┌ (single) + }); + + test("drawBox throws with too few arguments", () => { + const screen = new Bun.TUIScreen(10, 5); + expect(() => (screen as any).drawBox(0, 0, 5)).toThrow(); + expect(() => (screen as any).drawBox(0, 0)).toThrow(); + expect(() => (screen as any).drawBox()).toThrow(); + }); + + test("large box fills full screen", () => { + const screen = new Bun.TUIScreen(80, 24); + screen.drawBox(0, 0, 80, 24); + // Corners + expect(screen.getCell(0, 0).char).toBe("\u250C"); + expect(screen.getCell(79, 0).char).toBe("\u2510"); + expect(screen.getCell(0, 23).char).toBe("\u2514"); + expect(screen.getCell(79, 23).char).toBe("\u2518"); + // Middle of top border + expect(screen.getCell(40, 0).char).toBe("\u2500"); + // Middle of left border + expect(screen.getCell(0, 12).char).toBe("\u2502"); + // Interior should be empty (spaces) + expect(screen.getCell(1, 1).char).toBe(" "); + }); + + test("drawBox interior not filled without fill option", () => { + const screen = new Bun.TUIScreen(10, 5); + screen.fill(0, 0, 10, 5, "X"); + screen.drawBox(0, 0, 5, 4); + // Interior should retain original content (no fill) + expect(screen.getCell(1, 1).char).toBe("X"); + expect(screen.getCell(2, 2).char).toBe("X"); + }); + + test("drawBox with all options combined", () => { + const screen = new Bun.TUIScreen(20, 10); + const sid = screen.style({ fg: 0x00ff00 }); + screen.drawBox(2, 1, 10, 6, { + style: "double", + fill: true, + fillChar: ".", + styleId: sid, + }); + // Corners with double border + expect(screen.getCell(2, 1).char).toBe("\u2554"); // ╔ + expect(screen.getCell(11, 1).char).toBe("\u2557"); // ╗ + // Style applied + expect(screen.getCell(2, 1).styleId).toBe(sid); + // Interior filled with dots + expect(screen.getCell(3, 2).char).toBe("."); + expect(screen.getCell(10, 5).char).toBe("."); + }); + }); +}); diff --git a/test/js/bun/tui/writer.test.ts b/test/js/bun/tui/writer.test.ts new file mode 100644 index 0000000000..156b6d521a --- /dev/null +++ b/test/js/bun/tui/writer.test.ts @@ -0,0 +1,1512 @@ +import { describe, expect, test } from "bun:test"; +import { closeSync, openSync, readFileSync } from "fs"; +import { tempDir } from "harness"; +import { join } from "path"; + +/** Helper: render a screen to a file and return the ANSI output string. */ +function renderToString( + cols: number, + rows: number, + setup: (screen: InstanceType) => void, + renderOpts?: { + cursorX?: number; + cursorY?: number; + cursorVisible?: boolean; + cursorStyle?: string; + cursorBlinking?: boolean; + }, +): string { + using dir = tempDir("tui-writer", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(cols, rows); + setup(screen); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen, renderOpts); + closeSync(fd); + return readFileSync(filePath, "utf8"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } +} + +/** Helper: render a screen twice via the same writer, returning both outputs. */ +function renderTwice( + cols: number, + rows: number, + setup1: (screen: InstanceType) => void, + setup2: (screen: InstanceType) => void, +): { output1: string; output2: string } { + using dir = tempDir("tui-writer-diff2", {}); + const combinedPath = join(String(dir), "combined.bin"); + const fd = openSync(combinedPath, "w"); + const screen = new Bun.TUIScreen(cols, rows); + setup1(screen); + + const w = new Bun.TUITerminalWriter(Bun.file(fd)); + w.render(screen); + + // Read first output by checking file size + const firstSize = readFileSync(combinedPath).length; + + // Mutate and render again (diff path) + setup2(screen); + w.render(screen); + closeSync(fd); + + const combined = readFileSync(combinedPath); + return { + output1: combined.slice(0, firstSize).toString("utf8"), + output2: combined.slice(firstSize).toString("utf8"), + }; +} + +describe("Bun.TUITerminalWriter", () => { + // ─── Constructor ────────────────────────────────────────────────── + + test("constructor requires Bun.file() argument", () => { + expect(() => new (Bun.TUITerminalWriter as any)()).toThrow(); + expect(() => new (Bun.TUITerminalWriter as any)("not a file")).toThrow(); + expect(() => new (Bun.TUITerminalWriter as any)(42)).toThrow(); + }); + + test("constructor accepts Bun.file(fd)", () => { + using dir = tempDir("tui-writer-ctor", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + expect(writer).toBeDefined(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── Full Render ────────────────────────────────────────────────── + + test("render produces ANSI output with BSU/ESU", () => { + const output = renderToString(10, 3, screen => { + screen.setText(0, 0, "Hello"); + screen.setText(0, 1, "World"); + }); + + expect(output).toContain("\x1b[?2026h"); // BSU + expect(output).toContain("\x1b[?2026l"); // ESU + expect(output).toContain("Hello"); + expect(output).toContain("World"); + }); + + test("render with styled text produces SGR sequences", () => { + const output = renderToString(20, 3, screen => { + const bold = screen.style({ bold: true }); + screen.setText(0, 0, "Bold", bold); + }); + + expect(output).toContain("\x1b[1m"); // bold SGR + expect(output).toContain("\x1b[0m"); // reset + }); + + test("render with RGB colors produces 24-bit SGR", () => { + const output = renderToString(20, 3, screen => { + const red = screen.style({ fg: 0xff0000 }); + screen.setText(0, 0, "Red", red); + }); + + expect(output).toContain("\x1b[38;2;255;0;0m"); + }); + + test("render with background RGB color", () => { + const output = renderToString(20, 3, screen => { + const bg = screen.style({ bg: 0x00ff00 }); + screen.setText(0, 0, "Green", bg); + }); + + expect(output).toContain("\x1b[48;2;0;255;0m"); + }); + + test("render includes cursor hide/show", () => { + const output = renderToString(10, 3, screen => { + screen.setText(0, 0, "Hi"); + }); + + expect(output).toContain("\x1b[?25l"); // hide cursor + expect(output).toContain("\x1b[?25h"); // show cursor + }); + + test("render includes erase-to-EOL for each row", () => { + const output = renderToString(10, 3, screen => { + screen.setText(0, 0, "Hi"); + }); + + // Should have \x1b[K for row clearing + expect(output).toContain("\x1b[K"); + }); + + test("render emits all style flags", () => { + const output = renderToString(30, 1, screen => { + const s = screen.style({ + bold: true, + faint: true, + italic: true, + underline: "single", + blink: true, + inverse: true, + invisible: true, + strikethrough: true, + overline: true, + }); + screen.setText(0, 0, "AllStyles", s); + }); + + expect(output).toContain("\x1b[1m"); // bold + expect(output).toContain("\x1b[2m"); // faint + expect(output).toContain("\x1b[3m"); // italic + expect(output).toContain("\x1b[4m"); // underline single + expect(output).toContain("\x1b[5m"); // blink + expect(output).toContain("\x1b[7m"); // inverse + expect(output).toContain("\x1b[8m"); // invisible + expect(output).toContain("\x1b[9m"); // strikethrough + expect(output).toContain("\x1b[53m"); // overline + }); + + test("render emits all underline variants", () => { + for (const [variant, expected] of [ + ["single", "\x1b[4m"], + ["double", "\x1b[4:2m"], + ["curly", "\x1b[4:3m"], + ["dotted", "\x1b[4:4m"], + ["dashed", "\x1b[4:5m"], + ] as const) { + const output = renderToString(20, 1, screen => { + const s = screen.style({ underline: variant }); + screen.setText(0, 0, "Test", s); + }); + expect(output).toContain(expected); + } + }); + + test("render of CJK wide characters", () => { + const output = renderToString(20, 1, screen => { + screen.setText(0, 0, "A世B"); + }); + + expect(output).toContain("A"); + expect(output).toContain("世"); + expect(output).toContain("B"); + }); + + test("render of empty screen has minimal content", () => { + const output = renderToString(80, 24, _screen => { + // No content written + }); + + // Should still have BSU/ESU and cursor management + expect(output).toContain("\x1b[?2026h"); + expect(output).toContain("\x1b[?2026l"); + // Should not contain any printable text content + const stripped = output + .replace(/\x1b\[[^A-Za-z]*[A-Za-z]/g, "") // strip all escape sequences + .replace(/[\r\n]/g, "") // strip newlines + .trim(); + // Empty screen should produce only whitespace/clearing, not random text + expect(stripped).not.toContain("A"); + }); + + // ─── Diff Render ────────────────────────────────────────────────── + + test("second render with no changes produces minimal output", () => { + const { output1, output2 } = renderTwice( + 40, + 5, + screen => { + screen.setText(0, 0, "Hello"); + screen.setText(0, 1, "World"); + }, + _screen => { + // No changes + }, + ); + + // First render should have content + expect(output1).toContain("Hello"); + expect(output1).toContain("World"); + + // Second render should be much smaller — just BSU + ESU, no content + expect(output2.length).toBeLessThan(output1.length); + expect(output2).toContain("\x1b[?2026h"); + expect(output2).toContain("\x1b[?2026l"); + // Diff with no dirty rows should not contain the original text + expect(output2).not.toContain("Hello"); + expect(output2).not.toContain("World"); + }); + + test("single cell change produces small diff", () => { + const { output1, output2 } = renderTwice( + 40, + 5, + screen => { + screen.setText(0, 0, "Hello World"); + screen.setText(0, 1, "Line Two"); + screen.setText(0, 2, "Line Three"); + }, + screen => { + // Change just one cell + screen.setText(0, 0, "J"); // overwrite 'H' with 'J' + }, + ); + + // First render contains all text + expect(output1).toContain("Hello"); + + // Diff should contain 'J' but be much smaller than full render + expect(output2).toContain("J"); + expect(output2.length).toBeLessThan(output1.length); + }); + + test("style-only change between renders triggers diff", () => { + const { output1, output2 } = renderTwice( + 20, + 3, + screen => { + screen.setText(0, 0, "Text"); + }, + screen => { + // Change style but not character + const bold = screen.style({ bold: true }); + screen.setText(0, 0, "Text", bold); + }, + ); + + // Second render should contain the bold SGR + expect(output2).toContain("\x1b[1m"); + expect(output2).toContain("Text"); + }); + + test("row-level skip: unchanged rows produce no output", () => { + const { output2 } = renderTwice( + 40, + 10, + screen => { + for (let y = 0; y < 10; y++) { + screen.setText(0, y, `Row ${y}`); + } + }, + screen => { + // Only change row 5 + screen.setText(0, 5, "Changed!"); + }, + ); + + // Diff should contain the changed text + expect(output2).toContain("Changed!"); + // Diff should NOT contain unchanged rows + expect(output2).not.toContain("Row 0"); + expect(output2).not.toContain("Row 9"); + }); + + // ─── Writer clear ───────────────────────────────────────────────── + + test("clear resets writer state, next render is full", () => { + using dir = tempDir("tui-writer-clear", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen); + writer.clear(); + + screen.setText(0, 0, "After"); + writer.render(screen); + closeSync(fd); + + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("After"); + // After clear, should do a full render (with cursor hide/show) + expect(output).toContain("\x1b[?25l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── Render argument validation ─────────────────────────────────── + + test("render throws with no arguments", () => { + using dir = tempDir("tui-writer-err", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + expect(() => (writer as any).render()).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("render throws with non-Screen argument", () => { + using dir = tempDir("tui-writer-err2", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + expect(() => (writer as any).render({})).toThrow(); + expect(() => (writer as any).render(42)).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── Output size verification ───────────────────────────────────── + + test("full repaint byte count is reasonable", () => { + const output = renderToString(80, 24, screen => { + // Fill entire screen with ASCII + for (let y = 0; y < 24; y++) { + screen.setText(0, y, Buffer.alloc(80, "A").toString()); + } + }); + + // 80*24 = 1920 characters of content, plus ANSI overhead + // Should not be more than ~4x the raw content + expect(output.length).toBeGreaterThan(1920); // at least the content + expect(output.length).toBeLessThan(1920 * 4); // not wildly bloated + }); + + test("diff after single-cell change is much smaller than full repaint", () => { + const { output1, output2 } = renderTwice( + 80, + 24, + screen => { + for (let y = 0; y < 24; y++) { + screen.setText(0, y, Buffer.alloc(80, "A").toString()); + } + }, + screen => { + screen.setText(0, 0, "B"); // Change one cell + }, + ); + + // Diff should be at least 10x smaller than full render + expect(output2.length).toBeLessThan(output1.length / 5); + }); + + // ─── Multiple screens with same writer ──────────────────────────── + + test("rendering different-sized screen triggers full re-render", () => { + using dir = tempDir("tui-writer-resize", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen1 = new Bun.TUIScreen(20, 5); + screen1.setText(0, 0, "Small"); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen1); + + const firstSize = readFileSync(filePath).length; + + // Render a differently-sized screen + const screen2 = new Bun.TUIScreen(40, 10); + screen2.setText(0, 0, "Bigger"); + writer.render(screen2); + + closeSync(fd); + const combined = readFileSync(filePath, "utf8"); + const secondPart = combined.slice(firstSize); + + // Should do a full render since dimensions changed + expect(secondPart).toContain("Bigger"); + expect(secondPart).toContain("\x1b[?25l"); // cursor hide (full render) + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── Styled blank cells (background color) ──────────────────────── + + test("styled blank cells emit spaces to carry background", () => { + const output = renderToString(10, 1, screen => { + const bg = screen.style({ bg: 0xff0000 }); + screen.fill(0, 0, 5, 1, " ", bg); + screen.setText(5, 0, "X"); + }); + + // Should contain the bg color SGR before the spaces + expect(output).toContain("\x1b[48;2;255;0;0m"); + // Should contain X + expect(output).toContain("X"); + }); + + // ─── Cursor position control ─────────────────────────────────── + + test("render with cursorVisible: false hides cursor", () => { + const output = renderToString( + 10, + 3, + screen => { + screen.setText(0, 0, "Hi"); + }, + { cursorVisible: false }, + ); + + // Should contain cursor hide but not the show at the end + expect(output).toContain("\x1b[?25l"); + // The last cursor visibility should be hide + const lastHide = output.lastIndexOf("\x1b[?25l"); + const lastShow = output.lastIndexOf("\x1b[?25h"); + expect(lastHide).toBeGreaterThan(lastShow); + }); + + test("render with cursorVisible: true shows cursor", () => { + const output = renderToString( + 10, + 3, + screen => { + screen.setText(0, 0, "Hi"); + }, + { cursorVisible: true }, + ); + + expect(output).toContain("\x1b[?25h"); + }); + + test("render with cursor position emits movement", () => { + const output = renderToString( + 20, + 5, + screen => { + screen.setText(0, 0, "Hello"); + }, + { cursorX: 5, cursorY: 0 }, + ); + + // Output should contain the content + expect(output).toContain("Hello"); + }); + + // ─── Relative cursor movement ────────────────────────────────── + + test("uses relative cursor movement (no CUP sequences)", () => { + const output = renderToString(10, 3, screen => { + screen.setText(0, 0, "R1"); + screen.setText(0, 1, "R2"); + screen.setText(0, 2, "R3"); + }); + + // Should NOT contain absolute CUP (e.g., \x1b[1;1H) + // for the initial render (no prior render) + const cupPattern = /\x1b\[\d+;\d+H/; + expect(cupPattern.test(output)).toBe(false); + + // Should use CR+LF between rows + expect(output).toContain("\r\n"); + }); + + // ─── Hyperlink OSC 8 ─────────────────────────────────────────── + + test("render emits OSC 8 for hyperlinked cells", () => { + const output = renderToString(20, 1, screen => { + const id = screen.hyperlink("https://example.com"); + screen.setText(0, 0, "Click"); + for (let i = 0; i < 5; i++) screen.setHyperlink(i, 0, id); + }); + + // Should contain OSC 8 open with URL + expect(output).toContain("\x1b]8;;https://example.com\x1b\\"); + // Should contain OSC 8 close + expect(output).toContain("\x1b]8;;\x1b\\"); + // Should contain the text + expect(output).toContain("Click"); + }); + + // ─── close() / end() ────────────────────────────────────────────── + + test("close() prevents further render calls", () => { + using dir = tempDir("tui-writer-close", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen); + writer.close(); + expect(() => writer.render(screen)).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("end() is an alias for close()", () => { + using dir = tempDir("tui-writer-end", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen); + writer.end(); + expect(() => writer.render(screen)).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("close() is idempotent", () => { + using dir = tempDir("tui-writer-close2", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.close(); + writer.close(); // should not throw + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── Cursor Style (DECSCUSR) ────────────────────────────────────── + + test("render with cursorStyle: block emits DECSCUSR", () => { + const output = renderToString( + 10, + 3, + screen => { + screen.setText(0, 0, "Hi"); + }, + { cursorStyle: "block" }, + ); + // Block steady = \x1b[2 q + expect(output).toContain("\x1b[2 q"); + }); + + test("render with cursorStyle: line emits DECSCUSR", () => { + const output = renderToString( + 10, + 3, + screen => { + screen.setText(0, 0, "Hi"); + }, + { cursorStyle: "line" }, + ); + // Line steady = \x1b[6 q + expect(output).toContain("\x1b[6 q"); + }); + + test("render with cursorStyle: underline emits DECSCUSR", () => { + const output = renderToString( + 10, + 3, + screen => { + screen.setText(0, 0, "Hi"); + }, + { cursorStyle: "underline" }, + ); + // Underline steady = \x1b[4 q + expect(output).toContain("\x1b[4 q"); + }); + + test("render with cursorBlinking: true and cursorStyle: block", () => { + const output = renderToString( + 10, + 3, + screen => { + screen.setText(0, 0, "Hi"); + }, + { cursorStyle: "block", cursorBlinking: true }, + ); + // Block blinking = \x1b[1 q + expect(output).toContain("\x1b[1 q"); + }); + + test("render with cursorStyle: default resets DECSCUSR", () => { + // First render sets a cursor style + using dir = tempDir("tui-writer-cursor-default", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(10, 3); + screen.setText(0, 0, "Hi"); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen, { cursorStyle: "block" }); + const firstSize = readFileSync(filePath).length; + + // Second render with "default" should emit \x1b[0 q + writer.render(screen, { cursorStyle: "default" }); + closeSync(fd); + const combined = readFileSync(filePath, "utf8"); + const secondPart = combined.slice(firstSize); + expect(secondPart).toContain("\x1b[0 q"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("same cursor style on consecutive renders does NOT re-emit DECSCUSR", () => { + using dir = tempDir("tui-writer-cursor-same", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const screen = new Bun.TUIScreen(10, 3); + screen.setText(0, 0, "Hi"); + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.render(screen, { cursorStyle: "block" }); + const firstSize = readFileSync(filePath).length; + + // Second render with same style — should NOT contain DECSCUSR + writer.render(screen, { cursorStyle: "block" }); + closeSync(fd); + const combined = readFileSync(filePath, "utf8"); + const secondPart = combined.slice(firstSize); + expect(secondPart).not.toContain(" q"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── Alternate Screen ────────────────────────────────────────────── + + test("enterAltScreen emits \\x1b[?1049h", () => { + using dir = tempDir("tui-writer-altscreen", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enterAltScreen(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?1049h"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("exitAltScreen emits \\x1b[?1049l", () => { + using dir = tempDir("tui-writer-altscreen-exit", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enterAltScreen(); + writer.exitAltScreen(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?1049h"); + expect(output).toContain("\x1b[?1049l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("close() auto-exits alt screen", () => { + using dir = tempDir("tui-writer-altscreen-autoclose", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enterAltScreen(); + writer.close(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + // Should have both enter and exit + expect(output).toContain("\x1b[?1049h"); + expect(output).toContain("\x1b[?1049l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("enterAltScreen is idempotent", () => { + using dir = tempDir("tui-writer-altscreen-idem", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enterAltScreen(); + writer.enterAltScreen(); // second call should be no-op + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + // Should only have one enter sequence + const matches = output.split("\x1b[?1049h").length - 1; + expect(matches).toBe(1); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("enterAltScreen throws on closed writer", () => { + using dir = tempDir("tui-writer-altscreen-err", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.close(); + expect(() => writer.enterAltScreen()).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── onresize ────────────────────────────────────────────────────── + + test("onresize property exists", () => { + using dir = tempDir("tui-writer-onresize", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + // onresize is a setter/getter — users should use process.on("SIGWINCH") instead + expect("onresize" in writer).toBe(true); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + // ─── Mouse Tracking ───────────────────────────────────────────── + + describe("enableMouseTracking / disableMouseTracking", () => { + test("enableMouseTracking emits tracking escape sequences", () => { + using dir = tempDir("tui-writer-mouse-en", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableMouseTracking(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?1000h"); + expect(output).toContain("\x1b[?1002h"); + expect(output).toContain("\x1b[?1003h"); + expect(output).toContain("\x1b[?1006h"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("disableMouseTracking emits disable sequences", () => { + using dir = tempDir("tui-writer-mouse-dis", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableMouseTracking(); + writer.disableMouseTracking(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?1000l"); + expect(output).toContain("\x1b[?1002l"); + expect(output).toContain("\x1b[?1003l"); + expect(output).toContain("\x1b[?1006l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("enableMouseTracking is idempotent", () => { + using dir = tempDir("tui-writer-mouse-idem", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableMouseTracking(); + writer.enableMouseTracking(); // second call should be no-op + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + // Should only have one set of enable sequences + const matches = output.split("\x1b[?1000h").length - 1; + expect(matches).toBe(1); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("disableMouseTracking is no-op when not enabled", () => { + using dir = tempDir("tui-writer-mouse-noop", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.disableMouseTracking(); // not enabled — should be no-op + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).not.toContain("\x1b[?1000l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("enableMouseTracking throws on closed writer", () => { + using dir = tempDir("tui-writer-mouse-closed", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.close(); + expect(() => writer.enableMouseTracking()).toThrow(); + expect(() => writer.disableMouseTracking()).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("close() auto-disables mouse tracking", () => { + using dir = tempDir("tui-writer-mouse-autoclose", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableMouseTracking(); + writer.close(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + // Should have both enable and disable sequences + expect(output).toContain("\x1b[?1000h"); + expect(output).toContain("\x1b[?1000l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + }); + + // ─── Focus Tracking ───────────────────────────────────────────── + + describe("enableFocusTracking / disableFocusTracking", () => { + test("enableFocusTracking emits CSI ?1004h", () => { + using dir = tempDir("tui-writer-focus-en", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableFocusTracking(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?1004h"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("disableFocusTracking emits CSI ?1004l", () => { + using dir = tempDir("tui-writer-focus-dis", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableFocusTracking(); + writer.disableFocusTracking(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?1004h"); + expect(output).toContain("\x1b[?1004l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("enableFocusTracking is idempotent", () => { + using dir = tempDir("tui-writer-focus-idem", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableFocusTracking(); + writer.enableFocusTracking(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + const matches = output.split("\x1b[?1004h").length - 1; + expect(matches).toBe(1); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("disableFocusTracking is no-op when not enabled", () => { + using dir = tempDir("tui-writer-focus-noop", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.disableFocusTracking(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).not.toContain("\x1b[?1004l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("enableFocusTracking throws on closed writer", () => { + using dir = tempDir("tui-writer-focus-closed", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.close(); + expect(() => writer.enableFocusTracking()).toThrow(); + expect(() => writer.disableFocusTracking()).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("close() auto-disables focus tracking", () => { + using dir = tempDir("tui-writer-focus-autoclose", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableFocusTracking(); + writer.close(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?1004h"); + expect(output).toContain("\x1b[?1004l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + }); + + // ─── Bracketed Paste ──────────────────────────────────────────── + + describe("enableBracketedPaste / disableBracketedPaste", () => { + test("enableBracketedPaste emits CSI ?2004h", () => { + using dir = tempDir("tui-writer-paste-en", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableBracketedPaste(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?2004h"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("disableBracketedPaste emits CSI ?2004l", () => { + using dir = tempDir("tui-writer-paste-dis", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableBracketedPaste(); + writer.disableBracketedPaste(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[?2004h"); + expect(output).toContain("\x1b[?2004l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("enableBracketedPaste throws on closed writer", () => { + using dir = tempDir("tui-writer-paste-closed", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.close(); + expect(() => writer.enableBracketedPaste()).toThrow(); + expect(() => writer.disableBracketedPaste()).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + }); + + // ─── write() ──────────────────────────────────────────────────── + + describe("write", () => { + test("write sends raw string to output", () => { + using dir = tempDir("tui-writer-write-raw", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.write("Hello, raw!"); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("Hello, raw!"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("write sends escape sequences", () => { + using dir = tempDir("tui-writer-write-esc", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.write("\x1b[31mRed Text\x1b[0m"); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + expect(output).toContain("\x1b[31m"); + expect(output).toContain("Red Text"); + expect(output).toContain("\x1b[0m"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("write throws on closed writer", () => { + using dir = tempDir("tui-writer-write-closed", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.close(); + expect(() => writer.write("test")).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("write throws with non-string argument", () => { + using dir = tempDir("tui-writer-write-err", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + expect(() => (writer as any).write()).toThrow(); + expect(() => (writer as any).write(42)).toThrow(); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("write with empty string is no-op", () => { + using dir = tempDir("tui-writer-write-empty", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.write(""); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + // Should be empty or minimal + expect(output.length).toBeLessThanOrEqual(0); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + }); + + // ─── onresize callback ───────────────────────────────────────── + + describe("onresize callback", () => { + test("onresize setter and getter work", () => { + using dir = tempDir("tui-writer-onresize-sg", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + const cb = (_cols: number, _rows: number) => {}; + writer.onresize = cb; + expect(writer.onresize).toBe(cb); + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + + test("onresize can be set to null/undefined to clear", () => { + using dir = tempDir("tui-writer-onresize-clear", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.onresize = () => {}; + expect(writer.onresize).toBeDefined(); + writer.onresize = undefined as any; + closeSync(fd); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + }); + + // ─── Combined tracking with close ────────────────────────────── + + describe("combined tracking auto-disable on close", () => { + test("close auto-disables all active tracking modes", () => { + using dir = tempDir("tui-writer-all-track", {}); + const filePath = join(String(dir), "output.bin"); + const fd = openSync(filePath, "w"); + try { + const writer = new Bun.TUITerminalWriter(Bun.file(fd)); + writer.enableMouseTracking(); + writer.enableFocusTracking(); + writer.enterAltScreen(); + writer.close(); + closeSync(fd); + const output = readFileSync(filePath, "utf8"); + // Mouse tracking disabled + expect(output).toContain("\x1b[?1000l"); + // Focus tracking disabled + expect(output).toContain("\x1b[?1004l"); + // Alt screen exited + expect(output).toContain("\x1b[?1049l"); + } catch (e) { + try { + closeSync(fd); + } catch {} + throw e; + } + }); + }); +}); + +describe("Bun.TUIBufferWriter", () => { + /** Helper: render to an ArrayBuffer and return the ANSI output string + byte count. */ + function renderToArrayBuffer( + cols: number, + rows: number, + setup: (screen: InstanceType) => void, + renderOpts?: { cursorX?: number; cursorY?: number; cursorVisible?: boolean }, + bufSize = 65536, + ): { output: string; byteCount: number } { + const buf = new ArrayBuffer(bufSize); + const screen = new Bun.TUIScreen(cols, rows); + setup(screen); + const writer = new Bun.TUIBufferWriter(buf); + const byteCount = writer.render(screen, renderOpts); + const output = new TextDecoder().decode(new Uint8Array(buf, 0, byteCount)); + return { output, byteCount }; + } + + // ─── Constructor ────────────────────────────────────────────────── + + test("constructor accepts ArrayBuffer", () => { + const buf = new ArrayBuffer(1024); + const writer = new Bun.TUIBufferWriter(buf); + expect(writer).toBeDefined(); + }); + + test("constructor accepts Uint8Array", () => { + const buf = new Uint8Array(1024); + const writer = new Bun.TUIBufferWriter(buf); + expect(writer).toBeDefined(); + }); + + test("constructor rejects non-buffer arguments", () => { + expect(() => new (Bun.TUIBufferWriter as any)()).toThrow(); + expect(() => new (Bun.TUIBufferWriter as any)(42)).toThrow(); + expect(() => new (Bun.TUIBufferWriter as any)("string")).toThrow(); + }); + + // ─── Render ────────────────────────────────────────────────────── + + test("render returns byte count", () => { + const { byteCount } = renderToArrayBuffer(10, 3, screen => { + screen.setText(0, 0, "Hello"); + }); + expect(byteCount).toBeGreaterThan(0); + }); + + test("render produces same ANSI as terminal writer mode", () => { + const setup = (screen: InstanceType) => { + screen.setText(0, 0, "Hello"); + screen.setText(0, 1, "World"); + }; + + const fdOutput = renderToString(10, 3, setup); + const { output: bufOutput } = renderToArrayBuffer(10, 3, setup); + + expect(bufOutput).toBe(fdOutput); + }); + + test("render with styled text matches terminal writer mode", () => { + const setup = (screen: InstanceType) => { + const bold = screen.style({ bold: true }); + screen.setText(0, 0, "Bold", bold); + }; + + const fdOutput = renderToString(20, 3, setup); + const { output: bufOutput } = renderToArrayBuffer(20, 3, setup); + + expect(bufOutput).toBe(fdOutput); + }); + + test("render with cursor options", () => { + const { output } = renderToArrayBuffer( + 10, + 3, + screen => { + screen.setText(0, 0, "X"); + }, + { cursorX: 5, cursorY: 1, cursorVisible: false }, + ); + expect(output).toContain("\x1b[?25l"); // cursor hidden + }); + + test("render truncates when buffer is too small", () => { + const { byteCount } = renderToArrayBuffer( + 80, + 24, + screen => { + for (let y = 0; y < 24; y++) { + screen.setText(0, y, Buffer.alloc(80, "X").toString()); + } + }, + undefined, + 32, // very small buffer + ); + expect(byteCount).toBe(32); + }); + + test("diff rendering works across multiple renders", () => { + const buf = new ArrayBuffer(65536); + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUIBufferWriter(buf); + + // First render — full + screen.setText(0, 0, "Hello"); + const n1 = writer.render(screen); + const out1 = new TextDecoder().decode(new Uint8Array(buf, 0, n1)); + expect(out1).toContain("Hello"); + + // Second render — diff (only changed cells) + screen.setText(0, 0, "ABCDE"); + const n2 = writer.render(screen); + const out2 = new TextDecoder().decode(new Uint8Array(buf, 0, n2)); + expect(out2).toContain("ABCDE"); + // Diff output should be shorter than full render + expect(n2).toBeLessThan(n1); + }); + + test("clear resets diff state", () => { + const buf = new ArrayBuffer(65536); + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUIBufferWriter(buf); + + screen.setText(0, 0, "Hello"); + const n1 = writer.render(screen); + + writer.clear(); + + // After clear, next render should be a full render again + const n2 = writer.render(screen); + // Full render should be the same size as the first + expect(n2).toBe(n1); + }); + + // ─── byteOffset / byteLength ────────────────────────────────────── + + test("byteOffset and byteLength are set after render", () => { + const buf = new ArrayBuffer(65536); + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUIBufferWriter(buf); + + screen.setText(0, 0, "Hello"); + const n = writer.render(screen); + + expect(writer.byteOffset).toBe(n); + expect(writer.byteLength).toBe(n); + expect(writer.byteOffset).toBeGreaterThan(0); + }); + + test("byteLength exceeds byteOffset when buffer is too small", () => { + const buf = new ArrayBuffer(32); // very small + const screen = new Bun.TUIScreen(80, 24); + const writer = new Bun.TUIBufferWriter(buf); + + for (let y = 0; y < 24; y++) { + screen.setText(0, y, Buffer.alloc(80, "X").toString()); + } + writer.render(screen); + + expect(writer.byteOffset).toBe(32); // capped at buffer size + expect(writer.byteLength).toBeGreaterThan(32); // total rendered > buffer + }); + + test("byteOffset and byteLength reset after clear", () => { + const buf = new ArrayBuffer(65536); + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUIBufferWriter(buf); + + screen.setText(0, 0, "Hello"); + writer.render(screen); + expect(writer.byteOffset).toBeGreaterThan(0); + + writer.clear(); + expect(writer.byteOffset).toBe(0); + expect(writer.byteLength).toBe(0); + }); + + // ─── close() / end() ────────────────────────────────────────────── + + test("close() prevents further render calls", () => { + const buf = new ArrayBuffer(65536); + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUIBufferWriter(buf); + + writer.render(screen); + writer.close(); + expect(() => writer.render(screen)).toThrow(); + }); + + test("end() is an alias for close()", () => { + const buf = new ArrayBuffer(65536); + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUIBufferWriter(buf); + + writer.render(screen); + writer.end(); + expect(() => writer.render(screen)).toThrow(); + }); + + test("close() is idempotent", () => { + const buf = new ArrayBuffer(65536); + const writer = new Bun.TUIBufferWriter(buf); + + writer.close(); + writer.close(); // should not throw + }); + + test("close() resets byteOffset and byteLength", () => { + const buf = new ArrayBuffer(65536); + const screen = new Bun.TUIScreen(10, 3); + const writer = new Bun.TUIBufferWriter(buf); + + screen.setText(0, 0, "Hello"); + writer.render(screen); + expect(writer.byteOffset).toBeGreaterThan(0); + + writer.close(); + expect(writer.byteOffset).toBe(0); + expect(writer.byteLength).toBe(0); + }); +});