mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
TUI
This commit is contained in:
23
build.zig
23
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" },
|
||||
|
||||
12
bun.lock
12
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
8
cmake/targets/CloneGhostty.cmake
Normal file
8
cmake/targets/CloneGhostty.cmake
Normal file
@@ -0,0 +1,8 @@
|
||||
register_repository(
|
||||
NAME
|
||||
ghostty
|
||||
REPOSITORY
|
||||
ghostty-org/ghostty
|
||||
COMMIT
|
||||
1b7a15899ad40fba4ce020f537055d30eaf99ee8
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -32,5 +32,6 @@
|
||||
"bun",
|
||||
"bun.js",
|
||||
"types"
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "1.3.8"
|
||||
}
|
||||
@@ -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 "<unknown>" });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
44
src/bun.js/api/tui/TUIBufferWriter.classes.ts
Normal file
44
src/bun.js/api/tui/TUIBufferWriter.classes.ts
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
38
src/bun.js/api/tui/TUIKeyReader.classes.ts
Normal file
38
src/bun.js/api/tui/TUIKeyReader.classes.ts
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
73
src/bun.js/api/tui/TUIScreen.classes.ts
Normal file
73
src/bun.js/api/tui/TUIScreen.classes.ts
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
82
src/bun.js/api/tui/TUITerminalWriter.classes.ts
Normal file
82
src/bun.js/api/tui/TUITerminalWriter.classes.ts
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
177
src/bun.js/api/tui/buffer_writer.zig
Normal file
177
src/bun.js/api/tui/buffer_writer.zig
Normal file
@@ -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;
|
||||
631
src/bun.js/api/tui/key_reader.zig
Normal file
631
src/bun.js/api/tui/key_reader.zig
Normal file
@@ -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;
|
||||
407
src/bun.js/api/tui/renderer.zig
Normal file
407
src/bun.js/api/tui/renderer.zig
Normal file
@@ -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");
|
||||
794
src/bun.js/api/tui/screen.zig
Normal file
794
src/bun.js/api/tui/screen.zig
Normal file
@@ -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;
|
||||
543
src/bun.js/api/tui/terminal_writer.zig
Normal file
543
src/bun.js/api/tui/terminal_writer.zig
Normal file
@@ -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;
|
||||
5
src/bun.js/api/tui/tui.zig
Normal file
5
src/bun.js/api/tui/tui.zig
Normal file
@@ -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");
|
||||
@@ -24,6 +24,10 @@
|
||||
macro(TOML) \
|
||||
macro(YAML) \
|
||||
macro(Terminal) \
|
||||
macro(TUIScreen) \
|
||||
macro(TUITerminalWriter) \
|
||||
macro(TUIBufferWriter) \
|
||||
macro(TUIKeyReader) \
|
||||
macro(Transpiler) \
|
||||
macro(ValkeyClient) \
|
||||
macro(argv) \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
54
src/sys.zig
54
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");
|
||||
|
||||
286
test/js/bun/tui/bench.test.ts
Normal file
286
test/js/bun/tui/bench.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
605
test/js/bun/tui/e2e.test.ts
Normal file
605
test/js/bun/tui/e2e.test.ts
Normal file
@@ -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<typeof Bun.TUIScreen>) => 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<void> {
|
||||
await new Promise<void>(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<typeof Bun.TUIScreen>) => void,
|
||||
setup2: (screen: InstanceType<typeof Bun.TUIScreen>) => void,
|
||||
): Promise<Terminal> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
1039
test/js/bun/tui/key-reader.test.ts
Normal file
1039
test/js/bun/tui/key-reader.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1027
test/js/bun/tui/screen.test.ts
Normal file
1027
test/js/bun/tui/screen.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1512
test/js/bun/tui/writer.test.ts
Normal file
1512
test/js/bun/tui/writer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user