mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 06:12:08 +00:00
Compare commits
9 Commits
jarred/bar
...
jarred/tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1abb82719 | ||
|
|
90509ea61c | ||
|
|
39d14bc0c4 | ||
|
|
825fba7377 | ||
|
|
39702d2580 | ||
|
|
6713c836bd | ||
|
|
635034ee33 | ||
|
|
3e792d0d2e | ||
|
|
b7d505b6c1 |
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",
|
||||
|
||||
@@ -69,6 +69,7 @@ if(ENABLE_TINYCC)
|
||||
endif()
|
||||
|
||||
include(CloneZstd)
|
||||
include(CloneGhostty)
|
||||
|
||||
# --- Codegen ---
|
||||
|
||||
@@ -737,6 +738,7 @@ register_command(
|
||||
TARGETS
|
||||
clone-zig
|
||||
clone-zstd
|
||||
clone-ghostty
|
||||
bun-cppbind
|
||||
SOURCES
|
||||
${BUN_ZIG_SOURCES}
|
||||
|
||||
@@ -13,11 +13,11 @@ register_cmake_command(
|
||||
TARGETS
|
||||
c-ares
|
||||
ARGS
|
||||
-DCARES_STATIC=ON
|
||||
-DCARES_STATIC_PIC=ON # FORCE_PIC was set to 1, but CARES_STATIC_PIC was set to OFF??
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
|
||||
-DCARES_SHARED=OFF
|
||||
-DCARES_BUILD_TOOLS=OFF # this was set to ON?
|
||||
-DCARES_STATIC:BOOL=ON
|
||||
-DCARES_STATIC_PIC:BOOL=ON
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE:BOOL=ON
|
||||
-DCARES_SHARED:BOOL=OFF
|
||||
-DCARES_BUILD_TOOLS:BOOL=OFF
|
||||
-DCMAKE_INSTALL_LIBDIR=lib
|
||||
LIB_PATH
|
||||
lib
|
||||
|
||||
@@ -13,34 +13,34 @@ register_cmake_command(
|
||||
TARGETS
|
||||
archive_static
|
||||
ARGS
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
|
||||
-DBUILD_SHARED_LIBS=OFF
|
||||
-DENABLE_INSTALL=OFF
|
||||
-DENABLE_TEST=OFF
|
||||
-DENABLE_WERROR=OFF
|
||||
-DENABLE_BZip2=OFF
|
||||
-DENABLE_CAT=OFF
|
||||
-DENABLE_CPIO=OFF
|
||||
-DENABLE_UNZIP=OFF
|
||||
-DENABLE_EXPAT=OFF
|
||||
-DENABLE_ICONV=OFF
|
||||
-DENABLE_LIBB2=OFF
|
||||
-DENABLE_LibGCC=OFF
|
||||
-DENABLE_LIBXML2=OFF
|
||||
-DENABLE_WIN32_XMLLITE=OFF
|
||||
-DENABLE_LZ4=OFF
|
||||
-DENABLE_LZMA=OFF
|
||||
-DENABLE_LZO=OFF
|
||||
-DENABLE_MBEDTLS=OFF
|
||||
-DENABLE_NETTLE=OFF
|
||||
-DENABLE_OPENSSL=OFF
|
||||
-DENABLE_PCRE2POSIX=OFF
|
||||
-DENABLE_PCREPOSIX=OFF
|
||||
-DENABLE_ZSTD=OFF
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE:BOOL=ON
|
||||
-DBUILD_SHARED_LIBS:BOOL=OFF
|
||||
-DENABLE_INSTALL:BOOL=OFF
|
||||
-DENABLE_TEST:BOOL=OFF
|
||||
-DENABLE_WERROR:BOOL=OFF
|
||||
-DENABLE_BZip2:BOOL=OFF
|
||||
-DENABLE_CAT:BOOL=OFF
|
||||
-DENABLE_CPIO:BOOL=OFF
|
||||
-DENABLE_UNZIP:BOOL=OFF
|
||||
-DENABLE_EXPAT:BOOL=OFF
|
||||
-DENABLE_ICONV:BOOL=OFF
|
||||
-DENABLE_LIBB2:BOOL=OFF
|
||||
-DENABLE_LibGCC:BOOL=OFF
|
||||
-DENABLE_LIBXML2:BOOL=OFF
|
||||
-DENABLE_WIN32_XMLLITE:BOOL=OFF
|
||||
-DENABLE_LZ4:BOOL=OFF
|
||||
-DENABLE_LZMA:BOOL=OFF
|
||||
-DENABLE_LZO:BOOL=OFF
|
||||
-DENABLE_MBEDTLS:BOOL=OFF
|
||||
-DENABLE_NETTLE:BOOL=OFF
|
||||
-DENABLE_OPENSSL:BOOL=OFF
|
||||
-DENABLE_PCRE2POSIX:BOOL=OFF
|
||||
-DENABLE_PCREPOSIX:BOOL=OFF
|
||||
-DENABLE_ZSTD:BOOL=OFF
|
||||
# libarchive depends on zlib headers, otherwise it will
|
||||
# spawn a processes to compress instead of using the library.
|
||||
-DENABLE_ZLIB=OFF
|
||||
-DHAVE_ZLIB_H=ON
|
||||
-DENABLE_ZLIB:BOOL=OFF
|
||||
-DHAVE_ZLIB_H:BOOL=ON
|
||||
-DCMAKE_C_FLAGS="-I${VENDOR_PATH}/zlib"
|
||||
LIB_PATH
|
||||
libarchive
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
4
patches/ghostty/add-ghostty-terminal.patch
Normal file
4
patches/ghostty/add-ghostty-terminal.patch
Normal file
@@ -0,0 +1,4 @@
|
||||
--- /dev/null
|
||||
+++ b/src/ghostty_terminal.zig
|
||||
@@ -0,0 +1 @@
|
||||
+pub const terminal = @import("terminal/main.zig");
|
||||
11
patches/zlib/fix-macos-target-os-mac.patch
Normal file
11
patches/zlib/fix-macos-target-os-mac.patch
Normal file
@@ -0,0 +1,11 @@
|
||||
--- zutil.h
|
||||
+++ zutil.h
|
||||
@@ -127,7 +127,7 @@
|
||||
# endif
|
||||
#endif
|
||||
|
||||
-#if defined(MACOS) || defined(TARGET_OS_MAC)
|
||||
+#if defined(MACOS) || (defined(TARGET_OS_MAC) && !defined(__APPLE__))
|
||||
# define OS_CODE 7
|
||||
# ifndef Z_SOLO
|
||||
# if defined(__MWERKS__) && __dest_os != __be_os && __dest_os != __win32_os
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
77
src/bun.js/api/tui/TUIScreen.classes.ts
Normal file
77
src/bun.js/api/tui/TUIScreen.classes.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
},
|
||||
setAnsiText: {
|
||||
fn: "setAnsiText",
|
||||
length: 3,
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
190
src/bun.js/api/tui/buffer_writer.zig
Normal file
190
src/bun.js/api/tui/buffer_writer.zig
Normal file
@@ -0,0 +1,190 @@
|
||||
//! 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();
|
||||
|
||||
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;
|
||||
var use_inline = false;
|
||||
var viewport_h: u16 = 0;
|
||||
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();
|
||||
}
|
||||
if (try opts.getTruthy(globalThis, "inline")) |v| {
|
||||
if (v.isBoolean()) use_inline = v.asBoolean();
|
||||
}
|
||||
if (try opts.getTruthy(globalThis, "viewportHeight")) |v| {
|
||||
if (v.isNumber()) viewport_h = @intCast(@max(1, @min((try v.coerce(i32, globalThis)), 4096)));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
if (use_inline and viewport_h > 0) {
|
||||
this.renderer.renderInline(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
|
||||
} else {
|
||||
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 TuiScreen = @import("./screen.zig");
|
||||
const std = @import("std");
|
||||
|
||||
const TuiRenderer = @import("./renderer.zig");
|
||||
const CursorStyle = TuiRenderer.CursorStyle;
|
||||
|
||||
const bun = @import("bun");
|
||||
const jsc = bun.jsc;
|
||||
|
||||
const ghostty = @import("ghostty").terminal;
|
||||
const size = ghostty.size;
|
||||
724
src/bun.js/api/tui/key_reader.zig
Normal file
724
src/bun.js/api/tui/key_reader.zig
Normal file
@@ -0,0 +1,724 @@
|
||||
//! 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 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,
|
||||
/// Mode sequences enabled via constructor options.
|
||||
bracketed_paste: bool = false,
|
||||
focus_events: bool = false,
|
||||
kitty_keyboard: bool = false,
|
||||
_padding: u6 = 0,
|
||||
};
|
||||
|
||||
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*TuiKeyReader {
|
||||
if (comptime bun.Environment.isWindows) {
|
||||
return globalThis.throw("TUIKeyReader is not supported on Windows", .{});
|
||||
}
|
||||
|
||||
const arguments = callframe.arguments();
|
||||
|
||||
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", .{});
|
||||
}
|
||||
|
||||
// Parse optional constructor options.
|
||||
var want_bracketed_paste = false;
|
||||
var want_focus_events = false;
|
||||
var want_kitty_keyboard = false;
|
||||
if (arguments.len > 0 and arguments[0].isObject()) {
|
||||
const opts = arguments[0];
|
||||
if (try opts.getTruthy(globalThis, "bracketedPaste")) |v| {
|
||||
if (v.isBoolean()) want_bracketed_paste = v.asBoolean();
|
||||
}
|
||||
if (try opts.getTruthy(globalThis, "focusEvents")) |v| {
|
||||
if (v.isBoolean()) want_focus_events = v.asBoolean();
|
||||
}
|
||||
if (try opts.getTruthy(globalThis, "kittyKeyboard")) |v| {
|
||||
if (v.isBoolean()) want_kitty_keyboard = v.asBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
.bracketed_paste = want_bracketed_paste,
|
||||
.focus_events = want_focus_events,
|
||||
.kitty_keyboard = want_kitty_keyboard,
|
||||
},
|
||||
});
|
||||
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.
|
||||
|
||||
// Write mode-enabling sequences to stdout.
|
||||
this.enableModes();
|
||||
|
||||
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) {
|
||||
this.disableModes();
|
||||
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) {
|
||||
this.disableModes();
|
||||
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) {
|
||||
this.disableModes();
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Terminal mode sequences ---
|
||||
|
||||
/// Write mode-enabling escape sequences to stdout for modes requested
|
||||
/// in the constructor options. Written to stdout (fd 1) regardless of
|
||||
/// whether stdin is a TTY, since the user explicitly requested them.
|
||||
fn enableModes(this: *const TuiKeyReader) void {
|
||||
var buf: [64]u8 = undefined;
|
||||
var pos: usize = 0;
|
||||
if (this.flags.bracketed_paste) {
|
||||
const seq = "\x1b[?2004h";
|
||||
@memcpy(buf[pos..][0..seq.len], seq);
|
||||
pos += seq.len;
|
||||
}
|
||||
if (this.flags.focus_events) {
|
||||
const seq = "\x1b[?1004h";
|
||||
@memcpy(buf[pos..][0..seq.len], seq);
|
||||
pos += seq.len;
|
||||
}
|
||||
if (this.flags.kitty_keyboard) {
|
||||
const seq = "\x1b[>1u";
|
||||
@memcpy(buf[pos..][0..seq.len], seq);
|
||||
pos += seq.len;
|
||||
}
|
||||
if (pos > 0) {
|
||||
_ = bun.sys.write(bun.FD.fromNative(1), buf[0..pos]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write mode-disabling escape sequences to stdout. Called from close/deinit.
|
||||
fn disableModes(this: *TuiKeyReader) void {
|
||||
var buf: [64]u8 = undefined;
|
||||
var pos: usize = 0;
|
||||
// Disable in reverse order of enabling.
|
||||
if (this.flags.kitty_keyboard) {
|
||||
const seq = "\x1b[<u";
|
||||
@memcpy(buf[pos..][0..seq.len], seq);
|
||||
pos += seq.len;
|
||||
this.flags.kitty_keyboard = false;
|
||||
}
|
||||
if (this.flags.focus_events) {
|
||||
const seq = "\x1b[?1004l";
|
||||
@memcpy(buf[pos..][0..seq.len], seq);
|
||||
pos += seq.len;
|
||||
this.flags.focus_events = false;
|
||||
}
|
||||
if (this.flags.bracketed_paste) {
|
||||
const seq = "\x1b[?2004l";
|
||||
@memcpy(buf[pos..][0..seq.len], seq);
|
||||
pos += seq.len;
|
||||
this.flags.bracketed_paste = false;
|
||||
}
|
||||
if (pos > 0) {
|
||||
_ = bun.sys.write(bun.FD.fromNative(1), buf[0..pos]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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));
|
||||
event.put(globalThis, bun.String.static("option"), jsc.JSValue.jsBoolean(mod_alt));
|
||||
|
||||
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, 6);
|
||||
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));
|
||||
event.put(globalThis, bun.String.static("option"), 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 std = @import("std");
|
||||
const ghostty = @import("ghostty").terminal;
|
||||
|
||||
const bun = @import("bun");
|
||||
const jsc = bun.jsc;
|
||||
|
||||
const Parser = ghostty.Parser;
|
||||
const Action = Parser.Action;
|
||||
523
src/bun.js/api/tui/renderer.zig
Normal file
523
src/bun.js/api/tui/renderer.zig
Normal file
@@ -0,0 +1,523 @@
|
||||
//! 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();
|
||||
|
||||
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,
|
||||
/// Inline mode state: number of content rows that have scrolled into
|
||||
/// the terminal's scrollback buffer and are unreachable via cursor movement.
|
||||
scrollback_rows: size.CellCountInt = 0,
|
||||
/// The highest content row index that has been reached via LF emission.
|
||||
/// Rows beyond this require LF (which scrolls) rather than CUD (which doesn't).
|
||||
max_row_reached: size.CellCountInt = 0,
|
||||
/// Terminal viewport height, used for inline mode scrollback tracking.
|
||||
viewport_height: u16 = 0,
|
||||
/// True when rendering in inline mode for this frame.
|
||||
inline_mode: bool = false,
|
||||
|
||||
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.renderInner(buf, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, false, 0);
|
||||
}
|
||||
|
||||
pub fn renderInline(
|
||||
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,
|
||||
viewport_height: u16,
|
||||
) void {
|
||||
this.renderInner(buf, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, true, viewport_height);
|
||||
}
|
||||
|
||||
fn renderInner(
|
||||
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,
|
||||
inline_mode: bool,
|
||||
viewport_height: u16,
|
||||
) void {
|
||||
this.buf = buf;
|
||||
this.inline_mode = inline_mode;
|
||||
if (inline_mode and viewport_height > 0) {
|
||||
this.viewport_height = viewport_height;
|
||||
}
|
||||
|
||||
this.emit(BSU);
|
||||
|
||||
var 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);
|
||||
|
||||
// In inline mode, check if any dirty rows are in scrollback (unreachable).
|
||||
// If so, force a full redraw of the visible portion.
|
||||
if (!need_full and inline_mode and this.scrollback_rows > 0) {
|
||||
need_full = this.hasScrollbackChanges(screen);
|
||||
}
|
||||
|
||||
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;
|
||||
this.scrollback_rows = 0;
|
||||
this.max_row_reached = 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);
|
||||
}
|
||||
|
||||
/// Check if any cells in the scrollback region (unreachable rows) have changed.
|
||||
fn hasScrollbackChanges(this: *const TuiRenderer, screen: *const TuiScreen) bool {
|
||||
const prev = &(this.prev_page orelse return true);
|
||||
var y: size.CellCountInt = 0;
|
||||
while (y < this.scrollback_rows and 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) {
|
||||
if (@as(u64, @bitCast(cells[x])) != @as(u64, @bitCast(prev_cells[x]))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Rendering internals ---
|
||||
|
||||
fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void {
|
||||
this.emit("\x1b[?25l");
|
||||
|
||||
// In inline mode, we can only move up to the first visible row.
|
||||
// scrollback_rows tracks how many content rows are unreachable.
|
||||
const start_y: size.CellCountInt = if (this.inline_mode) this.scrollback_rows else 0;
|
||||
|
||||
if (this.has_rendered) {
|
||||
// Move cursor back to the start of our content region.
|
||||
const reachable_top = if (this.inline_mode) start_y else 0;
|
||||
if (this.cursor_y > reachable_top) {
|
||||
this.emitCSI(this.cursor_y - reachable_top, 'A');
|
||||
}
|
||||
this.emit("\r");
|
||||
this.cursor_x = 0;
|
||||
this.cursor_y = reachable_top;
|
||||
}
|
||||
this.current_style_id = 0;
|
||||
|
||||
var first_visible = true;
|
||||
var y: size.CellCountInt = start_y;
|
||||
while (y < screen.page.size.rows) : (y += 1) {
|
||||
if (!first_visible) {
|
||||
// In inline mode, use LF to create new lines (scrolls viewport).
|
||||
// In fullscreen mode, \r\n also works since we don't use alt screen here.
|
||||
this.emit("\r\n");
|
||||
}
|
||||
first_visible = false;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// Clear extra rows from previous render if content shrank.
|
||||
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;
|
||||
|
||||
// Update inline mode scrollback tracking.
|
||||
if (this.inline_mode and this.viewport_height > 0) {
|
||||
if (this.cursor_y >= this.max_row_reached) {
|
||||
this.max_row_reached = this.cursor_y;
|
||||
}
|
||||
// Total content rows that have been pushed through the viewport.
|
||||
// Rows beyond viewport_height are in scrollback.
|
||||
if (this.max_row_reached +| 1 > this.viewport_height) {
|
||||
this.scrollback_rows = (this.max_row_reached +| 1) - this.viewport_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = 4096 }) 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) {
|
||||
if (this.inline_mode) {
|
||||
// In inline mode, use LF for downward movement.
|
||||
// LF scrolls the viewport when at the bottom, CUD does not.
|
||||
var n = y - this.cursor_y;
|
||||
while (n > 0) : (n -= 1) {
|
||||
this.emit("\n");
|
||||
}
|
||||
// After LF, cursor is at column 0. We need to account for this
|
||||
// when positioning X below.
|
||||
this.cursor_x = 0;
|
||||
// Update max_row_reached for scrollback tracking.
|
||||
if (y > this.max_row_reached) {
|
||||
this.max_row_reached = y;
|
||||
if (this.viewport_height > 0 and this.max_row_reached +| 1 > this.viewport_height) {
|
||||
this.scrollback_rows = (this.max_row_reached +| 1) - this.viewport_height;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 TuiScreen = @import("./screen.zig");
|
||||
const bun = @import("bun");
|
||||
const std = @import("std");
|
||||
|
||||
const ghostty = @import("ghostty").terminal;
|
||||
const Cell = ghostty.Cell;
|
||||
const Style = ghostty.Style;
|
||||
const size = ghostty.size;
|
||||
const Page = ghostty.page.Page;
|
||||
1091
src/bun.js/api/tui/screen.zig
Normal file
1091
src/bun.js/api/tui/screen.zig
Normal file
File diff suppressed because it is too large
Load Diff
564
src/bun.js/api/tui/terminal_writer.zig
Normal file
564
src/bun.js/api/tui/terminal_writer.zig
Normal file
@@ -0,0 +1,564 @@
|
||||
//! 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();
|
||||
|
||||
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;
|
||||
var use_inline = false;
|
||||
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();
|
||||
}
|
||||
if (try opts.getTruthy(globalThis, "inline")) |v| {
|
||||
if (v.isBoolean()) use_inline = v.asBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
// Get viewport height for inline mode.
|
||||
const viewport_h: u16 = if (use_inline) blk: {
|
||||
break :blk switch (bun.sys.getWinsize(this.fd)) {
|
||||
.result => |ws| ws.row,
|
||||
.err => 24,
|
||||
};
|
||||
} else 0;
|
||||
|
||||
// Async double-buffered write.
|
||||
if (this.write_pending) {
|
||||
this.next_output.clearRetainingCapacity();
|
||||
if (use_inline) {
|
||||
this.renderer.renderInline(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
|
||||
} else {
|
||||
this.renderer.render(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
|
||||
}
|
||||
} else {
|
||||
this.output.clearRetainingCapacity();
|
||||
if (use_inline) {
|
||||
this.renderer.renderInline(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
|
||||
} else {
|
||||
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 TuiScreen = @import("./screen.zig");
|
||||
const std = @import("std");
|
||||
|
||||
const TuiRenderer = @import("./renderer.zig");
|
||||
const CursorStyle = TuiRenderer.CursorStyle;
|
||||
|
||||
const bun = @import("bun");
|
||||
const jsc = bun.jsc;
|
||||
|
||||
const ghostty = @import("ghostty").terminal;
|
||||
const size = ghostty.size;
|
||||
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;
|
||||
|
||||
@@ -948,6 +948,7 @@ pub const CommandLineReporter = struct {
|
||||
this.printSummary();
|
||||
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ this.jest.bail, if (this.jest.bail == 1) "" else "s" });
|
||||
Output.flush();
|
||||
this.writeJUnitReportIfNeeded();
|
||||
Global.exit(1);
|
||||
}
|
||||
},
|
||||
@@ -970,6 +971,20 @@ pub const CommandLineReporter = struct {
|
||||
Output.printStartEnd(bun.start_time, std.time.nanoTimestamp());
|
||||
}
|
||||
|
||||
/// Writes the JUnit reporter output file if a JUnit reporter is active and
|
||||
/// an outfile path was configured. This must be called before any early exit
|
||||
/// (e.g. bail) so that the report is not lost.
|
||||
pub fn writeJUnitReportIfNeeded(this: *CommandLineReporter) void {
|
||||
if (this.reporters.junit) |junit| {
|
||||
if (this.jest.test_options.reporter_outfile) |outfile| {
|
||||
if (junit.current_file.len > 0) {
|
||||
junit.endTestSuite() catch {};
|
||||
}
|
||||
junit.writeToFile(outfile) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generateCodeCoverage(this: *CommandLineReporter, vm: *jsc.VirtualMachine, opts: *TestCommand.CodeCoverageOptions, comptime reporters: TestCommand.Reporters, comptime enable_ansi_colors: bool) !void {
|
||||
if (comptime !reporters.text and !reporters.lcov) {
|
||||
return;
|
||||
@@ -1772,12 +1787,7 @@ pub const TestCommand = struct {
|
||||
Output.prettyError("\n", .{});
|
||||
Output.flush();
|
||||
|
||||
if (reporter.reporters.junit) |junit| {
|
||||
if (junit.current_file.len > 0) {
|
||||
junit.endTestSuite() catch {};
|
||||
}
|
||||
junit.writeToFile(ctx.test_options.reporter_outfile.?) catch {};
|
||||
}
|
||||
reporter.writeJUnitReportIfNeeded();
|
||||
|
||||
if (vm.hot_reload == .watch) {
|
||||
vm.runWithAPILock(jsc.VirtualMachine, vm, runEventLoopForWatch);
|
||||
@@ -1920,6 +1930,7 @@ pub const TestCommand = struct {
|
||||
if (reporter.jest.bail == reporter.summary().fail) {
|
||||
reporter.printSummary();
|
||||
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ reporter.jest.bail, if (reporter.jest.bail == 1) "" else "s" });
|
||||
reporter.writeJUnitReportIfNeeded();
|
||||
|
||||
vm.exit_handler.exit_code = 1;
|
||||
vm.is_shutting_down = true;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1154,7 +1154,7 @@ pub const Interpreter = struct {
|
||||
_ = callframe; // autofix
|
||||
|
||||
if (this.setupIOBeforeRun().asErr()) |e| {
|
||||
defer this.#deinitFromExec();
|
||||
defer this.#derefRootShellAndIOIfNeeded(true);
|
||||
const shellerr = bun.shell.ShellErr.newSys(e);
|
||||
return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop });
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -4350,6 +4354,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");
|
||||
|
||||
@@ -260,14 +260,35 @@ devTest("hmr handles rapid consecutive edits", {
|
||||
await Bun.sleep(1);
|
||||
}
|
||||
|
||||
// Wait event-driven for "render 10" to appear. Intermediate renders may
|
||||
// be skipped (watcher coalescing) and the final render may fire multiple
|
||||
// times (duplicate reloads), so we just listen for any occurrence.
|
||||
const finalRender = "render 10";
|
||||
while (true) {
|
||||
const message = await client.getStringMessage();
|
||||
if (message === finalRender) break;
|
||||
if (typeof message === "string" && message.includes("HMR_ERROR")) {
|
||||
throw new Error("Unexpected HMR error message: " + message);
|
||||
}
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const check = () => {
|
||||
for (const msg of client.messages) {
|
||||
if (typeof msg === "string" && msg.includes("HMR_ERROR")) {
|
||||
cleanup();
|
||||
reject(new Error("Unexpected HMR error message: " + msg));
|
||||
return;
|
||||
}
|
||||
if (msg === finalRender) {
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
client.off("message", check);
|
||||
};
|
||||
client.on("message", check);
|
||||
// Check messages already buffered.
|
||||
check();
|
||||
});
|
||||
// Drain all buffered messages — intermediate renders and possible
|
||||
// duplicates of the final render are expected and harmless.
|
||||
client.messages.length = 0;
|
||||
|
||||
const hmrErrors = await client.js`return globalThis.__hmrErrors ? [...globalThis.__hmrErrors] : [];`;
|
||||
if (hmrErrors.length > 0) {
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
282
test/js/bun/tui/demos/demo-ai-chat.ts
Normal file
282
test/js/bun/tui/demos/demo-ai-chat.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* demo-ai-chat.ts — Claude Code-style AI Chat (Inline, No Alt Screen)
|
||||
*
|
||||
* An inline chat interface inspired by Claude Code's terminal UI. Shows a
|
||||
* conversation with streaming-style token output, tool use blocks, thinking
|
||||
* indicators, and styled markdown-like formatting — all rendered inline
|
||||
* without alt screen.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-ai-chat.ts
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
|
||||
const W = Math.min(writer.columns || 80, 90);
|
||||
|
||||
// --- Styles (reused across screens) ---
|
||||
function makeStyles(screen: InstanceType<typeof Bun.TUIScreen>) {
|
||||
return {
|
||||
prompt: screen.style({ fg: 0x61afef, bold: true }),
|
||||
userText: screen.style({ fg: 0xffffff, bold: true }),
|
||||
assistantLabel: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
text: screen.style({ fg: 0xdcdcdc }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
code: screen.style({ fg: 0x98c379, bg: 0x1e2127 }),
|
||||
codeBorder: screen.style({ fg: 0x3e4451 }),
|
||||
codeLabel: screen.style({ fg: 0x5c6370, bg: 0x1e2127 }),
|
||||
toolName: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
toolBorder: screen.style({ fg: 0x3e4451 }),
|
||||
toolLabel: screen.style({ fg: 0x5c6370 }),
|
||||
thinking: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
thinkDots: screen.style({ fg: 0xc678dd }),
|
||||
bold: screen.style({ fg: 0xffffff, bold: true }),
|
||||
bullet: screen.style({ fg: 0x61afef }),
|
||||
separator: screen.style({ fg: 0x2c313a }),
|
||||
cost: screen.style({ fg: 0x5c6370 }),
|
||||
duration: screen.style({ fg: 0x98c379 }),
|
||||
fileRef: screen.style({ fg: 0x61afef, underline: "single" }),
|
||||
};
|
||||
}
|
||||
|
||||
function renderBlock(
|
||||
lines: string[],
|
||||
styles: ReturnType<typeof makeStyles>,
|
||||
drawFn: (screen: InstanceType<typeof Bun.TUIScreen>, st: typeof styles) => void,
|
||||
) {
|
||||
const h = lines.length;
|
||||
const screen = new Bun.TUIScreen(W, h);
|
||||
drawFn(screen, makeStyles(screen));
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- Helper: word wrap ---
|
||||
function wrap(text: string, width: number): string[] {
|
||||
const result: string[] = [];
|
||||
for (const para of text.split("\n")) {
|
||||
if (para.length === 0) {
|
||||
result.push("");
|
||||
continue;
|
||||
}
|
||||
const words = para.split(" ");
|
||||
let cur = "";
|
||||
for (const w of words) {
|
||||
if (cur.length === 0) cur = w;
|
||||
else if (cur.length + 1 + w.length <= width) cur += " " + w;
|
||||
else {
|
||||
result.push(cur);
|
||||
cur = w;
|
||||
}
|
||||
}
|
||||
if (cur) result.push(cur);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render the conversation
|
||||
// ============================================================
|
||||
|
||||
// --- 1. User prompt ---
|
||||
{
|
||||
const screen = new Bun.TUIScreen(W, 3);
|
||||
const st = makeStyles(screen);
|
||||
screen.setText(0, 0, "\u276f ", st.prompt);
|
||||
screen.setText(2, 0, "Fix the bug in src/bun.js/api/tui/screen.zig where styles overflow", st.userText);
|
||||
screen.setText(2, 1, " after 256 unique colors and add a style cache to prevent it", st.userText);
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- 2. Thinking indicator ---
|
||||
{
|
||||
const screen = new Bun.TUIScreen(W, 2);
|
||||
const st = makeStyles(screen);
|
||||
screen.setText(0, 0, "\u2728 ", st.assistantLabel);
|
||||
screen.setText(2, 0, "Claude", st.assistantLabel);
|
||||
screen.setText(0, 1, " \u25CF\u25CB\u25CB ", st.thinkDots);
|
||||
screen.setText(6, 1, "Thinking...", st.thinking);
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- 3. Assistant response with markdown-like formatting ---
|
||||
{
|
||||
const responseText = [
|
||||
"I found the issue. The Ghostty `StyleSet` is initialized with a capacity of",
|
||||
"256, but the demo creates 500+ unique styles (216-color cube + gradients).",
|
||||
"When the set is full, `styles.add()` returns an error, but `clearCells()`",
|
||||
"then tries to `release()` styles that were never properly ref-counted,",
|
||||
"hitting an assertion in the `RefCountedSet`.",
|
||||
"",
|
||||
"Here's my plan:",
|
||||
"",
|
||||
" 1. Increase style capacity from 256 to 4096",
|
||||
" 2. Add a StyleCache to deduplicate and prevent ref-count overflow",
|
||||
" 3. Fix clear() to zero cells directly instead of calling clearCells()",
|
||||
" 4. Fix resize() to re-add cached styles to the new page",
|
||||
];
|
||||
|
||||
const screen = new Bun.TUIScreen(W, responseText.length + 1);
|
||||
const st = makeStyles(screen);
|
||||
|
||||
for (let i = 0; i < responseText.length; i++) {
|
||||
const line = responseText[i];
|
||||
if (line.startsWith(" ") && /^\s+\d\./.test(line)) {
|
||||
// Numbered list
|
||||
const numEnd = line.indexOf(".");
|
||||
screen.setText(0, i, line.slice(0, numEnd + 1), st.bullet);
|
||||
screen.setText(numEnd + 1, i, line.slice(numEnd + 1), st.text);
|
||||
} else if (line.includes("`")) {
|
||||
// Inline code
|
||||
let x = 0;
|
||||
let inCode = false;
|
||||
for (const part of line.split("`")) {
|
||||
screen.setText(x, i, part, inCode ? st.code : st.text);
|
||||
x += part.length;
|
||||
inCode = !inCode;
|
||||
}
|
||||
} else {
|
||||
screen.setText(0, i, line, st.text);
|
||||
}
|
||||
}
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- 4. Tool use: Read file ---
|
||||
{
|
||||
const screen = new Bun.TUIScreen(W, 4);
|
||||
const st = makeStyles(screen);
|
||||
screen.setText(0, 0, " \u250C\u2500 ", st.toolBorder);
|
||||
screen.setText(5, 0, "Read", st.toolName);
|
||||
screen.setText(10, 0, "src/bun.js/api/tui/screen.zig", st.fileRef);
|
||||
screen.setText(0, 1, " \u2502", st.toolBorder);
|
||||
screen.setText(4, 1, " 795 lines | Zig", st.toolLabel);
|
||||
screen.setText(
|
||||
0,
|
||||
2,
|
||||
" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
||||
st.toolBorder,
|
||||
);
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- 5. Tool use: Edit file ---
|
||||
{
|
||||
const codeLines = [
|
||||
" const page = Page.init(.{",
|
||||
"- .cols = cols, .rows = rows, .styles = 256",
|
||||
"+ .cols = cols, .rows = rows, .styles = 4096",
|
||||
" }) catch {",
|
||||
];
|
||||
const screen = new Bun.TUIScreen(W, codeLines.length + 3);
|
||||
const st = makeStyles(screen);
|
||||
|
||||
screen.setText(0, 0, " \u250C\u2500 ", st.toolBorder);
|
||||
screen.setText(5, 0, "Edit", st.toolName);
|
||||
screen.setText(10, 0, "src/bun.js/api/tui/screen.zig:118", st.fileRef);
|
||||
for (let i = 0; i < codeLines.length; i++) {
|
||||
const line = codeLines[i];
|
||||
screen.setText(0, i + 1, " \u2502", st.toolBorder);
|
||||
if (line.startsWith("-")) {
|
||||
screen.setText(4, i + 1, line, screen.style({ fg: 0xe06c75, bg: 0x2a1517 }));
|
||||
} else if (line.startsWith("+")) {
|
||||
screen.setText(4, i + 1, line, screen.style({ fg: 0x98c379, bg: 0x152a17 }));
|
||||
} else {
|
||||
screen.setText(4, i + 1, line, st.code);
|
||||
}
|
||||
}
|
||||
screen.setText(
|
||||
0,
|
||||
codeLines.length + 1,
|
||||
" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
||||
st.toolBorder,
|
||||
);
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- 6. Tool use: Run tests ---
|
||||
{
|
||||
const screen = new Bun.TUIScreen(W, 5);
|
||||
const st = makeStyles(screen);
|
||||
screen.setText(0, 0, " \u250C\u2500 ", st.toolBorder);
|
||||
screen.setText(5, 0, "Bash", st.toolName);
|
||||
screen.setText(10, 0, "bun bd test test/js/bun/tui/screen.test.ts", st.codeLabel);
|
||||
screen.setText(0, 1, " \u2502", st.toolBorder);
|
||||
screen.setText(4, 1, " 105 pass", screen.style({ fg: 0x98c379, bold: true }));
|
||||
screen.setText(0, 2, " \u2502", st.toolBorder);
|
||||
screen.setText(4, 2, " 0 fail", screen.style({ fg: 0x98c379 }));
|
||||
screen.setText(0, 3, " \u2502", st.toolBorder);
|
||||
screen.setText(4, 3, " Ran 105 tests across 1 file. [680ms]", st.toolLabel);
|
||||
screen.setText(
|
||||
0,
|
||||
4,
|
||||
" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
||||
st.toolBorder,
|
||||
);
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- 7. Completion summary ---
|
||||
{
|
||||
const summaryLines = [
|
||||
"Fixed the style capacity overflow. Changes:",
|
||||
"",
|
||||
" \u2022 Increased StyleSet capacity: 256 \u2192 4096 (screen.zig, renderer.zig)",
|
||||
" \u2022 Added StyleCache hash map to prevent ref-count overflow on repeated style() calls",
|
||||
" \u2022 clear()/clearRect() now zero cells via @memset instead of clearCells()",
|
||||
" \u2022 resize() re-adds cached styles sorted by ID to preserve stability",
|
||||
"",
|
||||
"All 105 tests pass.",
|
||||
];
|
||||
|
||||
const screen = new Bun.TUIScreen(W, summaryLines.length + 1);
|
||||
const st = makeStyles(screen);
|
||||
for (let i = 0; i < summaryLines.length; i++) {
|
||||
const line = summaryLines[i];
|
||||
if (line.startsWith(" \u2022")) {
|
||||
screen.setText(0, i, " \u2022", st.bullet);
|
||||
// Find the colon to bold the label
|
||||
const colonIdx = line.indexOf(":");
|
||||
if (colonIdx > 4) {
|
||||
screen.setText(4, i, line.slice(4, colonIdx + 1), st.bold);
|
||||
screen.setText(4 + colonIdx - 3, i, line.slice(colonIdx + 1), st.text);
|
||||
} else {
|
||||
screen.setText(4, i, line.slice(4), st.text);
|
||||
}
|
||||
} else {
|
||||
screen.setText(0, i, line, st.text);
|
||||
}
|
||||
}
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// --- 8. Footer with cost/duration ---
|
||||
{
|
||||
const screen = new Bun.TUIScreen(W, 2);
|
||||
const st = makeStyles(screen);
|
||||
const sep = "\u2500".repeat(W);
|
||||
screen.setText(0, 0, sep, st.separator);
|
||||
screen.setText(0, 1, "Cooked for 1m 6s", st.duration);
|
||||
screen.setText(20, 1, "\u2022 3 tool uses", st.cost);
|
||||
screen.setText(36, 1, "\u2022 $0.04", st.cost);
|
||||
screen.setText(46, 1, "\u2022 4.2K tokens in, 1.8K out", st.cost);
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
writer.close();
|
||||
438
test/js/bun/tui/demos/demo-animation.ts
Normal file
438
test/js/bun/tui/demos/demo-animation.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* demo-animation.ts — Smooth Animations & Effects
|
||||
*
|
||||
* Showcases smooth terminal animations: bouncing ball, particle fountain,
|
||||
* wave effect, and a matrix rain effect. Demonstrates the rendering
|
||||
* performance of Bun's TUI diff renderer.
|
||||
*
|
||||
* Demonstrates: high-fps animation loops, mathematical animations, particle
|
||||
* systems, color gradients, setText, fill, style (fg/bg), TUITerminalWriter,
|
||||
* TUIKeyReader, alt screen, resize.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-animation.ts
|
||||
* Controls: 1-4 switch effects, Space pause, Q quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
tabActive: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
|
||||
tabInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
fps: screen.style({ fg: 0x98c379, bold: true }),
|
||||
};
|
||||
|
||||
// --- State ---
|
||||
let activeEffect = 0;
|
||||
let paused = false;
|
||||
let frame = 0;
|
||||
let lastTime = Date.now();
|
||||
let fpsCounter = 0;
|
||||
let displayFps = 0;
|
||||
|
||||
const effectNames = ["Bouncing Balls", "Particles", "Sine Wave", "Matrix Rain"];
|
||||
|
||||
// --- Effect 1: Bouncing Balls ---
|
||||
interface Ball {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
color: number;
|
||||
char: string;
|
||||
}
|
||||
|
||||
const balls: Ball[] = [];
|
||||
const ballColors = [0xff5555, 0x55ff55, 0x5555ff, 0xffff55, 0xff55ff, 0x55ffff, 0xffffff, 0xff8800];
|
||||
const ballChars = ["\u25cf", "\u2b24", "\u25c9", "\u25ce", "\u2022", "\u25cb"];
|
||||
|
||||
function initBalls() {
|
||||
balls.length = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
balls.push({
|
||||
x: Math.random() * (cols - 4) + 2,
|
||||
y: Math.random() * (rows - 6) + 3,
|
||||
vx: (Math.random() - 0.5) * 2,
|
||||
vy: (Math.random() - 0.5) * 1.5,
|
||||
color: ballColors[i % ballColors.length],
|
||||
char: ballChars[i % ballChars.length],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tickBalls() {
|
||||
const minX = 1,
|
||||
maxX = cols - 2,
|
||||
minY = 2,
|
||||
maxY = rows - 2;
|
||||
for (const ball of balls) {
|
||||
ball.x += ball.vx;
|
||||
ball.y += ball.vy;
|
||||
ball.vy += 0.05; // gravity
|
||||
|
||||
if (ball.x <= minX || ball.x >= maxX) {
|
||||
ball.vx *= -0.95;
|
||||
ball.x = Math.max(minX, Math.min(maxX, ball.x));
|
||||
}
|
||||
if (ball.y <= minY || ball.y >= maxY) {
|
||||
ball.vy *= -0.85;
|
||||
ball.y = Math.max(minY, Math.min(maxY, ball.y));
|
||||
if (Math.abs(ball.vy) < 0.3) ball.vy = -(2 + Math.random() * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBalls() {
|
||||
for (const ball of balls) {
|
||||
const ix = Math.round(ball.x);
|
||||
const iy = Math.round(ball.y);
|
||||
if (ix >= 0 && ix < cols && iy >= 2 && iy < rows - 1) {
|
||||
screen.setText(ix, iy, ball.char, screen.style({ fg: ball.color, bold: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Effect 2: Particles ---
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
color: number;
|
||||
}
|
||||
|
||||
const particles: Particle[] = [];
|
||||
|
||||
function spawnParticles(count: number) {
|
||||
const cx = cols / 2;
|
||||
const cy = rows / 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 0.5 + Math.random() * 2;
|
||||
const hue = (frame * 2 + Math.random() * 60) % 360;
|
||||
particles.push({
|
||||
x: cx,
|
||||
y: cy,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed * 0.5 - 0.5,
|
||||
life: 0,
|
||||
maxLife: 20 + Math.floor(Math.random() * 40),
|
||||
color: hslToRgb(hue, 1, 0.6),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tickParticles() {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i];
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.03; // gravity
|
||||
p.life++;
|
||||
if (p.life >= p.maxLife || p.x < 0 || p.x >= cols || p.y < 2 || p.y >= rows - 1) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
if (!paused) spawnParticles(3);
|
||||
}
|
||||
|
||||
function renderParticles() {
|
||||
for (const p of particles) {
|
||||
const ix = Math.round(p.x);
|
||||
const iy = Math.round(p.y);
|
||||
if (ix >= 0 && ix < cols && iy >= 2 && iy < rows - 1) {
|
||||
const fade = 1 - p.life / p.maxLife;
|
||||
const chars = ["\u2588", "\u2593", "\u2592", "\u2591", "\u00b7"];
|
||||
const ci = Math.min(chars.length - 1, Math.floor((1 - fade) * chars.length));
|
||||
const r = (p.color >> 16) & 0xff;
|
||||
const g = (p.color >> 8) & 0xff;
|
||||
const b = p.color & 0xff;
|
||||
const fr = Math.round(r * fade);
|
||||
const fg = Math.round(g * fade);
|
||||
const fb = Math.round(b * fade);
|
||||
screen.setText(ix, iy, chars[ci], screen.style({ fg: (fr << 16) | (fg << 8) | fb }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Effect 3: Sine Wave ---
|
||||
function renderSineWave() {
|
||||
const centerY = Math.floor((rows - 3) / 2) + 2;
|
||||
const amplitude = Math.min((rows - 5) / 2, 8);
|
||||
|
||||
for (let x = 0; x < cols; x++) {
|
||||
// Multiple overlapping waves
|
||||
const t = frame * 0.08;
|
||||
const y1 = Math.sin(x * 0.08 + t) * amplitude;
|
||||
const y2 = Math.sin(x * 0.12 + t * 1.3) * amplitude * 0.6;
|
||||
const y3 = Math.sin(x * 0.05 + t * 0.7) * amplitude * 0.4;
|
||||
|
||||
// Wave 1 (blue)
|
||||
const wy1 = Math.round(centerY + y1);
|
||||
if (wy1 >= 2 && wy1 < rows - 1) {
|
||||
const hue = (x * 3 + frame * 2) % 360;
|
||||
screen.setText(x, wy1, "\u2588", screen.style({ fg: hslToRgb(hue, 0.8, 0.5) }));
|
||||
}
|
||||
|
||||
// Wave 2 (trail)
|
||||
const wy2 = Math.round(centerY + y1 + y2);
|
||||
if (wy2 >= 2 && wy2 < rows - 1) {
|
||||
screen.setText(x, wy2, "\u2593", screen.style({ fg: hslToRgb((x * 3 + frame * 2 + 120) % 360, 0.6, 0.4) }));
|
||||
}
|
||||
|
||||
// Wave 3 (subtle)
|
||||
const wy3 = Math.round(centerY + y3);
|
||||
if (wy3 >= 2 && wy3 < rows - 1) {
|
||||
screen.setText(x, wy3, "\u2591", screen.style({ fg: hslToRgb((x * 3 + frame * 2 + 240) % 360, 0.4, 0.3) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Effect 4: Matrix Rain ---
|
||||
interface RainDrop {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
length: number;
|
||||
chars: number[];
|
||||
}
|
||||
|
||||
const rainDrops: RainDrop[] = [];
|
||||
|
||||
function initRain() {
|
||||
rainDrops.length = 0;
|
||||
for (let x = 0; x < cols; x += 2) {
|
||||
if (Math.random() < 0.4) {
|
||||
rainDrops.push(makeRainDrop(x));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeRainDrop(x: number): RainDrop {
|
||||
const length = 5 + Math.floor(Math.random() * 15);
|
||||
const chars: number[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
chars.push(0x30a0 + Math.floor(Math.random() * 96)); // Katakana
|
||||
}
|
||||
return {
|
||||
x,
|
||||
y: -length - Math.floor(Math.random() * rows),
|
||||
speed: 0.3 + Math.random() * 0.7,
|
||||
length,
|
||||
chars,
|
||||
};
|
||||
}
|
||||
|
||||
function tickRain() {
|
||||
for (let i = rainDrops.length - 1; i >= 0; i--) {
|
||||
const drop = rainDrops[i];
|
||||
drop.y += drop.speed;
|
||||
|
||||
// Randomize chars occasionally
|
||||
if (Math.random() < 0.1) {
|
||||
const ci = Math.floor(Math.random() * drop.chars.length);
|
||||
drop.chars[ci] = 0x30a0 + Math.floor(Math.random() * 96);
|
||||
}
|
||||
|
||||
if (drop.y > rows + drop.length) {
|
||||
rainDrops[i] = makeRainDrop(drop.x);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn new drops
|
||||
if (rainDrops.length < cols / 2 && Math.random() < 0.1) {
|
||||
const x = Math.floor(Math.random() * cols);
|
||||
rainDrops.push(makeRainDrop(x));
|
||||
}
|
||||
}
|
||||
|
||||
function renderRain() {
|
||||
for (const drop of rainDrops) {
|
||||
for (let i = 0; i < drop.length; i++) {
|
||||
const y = Math.floor(drop.y) - i;
|
||||
if (y >= 2 && y < rows - 1) {
|
||||
const brightness = i === 0 ? 1.0 : Math.max(0.1, 1.0 - (i / drop.length) * 0.8);
|
||||
const g = Math.round(255 * brightness);
|
||||
const r = i === 0 ? 200 : 0;
|
||||
const b = i === 0 ? 200 : 0;
|
||||
const color = (r << 16) | (g << 8) | b;
|
||||
const ch = String.fromCodePoint(drop.chars[i % drop.chars.length]);
|
||||
screen.setText(drop.x, y, ch, screen.style({ fg: color }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- HSL to RGB ---
|
||||
function hslToRgb(h: number, s: number, l: number): number {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
} else if (h < 180) {
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
b = x;
|
||||
}
|
||||
return (Math.round((r + m) * 255) << 16) | (Math.round((g + m) * 255) << 8) | Math.round((b + m) * 255);
|
||||
}
|
||||
|
||||
// --- Main render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Animations ", st.titleBar);
|
||||
|
||||
// Tabs
|
||||
let tx = 2;
|
||||
for (let i = 0; i < effectNames.length; i++) {
|
||||
const label = ` ${i + 1}:${effectNames[i]} `;
|
||||
screen.setText(tx, 1, label, i === activeEffect ? st.tabActive : st.tabInactive);
|
||||
tx += label.length + 1;
|
||||
}
|
||||
|
||||
// FPS
|
||||
screen.setText(cols - 10, 1, `${displayFps} fps`, st.fps);
|
||||
|
||||
// Render active effect
|
||||
switch (activeEffect) {
|
||||
case 0:
|
||||
renderBalls();
|
||||
break;
|
||||
case 1:
|
||||
renderParticles();
|
||||
break;
|
||||
case 2:
|
||||
renderSineWave();
|
||||
break;
|
||||
case 3:
|
||||
renderRain();
|
||||
break;
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerText = paused ? " PAUSED | 1-4:Effect | Space:Resume | Q:Quit " : " 1-4:Effect | Space:Pause | Q:Quit ";
|
||||
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "1":
|
||||
activeEffect = 0;
|
||||
initBalls();
|
||||
break;
|
||||
case "2":
|
||||
activeEffect = 1;
|
||||
particles.length = 0;
|
||||
break;
|
||||
case "3":
|
||||
activeEffect = 2;
|
||||
break;
|
||||
case "4":
|
||||
activeEffect = 3;
|
||||
initRain();
|
||||
break;
|
||||
case " ":
|
||||
paused = !paused;
|
||||
break;
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
if (activeEffect === 0) initBalls();
|
||||
if (activeEffect === 3) initRain();
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearInterval(timer);
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Animation loop ---
|
||||
initBalls();
|
||||
initRain();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (!paused) {
|
||||
frame++;
|
||||
switch (activeEffect) {
|
||||
case 0:
|
||||
tickBalls();
|
||||
break;
|
||||
case 1:
|
||||
tickParticles();
|
||||
break;
|
||||
case 3:
|
||||
tickRain();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// FPS counter
|
||||
fpsCounter++;
|
||||
const now = Date.now();
|
||||
if (now - lastTime >= 1000) {
|
||||
displayFps = fpsCounter;
|
||||
fpsCounter = 0;
|
||||
lastTime = now;
|
||||
}
|
||||
|
||||
render();
|
||||
}, 33); // ~30fps
|
||||
|
||||
render();
|
||||
83
test/js/bun/tui/demos/demo-banner.ts
Normal file
83
test/js/bun/tui/demos/demo-banner.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* demo-banner.ts — Large ASCII art banner with rainbow gradient colors.
|
||||
* Renders "BUN" in big block letters using style() with different fg colors per column.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = writer.columns || 80;
|
||||
const height = 12;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// "BUN" in 5-row block letters (each char is 6 columns wide + 1 gap)
|
||||
const letters: Record<string, string[]> = {
|
||||
B: ["█████ ", "█ █ ", "█████ ", "█ █ ", "█████ "],
|
||||
U: ["█ █ ", "█ █ ", "█ █ ", "█ █ ", "█████ "],
|
||||
N: ["█ █ ", "██ █ ", "█ █ █ ", "█ ██ ", "█ █ "],
|
||||
};
|
||||
|
||||
const word = "BUN";
|
||||
const letterWidth = 6;
|
||||
const totalWidth = word.length * (letterWidth + 1);
|
||||
const startX = Math.max(0, Math.floor((width - totalWidth) / 2));
|
||||
const startY = 2;
|
||||
|
||||
// Rainbow colors for the gradient
|
||||
const rainbow = [
|
||||
0xff0000, // red
|
||||
0xff4400, // orange-red
|
||||
0xff8800, // orange
|
||||
0xffcc00, // yellow-orange
|
||||
0xffff00, // yellow
|
||||
0x88ff00, // yellow-green
|
||||
0x00ff00, // green
|
||||
0x00ff88, // green-cyan
|
||||
0x00ffff, // cyan
|
||||
0x0088ff, // blue-cyan
|
||||
0x0044ff, // blue
|
||||
0x4400ff, // blue-violet
|
||||
0x8800ff, // violet
|
||||
0xcc00ff, // magenta-violet
|
||||
0xff00ff, // magenta
|
||||
0xff0088, // magenta-red
|
||||
0xff0044, // red-magenta
|
||||
0xff0000, // red again
|
||||
];
|
||||
|
||||
// Title line
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true, faint: true });
|
||||
const titleText = "~ Bun TUI Demo: Rainbow Banner ~";
|
||||
const titleX = Math.max(0, Math.floor((width - titleText.length) / 2));
|
||||
screen.setText(titleX, 0, titleText, titleStyle);
|
||||
|
||||
// Draw the block letters with per-column rainbow colors
|
||||
for (let ci = 0; ci < word.length; ci++) {
|
||||
const char = word[ci];
|
||||
const rows = letters[char];
|
||||
for (let row = 0; row < rows.length; row++) {
|
||||
const line = rows[row];
|
||||
for (let col = 0; col < line.length; col++) {
|
||||
if (line[col] === " ") continue;
|
||||
const globalCol = ci * (letterWidth + 1) + col;
|
||||
const colorIdx = Math.floor((globalCol / totalWidth) * rainbow.length) % rainbow.length;
|
||||
const s = screen.style({ fg: rainbow[colorIdx], bold: true });
|
||||
screen.setText(startX + globalCol, startY + row, line[col], s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tagline
|
||||
const tagline = "JavaScript runtime & toolkit";
|
||||
const tagStyle = screen.style({ fg: 0xaaaaaa, italic: true });
|
||||
const tagX = Math.max(0, Math.floor((width - tagline.length) / 2));
|
||||
screen.setText(tagX, startY + 6, tagline, tagStyle);
|
||||
|
||||
// Underline decoration
|
||||
const underline = "\u2500".repeat(Math.min(totalWidth, width - 2));
|
||||
const ulStyle = screen.style({ fg: 0x555555 });
|
||||
const ulX = Math.max(0, Math.floor((width - underline.length) / 2));
|
||||
screen.setText(ulX, startY + 8, underline, ulStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
109
test/js/bun/tui/demos/demo-barh.ts
Normal file
109
test/js/bun/tui/demos/demo-barh.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* demo-barh.ts — Horizontal bar chart comparing JS runtimes (Bun, Node, Deno)
|
||||
* on different benchmarks. Colored bars with labels and values.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 76);
|
||||
const height = 24;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const bunStyle = screen.style({ fg: 0xfbf0df, bg: 0xfbf0df });
|
||||
const bunLabel = screen.style({ fg: 0xfbf0df, bold: true });
|
||||
const nodeStyle = screen.style({ fg: 0x68a063, bg: 0x68a063 });
|
||||
const nodeLabel = screen.style({ fg: 0x68a063 });
|
||||
const denoStyle = screen.style({ fg: 0x70ffaf, bg: 0x70ffaf });
|
||||
const denoLabel = screen.style({ fg: 0x70ffaf });
|
||||
const valStyle = screen.style({ fg: 0xabb2bf });
|
||||
const unitStyle = screen.style({ fg: 0x5c6370 });
|
||||
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
|
||||
|
||||
const benchmarks = [
|
||||
{
|
||||
name: "HTTP req/s",
|
||||
unit: "req/s",
|
||||
results: [
|
||||
{ runtime: "Bun", value: 112400, style: bunStyle, label: bunLabel },
|
||||
{ runtime: "Node", value: 47200, style: nodeStyle, label: nodeLabel },
|
||||
{ runtime: "Deno", value: 68300, style: denoStyle, label: denoLabel },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "FFI calls/s",
|
||||
unit: "calls/s",
|
||||
results: [
|
||||
{ runtime: "Bun", value: 320000, style: bunStyle, label: bunLabel },
|
||||
{ runtime: "Node", value: 89000, style: nodeStyle, label: nodeLabel },
|
||||
{ runtime: "Deno", value: 142000, style: denoStyle, label: denoLabel },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "File read MB/s",
|
||||
unit: "MB/s",
|
||||
results: [
|
||||
{ runtime: "Bun", value: 3800, style: bunStyle, label: bunLabel },
|
||||
{ runtime: "Node", value: 1200, style: nodeStyle, label: nodeLabel },
|
||||
{ runtime: "Deno", value: 1900, style: denoStyle, label: denoLabel },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Startup (ms)",
|
||||
unit: "ms",
|
||||
results: [
|
||||
{ runtime: "Bun", value: 7, style: bunStyle, label: bunLabel },
|
||||
{ runtime: "Node", value: 35, style: nodeStyle, label: nodeLabel },
|
||||
{ runtime: "Deno", value: 24, style: denoStyle, label: denoLabel },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function formatNum(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
// Title
|
||||
screen.drawBox(0, 0, width, 3, { style: "rounded", styleId: dimStyle, fill: true, fillChar: " " });
|
||||
const title = "JavaScript Runtime Benchmarks";
|
||||
screen.setText(Math.floor((width - title.length) / 2), 1, title, titleStyle);
|
||||
|
||||
// Legend
|
||||
let ly = 3;
|
||||
screen.setText(4, ly, "\u2588 Bun", bunLabel);
|
||||
screen.setText(16, ly, "\u2588 Node.js", nodeLabel);
|
||||
screen.setText(30, ly, "\u2588 Deno", denoLabel);
|
||||
|
||||
const labelCol = 2;
|
||||
const barStart = 20;
|
||||
const maxBarWidth = width - barStart - 12;
|
||||
|
||||
let y = 5;
|
||||
for (const bench of benchmarks) {
|
||||
screen.setText(labelCol, y, bench.name, headerStyle);
|
||||
y++;
|
||||
|
||||
const maxVal = Math.max(...bench.results.map(r => r.value));
|
||||
|
||||
for (const result of bench.results) {
|
||||
const barLen = Math.max(1, Math.round((result.value / maxVal) * maxBarWidth));
|
||||
screen.setText(labelCol + 2, y, result.runtime.padEnd(6), result.label);
|
||||
screen.fill(barStart, y, barLen, 1, "\u2588", result.style);
|
||||
const valStr = ` ${formatNum(result.value)}`;
|
||||
screen.setText(barStart + barLen, y, valStr, valStyle);
|
||||
screen.setText(barStart + barLen + valStr.length, y, ` ${bench.unit}`, unitStyle);
|
||||
y++;
|
||||
}
|
||||
y++; // gap between benchmarks
|
||||
}
|
||||
|
||||
// Footer note
|
||||
screen.setText(2, y, "* Lower is better for Startup", dimStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
63
test/js/bun/tui/demos/demo-box-styles.ts
Normal file
63
test/js/bun/tui/demos/demo-box-styles.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* demo-box-styles.ts — Shows all 5 box drawing styles (single, double, rounded, heavy, ascii)
|
||||
* side by side with labels inside each box.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 78);
|
||||
const height = 12;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
const boxStyles: { name: string; style: "single" | "double" | "rounded" | "heavy" | "ascii"; color: number }[] = [
|
||||
{ name: "single", style: "single", color: 0x61afef },
|
||||
{ name: "double", style: "double", color: 0x98c379 },
|
||||
{ name: "rounded", style: "rounded", color: 0xe5c07b },
|
||||
{ name: "heavy", style: "heavy", color: 0xe06c75 },
|
||||
{ name: "ascii", style: "ascii", color: 0xc678dd },
|
||||
];
|
||||
|
||||
screen.setText(2, 0, "Box Drawing Styles", titleStyle);
|
||||
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
|
||||
|
||||
const boxWidth = 14;
|
||||
const boxHeight = 7;
|
||||
const gap = 1;
|
||||
const startY = 3;
|
||||
|
||||
// Calculate start X to center the boxes
|
||||
const totalWidth = boxStyles.length * boxWidth + (boxStyles.length - 1) * gap;
|
||||
const startX = Math.max(1, Math.floor((width - totalWidth) / 2));
|
||||
|
||||
for (let i = 0; i < boxStyles.length; i++) {
|
||||
const bs = boxStyles[i];
|
||||
const x = startX + i * (boxWidth + gap);
|
||||
const borderStyle = screen.style({ fg: bs.color });
|
||||
const labelStyle2 = screen.style({ fg: bs.color, bold: true });
|
||||
|
||||
screen.drawBox(x, startY, boxWidth, boxHeight, {
|
||||
style: bs.style,
|
||||
styleId: borderStyle,
|
||||
fill: true,
|
||||
fillChar: " ",
|
||||
});
|
||||
|
||||
// Label centered inside the box
|
||||
const labelX = x + Math.floor((boxWidth - bs.name.length) / 2);
|
||||
screen.setText(labelX, startY + 2, bs.name, labelStyle2);
|
||||
|
||||
// Show a sample character
|
||||
const sampleStyle = screen.style({ fg: bs.color, faint: true });
|
||||
screen.setText(x + 2, startY + 4, "Hello!", sampleStyle);
|
||||
}
|
||||
|
||||
// Footer note
|
||||
screen.setText(2, startY + boxHeight + 1, "Each box uses a different border drawing style", dimStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
85
test/js/bun/tui/demos/demo-calendar.ts
Normal file
85
test/js/bun/tui/demos/demo-calendar.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* demo-calendar.ts — Renders the current month as a calendar grid with
|
||||
* today highlighted. Uses drawBox for the border, styled day numbers.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = 30;
|
||||
const height = 12;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const borderStyle = screen.style({ fg: 0x3e4452 });
|
||||
const titleStyle = screen.style({ fg: 0x61afef, bold: true });
|
||||
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
|
||||
const dayStyle = screen.style({ fg: 0xabb2bf });
|
||||
const todayStyle = screen.style({ fg: 0x282c34, bg: 0x98c379, bold: true });
|
||||
const weekendStyle = screen.style({ fg: 0x5c6370 });
|
||||
const dimStyle = screen.style({ fg: 0x3e4452 });
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const today = now.getDate();
|
||||
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
// First day of month (0=Sun, 6=Sat)
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
// Days in month
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
// Draw border
|
||||
screen.drawBox(0, 0, width, height, { style: "rounded", styleId: borderStyle });
|
||||
|
||||
// Month + Year title
|
||||
const title = `${monthNames[month]} ${year}`;
|
||||
screen.setText(Math.floor((width - title.length) / 2), 1, title, titleStyle);
|
||||
|
||||
// Day headers
|
||||
const dayHeaders = "Su Mo Tu We Th Fr Sa";
|
||||
screen.setText(2, 3, dayHeaders, headerStyle);
|
||||
|
||||
// Separator
|
||||
screen.setText(2, 4, "\u2500".repeat(width - 4), dimStyle);
|
||||
|
||||
// Render days
|
||||
let row = 5;
|
||||
let col = firstDay;
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const x = 2 + col * 3;
|
||||
const str = String(d).padStart(2, " ");
|
||||
|
||||
let style = dayStyle;
|
||||
if (d === today) {
|
||||
style = todayStyle;
|
||||
} else if (col === 0 || col === 6) {
|
||||
style = weekendStyle;
|
||||
}
|
||||
|
||||
screen.setText(x, row, str, style);
|
||||
col++;
|
||||
if (col > 6) {
|
||||
col = 0;
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
369
test/js/bun/tui/demos/demo-canvas.ts
Normal file
369
test/js/bun/tui/demos/demo-canvas.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* demo-canvas.ts — Pixel Canvas with Half-Block Rendering
|
||||
*
|
||||
* A drawing canvas that uses Unicode half-block characters (▀▄█) to achieve
|
||||
* double vertical resolution — each terminal cell represents 2 pixels.
|
||||
* Includes shape tools, a color picker, and undo support.
|
||||
*
|
||||
* Demonstrates: half-block pixel rendering, fg+bg color combination per cell,
|
||||
* efficient cell updates, style interning for pixel colors, setText, fill,
|
||||
* style (fg/bg), drawBox, TUITerminalWriter, TUIKeyReader, alt screen, resize.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-canvas.ts
|
||||
* Controls: Arrow keys to move cursor, Space to plot pixel, 1-8 select color,
|
||||
* F fill area, C clear, U undo, Q quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
label: screen.style({ fg: 0xabb2bf }),
|
||||
value: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
cursor: screen.style({ fg: 0xffffff, bold: true }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
swatch: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
};
|
||||
|
||||
// --- Palette ---
|
||||
const palette = [
|
||||
0x000000, // Black
|
||||
0xff5555, // Red
|
||||
0x55ff55, // Green
|
||||
0x5555ff, // Blue
|
||||
0xffff55, // Yellow
|
||||
0xff55ff, // Magenta
|
||||
0x55ffff, // Cyan
|
||||
0xffffff, // White
|
||||
];
|
||||
|
||||
const paletteNames = ["Black", "Red", "Green", "Blue", "Yellow", "Magenta", "Cyan", "White"];
|
||||
|
||||
// --- Canvas state ---
|
||||
const HEADER_H = 1;
|
||||
const SIDEBAR_W = 16;
|
||||
const FOOTER_H = 1;
|
||||
|
||||
function canvasCellW() {
|
||||
return Math.max(4, cols - SIDEBAR_W - 2);
|
||||
}
|
||||
function canvasCellH() {
|
||||
return Math.max(3, rows - HEADER_H - FOOTER_H - 2);
|
||||
}
|
||||
// Pixel dimensions (2x vertical resolution via half-blocks)
|
||||
function canvasPixW() {
|
||||
return canvasCellW();
|
||||
}
|
||||
function canvasPixH() {
|
||||
return canvasCellH() * 2;
|
||||
}
|
||||
|
||||
// Pixel buffer: 0 = transparent, 1-8 = palette index
|
||||
let pixels: number[][] = [];
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let selectedColor = 2; // Green
|
||||
let undoStack: { x: number; y: number; oldColor: number }[][] = [];
|
||||
let currentStroke: { x: number; y: number; oldColor: number }[] = [];
|
||||
|
||||
function initPixels() {
|
||||
const w = canvasPixW();
|
||||
const h = canvasPixH();
|
||||
pixels = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
pixels.push(new Array(w).fill(0));
|
||||
}
|
||||
cursorX = Math.min(cursorX, w - 1);
|
||||
cursorY = Math.min(cursorY, h - 1);
|
||||
}
|
||||
|
||||
function getPixel(x: number, y: number): number {
|
||||
if (y < 0 || y >= pixels.length || x < 0 || x >= (pixels[0]?.length ?? 0)) return 0;
|
||||
return pixels[y][x];
|
||||
}
|
||||
|
||||
function setPixel(x: number, y: number, color: number) {
|
||||
if (y < 0 || y >= pixels.length || x < 0 || x >= (pixels[0]?.length ?? 0)) return;
|
||||
const old = pixels[y][x];
|
||||
if (old !== color) {
|
||||
currentStroke.push({ x, y, oldColor: old });
|
||||
pixels[y][x] = color;
|
||||
}
|
||||
}
|
||||
|
||||
function commitStroke() {
|
||||
if (currentStroke.length > 0) {
|
||||
undoStack.push(currentStroke);
|
||||
if (undoStack.length > 100) undoStack.shift();
|
||||
currentStroke = [];
|
||||
}
|
||||
}
|
||||
|
||||
function undo() {
|
||||
const stroke = undoStack.pop();
|
||||
if (!stroke) return;
|
||||
for (const { x, y, oldColor } of stroke) {
|
||||
if (y >= 0 && y < pixels.length && x >= 0 && x < pixels[0].length) {
|
||||
pixels[y][x] = oldColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw a built-in pattern
|
||||
function drawPattern() {
|
||||
const w = canvasPixW();
|
||||
const h = canvasPixH();
|
||||
// Draw a simple Bun logo-ish shape
|
||||
const cx = Math.floor(w / 2);
|
||||
const cy = Math.floor(h / 2);
|
||||
const r = Math.min(Math.floor(w / 4), Math.floor(h / 4));
|
||||
// Circle
|
||||
for (let angle = 0; angle < 360; angle += 2) {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const px = Math.round(cx + r * Math.cos(rad));
|
||||
const py = Math.round(cy + r * Math.sin(rad));
|
||||
setPixel(px, py, 5); // Yellow
|
||||
}
|
||||
// Fill center with a pattern
|
||||
for (let dy = -r + 2; dy < r - 2; dy++) {
|
||||
for (let dx = -r + 2; dx < r - 2; dx++) {
|
||||
if (dx * dx + dy * dy < (r - 2) * (r - 2)) {
|
||||
const px = cx + dx;
|
||||
const py = cy + dy;
|
||||
if ((px + py) % 3 === 0) setPixel(px, py, 3); // Green
|
||||
}
|
||||
}
|
||||
}
|
||||
// Eyes
|
||||
setPixel(cx - 3, cy - 2, 1); // Black eye
|
||||
setPixel(cx + 3, cy - 2, 1);
|
||||
// Smile
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
setPixel(cx + dx, cy + 3, 1);
|
||||
}
|
||||
commitStroke();
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
const cellW = canvasCellW();
|
||||
const cellH = canvasCellH();
|
||||
const ox = 1; // canvas offset X
|
||||
const oy = HEADER_H + 1; // canvas offset Y
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Pixel Canvas ", st.titleBar);
|
||||
|
||||
// Border around canvas
|
||||
screen.drawBox(ox - 1, oy - 1, cellW + 2, cellH + 2, { style: "rounded", styleId: st.border });
|
||||
|
||||
// Render pixels using half-block characters
|
||||
// Each cell row represents 2 pixel rows: top pixel = fg, bottom pixel = bg
|
||||
for (let cy = 0; cy < cellH; cy++) {
|
||||
const py0 = cy * 2; // top pixel row
|
||||
const py1 = cy * 2 + 1; // bottom pixel row
|
||||
|
||||
for (let cx = 0; cx < cellW; cx++) {
|
||||
const top = getPixel(cx, py0);
|
||||
const bot = getPixel(cx, py1);
|
||||
|
||||
if (top === 0 && bot === 0) {
|
||||
// Both transparent — skip (already cleared)
|
||||
continue;
|
||||
}
|
||||
|
||||
let ch: string;
|
||||
let fg: number;
|
||||
let bg: number;
|
||||
|
||||
if (top !== 0 && bot !== 0) {
|
||||
// Both pixels set: use upper half block, fg=top color, bg=bottom color
|
||||
ch = "\u2580"; // ▀
|
||||
fg = palette[top - 1] ?? 0xffffff;
|
||||
bg = palette[bot - 1] ?? 0xffffff;
|
||||
} else if (top !== 0) {
|
||||
// Only top pixel: use upper half block with fg=top, bg=transparent(black)
|
||||
ch = "\u2580"; // ▀
|
||||
fg = palette[top - 1] ?? 0xffffff;
|
||||
bg = 0x1a1a1a;
|
||||
} else {
|
||||
// Only bottom pixel: use lower half block with fg=bottom
|
||||
ch = "\u2584"; // ▄
|
||||
fg = palette[bot - 1] ?? 0xffffff;
|
||||
bg = 0x1a1a1a;
|
||||
}
|
||||
|
||||
const sid = screen.style({ fg, bg });
|
||||
screen.setText(ox + cx, oy + cy, ch, sid);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw cursor
|
||||
const cursorCellX = cursorX;
|
||||
const cursorCellY = Math.floor(cursorY / 2);
|
||||
const isTopHalf = cursorY % 2 === 0;
|
||||
if (cursorCellX >= 0 && cursorCellX < cellW && cursorCellY >= 0 && cursorCellY < cellH) {
|
||||
// Use a crosshair indicator
|
||||
const cursorChar = isTopHalf ? "\u2580" : "\u2584"; // ▀ or ▄
|
||||
const curColor = palette[selectedColor - 1] ?? 0xffffff;
|
||||
screen.setText(ox + cursorCellX, oy + cursorCellY, cursorChar, screen.style({ fg: curColor, bg: 0x333333 }));
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
const sx = ox + cellW + 2;
|
||||
if (sx + SIDEBAR_W <= cols) {
|
||||
let sy = oy;
|
||||
|
||||
screen.setText(sx, sy, "Cursor", st.label);
|
||||
sy++;
|
||||
screen.setText(sx, sy, `(${cursorX}, ${cursorY})`, st.value);
|
||||
sy += 2;
|
||||
|
||||
screen.setText(sx, sy, "Color", st.label);
|
||||
sy++;
|
||||
// Color palette
|
||||
for (let i = 0; i < palette.length; i++) {
|
||||
const c = palette[i];
|
||||
const isActive = i + 1 === selectedColor;
|
||||
const indicator = isActive ? "\u25b6" : " ";
|
||||
const indStyle = isActive ? st.swatch : st.dim;
|
||||
screen.setText(sx, sy, indicator, indStyle);
|
||||
screen.fill(sx + 2, sy, 2, 1, " ", screen.style({ bg: c }));
|
||||
const label = `${i + 1}:${paletteNames[i]}`;
|
||||
screen.setText(sx + 5, sy, label.slice(0, SIDEBAR_W - 6), isActive ? st.value : st.dim);
|
||||
sy++;
|
||||
}
|
||||
|
||||
sy++;
|
||||
screen.setText(sx, sy, "Canvas", st.label);
|
||||
sy++;
|
||||
screen.setText(sx, sy, `${canvasPixW()}x${canvasPixH()}px`, st.value);
|
||||
sy++;
|
||||
const pixCount = pixels.flat().filter(p => p !== 0).length;
|
||||
screen.setText(sx, sy, `${pixCount} pixels`, st.dim);
|
||||
sy++;
|
||||
screen.setText(sx, sy, `${undoStack.length} undos`, st.dim);
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerText = " Arrows:Move Space:Draw 1-8:Color F:Fill C:Clear U:Undo D:Demo q:Quit ";
|
||||
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const pw = canvasPixW();
|
||||
const ph = canvasPixH();
|
||||
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
cursorY = Math.max(0, cursorY - 1);
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
cursorY = Math.min(ph - 1, cursorY + 1);
|
||||
break;
|
||||
case "left":
|
||||
case "h":
|
||||
cursorX = Math.max(0, cursorX - 1);
|
||||
break;
|
||||
case "right":
|
||||
case "l":
|
||||
cursorX = Math.min(pw - 1, cursorX + 1);
|
||||
break;
|
||||
case " ":
|
||||
setPixel(cursorX, cursorY, selectedColor);
|
||||
commitStroke();
|
||||
break;
|
||||
case "x":
|
||||
// Erase pixel
|
||||
setPixel(cursorX, cursorY, 0);
|
||||
commitStroke();
|
||||
break;
|
||||
case "1":
|
||||
case "2":
|
||||
case "3":
|
||||
case "4":
|
||||
case "5":
|
||||
case "6":
|
||||
case "7":
|
||||
case "8":
|
||||
selectedColor = parseInt(name);
|
||||
break;
|
||||
case "f":
|
||||
// Fill entire canvas with selected color
|
||||
for (let py = 0; py < ph; py++) {
|
||||
for (let px = 0; px < pw; px++) {
|
||||
setPixel(px, py, selectedColor);
|
||||
}
|
||||
}
|
||||
commitStroke();
|
||||
break;
|
||||
case "c":
|
||||
// Clear canvas
|
||||
for (let py = 0; py < ph; py++) {
|
||||
for (let px = 0; px < pw; px++) {
|
||||
setPixel(px, py, 0);
|
||||
}
|
||||
}
|
||||
commitStroke();
|
||||
break;
|
||||
case "u":
|
||||
undo();
|
||||
break;
|
||||
case "d":
|
||||
drawPattern();
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
initPixels();
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
initPixels();
|
||||
render();
|
||||
109
test/js/bun/tui/demos/demo-changelog.ts
Normal file
109
test/js/bun/tui/demos/demo-changelog.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* demo-changelog.ts — Styled changelog output with version headers,
|
||||
* categorized entries (Added, Fixed, Changed, Removed) with colored bullets.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 68);
|
||||
const height = 36;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const versionStyle = screen.style({ fg: 0x61afef, bold: true });
|
||||
const dateStyle = screen.style({ fg: 0x5c6370 });
|
||||
const dimStyle = screen.style({ fg: 0x3e4452 });
|
||||
const addedTag = screen.style({ fg: 0x282c34, bg: 0x98c379, bold: true });
|
||||
const addedBullet = screen.style({ fg: 0x98c379 });
|
||||
const fixedTag = screen.style({ fg: 0x282c34, bg: 0x61afef, bold: true });
|
||||
const fixedBullet = screen.style({ fg: 0x61afef });
|
||||
const changedTag = screen.style({ fg: 0x282c34, bg: 0xe5c07b, bold: true });
|
||||
const changedBullet = screen.style({ fg: 0xe5c07b });
|
||||
const removedTag = screen.style({ fg: 0x282c34, bg: 0xe06c75, bold: true });
|
||||
const removedBullet = screen.style({ fg: 0xe06c75 });
|
||||
const textStyle = screen.style({ fg: 0xabb2bf });
|
||||
|
||||
interface ChangeEntry {
|
||||
category: "Added" | "Fixed" | "Changed" | "Removed";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface VersionEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: ChangeEntry[];
|
||||
}
|
||||
|
||||
const changelog: VersionEntry[] = [
|
||||
{
|
||||
version: "v1.2.0",
|
||||
date: "2025-01-15",
|
||||
changes: [
|
||||
{ category: "Added", text: "TUI screen rendering API" },
|
||||
{ category: "Added", text: "True color support in terminal writer" },
|
||||
{ category: "Fixed", text: "Wide character handling in fill()" },
|
||||
{ category: "Changed", text: "Style capacity increased to 4096" },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v1.1.5",
|
||||
date: "2024-12-20",
|
||||
changes: [
|
||||
{ category: "Fixed", text: "ZWJ emoji clustering in setText()" },
|
||||
{ category: "Fixed", text: "BufferedWriter double-free on close" },
|
||||
{ category: "Removed", text: "Deprecated Screen.render() method" },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v1.1.0",
|
||||
date: "2024-11-30",
|
||||
changes: [
|
||||
{ category: "Added", text: "Box drawing with 5 border styles" },
|
||||
{ category: "Added", text: "Clipping rectangle stack" },
|
||||
{ category: "Changed", text: "Renamed Bun.Screen to Bun.TUIScreen" },
|
||||
{ category: "Fixed", text: "Resize preserves existing content" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tagStyles: Record<string, { tag: number; bullet: number }> = {
|
||||
Added: { tag: addedTag, bullet: addedBullet },
|
||||
Fixed: { tag: fixedTag, bullet: fixedBullet },
|
||||
Changed: { tag: changedTag, bullet: changedBullet },
|
||||
Removed: { tag: removedTag, bullet: removedBullet },
|
||||
};
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "CHANGELOG", titleStyle);
|
||||
screen.setText(2, 1, "\u2550".repeat(width - 4), dimStyle);
|
||||
|
||||
let y = 3;
|
||||
|
||||
for (const ver of changelog) {
|
||||
// Version header
|
||||
screen.setText(2, y, ver.version, versionStyle);
|
||||
screen.setText(2 + ver.version.length + 1, y, `(${ver.date})`, dateStyle);
|
||||
y++;
|
||||
screen.setText(2, y, "\u2500".repeat(width - 4), dimStyle);
|
||||
y++;
|
||||
|
||||
for (const change of ver.changes) {
|
||||
const styles = tagStyles[change.category];
|
||||
|
||||
// Category tag
|
||||
const tag = ` ${change.category} `;
|
||||
screen.setText(4, y, tag, styles.tag);
|
||||
|
||||
// Bullet and text
|
||||
const textX = 4 + tag.length + 1;
|
||||
screen.setText(textX, y, "\u2022", styles.bullet);
|
||||
screen.setText(textX + 2, y, change.text, textStyle);
|
||||
y++;
|
||||
}
|
||||
y++; // gap between versions
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
336
test/js/bun/tui/demos/demo-chart.ts
Normal file
336
test/js/bun/tui/demos/demo-chart.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* demo-chart.ts — Terminal Charts & Sparklines
|
||||
*
|
||||
* Live-updating bar charts, sparklines, and a horizontal histogram
|
||||
* with simulated real-time data feeds.
|
||||
*
|
||||
* Demonstrates: setInterval animation, dynamic data, fill, setText,
|
||||
* style (fg/bg/bold), drawBox, mathematical layout, TUITerminalWriter,
|
||||
* TUIKeyReader, alt screen, resize handling.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-chart.ts
|
||||
* Controls: 1-3 to switch views, Space to pause, R to reset, Q / Ctrl+C to quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
header: screen.style({ fg: 0x61afef, bold: true }),
|
||||
label: screen.style({ fg: 0xabb2bf }),
|
||||
value: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
axis: screen.style({ fg: 0x5c6370 }),
|
||||
barGreen: screen.style({ bg: 0x98c379 }),
|
||||
barBlue: screen.style({ bg: 0x61afef }),
|
||||
barYellow: screen.style({ bg: 0xe5c07b }),
|
||||
barRed: screen.style({ bg: 0xe06c75 }),
|
||||
barCyan: screen.style({ bg: 0x56b6c2 }),
|
||||
barMagenta: screen.style({ bg: 0xc678dd }),
|
||||
sparkHigh: screen.style({ fg: 0x98c379 }),
|
||||
sparkMid: screen.style({ fg: 0xe5c07b }),
|
||||
sparkLow: screen.style({ fg: 0xe06c75 }),
|
||||
tabActive: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
tabInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
|
||||
};
|
||||
|
||||
const barColors = [st.barGreen, st.barBlue, st.barYellow, st.barRed, st.barCyan, st.barMagenta];
|
||||
|
||||
// --- Sparkline characters (Unicode block elements, 8 levels) ---
|
||||
const SPARK = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
||||
|
||||
// --- Data ---
|
||||
const MAX_HISTORY = 120;
|
||||
const series: { name: string; data: number[]; color: number }[] = [
|
||||
{ name: "CPU", data: [], color: 0 },
|
||||
{ name: "Memory", data: [], color: 1 },
|
||||
{ name: "Network", data: [], color: 2 },
|
||||
{ name: "Disk I/O", data: [], color: 3 },
|
||||
{ name: "Requests", data: [], color: 4 },
|
||||
{ name: "Latency", data: [], color: 5 },
|
||||
];
|
||||
|
||||
// Simulated data generators
|
||||
function genCPU(prev: number): number {
|
||||
return Math.max(0, Math.min(100, prev + (Math.random() - 0.48) * 15));
|
||||
}
|
||||
function genMemory(prev: number): number {
|
||||
return Math.max(20, Math.min(95, prev + (Math.random() - 0.5) * 3));
|
||||
}
|
||||
function genNetwork(prev: number): number {
|
||||
return Math.max(0, Math.min(100, prev + (Math.random() - 0.45) * 20));
|
||||
}
|
||||
function genDiskIO(prev: number): number {
|
||||
return Math.max(0, Math.min(100, prev + (Math.random() - 0.5) * 8));
|
||||
}
|
||||
function genRequests(prev: number): number {
|
||||
return Math.max(0, Math.min(100, prev + (Math.random() - 0.47) * 25));
|
||||
}
|
||||
function genLatency(prev: number): number {
|
||||
return Math.max(5, Math.min(100, prev + (Math.random() - 0.5) * 12));
|
||||
}
|
||||
|
||||
const generators = [genCPU, genMemory, genNetwork, genDiskIO, genRequests, genLatency];
|
||||
|
||||
// --- State ---
|
||||
let paused = false;
|
||||
let activeView = 0; // 0=sparklines, 1=bar chart, 2=histogram
|
||||
let tickCount = 0;
|
||||
|
||||
function addDataPoint() {
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
const prev = series[i].data.length > 0 ? series[i].data[series[i].data.length - 1] : 50;
|
||||
series[i].data.push(generators[i](prev));
|
||||
if (series[i].data.length > MAX_HISTORY) series[i].data.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
for (const s of series) s.data = [];
|
||||
tickCount = 0;
|
||||
}
|
||||
|
||||
// Initialize with some data
|
||||
for (let i = 0; i < 60; i++) addDataPoint();
|
||||
|
||||
// --- Render helpers ---
|
||||
|
||||
function drawSparkline(x: number, y: number, width: number, data: number[], min: number, max: number) {
|
||||
const range = max - min || 1;
|
||||
const start = Math.max(0, data.length - width);
|
||||
for (let i = 0; i < width; i++) {
|
||||
const di = start + i;
|
||||
if (di >= data.length) break;
|
||||
const normalized = (data[di] - min) / range;
|
||||
const level = Math.round(normalized * 8);
|
||||
const ch = SPARK[Math.max(0, Math.min(8, level))];
|
||||
const style = normalized > 0.7 ? st.sparkHigh : normalized > 0.3 ? st.sparkMid : st.sparkLow;
|
||||
screen.setText(x + i, y, ch, style);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBarChart(x: number, y: number, width: number, height: number) {
|
||||
const barW = Math.max(1, Math.floor((width - 2) / series.length) - 1);
|
||||
const gap = 1;
|
||||
|
||||
// Y-axis
|
||||
for (let row = 0; row < height; row++) {
|
||||
const val = Math.round(100 - (row / (height - 1)) * 100);
|
||||
if (row === 0 || row === height - 1 || row === Math.floor(height / 2)) {
|
||||
const label = String(val).padStart(3);
|
||||
screen.setText(x, y + row, label, st.axis);
|
||||
}
|
||||
screen.setText(x + 4, y + row, "\u2502", st.axis); // │
|
||||
}
|
||||
|
||||
// X-axis
|
||||
for (let i = 0; i < width - 5; i++) {
|
||||
screen.setText(x + 5 + i, y + height, "\u2500", st.axis); // ─
|
||||
}
|
||||
screen.setText(x + 4, y + height, "\u2514", st.axis); // └
|
||||
|
||||
// Bars
|
||||
for (let si = 0; si < series.length; si++) {
|
||||
const s = series[si];
|
||||
const val = s.data.length > 0 ? s.data[s.data.length - 1] : 0;
|
||||
const barH = Math.round((val / 100) * (height - 1));
|
||||
const bx = x + 5 + si * (barW + gap);
|
||||
|
||||
// Fill bar from bottom
|
||||
for (let row = 0; row < barH; row++) {
|
||||
const by = y + height - 1 - row;
|
||||
screen.fill(bx, by, barW, 1, " ", barColors[si % barColors.length]);
|
||||
}
|
||||
|
||||
// Label below
|
||||
const label = s.name.slice(0, barW + gap);
|
||||
screen.setText(bx, y + height + 1, label, st.label);
|
||||
|
||||
// Value on top
|
||||
const valStr = Math.round(val).toString();
|
||||
screen.setText(bx, y + Math.max(0, height - 1 - barH - 1), valStr, st.value);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHistogram(x: number, y: number, width: number, height: number) {
|
||||
const barMaxW = width - 14; // leave room for labels and values
|
||||
|
||||
for (let si = 0; si < series.length && si < height; si++) {
|
||||
const s = series[si];
|
||||
const val = s.data.length > 0 ? s.data[s.data.length - 1] : 0;
|
||||
const barW = Math.round((val / 100) * barMaxW);
|
||||
const by = y + si * 2;
|
||||
|
||||
// Label
|
||||
screen.setText(x, by, s.name.padEnd(10).slice(0, 10), st.label);
|
||||
|
||||
// Bar
|
||||
if (barW > 0) {
|
||||
screen.fill(x + 10, by, barW, 1, " ", barColors[si % barColors.length]);
|
||||
}
|
||||
|
||||
// Value
|
||||
const valStr = `${Math.round(val)}%`;
|
||||
screen.setText(x + 10 + barMaxW + 1, by, valStr.padStart(4), st.value);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
const title = " System Monitor ";
|
||||
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, st.titleBar);
|
||||
|
||||
// Tabs
|
||||
const tabs = ["Sparklines", "Bar Chart", "Histogram"];
|
||||
let tx = 2;
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const label = ` ${i + 1}:${tabs[i]} `;
|
||||
const style = i === activeView ? st.tabActive : st.tabInactive;
|
||||
screen.setText(tx, 1, label, style);
|
||||
tx += label.length + 1;
|
||||
}
|
||||
|
||||
// Status
|
||||
const statusText = paused ? "PAUSED" : `Tick: ${tickCount}`;
|
||||
screen.setText(cols - statusText.length - 2, 1, statusText, paused ? st.sparkLow : st.dim);
|
||||
|
||||
const contentY = 3;
|
||||
const contentH = rows - contentY - 2;
|
||||
const contentW = cols - 4;
|
||||
|
||||
if (activeView === 0) {
|
||||
// --- Sparklines view ---
|
||||
const sparkW = Math.min(contentW - 16, MAX_HISTORY);
|
||||
for (let si = 0; si < series.length; si++) {
|
||||
const s = series[si];
|
||||
const sy = contentY + si * 4;
|
||||
if (sy + 2 >= rows - 2) break;
|
||||
|
||||
// Label and current value
|
||||
const val = s.data.length > 0 ? s.data[s.data.length - 1] : 0;
|
||||
screen.setText(2, sy, s.name.padEnd(10), st.label);
|
||||
screen.setText(12, sy, `${Math.round(val)}%`, st.value);
|
||||
|
||||
// Min/max
|
||||
if (s.data.length > 0) {
|
||||
const min = Math.round(Math.min(...s.data));
|
||||
const max = Math.round(Math.max(...s.data));
|
||||
const avg = Math.round(s.data.reduce((a, b) => a + b, 0) / s.data.length);
|
||||
screen.setText(18, sy, `min:${min} avg:${avg} max:${max}`, st.dim);
|
||||
}
|
||||
|
||||
// Sparkline
|
||||
drawSparkline(2, sy + 1, sparkW, s.data, 0, 100);
|
||||
|
||||
// Separator
|
||||
if (si < series.length - 1) {
|
||||
for (let i = 0; i < sparkW + 2; i++) {
|
||||
screen.setText(1 + i, sy + 2, "\u2500", st.dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (activeView === 1) {
|
||||
// --- Bar chart view ---
|
||||
screen.setText(2, contentY, "Current Values", st.header);
|
||||
drawBarChart(2, contentY + 1, contentW, Math.min(contentH - 4, 20));
|
||||
} else {
|
||||
// --- Histogram view ---
|
||||
screen.setText(2, contentY, "Horizontal Bars", st.header);
|
||||
drawHistogram(2, contentY + 1, contentW, Math.min(contentH - 2, series.length * 2));
|
||||
|
||||
// Add a mini sparkline section below
|
||||
const histH = series.length * 2 + 2;
|
||||
if (contentY + histH + 6 < rows - 2) {
|
||||
screen.setText(2, contentY + histH, "Trend (last 60s)", st.header);
|
||||
for (let si = 0; si < Math.min(series.length, 3); si++) {
|
||||
const s = series[si];
|
||||
const sy = contentY + histH + 1 + si * 2;
|
||||
screen.setText(2, sy, s.name.padEnd(10), st.label);
|
||||
drawSparkline(12, sy, Math.min(contentW - 14, 60), s.data, 0, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
const footerText = " 1-3: Switch view | Space: Pause | R: Reset | Q: Quit ";
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "1":
|
||||
activeView = 0;
|
||||
break;
|
||||
case "2":
|
||||
activeView = 1;
|
||||
break;
|
||||
case "3":
|
||||
activeView = 2;
|
||||
break;
|
||||
case " ":
|
||||
paused = !paused;
|
||||
break;
|
||||
case "r":
|
||||
resetData();
|
||||
break;
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearInterval(timer);
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Animation ---
|
||||
const timer = setInterval(() => {
|
||||
if (!paused) {
|
||||
addDataPoint();
|
||||
tickCount++;
|
||||
}
|
||||
render();
|
||||
}, 200); // 5 fps
|
||||
|
||||
render();
|
||||
362
test/js/bun/tui/demos/demo-chat.ts
Normal file
362
test/js/bun/tui/demos/demo-chat.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* demo-chat.ts — Chat Interface
|
||||
*
|
||||
* A polished chat interface with message bubbles, timestamps, an input field
|
||||
* with a blinking cursor, typing indicators, and a simulated bot.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-chat.ts
|
||||
* Controls: Type to compose, Enter to send, Up/Down to scroll, Ctrl+C to quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
writer.enableBracketedPaste();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
|
||||
myBg: screen.style({ fg: 0xffffff, bg: 0x2d5a9e }),
|
||||
myName: screen.style({ fg: 0xa8d0ff, bg: 0x2d5a9e, bold: true }),
|
||||
myTime: screen.style({ fg: 0x7cacd6, bg: 0x2d5a9e }),
|
||||
theirBg: screen.style({ fg: 0xdcdcdc, bg: 0x383838 }),
|
||||
theirName: screen.style({ fg: 0x98c379, bg: 0x383838, bold: true }),
|
||||
theirTime: screen.style({ fg: 0x777777, bg: 0x383838 }),
|
||||
systemMsg: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
inputBorder: screen.style({ fg: 0x61afef }),
|
||||
inputText: screen.style({ fg: 0xffffff }),
|
||||
inputPlaceholder: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
online: screen.style({ fg: 0x98c379, bold: true }),
|
||||
typing: screen.style({ fg: 0xe5c07b, italic: true }),
|
||||
};
|
||||
|
||||
// --- Data ---
|
||||
interface Message {
|
||||
sender: string;
|
||||
text: string;
|
||||
time: Date;
|
||||
isMe: boolean;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
const messages: Message[] = [
|
||||
{ sender: "", text: "Welcome to Bun TUI Chat!", time: new Date(), isMe: false, isSystem: true },
|
||||
{
|
||||
sender: "Bun Bot",
|
||||
text: "Hey! I'm a demo bot. Try sending me a message!",
|
||||
time: new Date(Date.now() - 60000),
|
||||
isMe: false,
|
||||
},
|
||||
{
|
||||
sender: "Bun Bot",
|
||||
text: "This chat is built with Bun.TUIScreen and Bun.TUIKeyReader. The message bubbles use fill() for backgrounds and setText() for content.",
|
||||
time: new Date(Date.now() - 30000),
|
||||
isMe: false,
|
||||
},
|
||||
];
|
||||
|
||||
let inputText = "";
|
||||
let inputCursor = 0;
|
||||
let scrollOffset = 0;
|
||||
let typingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let showTyping = false;
|
||||
|
||||
const botReplies = [
|
||||
"That's cool! The TUI library uses Ghostty's cell grid internally.",
|
||||
"Try resizing the terminal — the layout adapts automatically!",
|
||||
"The diff renderer only updates changed cells. Super efficient!",
|
||||
"Each style is interned with a numeric ID. Up to 4096 unique styles.",
|
||||
"Did you know? The renderer uses synchronized update markers to prevent flicker.",
|
||||
"Mouse tracking is supported too — check out demo-mouse!",
|
||||
"You can use hyperlinks in the terminal with screen.hyperlink()!",
|
||||
"Nice message! The word wrapping handles long text gracefully.",
|
||||
];
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function wrapText(text: string, width: number): string[] {
|
||||
if (width <= 0) return [text];
|
||||
const lines: string[] = [];
|
||||
for (const paragraph of text.split("\n")) {
|
||||
const words = paragraph.split(" ");
|
||||
let cur = "";
|
||||
for (const word of words) {
|
||||
if (cur.length === 0) {
|
||||
cur = word;
|
||||
} else if (cur.length + 1 + word.length <= width) {
|
||||
cur += " " + word;
|
||||
} else {
|
||||
lines.push(cur);
|
||||
cur = word;
|
||||
}
|
||||
}
|
||||
if (cur.length > 0) lines.push(cur);
|
||||
}
|
||||
return lines.length > 0 ? lines : [""];
|
||||
}
|
||||
|
||||
function sendMessage(text: string) {
|
||||
if (text.trim().length === 0) return;
|
||||
messages.push({ sender: "You", text: text.trim(), time: new Date(), isMe: true });
|
||||
inputText = "";
|
||||
inputCursor = 0;
|
||||
scrollOffset = 0;
|
||||
showTyping = true;
|
||||
render();
|
||||
|
||||
typingTimer = setTimeout(
|
||||
() => {
|
||||
showTyping = false;
|
||||
messages.push({
|
||||
sender: "Bun Bot",
|
||||
text: botReplies[Math.floor(Math.random() * botReplies.length)],
|
||||
time: new Date(),
|
||||
isMe: false,
|
||||
});
|
||||
scrollOffset = 0;
|
||||
render();
|
||||
},
|
||||
600 + Math.random() * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Chat ", st.titleBar);
|
||||
const onlineText = "\u25cf Online";
|
||||
screen.setText(cols - onlineText.length - 2, 0, onlineText, st.online);
|
||||
|
||||
// Input box (bottom 3 rows)
|
||||
const inputY = rows - 3;
|
||||
screen.drawBox(0, inputY, cols, 3, { style: "rounded", styleId: st.inputBorder });
|
||||
if (inputText.length > 0) {
|
||||
screen.setText(2, inputY + 1, inputText.slice(0, cols - 4), st.inputText);
|
||||
} else {
|
||||
screen.setText(2, inputY + 1, "Type a message...", st.inputPlaceholder);
|
||||
}
|
||||
|
||||
// Typing indicator
|
||||
const typingY = showTyping ? inputY - 1 : inputY;
|
||||
if (showTyping) {
|
||||
screen.setText(2, inputY - 1, "Bun Bot is typing...", st.typing);
|
||||
}
|
||||
|
||||
// Message area
|
||||
const msgTop = 1;
|
||||
const msgBot = typingY;
|
||||
const msgH = msgBot - msgTop;
|
||||
if (msgH <= 0) {
|
||||
writer.render(screen, {
|
||||
cursorVisible: true,
|
||||
cursorX: 2 + inputCursor,
|
||||
cursorY: inputY + 1,
|
||||
cursorStyle: "line",
|
||||
cursorBlinking: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const bubbleMaxW = Math.min(Math.floor(cols * 0.7), cols - 6);
|
||||
|
||||
// Build rendered lines for all messages
|
||||
interface RenderedLine {
|
||||
x: number;
|
||||
width: number;
|
||||
text: string;
|
||||
style: number;
|
||||
}
|
||||
const allLines: RenderedLine[][] = []; // each message = array of rendered lines
|
||||
|
||||
for (const msg of messages) {
|
||||
const msgLines: RenderedLine[] = [];
|
||||
if (msg.isSystem) {
|
||||
const line = `\u2500\u2500 ${msg.text} \u2500\u2500`;
|
||||
msgLines.push({
|
||||
x: Math.max(0, Math.floor((cols - line.length) / 2)),
|
||||
width: line.length,
|
||||
text: line,
|
||||
style: st.systemMsg,
|
||||
});
|
||||
} else {
|
||||
const textW = bubbleMaxW - 4;
|
||||
const wrapped = wrapText(msg.text, textW);
|
||||
const contentW = Math.max(...wrapped.map(l => l.length), msg.sender.length + formatTime(msg.time).length + 3);
|
||||
const bubbleW = Math.min(bubbleMaxW, contentW + 4);
|
||||
const bx = msg.isMe ? cols - bubbleW - 1 : 1;
|
||||
const bgSt = msg.isMe ? st.myBg : st.theirBg;
|
||||
const nameSt = msg.isMe ? st.myName : st.theirName;
|
||||
const timeSt = msg.isMe ? st.myTime : st.theirTime;
|
||||
|
||||
// Name + time line (with bg fill)
|
||||
const timeStr = formatTime(msg.time);
|
||||
msgLines.push({ x: bx, width: bubbleW, text: `__BG__`, style: bgSt }); // background marker
|
||||
msgLines[msgLines.length - 1] = { x: bx, width: bubbleW, text: "", style: bgSt }; // fill bg
|
||||
// We'll render name and time separately
|
||||
msgLines.push({ x: bx + 2, width: 0, text: msg.sender, style: nameSt });
|
||||
msgLines.push({ x: bx + bubbleW - timeStr.length - 2, width: 0, text: timeStr, style: timeSt });
|
||||
|
||||
// Text lines
|
||||
for (const line of wrapped) {
|
||||
msgLines.push({ x: bx, width: bubbleW, text: "", style: bgSt }); // bg
|
||||
msgLines.push({ x: bx + 2, width: 0, text: line, style: bgSt }); // text
|
||||
}
|
||||
}
|
||||
allLines.push(msgLines);
|
||||
}
|
||||
|
||||
// Calculate per-message rendered heights
|
||||
const msgRenderedH: number[] = messages.map((msg, i) => {
|
||||
if (msg.isSystem) return 1;
|
||||
const textW = bubbleMaxW - 4;
|
||||
return wrapText(msg.text, textW).length + 1; // +1 for name row
|
||||
});
|
||||
|
||||
// Render from bottom up with scroll
|
||||
let drawY = msgBot - 1 + scrollOffset;
|
||||
for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const msg = messages[mi];
|
||||
const h = msgRenderedH[mi];
|
||||
const topRow = drawY - h + 1;
|
||||
|
||||
if (topRow < msgBot && drawY >= msgTop) {
|
||||
if (msg.isSystem) {
|
||||
if (drawY >= msgTop && drawY < msgBot) {
|
||||
const line = `\u2500\u2500 ${msg.text} \u2500\u2500`;
|
||||
screen.setText(Math.max(0, Math.floor((cols - line.length) / 2)), drawY, line, st.systemMsg);
|
||||
}
|
||||
} else {
|
||||
const textW = bubbleMaxW - 4;
|
||||
const wrapped = wrapText(msg.text, textW);
|
||||
const contentW = Math.max(...wrapped.map(l => l.length), msg.sender.length + formatTime(msg.time).length + 3);
|
||||
const bubbleW = Math.min(bubbleMaxW, contentW + 4);
|
||||
const bx = msg.isMe ? cols - bubbleW - 1 : 1;
|
||||
const bgSt = msg.isMe ? st.myBg : st.theirBg;
|
||||
const nameSt = msg.isMe ? st.myName : st.theirName;
|
||||
const timeSt = msg.isMe ? st.myTime : st.theirTime;
|
||||
|
||||
// Fill bubble background
|
||||
for (let r = 0; r < h; r++) {
|
||||
const ry = topRow + r;
|
||||
if (ry >= msgTop && ry < msgBot) {
|
||||
screen.fill(bx, ry, bubbleW, 1, " ", bgSt);
|
||||
}
|
||||
}
|
||||
|
||||
// Name + time
|
||||
if (topRow >= msgTop && topRow < msgBot) {
|
||||
screen.setText(bx + 2, topRow, msg.sender, nameSt);
|
||||
const timeStr = formatTime(msg.time);
|
||||
screen.setText(bx + bubbleW - timeStr.length - 2, topRow, timeStr, timeSt);
|
||||
}
|
||||
|
||||
// Text
|
||||
for (let li = 0; li < wrapped.length; li++) {
|
||||
const ry = topRow + 1 + li;
|
||||
if (ry >= msgTop && ry < msgBot) {
|
||||
screen.setText(bx + 2, ry, wrapped[li], bgSt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drawY = topRow - 1; // 1-line gap between messages
|
||||
}
|
||||
|
||||
writer.render(screen, {
|
||||
cursorX: 2 + Math.min(inputCursor, cols - 4),
|
||||
cursorY: inputY + 1,
|
||||
cursorVisible: true,
|
||||
cursorStyle: "line",
|
||||
cursorBlinking: true,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
|
||||
const { name, ctrl, alt } = event;
|
||||
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "enter":
|
||||
sendMessage(inputText);
|
||||
break;
|
||||
case "backspace":
|
||||
if (inputCursor > 0) {
|
||||
inputText = inputText.slice(0, inputCursor - 1) + inputText.slice(inputCursor);
|
||||
inputCursor--;
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
if (inputCursor < inputText.length)
|
||||
inputText = inputText.slice(0, inputCursor) + inputText.slice(inputCursor + 1);
|
||||
break;
|
||||
case "left":
|
||||
if (inputCursor > 0) inputCursor--;
|
||||
break;
|
||||
case "right":
|
||||
if (inputCursor < inputText.length) inputCursor++;
|
||||
break;
|
||||
case "home":
|
||||
inputCursor = 0;
|
||||
break;
|
||||
case "end":
|
||||
inputCursor = inputText.length;
|
||||
break;
|
||||
case "up":
|
||||
scrollOffset += 2;
|
||||
break;
|
||||
case "down":
|
||||
scrollOffset = Math.max(0, scrollOffset - 2);
|
||||
break;
|
||||
default:
|
||||
if (!ctrl && !alt && name.length === 1) {
|
||||
inputText = inputText.slice(0, inputCursor) + name + inputText.slice(inputCursor);
|
||||
inputCursor++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
reader.onpaste = (text: string) => {
|
||||
const line = text.split("\n")[0];
|
||||
inputText = inputText.slice(0, inputCursor) + line + inputText.slice(inputCursor);
|
||||
inputCursor += line.length;
|
||||
render();
|
||||
};
|
||||
|
||||
writer.onresize = (c: number, r: number) => {
|
||||
cols = c;
|
||||
rows = r;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
if (typingTimer) clearTimeout(typingTimer);
|
||||
writer.disableBracketedPaste();
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
render();
|
||||
473
test/js/bun/tui/demos/demo-colors.ts
Normal file
473
test/js/bun/tui/demos/demo-colors.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* demo-colors.ts — Color Palette Showcase
|
||||
*
|
||||
* Displays the full range of terminal styling capabilities:
|
||||
* - All 256 indexed-style colors (via true color approximation)
|
||||
* - True color gradients (RGB ramps)
|
||||
* - All text attributes (bold, italic, underline variants, strikethrough, etc.)
|
||||
* - Box drawing styles (single, double, rounded, heavy)
|
||||
*
|
||||
* Demonstrates: style (fg, bg, bold, italic, faint, blink, inverse, invisible,
|
||||
* strikethrough, overline, underline variants, underlineColor), fill, setText,
|
||||
* drawBox, TUITerminalWriter, TUIKeyReader.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-colors.ts
|
||||
* Exit: Press 'q' or Ctrl+C
|
||||
*/
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
let scrollY = 0;
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const headerStyle = screen.style({ fg: 0x61afef, bold: true });
|
||||
const subheaderStyle = screen.style({ fg: 0xe5c07b, bold: true });
|
||||
const labelStyle = screen.style({ fg: 0xabb2bf });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const footerStyle = screen.style({ fg: 0x5c6370, italic: true });
|
||||
|
||||
// --- Standard 16-color palette (approximate RGB values) ---
|
||||
const ansi16: number[] = [
|
||||
0x000000,
|
||||
0xcc0000,
|
||||
0x00cc00,
|
||||
0xcccc00,
|
||||
0x0000cc,
|
||||
0xcc00cc,
|
||||
0x00cccc,
|
||||
0xcccccc, // normal
|
||||
0x555555,
|
||||
0xff5555,
|
||||
0x55ff55,
|
||||
0xffff55,
|
||||
0x5555ff,
|
||||
0xff55ff,
|
||||
0x55ffff,
|
||||
0xffffff, // bright
|
||||
];
|
||||
|
||||
// --- Build the "virtual canvas" content, then render the visible window ---
|
||||
// We build an array of draw commands so we can scroll through them.
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
height: number;
|
||||
draw: (startX: number, startY: number, width: number) => void;
|
||||
}
|
||||
|
||||
const sections: Section[] = [];
|
||||
|
||||
// --- Section 1: Text Attributes ---
|
||||
sections.push({
|
||||
title: "Text Attributes",
|
||||
height: 14,
|
||||
draw(x, y, _w) {
|
||||
const attrs: { label: string; opts: Record<string, any> }[] = [
|
||||
{ label: "Bold", opts: { bold: true } },
|
||||
{ label: "Italic", opts: { italic: true } },
|
||||
{ label: "Faint (Dim)", opts: { faint: true } },
|
||||
{ label: "Underline (single)", opts: { underline: "single" } },
|
||||
{ label: "Underline (double)", opts: { underline: "double" } },
|
||||
{ label: "Underline (curly)", opts: { underline: "curly" } },
|
||||
{ label: "Underline (dotted)", opts: { underline: "dotted" } },
|
||||
{ label: "Underline (dashed)", opts: { underline: "dashed" } },
|
||||
{ label: "Strikethrough", opts: { strikethrough: true } },
|
||||
{ label: "Overline", opts: { overline: true } },
|
||||
{ label: "Inverse", opts: { inverse: true } },
|
||||
{ label: "Blink", opts: { blink: true } },
|
||||
{ label: "Bold + Italic + Underline", opts: { bold: true, italic: true, underline: "single" } },
|
||||
];
|
||||
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
const { label, opts } = attrs[i];
|
||||
const sid = screen.style(opts as any);
|
||||
screen.setText(x, y + i, ` ${label.padEnd(30)}`, labelStyle);
|
||||
screen.setText(x + 32, y + i, "The quick brown fox", sid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- Section 2: Underline with Color ---
|
||||
sections.push({
|
||||
title: "Colored Underlines",
|
||||
height: 6,
|
||||
draw(x, y, _w) {
|
||||
const colors = [
|
||||
{ label: "Red underline", color: 0xff0000 },
|
||||
{ label: "Green underline", color: 0x00ff00 },
|
||||
{ label: "Blue underline", color: 0x0000ff },
|
||||
{ label: "Yellow curly", color: 0xffff00 },
|
||||
{ label: "Magenta dashed", color: 0xff00ff },
|
||||
];
|
||||
const ulTypes: Array<"single" | "single" | "single" | "curly" | "dashed"> = [
|
||||
"single",
|
||||
"single",
|
||||
"single",
|
||||
"curly",
|
||||
"dashed",
|
||||
];
|
||||
for (let i = 0; i < colors.length; i++) {
|
||||
const { label, color } = colors[i];
|
||||
const sid = screen.style({
|
||||
underline: ulTypes[i],
|
||||
underlineColor: color,
|
||||
fg: 0xffffff,
|
||||
});
|
||||
screen.setText(x, y + i, ` ${label.padEnd(22)}`, labelStyle);
|
||||
screen.setText(x + 24, y + i, "Styled underline text", sid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- Section 3: 16 Standard Colors ---
|
||||
sections.push({
|
||||
title: "Standard 16 Colors (Foreground)",
|
||||
height: 3,
|
||||
draw(x, y, _w) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const sid = screen.style({ fg: ansi16[i] });
|
||||
const colX = x + 2 + i * 4;
|
||||
screen.setText(colX, y, `${String(i).padStart(2)} `, labelStyle);
|
||||
screen.setText(colX, y + 1, "\u2588\u2588", sid); // full block characters
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
sections.push({
|
||||
title: "Standard 16 Colors (Background)",
|
||||
height: 3,
|
||||
draw(x, y, _w) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const sid = screen.style({ bg: ansi16[i], fg: i < 8 ? 0xffffff : 0x000000 });
|
||||
const colX = x + 2 + i * 4;
|
||||
screen.setText(colX, y, `${String(i).padStart(2)} `, labelStyle);
|
||||
screen.fill(colX, y + 1, 3, 1, " ", sid);
|
||||
screen.setText(colX, y + 1, `${String(i).padStart(2)}`, sid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- Section 4: 6x6x6 Color Cube (216 colors, indices 16-231) ---
|
||||
sections.push({
|
||||
title: "216 Color Cube (6x6x6)",
|
||||
height: 8,
|
||||
draw(x, y, w) {
|
||||
// Display as 6 rows of 36 colors
|
||||
for (let row = 0; row < 6; row++) {
|
||||
for (let col = 0; col < 36; col++) {
|
||||
const idx = row * 36 + col;
|
||||
// Convert 216-index to RGB
|
||||
const r = Math.floor(idx / 36);
|
||||
const g = Math.floor((idx % 36) / 6);
|
||||
const b = idx % 6;
|
||||
const rVal = r === 0 ? 0 : 55 + r * 40;
|
||||
const gVal = g === 0 ? 0 : 55 + g * 40;
|
||||
const bVal = b === 0 ? 0 : 55 + b * 40;
|
||||
const rgb = (rVal << 16) | (gVal << 8) | bVal;
|
||||
const sid = screen.style({ bg: rgb });
|
||||
const colX = x + 2 + col * 2;
|
||||
if (colX + 1 < x + w) {
|
||||
screen.fill(colX, y + row, 2, 1, " ", sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Label
|
||||
screen.setText(x + 2, y + 7, "Rows: R(0-5) | Columns: G*6+B", dimStyle);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Section 5: Grayscale Ramp (24 shades, indices 232-255) ---
|
||||
sections.push({
|
||||
title: "24-Step Grayscale Ramp",
|
||||
height: 3,
|
||||
draw(x, y, w) {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const v = 8 + i * 10; // 8, 18, 28, ... 238
|
||||
const rgb = (v << 16) | (v << 8) | v;
|
||||
const sid = screen.style({ bg: rgb });
|
||||
const colX = x + 2 + i * 3;
|
||||
if (colX + 2 < x + w) {
|
||||
screen.fill(colX, y, 3, 1, " ", sid);
|
||||
// Show hex value below for some
|
||||
if (i % 4 === 0) {
|
||||
const hex = v.toString(16).padStart(2, "0");
|
||||
screen.setText(colX, y + 1, hex, dimStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- Section 6: True Color Gradients ---
|
||||
sections.push({
|
||||
title: "True Color Gradients",
|
||||
height: 8,
|
||||
draw(x, y, w) {
|
||||
const barWidth = Math.min(64, w - 4);
|
||||
|
||||
// Red gradient
|
||||
screen.setText(x + 2, y, "R:", labelStyle);
|
||||
for (let i = 0; i < barWidth; i++) {
|
||||
const v = Math.round((i / (barWidth - 1)) * 255);
|
||||
const sid = screen.style({ bg: v << 16 });
|
||||
screen.fill(x + 4 + i, y, 1, 1, " ", sid);
|
||||
}
|
||||
|
||||
// Green gradient
|
||||
screen.setText(x + 2, y + 1, "G:", labelStyle);
|
||||
for (let i = 0; i < barWidth; i++) {
|
||||
const v = Math.round((i / (barWidth - 1)) * 255);
|
||||
const sid = screen.style({ bg: v << 8 });
|
||||
screen.fill(x + 4 + i, y + 1, 1, 1, " ", sid);
|
||||
}
|
||||
|
||||
// Blue gradient
|
||||
screen.setText(x + 2, y + 2, "B:", labelStyle);
|
||||
for (let i = 0; i < barWidth; i++) {
|
||||
const v = Math.round((i / (barWidth - 1)) * 255);
|
||||
const sid = screen.style({ bg: v });
|
||||
screen.fill(x + 4 + i, y + 2, 1, 1, " ", sid);
|
||||
}
|
||||
|
||||
// Rainbow gradient (hue sweep)
|
||||
screen.setText(x + 2, y + 4, "Rainbow:", labelStyle);
|
||||
for (let i = 0; i < barWidth; i++) {
|
||||
const hue = (i / barWidth) * 360;
|
||||
const rgb = hslToRgb(hue, 1.0, 0.5);
|
||||
const sid = screen.style({ bg: rgb });
|
||||
screen.fill(x + 4 + i, y + 4, 1, 1, " ", sid);
|
||||
}
|
||||
|
||||
// Foreground rainbow text
|
||||
screen.setText(x + 2, y + 6, "Text:", labelStyle);
|
||||
const sampleText = "The quick brown fox jumps over the lazy dog";
|
||||
for (let i = 0; i < Math.min(sampleText.length, barWidth); i++) {
|
||||
const hue = (i / sampleText.length) * 360;
|
||||
const rgb = hslToRgb(hue, 1.0, 0.5);
|
||||
const sid = screen.style({ fg: rgb });
|
||||
screen.setText(x + 8 + i, y + 6, sampleText[i], sid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- Section 7: Box Drawing Styles ---
|
||||
sections.push({
|
||||
title: "Box Drawing Styles",
|
||||
height: 8,
|
||||
draw(x, y, w) {
|
||||
const boxStyles: Array<{ name: string; style: string }> = [
|
||||
{ name: "single", style: "single" },
|
||||
{ name: "double", style: "double" },
|
||||
{ name: "rounded", style: "rounded" },
|
||||
{ name: "heavy", style: "heavy" },
|
||||
];
|
||||
const boxWidth = Math.min(16, Math.floor((w - 4) / boxStyles.length));
|
||||
|
||||
for (let i = 0; i < boxStyles.length; i++) {
|
||||
const { name, style } = boxStyles[i];
|
||||
const bx = x + 2 + i * (boxWidth + 1);
|
||||
const borderColor = screen.style({ fg: 0x61afef });
|
||||
screen.drawBox(bx, y, boxWidth, 5, {
|
||||
style,
|
||||
styleId: borderColor,
|
||||
fill: true,
|
||||
});
|
||||
// Label inside the box
|
||||
const labelX = bx + Math.max(1, Math.floor((boxWidth - name.length) / 2));
|
||||
screen.setText(labelX, y + 2, name, labelStyle);
|
||||
}
|
||||
|
||||
// Nested boxes demo
|
||||
if (w > 40) {
|
||||
const nestedX = x + 2;
|
||||
const nestedY = y + 6;
|
||||
const outerBorder = screen.style({ fg: 0xe06c75 });
|
||||
const innerBorder = screen.style({ fg: 0x98c379 });
|
||||
screen.drawBox(nestedX, nestedY, 20, 2, { style: "double", styleId: outerBorder });
|
||||
// Note: nested box needs at least 2x2 to render
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- Section 8: Combined Styles ---
|
||||
sections.push({
|
||||
title: "Combined Foreground + Background",
|
||||
height: 6,
|
||||
draw(x, y, _w) {
|
||||
const combos: Array<{ label: string; fg: number; bg: number; extra?: Record<string, any> }> = [
|
||||
{ label: "White on Red", fg: 0xffffff, bg: 0xcc0000 },
|
||||
{ label: "Black on Yellow", fg: 0x000000, bg: 0xcccc00 },
|
||||
{ label: "Cyan on Blue", fg: 0x00ffff, bg: 0x000088 },
|
||||
{ label: "Bold Green on Black", fg: 0x00ff00, bg: 0x000000, extra: { bold: true } },
|
||||
{ label: "Italic White on Purple", fg: 0xffffff, bg: 0x880088, extra: { italic: true } },
|
||||
];
|
||||
for (let i = 0; i < combos.length; i++) {
|
||||
const { label, fg, bg, extra } = combos[i];
|
||||
const sid = screen.style({ fg, bg, ...extra } as any);
|
||||
screen.setText(x + 2, y + i, ` ${label.padEnd(30)}`, labelStyle);
|
||||
screen.setText(x + 34, y + i, ` ${label} `, sid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- HSL to RGB helper ---
|
||||
function hslToRgb(h: number, s: number, l: number): number {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
} else if (h < 180) {
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
b = x;
|
||||
}
|
||||
const ri = Math.round((r + m) * 255);
|
||||
const gi = Math.round((g + m) * 255);
|
||||
const bi = Math.round((b + m) * 255);
|
||||
return (ri << 16) | (gi << 8) | bi;
|
||||
}
|
||||
|
||||
// --- Calculate total virtual height ---
|
||||
function totalHeight(): number {
|
||||
let h = 1; // top padding
|
||||
for (const section of sections) {
|
||||
h += 1 + section.height + 1; // header + content + spacing
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
const titleBar = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
|
||||
screen.fill(0, 0, cols, 1, " ", titleBar);
|
||||
const title = " Bun TUI Color Palette ";
|
||||
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, titleBar);
|
||||
|
||||
// Scrollable content area
|
||||
const contentStartY = 1;
|
||||
const contentHeight = rows - 2; // leave room for footer
|
||||
let virtualY = 1 - scrollY; // current y position in virtual space
|
||||
|
||||
for (const section of sections) {
|
||||
// Section header
|
||||
if (virtualY >= contentStartY - 1 && virtualY < contentStartY + contentHeight) {
|
||||
const drawY = virtualY;
|
||||
if (drawY >= contentStartY && drawY < contentStartY + contentHeight) {
|
||||
screen.setText(1, drawY, `\u2500\u2500 ${section.title} `, headerStyle);
|
||||
// Fill rest of line with thin rule
|
||||
const ruleStart = 5 + section.title.length;
|
||||
for (let rx = ruleStart; rx < cols - 1; rx++) {
|
||||
screen.setText(rx, drawY, "\u2500", dimStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
virtualY++;
|
||||
|
||||
// Section content
|
||||
const contentDrawY = virtualY;
|
||||
if (contentDrawY + section.height > contentStartY && contentDrawY < contentStartY + contentHeight) {
|
||||
// Clip to visible area
|
||||
screen.clip(0, contentStartY, cols, contentStartY + contentHeight);
|
||||
section.draw(0, contentDrawY, cols);
|
||||
screen.unclip();
|
||||
}
|
||||
virtualY += section.height + 1; // content + spacing
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
screen.fill(0, footerY, cols, 1, " ", dimStyle);
|
||||
const total = totalHeight();
|
||||
const scrollPct = total > contentHeight ? Math.round((scrollY / (total - contentHeight)) * 100) : 0;
|
||||
const footerText = ` Scroll: \u2191\u2193/PgUp/PgDn | ${scrollPct}% | q/Ctrl+C: Quit `;
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), footerStyle);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScroll = Math.max(0, totalHeight() - (rows - 2));
|
||||
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
scrollY = Math.max(0, scrollY - 1);
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
scrollY = Math.min(maxScroll, scrollY + 1);
|
||||
break;
|
||||
case "pageup":
|
||||
scrollY = Math.max(0, scrollY - (rows - 3));
|
||||
break;
|
||||
case "pagedown":
|
||||
scrollY = Math.min(maxScroll, scrollY + (rows - 3));
|
||||
break;
|
||||
case "home":
|
||||
scrollY = 0;
|
||||
break;
|
||||
case "end":
|
||||
scrollY = maxScroll;
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Initial render ---
|
||||
render();
|
||||
263
test/js/bun/tui/demos/demo-dashboard.ts
Normal file
263
test/js/bun/tui/demos/demo-dashboard.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* demo-dashboard.ts — System Dashboard
|
||||
*
|
||||
* A full-screen dashboard showing system information in styled panels.
|
||||
* Demonstrates: drawBox, setText, style (fg/bg/bold/italic), fill, alt screen,
|
||||
* TUITerminalWriter, TUIKeyReader, and resize handling.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-dashboard.ts
|
||||
* Exit: Press 'q' or Ctrl+C
|
||||
*/
|
||||
|
||||
import { arch, cpus, freemem, homedir, hostname, platform, tmpdir, totalmem, uptime } from "os";
|
||||
|
||||
// --- Setup writer and key reader ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Style palette ---
|
||||
const styles = {
|
||||
// Title bar
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
// Section headers
|
||||
header: screen.style({ fg: 0x61afef, bold: true }),
|
||||
// Labels (left column in panels)
|
||||
label: screen.style({ fg: 0xabb2bf }),
|
||||
// Values (right column in panels)
|
||||
value: screen.style({ fg: 0xe5c07b }),
|
||||
// Highlighted values
|
||||
valueHigh: screen.style({ fg: 0x98c379, bold: true }),
|
||||
valueLow: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
// Box borders
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
borderAccent: screen.style({ fg: 0x61afef }),
|
||||
// Footer / help text
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
// Separator
|
||||
dim: screen.style({ fg: 0x3e4451 }),
|
||||
// Progress bar fill
|
||||
barFill: screen.style({ fg: 0x000000, bg: 0x98c379 }),
|
||||
barEmpty: screen.style({ fg: 0x3e4451 }),
|
||||
// Warning
|
||||
warning: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
};
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
/** Format bytes into a human-readable string. */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Format seconds into a human-readable uptime string. */
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (mins > 0) parts.push(`${mins}m`);
|
||||
parts.push(`${secs}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Draw a labeled key-value pair inside a panel. */
|
||||
function drawKV(x: number, y: number, maxWidth: number, label: string, value: string, valueStyle?: number) {
|
||||
screen.setText(x, y, label, styles.label);
|
||||
const valX = x + label.length;
|
||||
const remaining = maxWidth - label.length;
|
||||
if (remaining > 0) {
|
||||
screen.setText(valX, y, value.slice(0, remaining), valueStyle ?? styles.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw a horizontal progress bar. */
|
||||
function drawProgressBar(x: number, y: number, width: number, ratio: number, label: string) {
|
||||
screen.setText(x, y, label, styles.label);
|
||||
const barX = x + label.length + 1;
|
||||
const barWidth = width - label.length - 1 - 7; // leave room for percentage
|
||||
if (barWidth < 3) return;
|
||||
const filledCount = Math.round(ratio * barWidth);
|
||||
screen.fill(barX, y, filledCount, 1, " ", styles.barFill);
|
||||
if (barWidth - filledCount > 0) {
|
||||
screen.fill(barX + filledCount, y, barWidth - filledCount, 1, " ", styles.barEmpty);
|
||||
}
|
||||
const pct = `${Math.round(ratio * 100)}%`;
|
||||
screen.setText(barX + barWidth + 1, y, pct.padStart(4), ratio > 0.85 ? styles.valueLow : styles.valueHigh);
|
||||
}
|
||||
|
||||
// --- Main render function ---
|
||||
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar — full width
|
||||
screen.fill(0, 0, cols, 1, " ", styles.titleBar);
|
||||
const title = " Bun TUI Dashboard ";
|
||||
const titleX = Math.max(0, Math.floor((cols - title.length) / 2));
|
||||
screen.setText(titleX, 0, title, styles.titleBar);
|
||||
|
||||
// Timestamp on right
|
||||
const now = new Date().toLocaleTimeString();
|
||||
if (cols > now.length + 2) {
|
||||
screen.setText(cols - now.length - 1, 0, now, styles.titleBar);
|
||||
}
|
||||
|
||||
// Calculate panel layout
|
||||
const contentY = 2;
|
||||
const panelHeight = Math.min(10, rows - contentY - 3);
|
||||
if (panelHeight < 4) {
|
||||
screen.setText(0, 2, "Terminal too small!", styles.warning);
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const halfWidth = Math.floor((cols - 3) / 2);
|
||||
const leftX = 1;
|
||||
const rightX = leftX + halfWidth + 1;
|
||||
|
||||
// --- Panel 1: System Info (top-left) ---
|
||||
screen.drawBox(leftX, contentY, halfWidth, panelHeight, {
|
||||
style: "rounded",
|
||||
styleId: styles.borderAccent,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(leftX + 2, contentY, " System Info ", styles.header);
|
||||
|
||||
const infoX = leftX + 2;
|
||||
const infoW = halfWidth - 4;
|
||||
let infoY = contentY + 1;
|
||||
drawKV(infoX, infoY++, infoW, "Hostname: ", hostname());
|
||||
drawKV(infoX, infoY++, infoW, "Platform: ", `${platform()} (${arch()})`);
|
||||
drawKV(infoX, infoY++, infoW, "Bun: ", Bun.version, styles.valueHigh);
|
||||
drawKV(infoX, infoY++, infoW, "Home: ", homedir());
|
||||
drawKV(infoX, infoY++, infoW, "Uptime: ", formatUptime(uptime()));
|
||||
drawKV(infoX, infoY++, infoW, "Terminal: ", `${cols}x${rows}`);
|
||||
|
||||
// --- Panel 2: CPU Info (top-right) ---
|
||||
screen.drawBox(rightX, contentY, halfWidth, panelHeight, {
|
||||
style: "rounded",
|
||||
styleId: styles.borderAccent,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(rightX + 2, contentY, " CPU Info ", styles.header);
|
||||
|
||||
const cpuInfo = cpus();
|
||||
const cpuX = rightX + 2;
|
||||
const cpuW = halfWidth - 4;
|
||||
let cpuY = contentY + 1;
|
||||
drawKV(cpuX, cpuY++, cpuW, "Model: ", cpuInfo.length > 0 ? cpuInfo[0].model : "Unknown");
|
||||
drawKV(cpuX, cpuY++, cpuW, "Cores: ", `${cpuInfo.length}`);
|
||||
if (cpuInfo.length > 0) {
|
||||
drawKV(cpuX, cpuY++, cpuW, "Speed: ", `${cpuInfo[0].speed} MHz`);
|
||||
}
|
||||
|
||||
// Show per-core load as mini bars
|
||||
const maxCoresToShow = Math.min(cpuInfo.length, panelHeight - 5);
|
||||
for (let i = 0; i < maxCoresToShow; i++) {
|
||||
const core = cpuInfo[i];
|
||||
const total = core.times.user + core.times.nice + core.times.sys + core.times.idle + core.times.irq;
|
||||
const busy = total > 0 ? 1 - core.times.idle / total : 0;
|
||||
drawProgressBar(cpuX, cpuY++, cpuW, busy, `Core ${i}: `);
|
||||
}
|
||||
|
||||
// --- Panel 3: Memory (bottom-left) ---
|
||||
const memY = contentY + panelHeight + 1;
|
||||
const memHeight = Math.min(6, rows - memY - 2);
|
||||
if (memHeight >= 4) {
|
||||
screen.drawBox(leftX, memY, halfWidth, memHeight, {
|
||||
style: "rounded",
|
||||
styleId: styles.border,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(leftX + 2, memY, " Memory ", styles.header);
|
||||
|
||||
const total = totalmem();
|
||||
const free = freemem();
|
||||
const used = total - free;
|
||||
const ratio = total > 0 ? used / total : 0;
|
||||
|
||||
const memX = leftX + 2;
|
||||
const memW = halfWidth - 4;
|
||||
let my = memY + 1;
|
||||
drawKV(memX, my++, memW, "Total: ", formatBytes(total));
|
||||
drawKV(memX, my++, memW, "Used: ", formatBytes(used), ratio > 0.85 ? styles.valueLow : styles.value);
|
||||
drawKV(memX, my++, memW, "Free: ", formatBytes(free), styles.valueHigh);
|
||||
if (my < memY + memHeight - 1) {
|
||||
drawProgressBar(memX, my++, memW, ratio, "Usage: ");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Panel 4: Bun Runtime Info (bottom-right) ---
|
||||
if (memHeight >= 4) {
|
||||
screen.drawBox(rightX, memY, halfWidth, memHeight, {
|
||||
style: "rounded",
|
||||
styleId: styles.border,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(rightX + 2, memY, " Bun Runtime ", styles.header);
|
||||
|
||||
const bunX = rightX + 2;
|
||||
const bunW = halfWidth - 4;
|
||||
let by = memY + 1;
|
||||
drawKV(bunX, by++, bunW, "Version: ", Bun.version);
|
||||
drawKV(bunX, by++, bunW, "Revision: ", Bun.revision.slice(0, 8));
|
||||
drawKV(bunX, by++, bunW, "Main: ", Bun.main.split("/").pop() ?? Bun.main);
|
||||
if (by < memY + memHeight - 1) {
|
||||
drawKV(bunX, by++, bunW, "Tmp Dir: ", tmpdir());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Footer ---
|
||||
const footerY = rows - 1;
|
||||
screen.fill(0, footerY, cols, 1, " ", styles.dim);
|
||||
const footerText = " Press 'q' or Ctrl+C to exit | Auto-refreshes every second ";
|
||||
screen.setText(Math.max(0, Math.floor((cols - footerText.length) / 2)), footerY, footerText, styles.footer);
|
||||
|
||||
// Render to terminal
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Handle resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Handle input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
if (event.name === "q" || (event.name === "c" && event.ctrl)) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearInterval(timer);
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Initial render and refresh loop ---
|
||||
render();
|
||||
const timer = setInterval(render, 1000);
|
||||
98
test/js/bun/tui/demos/demo-deps.ts
Normal file
98
test/js/bun/tui/demos/demo-deps.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* demo-deps.ts — Dependency audit report showing packages with version,
|
||||
* latest version, severity indicators, and a summary.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 76);
|
||||
const height = 24;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
|
||||
const pkgStyle = screen.style({ fg: 0xabb2bf });
|
||||
const versionStyle = screen.style({ fg: 0x61afef });
|
||||
const okStyle = screen.style({ fg: 0x98c379, bold: true });
|
||||
const warnStyle = screen.style({ fg: 0xe5c07b, bold: true });
|
||||
const critStyle = screen.style({ fg: 0xe06c75, bold: true });
|
||||
const borderStyle = screen.style({ fg: 0x3e4452 });
|
||||
const summaryLabel = screen.style({ fg: 0xabb2bf });
|
||||
|
||||
interface Dep {
|
||||
name: string;
|
||||
current: string;
|
||||
latest: string;
|
||||
severity: "ok" | "warn" | "critical";
|
||||
}
|
||||
|
||||
const deps: Dep[] = [
|
||||
{ name: "typescript", current: "5.3.3", latest: "5.3.3", severity: "ok" },
|
||||
{ name: "esbuild", current: "0.19.8", latest: "0.19.11", severity: "warn" },
|
||||
{ name: "react", current: "18.2.0", latest: "18.2.0", severity: "ok" },
|
||||
{ name: "next", current: "14.0.1", latest: "14.0.4", severity: "warn" },
|
||||
{ name: "lodash", current: "4.17.19", latest: "4.17.21", severity: "critical" },
|
||||
{ name: "express", current: "4.18.2", latest: "4.18.2", severity: "ok" },
|
||||
{ name: "axios", current: "1.4.0", latest: "1.6.2", severity: "warn" },
|
||||
{ name: "zod", current: "3.22.4", latest: "3.22.4", severity: "ok" },
|
||||
{ name: "prisma", current: "5.5.0", latest: "5.7.1", severity: "warn" },
|
||||
{ name: "jsonwebtoken", current: "8.5.1", latest: "9.0.2", severity: "critical" },
|
||||
{ name: "ws", current: "8.14.2", latest: "8.16.0", severity: "warn" },
|
||||
{ name: "dotenv", current: "16.3.1", latest: "16.3.1", severity: "ok" },
|
||||
];
|
||||
|
||||
const severityIcons: Record<string, { icon: string; style: number }> = {
|
||||
ok: { icon: "\u2714 OK", style: okStyle },
|
||||
warn: { icon: "\u26A0 Update", style: warnStyle },
|
||||
critical: { icon: "\u2718 Critical", style: critStyle },
|
||||
};
|
||||
|
||||
// Title box
|
||||
screen.drawBox(0, 0, width, 3, { style: "rounded", styleId: borderStyle, fill: true, fillChar: " " });
|
||||
const title = "Dependency Audit Report";
|
||||
screen.setText(Math.floor((width - title.length) / 2), 1, title, titleStyle);
|
||||
|
||||
// Column headers
|
||||
const nameCol = 2;
|
||||
const curCol = 22;
|
||||
const latCol = 34;
|
||||
const sevCol = 46;
|
||||
|
||||
let y = 4;
|
||||
screen.setText(nameCol, y, "Package", headerStyle);
|
||||
screen.setText(curCol, y, "Current", headerStyle);
|
||||
screen.setText(latCol, y, "Latest", headerStyle);
|
||||
screen.setText(sevCol, y, "Status", headerStyle);
|
||||
y++;
|
||||
screen.setText(1, y, "\u2500".repeat(width - 2), dimStyle);
|
||||
y++;
|
||||
|
||||
for (const dep of deps) {
|
||||
screen.setText(nameCol, y, dep.name, pkgStyle);
|
||||
screen.setText(curCol, y, dep.current, versionStyle);
|
||||
screen.setText(latCol, y, dep.latest, versionStyle);
|
||||
const sev = severityIcons[dep.severity];
|
||||
screen.setText(sevCol, y, sev.icon, sev.style);
|
||||
y++;
|
||||
}
|
||||
|
||||
// Summary
|
||||
y++;
|
||||
screen.setText(1, y, "\u2500".repeat(width - 2), dimStyle);
|
||||
y++;
|
||||
|
||||
const okCount = deps.filter(d => d.severity === "ok").length;
|
||||
const warnCount = deps.filter(d => d.severity === "warn").length;
|
||||
const critCount = deps.filter(d => d.severity === "critical").length;
|
||||
|
||||
screen.setText(2, y, `${deps.length} packages scanned`, summaryLabel);
|
||||
y++;
|
||||
screen.setText(4, y, `\u2714 ${okCount} up to date`, okStyle);
|
||||
screen.setText(22, y, `\u26A0 ${warnCount} updates available`, warnStyle);
|
||||
screen.setText(48, y, `\u2718 ${critCount} critical`, critStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
344
test/js/bun/tui/demos/demo-diff.ts
Normal file
344
test/js/bun/tui/demos/demo-diff.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* demo-diff.ts — Side-by-Side Diff Viewer
|
||||
*
|
||||
* Displays two text buffers side-by-side with colored diff highlighting.
|
||||
* Added, removed, and changed lines are styled differently. Uses screen.copy()
|
||||
* to compose the split-pane layout from separate sub-screens.
|
||||
*
|
||||
* Demonstrates: copy() between TuiScreens, side-by-side layout, diff algorithm,
|
||||
* line numbers, synchronized scrolling, setText, fill, style (fg/bg/bold/faint),
|
||||
* drawBox, clip/unclip, TUITerminalWriter, TUIKeyReader, alt screen, resize.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-diff.ts
|
||||
* Controls: j/k scroll, Tab switch active pane, Q quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0xe06c75, bold: true }),
|
||||
lineNum: screen.style({ fg: 0x5c6370 }),
|
||||
lineNumActive: screen.style({ fg: 0xe5c07b }),
|
||||
text: screen.style({ fg: 0xabb2bf }),
|
||||
added: screen.style({ fg: 0x98c379, bg: 0x1a2e1a }),
|
||||
addedGutter: screen.style({ fg: 0x98c379, bold: true }),
|
||||
removed: screen.style({ fg: 0xe06c75, bg: 0x2e1a1a }),
|
||||
removedGutter: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
changed: screen.style({ fg: 0xe5c07b, bg: 0x2e2a1a }),
|
||||
changedGutter: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
same: screen.style({ fg: 0xabb2bf }),
|
||||
header: screen.style({ fg: 0x61afef, bold: true }),
|
||||
headerBg: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
|
||||
separator: screen.style({ fg: 0x3e4451 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
stats: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
activeBorder: screen.style({ fg: 0x61afef }),
|
||||
};
|
||||
|
||||
// --- Sample diff data ---
|
||||
const leftLines = [
|
||||
"import { serve } from 'bun';",
|
||||
"",
|
||||
"const server = serve({",
|
||||
" port: 3000,",
|
||||
" fetch(req) {",
|
||||
" const url = new URL(req.url);",
|
||||
"",
|
||||
" if (url.pathname === '/') {",
|
||||
" return new Response('Hello World');",
|
||||
" }",
|
||||
"",
|
||||
" if (url.pathname === '/api/users') {",
|
||||
" return Response.json([",
|
||||
" { id: 1, name: 'Alice' },",
|
||||
" { id: 2, name: 'Bob' },",
|
||||
" ]);",
|
||||
" }",
|
||||
"",
|
||||
" return new Response('Not Found', {",
|
||||
" status: 404,",
|
||||
" });",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"console.log(`Server running on port ${server.port}`);",
|
||||
];
|
||||
|
||||
const rightLines = [
|
||||
"import { serve, file } from 'bun';",
|
||||
"import { join } from 'path';",
|
||||
"",
|
||||
"const server = serve({",
|
||||
" port: process.env.PORT || 3000,",
|
||||
" fetch(req) {",
|
||||
" const url = new URL(req.url);",
|
||||
"",
|
||||
" if (url.pathname === '/') {",
|
||||
" return new Response('Hello, Bun!', {",
|
||||
" headers: { 'Content-Type': 'text/plain' },",
|
||||
" });",
|
||||
" }",
|
||||
"",
|
||||
" if (url.pathname === '/api/users') {",
|
||||
" const users = await getUsers();",
|
||||
" return Response.json(users);",
|
||||
" }",
|
||||
"",
|
||||
" if (url.pathname.startsWith('/static/')) {",
|
||||
" return new Response(file(join('public', url.pathname)));",
|
||||
" }",
|
||||
"",
|
||||
" return new Response('Not Found', {",
|
||||
" status: 404,",
|
||||
" headers: { 'X-Error': 'route-not-found' },",
|
||||
" });",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"console.log(`Server: http://localhost:${server.port}`);",
|
||||
];
|
||||
|
||||
// --- Simple LCS-based diff ---
|
||||
type DiffType = "same" | "added" | "removed" | "changed";
|
||||
interface DiffLine {
|
||||
left: string;
|
||||
right: string;
|
||||
leftNum: number;
|
||||
rightNum: number;
|
||||
type: DiffType;
|
||||
}
|
||||
|
||||
function computeDiff(): DiffLine[] {
|
||||
const result: DiffLine[] = [];
|
||||
let li = 0,
|
||||
ri = 0;
|
||||
|
||||
// Simple line-by-line comparison with basic alignment
|
||||
while (li < leftLines.length || ri < rightLines.length) {
|
||||
if (li >= leftLines.length) {
|
||||
result.push({ left: "", right: rightLines[ri], leftNum: 0, rightNum: ri + 1, type: "added" });
|
||||
ri++;
|
||||
} else if (ri >= rightLines.length) {
|
||||
result.push({ left: leftLines[li], right: "", leftNum: li + 1, rightNum: 0, type: "removed" });
|
||||
li++;
|
||||
} else if (leftLines[li] === rightLines[ri]) {
|
||||
result.push({ left: leftLines[li], right: rightLines[ri], leftNum: li + 1, rightNum: ri + 1, type: "same" });
|
||||
li++;
|
||||
ri++;
|
||||
} else {
|
||||
// Check if next left matches current right (insertion)
|
||||
const leftMatchesAhead = leftLines.indexOf(rightLines[ri], li + 1);
|
||||
const rightMatchesAhead = rightLines.indexOf(leftLines[li], ri + 1);
|
||||
|
||||
if (rightMatchesAhead >= 0 && (leftMatchesAhead < 0 || rightMatchesAhead - ri < leftMatchesAhead - li)) {
|
||||
// Lines added on right
|
||||
while (ri < rightMatchesAhead) {
|
||||
result.push({ left: "", right: rightLines[ri], leftNum: 0, rightNum: ri + 1, type: "added" });
|
||||
ri++;
|
||||
}
|
||||
} else if (leftMatchesAhead >= 0) {
|
||||
// Lines removed from left
|
||||
while (li < leftMatchesAhead) {
|
||||
result.push({ left: leftLines[li], right: "", leftNum: li + 1, rightNum: 0, type: "removed" });
|
||||
li++;
|
||||
}
|
||||
} else {
|
||||
// Changed line
|
||||
result.push({ left: leftLines[li], right: rightLines[ri], leftNum: li + 1, rightNum: ri + 1, type: "changed" });
|
||||
li++;
|
||||
ri++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const diffLines = computeDiff();
|
||||
|
||||
// --- State ---
|
||||
let scrollOffset = 0;
|
||||
let activePane = 0; // 0=left, 1=right
|
||||
|
||||
// Stats
|
||||
const addedCount = diffLines.filter(d => d.type === "added").length;
|
||||
const removedCount = diffLines.filter(d => d.type === "removed").length;
|
||||
const changedCount = diffLines.filter(d => d.type === "changed").length;
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Diff Viewer ", st.titleBar);
|
||||
const statsText = `+${addedCount} -${removedCount} ~${changedCount}`;
|
||||
screen.setText(cols - statsText.length - 2, 0, statsText, st.titleBar);
|
||||
|
||||
// File headers
|
||||
const halfW = Math.floor((cols - 1) / 2);
|
||||
screen.fill(0, 1, halfW, 1, " ", st.headerBg);
|
||||
screen.fill(halfW + 1, 1, halfW, 1, " ", st.headerBg);
|
||||
screen.setText(2, 1, "original.ts", activePane === 0 ? st.header : st.headerBg);
|
||||
screen.setText(halfW + 3, 1, "modified.ts", activePane === 1 ? st.header : st.headerBg);
|
||||
|
||||
// Separator
|
||||
for (let y = 1; y < rows - 1; y++) {
|
||||
screen.setText(halfW, y, "\u2502", st.separator);
|
||||
}
|
||||
|
||||
// Diff lines
|
||||
const contentY = 2;
|
||||
const contentH = rows - contentY - 1;
|
||||
const gutterW = 4;
|
||||
const textW = halfW - gutterW - 2;
|
||||
|
||||
// Ensure scroll is valid
|
||||
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, diffLines.length - contentH)));
|
||||
|
||||
for (let vi = 0; vi < contentH; vi++) {
|
||||
const di = scrollOffset + vi;
|
||||
if (di >= diffLines.length) break;
|
||||
const diff = diffLines[di];
|
||||
const y = contentY + vi;
|
||||
|
||||
// Gutter indicator
|
||||
let gutterChar = " ";
|
||||
let gutterStyle = st.lineNum;
|
||||
switch (diff.type) {
|
||||
case "added":
|
||||
gutterChar = "+";
|
||||
gutterStyle = st.addedGutter;
|
||||
break;
|
||||
case "removed":
|
||||
gutterChar = "-";
|
||||
gutterStyle = st.removedGutter;
|
||||
break;
|
||||
case "changed":
|
||||
gutterChar = "~";
|
||||
gutterStyle = st.changedGutter;
|
||||
break;
|
||||
}
|
||||
|
||||
// Left pane
|
||||
const leftBg = diff.type === "removed" ? st.removed : diff.type === "changed" ? st.changed : st.text;
|
||||
if (diff.leftNum > 0) {
|
||||
screen.setText(0, y, String(diff.leftNum).padStart(gutterW - 1), st.lineNum);
|
||||
}
|
||||
screen.setText(gutterW - 1, y, gutterChar, gutterStyle);
|
||||
const leftText = diff.left.slice(0, textW);
|
||||
if (leftText.length > 0) {
|
||||
screen.setText(gutterW + 1, y, leftText, diff.type === "same" ? st.same : leftBg);
|
||||
}
|
||||
if (diff.type === "removed" || diff.type === "changed") {
|
||||
// Fill background for visibility
|
||||
for (let x = gutterW + 1 + leftText.length; x < halfW; x++) {
|
||||
screen.setText(x, y, " ", leftBg);
|
||||
}
|
||||
}
|
||||
|
||||
// Right pane
|
||||
const rightX = halfW + 1;
|
||||
const rightBg = diff.type === "added" ? st.added : diff.type === "changed" ? st.changed : st.text;
|
||||
if (diff.rightNum > 0) {
|
||||
screen.setText(rightX, y, String(diff.rightNum).padStart(gutterW - 1), st.lineNum);
|
||||
}
|
||||
screen.setText(rightX + gutterW - 1, y, gutterChar, gutterStyle);
|
||||
const rightText = diff.right.slice(0, textW);
|
||||
if (rightText.length > 0) {
|
||||
screen.setText(rightX + gutterW + 1, y, rightText, diff.type === "same" ? st.same : rightBg);
|
||||
}
|
||||
if (diff.type === "added" || diff.type === "changed") {
|
||||
for (let x = rightX + gutterW + 1 + rightText.length; x < cols; x++) {
|
||||
screen.setText(x, y, " ", rightBg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll position
|
||||
if (diffLines.length > contentH) {
|
||||
const barH = Math.max(1, Math.floor((contentH * contentH) / diffLines.length));
|
||||
const barPos = Math.floor((scrollOffset / Math.max(1, diffLines.length - contentH)) * (contentH - barH));
|
||||
for (let i = 0; i < contentH; i++) {
|
||||
const ch = i >= barPos && i < barPos + barH ? "\u2588" : "\u2502";
|
||||
screen.setText(cols - 1, contentY + i, ch, i >= barPos && i < barPos + barH ? st.header : st.separator);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerText = ` j/k:Scroll | Tab:Switch pane | ${diffLines.length} lines | q:Quit `;
|
||||
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
scrollOffset = Math.max(0, scrollOffset - 1);
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
scrollOffset++;
|
||||
break;
|
||||
case "pageup":
|
||||
scrollOffset = Math.max(0, scrollOffset - (rows - 4));
|
||||
break;
|
||||
case "pagedown":
|
||||
scrollOffset += rows - 4;
|
||||
break;
|
||||
case "home":
|
||||
case "g":
|
||||
scrollOffset = 0;
|
||||
break;
|
||||
case "end":
|
||||
scrollOffset = diffLines.length;
|
||||
break;
|
||||
case "tab":
|
||||
activePane = 1 - activePane;
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
render();
|
||||
463
test/js/bun/tui/demos/demo-file-browser.ts
Normal file
463
test/js/bun/tui/demos/demo-file-browser.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* demo-file-browser.ts — File Browser
|
||||
*
|
||||
* A keyboard-navigable file browser with directory tree, file details,
|
||||
* file type icons, and breadcrumb path display.
|
||||
*
|
||||
* Demonstrates: real filesystem integration (fs.readdirSync, fs.statSync),
|
||||
* tree navigation, setText, fill, style (fg/bg/bold/italic), drawBox,
|
||||
* TUITerminalWriter, TUIKeyReader, alt screen, resize handling.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-file-browser.ts
|
||||
* Controls: j/k or arrows to navigate, Enter to open dir, Backspace to go up,
|
||||
* / to filter, Q / Ctrl+C to quit
|
||||
*/
|
||||
|
||||
import { readdirSync, statSync } from "fs";
|
||||
import { basename, dirname, join, resolve } from "path";
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
pathBg: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
|
||||
pathSegment: screen.style({ fg: 0x61afef, bg: 0x21252b, bold: true }),
|
||||
pathSep: screen.style({ fg: 0x5c6370, bg: 0x21252b }),
|
||||
dir: screen.style({ fg: 0x61afef, bold: true }),
|
||||
dirSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
file: screen.style({ fg: 0xabb2bf }),
|
||||
fileSelected: screen.style({ fg: 0x000000, bg: 0x61afef }),
|
||||
symlink: screen.style({ fg: 0xc678dd, italic: true }),
|
||||
symlinkSelected: screen.style({ fg: 0x000000, bg: 0xc678dd }),
|
||||
executable: screen.style({ fg: 0x98c379, bold: true }),
|
||||
execSelected: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
|
||||
hidden: screen.style({ fg: 0x5c6370 }),
|
||||
hiddenSelected: screen.style({ fg: 0x000000, bg: 0x5c6370 }),
|
||||
icon: screen.style({ fg: 0xe5c07b }),
|
||||
iconSelected: screen.style({ fg: 0x000000, bg: 0x61afef }),
|
||||
selectedBg: screen.style({ bg: 0x61afef }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
detailHeader: screen.style({ fg: 0x61afef, bold: true }),
|
||||
detailLabel: screen.style({ fg: 0xabb2bf }),
|
||||
detailValue: screen.style({ fg: 0xe5c07b }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
count: screen.style({ fg: 0xe5c07b }),
|
||||
error: screen.style({ fg: 0xe06c75, italic: true }),
|
||||
filterLabel: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
filterText: screen.style({ fg: 0xffffff }),
|
||||
};
|
||||
|
||||
// --- File type icons ---
|
||||
function getIcon(name: string, isDir: boolean): string {
|
||||
if (isDir) return "\u{1F4C1}"; // 📁
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
switch (ext) {
|
||||
case "ts":
|
||||
case "tsx":
|
||||
return "\u{1F7E6}"; // 🟦
|
||||
case "js":
|
||||
case "jsx":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return "\u{1F7E8}"; // 🟨
|
||||
case "json":
|
||||
return "\u{1F4CB}"; // 📋
|
||||
case "md":
|
||||
case "txt":
|
||||
return "\u{1F4DD}"; // 📝
|
||||
case "zig":
|
||||
return "\u26A1"; // ⚡
|
||||
case "cpp":
|
||||
case "c":
|
||||
case "h":
|
||||
return "\u2699"; // ⚙
|
||||
case "rs":
|
||||
return "\u{1F980}"; // 🦀
|
||||
case "go":
|
||||
return "\u{1F439}"; // 🐹
|
||||
case "py":
|
||||
return "\u{1F40D}"; // 🐍
|
||||
case "toml":
|
||||
case "yaml":
|
||||
case "yml":
|
||||
return "\u2699"; // ⚙
|
||||
case "lock":
|
||||
return "\u{1F512}"; // 🔒
|
||||
case "gitignore":
|
||||
return "\u{1F6AB}"; // 🚫
|
||||
default:
|
||||
return "\u{1F4C4}"; // 📄
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entry type ---
|
||||
interface Entry {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
isSymlink: boolean;
|
||||
isExecutable: boolean;
|
||||
isHidden: boolean;
|
||||
size: number;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
let currentPath = resolve(process.cwd());
|
||||
let entries: Entry[] = [];
|
||||
let filteredEntries: Entry[] = [];
|
||||
let selectedIndex = 0;
|
||||
let scrollOffset = 0;
|
||||
let filterMode = false;
|
||||
let filterText = "";
|
||||
let errorMsg = "";
|
||||
|
||||
function loadDir(path: string) {
|
||||
errorMsg = "";
|
||||
try {
|
||||
const names = readdirSync(path);
|
||||
entries = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const full = join(path, name);
|
||||
const stat = statSync(full, { throwIfNoEntry: false });
|
||||
if (!stat) continue;
|
||||
entries.push({
|
||||
name,
|
||||
isDir: stat.isDirectory(),
|
||||
isSymlink: stat.isSymbolicLink(),
|
||||
isExecutable: !stat.isDirectory() && (stat.mode & 0o111) !== 0,
|
||||
isHidden: name.startsWith("."),
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
});
|
||||
} catch {
|
||||
// Skip entries we can't stat
|
||||
}
|
||||
}
|
||||
// Sort: dirs first, then alphabetically
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
// Clear filter when navigating to a new directory
|
||||
filterText = "";
|
||||
filterMode = false;
|
||||
applyFilter();
|
||||
selectedIndex = 0;
|
||||
scrollOffset = 0;
|
||||
currentPath = path;
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message || "Failed to read directory";
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
if (filterText.length === 0) {
|
||||
filteredEntries = entries;
|
||||
} else {
|
||||
const q = filterText.toLowerCase();
|
||||
filteredEntries = entries.filter(e => e.name.toLowerCase().includes(q));
|
||||
}
|
||||
if (selectedIndex >= filteredEntries.length) {
|
||||
selectedIndex = Math.max(0, filteredEntries.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(1, 0, " File Browser ", st.titleBar);
|
||||
|
||||
// Breadcrumb path
|
||||
screen.fill(0, 1, cols, 1, " ", st.pathBg);
|
||||
const pathParts = currentPath.split("/").filter(Boolean);
|
||||
let px = 1;
|
||||
screen.setText(px, 1, "/", st.pathSep);
|
||||
px++;
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const part = pathParts[i];
|
||||
if (px + part.length + 1 >= cols - 3) {
|
||||
screen.setText(px, 1, "...", st.pathSep);
|
||||
break;
|
||||
}
|
||||
screen.setText(px, 1, part, st.pathSegment);
|
||||
px += part.length;
|
||||
if (i < pathParts.length - 1) {
|
||||
screen.setText(px, 1, "/", st.pathSep);
|
||||
px++;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter bar (if active)
|
||||
const filterY = 2;
|
||||
if (filterMode) {
|
||||
screen.setText(1, filterY, "/ ", st.filterLabel);
|
||||
screen.setText(3, filterY, filterText + "_", st.filterText);
|
||||
} else if (filterText.length > 0) {
|
||||
screen.setText(1, filterY, "Filter: ", st.detailLabel);
|
||||
screen.setText(9, filterY, filterText, st.filterText);
|
||||
screen.setText(9 + filterText.length + 1, filterY, `(${filteredEntries.length}/${entries.length})`, st.count);
|
||||
}
|
||||
|
||||
// File list area
|
||||
const listY = 3;
|
||||
const listH = rows - listY - 1;
|
||||
const detailW = 30;
|
||||
const listW = cols - detailW - 3;
|
||||
|
||||
// Ensure selected is visible
|
||||
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
||||
if (selectedIndex >= scrollOffset + listH) scrollOffset = selectedIndex - listH + 1;
|
||||
|
||||
// Error message
|
||||
if (errorMsg.length > 0) {
|
||||
screen.setText(2, listY + 1, errorMsg.slice(0, listW - 2), st.error);
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// File entries
|
||||
const visibleCount = Math.min(listH, filteredEntries.length - scrollOffset);
|
||||
for (let vi = 0; vi < visibleCount; vi++) {
|
||||
const idx = scrollOffset + vi;
|
||||
const entry = filteredEntries[idx];
|
||||
const y = listY + vi;
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
if (isSelected) {
|
||||
screen.fill(0, y, listW + 1, 1, " ", st.selectedBg);
|
||||
}
|
||||
|
||||
// Icon (using 2 chars for wide emoji)
|
||||
const icon = getIcon(entry.name, entry.isDir);
|
||||
screen.setText(1, y, icon, isSelected ? st.iconSelected : st.icon);
|
||||
|
||||
// Name
|
||||
let nameStyle: number;
|
||||
if (entry.isDir) {
|
||||
nameStyle = isSelected ? st.dirSelected : st.dir;
|
||||
} else if (entry.isSymlink) {
|
||||
nameStyle = isSelected ? st.symlinkSelected : st.symlink;
|
||||
} else if (entry.isExecutable) {
|
||||
nameStyle = isSelected ? st.execSelected : st.executable;
|
||||
} else if (entry.isHidden) {
|
||||
nameStyle = isSelected ? st.hiddenSelected : st.hidden;
|
||||
} else {
|
||||
nameStyle = isSelected ? st.fileSelected : st.file;
|
||||
}
|
||||
|
||||
const nameX = 4; // after icon + space
|
||||
const maxNameW = listW - nameX - 12;
|
||||
let displayName = entry.name;
|
||||
if (entry.isDir) displayName += "/";
|
||||
if (displayName.length > maxNameW) displayName = displayName.slice(0, maxNameW - 1) + "\u2026";
|
||||
screen.setText(nameX, y, displayName, nameStyle);
|
||||
|
||||
// Size (right-aligned)
|
||||
if (!entry.isDir) {
|
||||
const sizeStr = formatSize(entry.size).padStart(10);
|
||||
screen.setText(listW - 10, y, sizeStr, isSelected ? st.fileSelected : st.detailLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
if (scrollOffset > 0) {
|
||||
screen.setText(listW, listY, "\u25b2", st.count); // ▲
|
||||
}
|
||||
if (scrollOffset + listH < filteredEntries.length) {
|
||||
screen.setText(listW, listY + listH - 1, "\u25bc", st.count); // ▼
|
||||
}
|
||||
|
||||
// --- Detail panel ---
|
||||
const detailX = listW + 2;
|
||||
if (detailW > 14 && filteredEntries.length > 0 && selectedIndex < filteredEntries.length) {
|
||||
const sel = filteredEntries[selectedIndex];
|
||||
screen.drawBox(detailX, listY, detailW, Math.min(12, listH), {
|
||||
style: "rounded",
|
||||
styleId: st.border,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(detailX + 2, listY, " Details ", st.detailHeader);
|
||||
|
||||
let dy = listY + 1;
|
||||
screen.setText(detailX + 2, dy, "Name:", st.detailLabel);
|
||||
screen.setText(detailX + 9, dy, sel.name.slice(0, detailW - 11), st.detailValue);
|
||||
dy++;
|
||||
|
||||
screen.setText(detailX + 2, dy, "Type:", st.detailLabel);
|
||||
const typeStr = sel.isDir ? "Directory" : sel.isSymlink ? "Symlink" : "File";
|
||||
screen.setText(detailX + 9, dy, typeStr, st.detailValue);
|
||||
dy++;
|
||||
|
||||
if (!sel.isDir) {
|
||||
screen.setText(detailX + 2, dy, "Size:", st.detailLabel);
|
||||
screen.setText(detailX + 9, dy, formatSize(sel.size), st.detailValue);
|
||||
dy++;
|
||||
}
|
||||
|
||||
screen.setText(detailX + 2, dy, "Modified:", st.detailLabel);
|
||||
dy++;
|
||||
screen.setText(detailX + 2, dy, formatDate(sel.mtime).slice(0, detailW - 4), st.detailValue);
|
||||
dy++;
|
||||
|
||||
if (sel.isExecutable) {
|
||||
dy++;
|
||||
screen.setText(detailX + 2, dy, "Executable", st.executable);
|
||||
}
|
||||
if (sel.isHidden) {
|
||||
dy++;
|
||||
screen.setText(detailX + 2, dy, "Hidden", st.hidden);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
const footerText = " \u2191\u2193/jk: Navigate | Enter: Open | Backspace: Up | /: Filter | q: Quit ";
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean; sequence: string }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (filterMode) {
|
||||
switch (name) {
|
||||
case "escape":
|
||||
filterMode = false;
|
||||
filterText = "";
|
||||
applyFilter();
|
||||
break;
|
||||
case "enter":
|
||||
filterMode = false;
|
||||
break;
|
||||
case "backspace":
|
||||
if (filterText.length > 0) {
|
||||
filterText = filterText.slice(0, -1);
|
||||
applyFilter();
|
||||
} else {
|
||||
filterMode = false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (!ctrl && !event.alt && name.length === 1) {
|
||||
filterText += name;
|
||||
applyFilter();
|
||||
}
|
||||
break;
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "q":
|
||||
cleanup();
|
||||
return;
|
||||
case "up":
|
||||
case "k":
|
||||
if (selectedIndex > 0) selectedIndex--;
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
if (selectedIndex < filteredEntries.length - 1) selectedIndex++;
|
||||
break;
|
||||
case "home":
|
||||
case "g":
|
||||
selectedIndex = 0;
|
||||
break;
|
||||
case "end":
|
||||
selectedIndex = Math.max(0, filteredEntries.length - 1);
|
||||
break;
|
||||
case "pageup":
|
||||
selectedIndex = Math.max(0, selectedIndex - (rows - 5));
|
||||
break;
|
||||
case "pagedown":
|
||||
selectedIndex = Math.min(filteredEntries.length - 1, selectedIndex + (rows - 5));
|
||||
break;
|
||||
case "enter": {
|
||||
const sel = filteredEntries[selectedIndex];
|
||||
if (sel?.isDir) {
|
||||
loadDir(join(currentPath, sel.name));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "backspace": {
|
||||
const parent = dirname(currentPath);
|
||||
if (parent !== currentPath) {
|
||||
const oldDir = basename(currentPath);
|
||||
loadDir(parent);
|
||||
// Try to select the directory we came from
|
||||
const idx = filteredEntries.findIndex(e => e.name === oldDir);
|
||||
if (idx >= 0) selectedIndex = idx;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/":
|
||||
filterMode = true;
|
||||
filterText = "";
|
||||
break;
|
||||
case "escape":
|
||||
if (filterText.length > 0) {
|
||||
filterText = "";
|
||||
applyFilter();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
loadDir(currentPath);
|
||||
render();
|
||||
116
test/js/bun/tui/demos/demo-flags.ts
Normal file
116
test/js/bun/tui/demos/demo-flags.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* demo-flags.ts — Renders several country flags using colored block characters.
|
||||
* Shows France, Germany, Italy, Japan, and Ukraine using full and half blocks.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 72);
|
||||
const height = 22;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const labelStyle = screen.style({ fg: 0xabb2bf });
|
||||
|
||||
// Flag dimensions
|
||||
const flagW = 12;
|
||||
const flagH = 4;
|
||||
|
||||
interface Flag {
|
||||
name: string;
|
||||
render: (sx: number, sy: number) => void;
|
||||
}
|
||||
|
||||
function fillBlock(sx: number, sy: number, w: number, h: number, color: number) {
|
||||
const s = screen.style({ fg: color, bg: color });
|
||||
screen.fill(sx, sy, w, h, "\u2588", s);
|
||||
}
|
||||
|
||||
// Use half-block technique: top half uses fg color, bottom half uses bg with upper-half block
|
||||
function halfRow(sx: number, y: number, w: number, topColor: number, bottomColor: number) {
|
||||
const s = screen.style({ fg: topColor, bg: bottomColor });
|
||||
screen.fill(sx, y, w, 1, "\u2580", s);
|
||||
}
|
||||
|
||||
const flags: Flag[] = [
|
||||
{
|
||||
name: "France",
|
||||
render: (sx, sy) => {
|
||||
const third = Math.floor(flagW / 3);
|
||||
fillBlock(sx, sy, third, flagH, 0x002395); // Blue
|
||||
fillBlock(sx + third, sy, third, flagH, 0xffffff); // White
|
||||
fillBlock(sx + third * 2, sy, flagW - third * 2, flagH, 0xed2939); // Red
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Germany",
|
||||
render: (sx, sy) => {
|
||||
// 3 horizontal stripes - use half blocks for better resolution
|
||||
halfRow(sx, sy, flagW, 0x000000, 0x000000); // Black top
|
||||
halfRow(sx, sy + 1, flagW, 0x000000, 0xdd0000); // Black / Red
|
||||
halfRow(sx, sy + 2, flagW, 0xdd0000, 0xffcc00); // Red / Gold
|
||||
halfRow(sx, sy + 3, flagW, 0xffcc00, 0xffcc00); // Gold bottom
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Italy",
|
||||
render: (sx, sy) => {
|
||||
const third = Math.floor(flagW / 3);
|
||||
fillBlock(sx, sy, third, flagH, 0x009246); // Green
|
||||
fillBlock(sx + third, sy, third, flagH, 0xffffff); // White
|
||||
fillBlock(sx + third * 2, sy, flagW - third * 2, flagH, 0xce2b37); // Red
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Japan",
|
||||
render: (sx, sy) => {
|
||||
// White background
|
||||
fillBlock(sx, sy, flagW, flagH, 0xffffff);
|
||||
// Red circle in center (approximated with blocks)
|
||||
const cx = sx + Math.floor(flagW / 2);
|
||||
const cy = sy + Math.floor(flagH / 2);
|
||||
const redStyle = screen.style({ fg: 0xbc002d, bg: 0xbc002d });
|
||||
// Draw a rough circle
|
||||
screen.fill(cx - 1, cy - 1, 3, 1, "\u2588", redStyle);
|
||||
screen.fill(cx - 2, cy, 5, 1, "\u2588", redStyle);
|
||||
screen.fill(cx - 1, cy + 1, 3, 1, "\u2588", redStyle);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Ukraine",
|
||||
render: (sx, sy) => {
|
||||
// Blue top half, yellow bottom half
|
||||
fillBlock(sx, sy, flagW, 2, 0x0057b7); // Blue
|
||||
fillBlock(sx, sy + 2, flagW, 2, 0xffd700); // Yellow
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "World Flags", titleStyle);
|
||||
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
|
||||
|
||||
// Layout: 3 flags per row
|
||||
const gap = 3;
|
||||
const colWidth = flagW + gap;
|
||||
const flagsPerRow = 3;
|
||||
let y = 3;
|
||||
|
||||
for (let i = 0; i < flags.length; i++) {
|
||||
const col = i % flagsPerRow;
|
||||
const row = Math.floor(i / flagsPerRow);
|
||||
const x = 2 + col * (colWidth + 2);
|
||||
const fy = y + row * (flagH + 3);
|
||||
|
||||
// Label
|
||||
screen.setText(x, fy, flags[i].name, labelStyle);
|
||||
|
||||
// Draw flag
|
||||
flags[i].render(x, fy + 1);
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
584
test/js/bun/tui/demos/demo-form.ts
Normal file
584
test/js/bun/tui/demos/demo-form.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* demo-form.ts — Interactive Form
|
||||
*
|
||||
* A multi-field form with text inputs, radio buttons, checkboxes,
|
||||
* a dropdown select, and form validation/submission.
|
||||
*
|
||||
* Demonstrates: multiple input types, field focus management, cursor in text
|
||||
* fields, toggle states, dropdown menus, validation feedback, setText, fill,
|
||||
* style (fg/bg/bold/italic/underline/inverse), drawBox, TUITerminalWriter,
|
||||
* TUIKeyReader, alt screen, resize.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-form.ts
|
||||
* Controls: Tab/Shift+Tab between fields, Space toggle, Enter submit/select,
|
||||
* type in text fields, Q quit (when not in text field)
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0xc678dd, bold: true }),
|
||||
fieldLabel: screen.style({ fg: 0xabb2bf, bold: true }),
|
||||
fieldLabelFocused: screen.style({ fg: 0x61afef, bold: true }),
|
||||
inputBg: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
|
||||
inputFocused: screen.style({ fg: 0xffffff, bg: 0x3e4451 }),
|
||||
inputBorder: screen.style({ fg: 0x5c6370 }),
|
||||
inputBorderFocused: screen.style({ fg: 0x61afef }),
|
||||
placeholder: screen.style({ fg: 0x5c6370, bg: 0x2c313a, italic: true }),
|
||||
radio: screen.style({ fg: 0xabb2bf }),
|
||||
radioSelected: screen.style({ fg: 0x98c379, bold: true }),
|
||||
radioFocused: screen.style({ fg: 0x61afef }),
|
||||
radioSelectedFocused: screen.style({ fg: 0x98c379, bg: 0x2c313a, bold: true }),
|
||||
checkbox: screen.style({ fg: 0xabb2bf }),
|
||||
checkboxChecked: screen.style({ fg: 0x98c379, bold: true }),
|
||||
checkboxFocused: screen.style({ fg: 0x61afef }),
|
||||
checkboxCheckedFocused: screen.style({ fg: 0x98c379, bg: 0x2c313a, bold: true }),
|
||||
dropdown: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
|
||||
dropdownFocused: screen.style({ fg: 0xffffff, bg: 0x3e4451 }),
|
||||
dropdownOpen: screen.style({ fg: 0xffffff, bg: 0x3e4451 }),
|
||||
dropdownItem: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
|
||||
dropdownItemSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
button: screen.style({ fg: 0xabb2bf, bg: 0x3e4451, bold: true }),
|
||||
buttonFocused: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
buttonSubmit: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
|
||||
error: screen.style({ fg: 0xe06c75, italic: true }),
|
||||
success: screen.style({ fg: 0x98c379, bold: true }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
required: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
sectionHeader: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
};
|
||||
|
||||
// --- Field types ---
|
||||
type FieldType = "text" | "radio" | "checkbox" | "dropdown" | "button";
|
||||
|
||||
interface TextField {
|
||||
type: "text";
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
cursor: number;
|
||||
error: string;
|
||||
}
|
||||
interface RadioField {
|
||||
type: "radio";
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: number;
|
||||
}
|
||||
interface CheckboxField {
|
||||
type: "checkbox";
|
||||
label: string;
|
||||
options: { label: string; checked: boolean }[];
|
||||
}
|
||||
interface DropdownField {
|
||||
type: "dropdown";
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: number;
|
||||
open: boolean;
|
||||
highlightIdx: number;
|
||||
}
|
||||
interface ButtonField {
|
||||
type: "button";
|
||||
label: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
type Field = TextField | RadioField | CheckboxField | DropdownField | ButtonField;
|
||||
|
||||
// --- Form fields ---
|
||||
const fields: Field[] = [
|
||||
{ type: "text", label: "Name", value: "", placeholder: "Enter your name", required: true, cursor: 0, error: "" },
|
||||
{ type: "text", label: "Email", value: "", placeholder: "you@example.com", required: true, cursor: 0, error: "" },
|
||||
{
|
||||
type: "radio",
|
||||
label: "Role",
|
||||
options: ["Developer", "Designer", "Manager", "Other"],
|
||||
selected: 0,
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
label: "Interests",
|
||||
options: [
|
||||
{ label: "Frontend", checked: false },
|
||||
{ label: "Backend", checked: true },
|
||||
{ label: "DevOps", checked: false },
|
||||
{ label: "Mobile", checked: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "dropdown",
|
||||
label: "Experience",
|
||||
options: ["< 1 year", "1-3 years", "3-5 years", "5-10 years", "10+ years"],
|
||||
selected: 0,
|
||||
open: false,
|
||||
highlightIdx: 0,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Message",
|
||||
value: "",
|
||||
placeholder: "Optional message...",
|
||||
required: false,
|
||||
cursor: 0,
|
||||
error: "",
|
||||
},
|
||||
{ type: "button", label: "Submit", action: "submit" },
|
||||
{ type: "button", label: "Reset", action: "reset" },
|
||||
];
|
||||
|
||||
// --- State ---
|
||||
let focusedField = 0;
|
||||
let submitted = false;
|
||||
let submitResult = "";
|
||||
let scrollY = 0;
|
||||
|
||||
// --- Validation ---
|
||||
function validate(): boolean {
|
||||
let valid = true;
|
||||
for (const field of fields) {
|
||||
if (field.type === "text") {
|
||||
field.error = "";
|
||||
if (field.required && field.value.trim().length === 0) {
|
||||
field.error = `${field.label} is required`;
|
||||
valid = false;
|
||||
}
|
||||
if (field.label === "Email" && field.value.length > 0 && !field.value.includes("@")) {
|
||||
field.error = "Invalid email address";
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
for (const field of fields) {
|
||||
if (field.type === "text") {
|
||||
field.value = "";
|
||||
field.cursor = 0;
|
||||
field.error = "";
|
||||
} else if (field.type === "radio") {
|
||||
field.selected = 0;
|
||||
} else if (field.type === "checkbox") {
|
||||
for (const opt of field.options) opt.checked = false;
|
||||
} else if (field.type === "dropdown") {
|
||||
field.selected = 0;
|
||||
field.open = false;
|
||||
}
|
||||
}
|
||||
submitted = false;
|
||||
submitResult = "";
|
||||
focusedField = 0;
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Registration Form ", st.titleBar);
|
||||
|
||||
const formX = 3;
|
||||
const formW = Math.min(70, cols - 6);
|
||||
let y = 2 - scrollY;
|
||||
|
||||
for (let fi = 0; fi < fields.length; fi++) {
|
||||
const field = fields[fi];
|
||||
const isFocused = fi === focusedField;
|
||||
|
||||
if (y < 1 || y >= rows - 2) {
|
||||
y += fieldHeight(field);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case "text": {
|
||||
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
|
||||
screen.setText(formX, y, field.label, labelStyle);
|
||||
if (field.required) screen.setText(formX + field.label.length + 1, y, "*", st.required);
|
||||
y++;
|
||||
|
||||
const inputW = Math.min(formW, 50);
|
||||
const borderStyle = isFocused ? st.inputBorderFocused : st.inputBorder;
|
||||
const inputStyle = isFocused ? st.inputFocused : st.inputBg;
|
||||
|
||||
// Input box
|
||||
screen.fill(formX, y, inputW, 1, " ", inputStyle);
|
||||
|
||||
if (field.value.length > 0) {
|
||||
screen.setText(formX + 1, y, field.value.slice(0, inputW - 2), inputStyle);
|
||||
} else if (!isFocused) {
|
||||
screen.setText(formX + 1, y, field.placeholder.slice(0, inputW - 2), st.placeholder);
|
||||
}
|
||||
|
||||
// Borders
|
||||
screen.setText(formX - 1, y, "[", borderStyle);
|
||||
screen.setText(formX + inputW, y, "]", borderStyle);
|
||||
y++;
|
||||
|
||||
// Error
|
||||
if (field.error) {
|
||||
screen.setText(formX, y, field.error, st.error);
|
||||
}
|
||||
y += 2;
|
||||
break;
|
||||
}
|
||||
|
||||
case "radio": {
|
||||
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
|
||||
screen.setText(formX, y, field.label, labelStyle);
|
||||
y++;
|
||||
|
||||
for (let oi = 0; oi < field.options.length; oi++) {
|
||||
const opt = field.options[oi];
|
||||
const isSelected = oi === field.selected;
|
||||
const bullet = isSelected ? "(\u25cf)" : "(\u25cb)";
|
||||
const optStyle = isFocused
|
||||
? isSelected
|
||||
? st.radioSelectedFocused
|
||||
: st.radioFocused
|
||||
: isSelected
|
||||
? st.radioSelected
|
||||
: st.radio;
|
||||
screen.setText(formX + 2, y, `${bullet} ${opt}`, optStyle);
|
||||
y++;
|
||||
}
|
||||
y++;
|
||||
break;
|
||||
}
|
||||
|
||||
case "checkbox": {
|
||||
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
|
||||
screen.setText(formX, y, field.label, labelStyle);
|
||||
y++;
|
||||
|
||||
for (let oi = 0; oi < field.options.length; oi++) {
|
||||
const opt = field.options[oi];
|
||||
const box = opt.checked ? "[\u2713]" : "[ ]";
|
||||
const optStyle = isFocused
|
||||
? opt.checked
|
||||
? st.checkboxCheckedFocused
|
||||
: st.checkboxFocused
|
||||
: opt.checked
|
||||
? st.checkboxChecked
|
||||
: st.checkbox;
|
||||
screen.setText(formX + 2, y, `${box} ${opt.label}`, optStyle);
|
||||
y++;
|
||||
}
|
||||
y++;
|
||||
break;
|
||||
}
|
||||
|
||||
case "dropdown": {
|
||||
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
|
||||
screen.setText(formX, y, field.label, labelStyle);
|
||||
y++;
|
||||
|
||||
const ddW = 30;
|
||||
const ddStyle = isFocused ? st.dropdownFocused : st.dropdown;
|
||||
screen.fill(formX, y, ddW, 1, " ", ddStyle);
|
||||
const selected = field.options[field.selected];
|
||||
screen.setText(formX + 1, y, selected.slice(0, ddW - 4), ddStyle);
|
||||
screen.setText(formX + ddW - 2, y, field.open ? "\u25b2" : "\u25bc", ddStyle);
|
||||
y++;
|
||||
|
||||
// Dropdown items (when open)
|
||||
if (field.open) {
|
||||
for (let oi = 0; oi < field.options.length; oi++) {
|
||||
const itemStyle = oi === field.highlightIdx ? st.dropdownItemSelected : st.dropdownItem;
|
||||
screen.fill(formX, y, ddW, 1, " ", itemStyle);
|
||||
screen.setText(formX + 1, y, field.options[oi].slice(0, ddW - 2), itemStyle);
|
||||
y++;
|
||||
}
|
||||
}
|
||||
y++;
|
||||
break;
|
||||
}
|
||||
|
||||
case "button": {
|
||||
const btnStyle = isFocused ? (field.action === "submit" ? st.buttonSubmit : st.buttonFocused) : st.button;
|
||||
const btnText = ` ${field.label} `;
|
||||
screen.setText(formX, y, btnText, btnStyle);
|
||||
// Put reset button next to submit
|
||||
if (field.action === "submit" && fi + 1 < fields.length && fields[fi + 1].type === "button") {
|
||||
// Skip — render both buttons on same line
|
||||
}
|
||||
if (field.action === "reset") {
|
||||
// Already rendered alongside submit
|
||||
}
|
||||
y += 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit result
|
||||
if (submitted) {
|
||||
const resY = Math.min(y + 1, rows - 4);
|
||||
if (submitResult.startsWith("Error")) {
|
||||
screen.setText(formX, resY, submitResult, st.error);
|
||||
} else {
|
||||
screen.drawBox(formX - 1, resY - 1, formW, 4, { style: "rounded", styleId: st.border, fill: true });
|
||||
screen.setText(formX, resY, "\u2713 " + submitResult, st.success);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerText = " Tab:Next field | Shift+Tab:Prev | Space:Toggle | Enter:Submit/Select | Ctrl+C:Quit ";
|
||||
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
|
||||
|
||||
// Cursor for text fields
|
||||
const focused = fields[focusedField];
|
||||
if (focused.type === "text") {
|
||||
writer.render(screen, {
|
||||
cursorVisible: true,
|
||||
cursorX: 3 + 1 + focused.cursor,
|
||||
cursorY: fieldY(focusedField) + 1 - scrollY,
|
||||
cursorStyle: "line",
|
||||
cursorBlinking: true,
|
||||
});
|
||||
} else {
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
}
|
||||
|
||||
function fieldHeight(field: Field): number {
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
return 4;
|
||||
case "radio":
|
||||
return field.options.length + 2;
|
||||
case "checkbox":
|
||||
return field.options.length + 2;
|
||||
case "dropdown": {
|
||||
const base = 3;
|
||||
return field.open ? base + field.options.length : base;
|
||||
}
|
||||
case "button":
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
function fieldY(idx: number): number {
|
||||
let y = 2;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
y += fieldHeight(fields[i]);
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; shift: boolean; alt: boolean }) => {
|
||||
const { name, ctrl, shift } = event;
|
||||
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const focused = fields[focusedField];
|
||||
|
||||
// Close any open dropdown when leaving
|
||||
const closeDropdowns = () => {
|
||||
for (const f of fields) {
|
||||
if (f.type === "dropdown") f.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle open dropdown
|
||||
if (focused.type === "dropdown" && focused.open) {
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
focused.highlightIdx = Math.max(0, focused.highlightIdx - 1);
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
focused.highlightIdx = Math.min(focused.options.length - 1, focused.highlightIdx + 1);
|
||||
break;
|
||||
case "enter":
|
||||
case " ":
|
||||
focused.selected = focused.highlightIdx;
|
||||
focused.open = false;
|
||||
break;
|
||||
case "escape":
|
||||
focused.open = false;
|
||||
break;
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Text field input
|
||||
if (focused.type === "text") {
|
||||
switch (name) {
|
||||
case "tab":
|
||||
closeDropdowns();
|
||||
if (shift) {
|
||||
focusedField = (focusedField - 1 + fields.length) % fields.length;
|
||||
} else {
|
||||
focusedField = (focusedField + 1) % fields.length;
|
||||
}
|
||||
ensureVisible();
|
||||
break;
|
||||
case "backspace":
|
||||
if (focused.cursor > 0) {
|
||||
focused.value = focused.value.slice(0, focused.cursor - 1) + focused.value.slice(focused.cursor);
|
||||
focused.cursor--;
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
if (focused.cursor < focused.value.length) {
|
||||
focused.value = focused.value.slice(0, focused.cursor) + focused.value.slice(focused.cursor + 1);
|
||||
}
|
||||
break;
|
||||
case "left":
|
||||
if (focused.cursor > 0) focused.cursor--;
|
||||
break;
|
||||
case "right":
|
||||
if (focused.cursor < focused.value.length) focused.cursor++;
|
||||
break;
|
||||
case "home":
|
||||
focused.cursor = 0;
|
||||
break;
|
||||
case "end":
|
||||
focused.cursor = focused.value.length;
|
||||
break;
|
||||
case "enter":
|
||||
closeDropdowns();
|
||||
focusedField = (focusedField + 1) % fields.length;
|
||||
ensureVisible();
|
||||
break;
|
||||
default:
|
||||
if (!ctrl && !event.alt && name.length === 1) {
|
||||
focused.value = focused.value.slice(0, focused.cursor) + name + focused.value.slice(focused.cursor);
|
||||
focused.cursor++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-text field input
|
||||
switch (name) {
|
||||
case "q":
|
||||
cleanup();
|
||||
return;
|
||||
case "tab":
|
||||
closeDropdowns();
|
||||
if (shift) {
|
||||
focusedField = (focusedField - 1 + fields.length) % fields.length;
|
||||
} else {
|
||||
focusedField = (focusedField + 1) % fields.length;
|
||||
}
|
||||
ensureVisible();
|
||||
break;
|
||||
case "up":
|
||||
case "k":
|
||||
if (focused.type === "radio") {
|
||||
focused.selected = Math.max(0, focused.selected - 1);
|
||||
}
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
if (focused.type === "radio") {
|
||||
focused.selected = Math.min(focused.options.length - 1, focused.selected + 1);
|
||||
}
|
||||
break;
|
||||
case " ":
|
||||
if (focused.type === "checkbox") {
|
||||
// Toggle the first unchecked, or cycle through
|
||||
const allChecked = focused.options.every(o => o.checked);
|
||||
if (allChecked) {
|
||||
for (const o of focused.options) o.checked = false;
|
||||
} else {
|
||||
// Toggle next unchecked
|
||||
const nextUnchecked = focused.options.findIndex(o => !o.checked);
|
||||
if (nextUnchecked >= 0) focused.options[nextUnchecked].checked = true;
|
||||
}
|
||||
} else if (focused.type === "dropdown") {
|
||||
focused.open = !focused.open;
|
||||
focused.highlightIdx = focused.selected;
|
||||
}
|
||||
break;
|
||||
case "enter":
|
||||
if (focused.type === "dropdown") {
|
||||
focused.open = !focused.open;
|
||||
focused.highlightIdx = focused.selected;
|
||||
} else if (focused.type === "button") {
|
||||
if (focused.action === "submit") {
|
||||
submitted = true;
|
||||
if (validate()) {
|
||||
const textFields = fields.filter((f): f is TextField => f.type === "text");
|
||||
submitResult = `Form submitted! Name: ${textFields[0].value}, Email: ${textFields[1].value}`;
|
||||
} else {
|
||||
submitResult = "Error: Please fix the highlighted fields";
|
||||
}
|
||||
} else if (focused.action === "reset") {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
function ensureVisible() {
|
||||
const fy = fieldY(focusedField);
|
||||
const fh = fieldHeight(fields[focusedField]);
|
||||
if (fy - scrollY < 2) {
|
||||
scrollY = Math.max(0, fy - 2);
|
||||
} else if (fy + fh - scrollY > rows - 2) {
|
||||
scrollY = fy + fh - rows + 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Paste ---
|
||||
reader.onpaste = (text: string) => {
|
||||
const focused = fields[focusedField];
|
||||
if (focused.type === "text") {
|
||||
const line = text.split("\n")[0];
|
||||
focused.value = focused.value.slice(0, focused.cursor) + line + focused.value.slice(focused.cursor);
|
||||
focused.cursor += line.length;
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
render();
|
||||
131
test/js/bun/tui/demos/demo-gradient.ts
Normal file
131
test/js/bun/tui/demos/demo-gradient.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* demo-gradient.ts — Renders multiple color gradient strips (rainbow, warm, cool, grayscale)
|
||||
* using true color. Each strip is one row of colored block characters.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 72);
|
||||
const height = 16;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const labelStyle = screen.style({ fg: 0xabb2bf });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return Math.round(a + (b - a) * t);
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): number {
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
||||
}
|
||||
|
||||
interface GradientDef {
|
||||
name: string;
|
||||
fn: (t: number) => number; // t: 0..1 -> rgb hex
|
||||
}
|
||||
|
||||
const gradients: GradientDef[] = [
|
||||
{
|
||||
name: "Rainbow",
|
||||
fn: t => {
|
||||
const [r, g, b] = hslToRgb(t * 360, 1.0, 0.5);
|
||||
return rgbToHex(r, g, b);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Warm",
|
||||
fn: t => {
|
||||
const r = lerp(255, 255, t);
|
||||
const g = lerp(50, 200, t);
|
||||
const b = lerp(0, 50, t);
|
||||
return rgbToHex(r, g, b);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Cool",
|
||||
fn: t => {
|
||||
const r = lerp(0, 100, t);
|
||||
const g = lerp(100, 200, t);
|
||||
const b = lerp(200, 255, t);
|
||||
return rgbToHex(r, g, b);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Ocean",
|
||||
fn: t => {
|
||||
const r = lerp(0, 20, t);
|
||||
const g = lerp(30, 180, t);
|
||||
const b = lerp(80, 255, t);
|
||||
return rgbToHex(r, g, b);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Forest",
|
||||
fn: t => {
|
||||
const r = lerp(10, 80, t);
|
||||
const g = lerp(40, 200, t);
|
||||
const b = lerp(10, 60, t);
|
||||
return rgbToHex(r, g, b);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Sunset",
|
||||
fn: t => {
|
||||
if (t < 0.5) {
|
||||
const t2 = t * 2;
|
||||
return rgbToHex(lerp(255, 255, t2), lerp(60, 150, t2), lerp(0, 50, t2));
|
||||
}
|
||||
const t2 = (t - 0.5) * 2;
|
||||
return rgbToHex(lerp(255, 100, t2), lerp(150, 50, t2), lerp(50, 150, t2));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Grayscale",
|
||||
fn: t => {
|
||||
const v = Math.round(t * 255);
|
||||
return rgbToHex(v, v, v);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "True Color Gradients", titleStyle);
|
||||
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
|
||||
|
||||
const stripStart = 13;
|
||||
const stripWidth = width - stripStart - 2;
|
||||
let y = 3;
|
||||
|
||||
for (const grad of gradients) {
|
||||
screen.setText(2, y, grad.name.padEnd(10), labelStyle);
|
||||
for (let x = 0; x < stripWidth; x++) {
|
||||
const t = x / (stripWidth - 1);
|
||||
const color = grad.fn(t);
|
||||
const s = screen.style({ fg: color });
|
||||
screen.setText(stripStart + x, y, "\u2588", s);
|
||||
}
|
||||
y += 2;
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
98
test/js/bun/tui/demos/demo-heatmap.ts
Normal file
98
test/js/bun/tui/demos/demo-heatmap.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* demo-heatmap.ts — GitHub-style contribution heatmap grid using block chars
|
||||
* with green shading. Shows 52 weeks x 7 days with random data and month labels.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const weeks = 52;
|
||||
const days = 7;
|
||||
const width = Math.max(weeks + 8, 64);
|
||||
const height = days + 6;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
// Green contribution levels
|
||||
const levels = [
|
||||
screen.style({ fg: 0x161b22 }), // 0: no contributions
|
||||
screen.style({ fg: 0x0e4429 }), // 1: light
|
||||
screen.style({ fg: 0x006d32 }), // 2: medium
|
||||
screen.style({ fg: 0x26a641 }), // 3: good
|
||||
screen.style({ fg: 0x39d353 }), // 4: max
|
||||
];
|
||||
|
||||
const blockChar = "\u2588";
|
||||
|
||||
// Seeded pseudo-random
|
||||
let seed = 42;
|
||||
function rand() {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
}
|
||||
|
||||
// Generate contribution data
|
||||
const data: number[][] = [];
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
const week: number[] = [];
|
||||
for (let d = 0; d < days; d++) {
|
||||
const r = rand();
|
||||
// Weight towards lower values
|
||||
if (r < 0.3) week.push(0);
|
||||
else if (r < 0.55) week.push(1);
|
||||
else if (r < 0.75) week.push(2);
|
||||
else if (r < 0.9) week.push(3);
|
||||
else week.push(4);
|
||||
}
|
||||
data.push(week);
|
||||
}
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "Contribution Activity", titleStyle);
|
||||
|
||||
// Count total
|
||||
let totalContribs = 0;
|
||||
for (const week of data) for (const d of week) totalContribs += d;
|
||||
screen.setText(2, 1, `${totalContribs} contributions in the last year`, dimStyle);
|
||||
|
||||
// Day labels
|
||||
const dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const gridX = 6;
|
||||
const gridY = 3;
|
||||
|
||||
for (let d = 0; d < days; d++) {
|
||||
if (d % 2 === 0) {
|
||||
screen.setText(1, gridY + d, dayLabels[d], dimStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// Month labels (approximate positions)
|
||||
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const weekPos = Math.floor((m / 12) * weeks);
|
||||
screen.setText(gridX + weekPos, gridY - 1, months[m], dimStyle);
|
||||
}
|
||||
|
||||
// Draw heatmap grid
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
for (let d = 0; d < days; d++) {
|
||||
const level = data[w][d];
|
||||
screen.setText(gridX + w, gridY + d, blockChar, levels[level]);
|
||||
}
|
||||
}
|
||||
|
||||
// Legend
|
||||
const legendY = gridY + days + 1;
|
||||
screen.setText(2, legendY, "Less", dimStyle);
|
||||
let lx = 7;
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
screen.setText(lx, legendY, blockChar, levels[i]);
|
||||
lx += 2;
|
||||
}
|
||||
screen.setText(lx, legendY, "More", dimStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
403
test/js/bun/tui/demos/demo-htop.ts
Normal file
403
test/js/bun/tui/demos/demo-htop.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* demo-htop.ts — Process Viewer (htop-style)
|
||||
*
|
||||
* A real-time process viewer showing system processes with CPU/memory bars,
|
||||
* sortable columns, process tree, and auto-refresh.
|
||||
*
|
||||
* Demonstrates: real system data (Bun.spawn + ps), dense tabular layout,
|
||||
* progress bars inside table cells, auto-refresh, sort state, setText, fill,
|
||||
* style (fg/bg/bold), TUITerminalWriter, TUIKeyReader, alt screen, resize.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-htop.ts
|
||||
* Controls: j/k navigate, P sort by CPU, M sort by memory, N sort by name,
|
||||
* / filter, Space pause, Q/Ctrl+C quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
headerBg: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
meterLabel: screen.style({ fg: 0xabb2bf, bold: true }),
|
||||
meterBar: screen.style({ bg: 0x98c379 }),
|
||||
meterBarMed: screen.style({ bg: 0xe5c07b }),
|
||||
meterBarHigh: screen.style({ bg: 0xe06c75 }),
|
||||
meterEmpty: screen.style({ bg: 0x2c313a }),
|
||||
meterText: screen.style({ fg: 0xabb2bf }),
|
||||
colHeader: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
|
||||
colHeaderSort: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
|
||||
row: screen.style({ fg: 0xabb2bf }),
|
||||
rowAlt: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
|
||||
rowSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
pid: screen.style({ fg: 0x56b6c2 }),
|
||||
pidAlt: screen.style({ fg: 0x56b6c2, bg: 0x21252b }),
|
||||
pidSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
cpuHigh: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
cpuMed: screen.style({ fg: 0xe5c07b }),
|
||||
cpuLow: screen.style({ fg: 0x98c379 }),
|
||||
cpuSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
memHigh: screen.style({ fg: 0xe06c75 }),
|
||||
memLow: screen.style({ fg: 0xabb2bf }),
|
||||
memSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
footer: screen.style({ fg: 0x000000, bg: 0x3e4451, bold: true }),
|
||||
footerKey: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
|
||||
footerLabel: screen.style({ fg: 0xabb2bf, bg: 0x3e4451 }),
|
||||
filterBar: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
|
||||
filterLabel: screen.style({ fg: 0xe5c07b, bg: 0x2c313a, bold: true }),
|
||||
paused: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
uptime: screen.style({ fg: 0x98c379 }),
|
||||
count: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
};
|
||||
|
||||
// --- Process data ---
|
||||
interface Process {
|
||||
pid: number;
|
||||
user: string;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
vsz: number;
|
||||
rss: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
let processes: Process[] = [];
|
||||
let selectedIndex = 0;
|
||||
let scrollOffset = 0;
|
||||
let sortBy: "cpu" | "mem" | "pid" | "name" = "cpu";
|
||||
let sortAsc = false;
|
||||
let paused = false;
|
||||
let filterMode = false;
|
||||
let filterText = "";
|
||||
let totalCpu = 0;
|
||||
let totalMem = 0;
|
||||
let processCount = 0;
|
||||
|
||||
async function refreshProcesses() {
|
||||
try {
|
||||
await using proc = Bun.spawn({
|
||||
cmd: ["ps", "aux"],
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
});
|
||||
const output = await proc.stdout.text();
|
||||
const lines = output.split("\n").slice(1); // skip header
|
||||
|
||||
processes = [];
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 11) continue;
|
||||
const pid = parseInt(parts[1]);
|
||||
if (isNaN(pid)) continue;
|
||||
processes.push({
|
||||
pid,
|
||||
user: parts[0],
|
||||
cpu: parseFloat(parts[2]) || 0,
|
||||
mem: parseFloat(parts[3]) || 0,
|
||||
vsz: parseInt(parts[4]) || 0,
|
||||
rss: parseInt(parts[5]) || 0,
|
||||
command: parts.slice(10).join(" "),
|
||||
});
|
||||
}
|
||||
|
||||
totalCpu = processes.reduce((s, p) => s + p.cpu, 0);
|
||||
totalMem = processes.reduce((s, p) => s + p.mem, 0);
|
||||
processCount = processes.length;
|
||||
sortProcesses();
|
||||
} catch {
|
||||
// silently ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
function sortProcesses() {
|
||||
processes.sort((a, b) => {
|
||||
let cmp: number;
|
||||
switch (sortBy) {
|
||||
case "cpu":
|
||||
cmp = a.cpu - b.cpu;
|
||||
break;
|
||||
case "mem":
|
||||
cmp = a.mem - b.mem;
|
||||
break;
|
||||
case "pid":
|
||||
cmp = a.pid - b.pid;
|
||||
break;
|
||||
case "name":
|
||||
cmp = a.command.localeCompare(b.command);
|
||||
break;
|
||||
}
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredProcesses(): Process[] {
|
||||
if (filterText.length === 0) return processes;
|
||||
const q = filterText.toLowerCase();
|
||||
return processes.filter(
|
||||
p => p.command.toLowerCase().includes(q) || p.user.toLowerCase().includes(q) || String(p.pid).includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Meter drawing ---
|
||||
function drawMeter(x: number, y: number, width: number, value: number, max: number, label: string) {
|
||||
screen.setText(x, y, label.padEnd(5), st.meterLabel);
|
||||
const barX = x + 5;
|
||||
const barW = width - 5 - 6;
|
||||
const ratio = Math.min(value / max, 1);
|
||||
const filled = Math.round(ratio * barW);
|
||||
|
||||
screen.setText(barX, y, "[", st.meterText);
|
||||
for (let i = 0; i < barW; i++) {
|
||||
if (i < filled) {
|
||||
const barStyle = ratio > 0.8 ? st.meterBarHigh : ratio > 0.5 ? st.meterBarMed : st.meterBar;
|
||||
screen.fill(barX + 1 + i, y, 1, 1, " ", barStyle);
|
||||
} else {
|
||||
screen.fill(barX + 1 + i, y, 1, 1, " ", st.meterEmpty);
|
||||
}
|
||||
}
|
||||
screen.setText(barX + barW + 1, y, "]", st.meterText);
|
||||
const pctText = `${value.toFixed(1)}%`;
|
||||
screen.setText(barX + barW + 2, y, pctText, st.meterText);
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
const filtered = getFilteredProcesses();
|
||||
|
||||
// --- Top meters ---
|
||||
const halfW = Math.floor((cols - 2) / 2);
|
||||
drawMeter(1, 0, halfW, Math.min(totalCpu, 100), 100, "CPU:");
|
||||
drawMeter(1 + halfW, 0, halfW, Math.min(totalMem, 100), 100, "MEM:");
|
||||
|
||||
// Process count and uptime
|
||||
screen.setText(1, 1, `Tasks: `, st.meterLabel);
|
||||
screen.setText(8, 1, `${processCount}`, st.count);
|
||||
if (paused) {
|
||||
screen.setText(8 + String(processCount).length + 2, 1, "PAUSED", st.paused);
|
||||
}
|
||||
|
||||
// Filter bar
|
||||
if (filterMode) {
|
||||
screen.setText(1, 2, "Filter: ", st.filterLabel);
|
||||
screen.setText(9, 2, filterText + "_", st.filterBar);
|
||||
} else if (filterText.length > 0) {
|
||||
screen.setText(1, 2, `Filter: ${filterText} (${filtered.length}/${processCount})`, st.meterText);
|
||||
}
|
||||
|
||||
// --- Column headers ---
|
||||
const tableY = 3;
|
||||
screen.fill(0, tableY, cols, 1, " ", st.colHeader);
|
||||
|
||||
const colDefs = [
|
||||
{ label: "PID", width: 7, key: "pid" as const },
|
||||
{ label: "USER", width: 10, key: "name" as const },
|
||||
{ label: "%CPU", width: 7, key: "cpu" as const },
|
||||
{ label: "%MEM", width: 7, key: "mem" as const },
|
||||
{ label: "RSS", width: 10, key: "mem" as const },
|
||||
{ label: "COMMAND", width: 0, key: "name" as const }, // fill remaining
|
||||
];
|
||||
|
||||
let cx = 0;
|
||||
for (const col of colDefs) {
|
||||
const w = col.width === 0 ? cols - cx : col.width;
|
||||
const isSorted =
|
||||
sortBy === col.key &&
|
||||
(col.label === "%CPU" || col.label === "%MEM" || col.label === "PID" || col.label === "COMMAND");
|
||||
const style = isSorted ? st.colHeaderSort : st.colHeader;
|
||||
const arrow = isSorted ? (sortAsc ? "\u25b2" : "\u25bc") : "";
|
||||
screen.setText(cx, tableY, ` ${col.label}${arrow}`.padEnd(w).slice(0, w), style);
|
||||
cx += w;
|
||||
}
|
||||
|
||||
// --- Process rows ---
|
||||
const listY = tableY + 1;
|
||||
const listH = rows - listY - 1;
|
||||
|
||||
if (selectedIndex >= filtered.length) selectedIndex = Math.max(0, filtered.length - 1);
|
||||
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
||||
if (selectedIndex >= scrollOffset + listH) scrollOffset = selectedIndex - listH + 1;
|
||||
|
||||
const visibleCount = Math.min(listH, filtered.length - scrollOffset);
|
||||
for (let vi = 0; vi < visibleCount; vi++) {
|
||||
const idx = scrollOffset + vi;
|
||||
const proc = filtered[idx];
|
||||
const y = listY + vi;
|
||||
const isSel = idx === selectedIndex;
|
||||
const isAlt = vi % 2 === 1;
|
||||
|
||||
const rowStyle = isSel ? st.rowSel : isAlt ? st.rowAlt : st.row;
|
||||
const pidStyle = isSel ? st.pidSel : isAlt ? st.pidAlt : st.pid;
|
||||
const memStyle = isSel ? st.memSel : proc.mem > 5 ? st.memHigh : st.memLow;
|
||||
const cpuStyle = isSel ? st.cpuSel : proc.cpu > 50 ? st.cpuHigh : proc.cpu > 10 ? st.cpuMed : st.cpuLow;
|
||||
|
||||
if (isSel || isAlt) {
|
||||
screen.fill(0, y, cols, 1, " ", rowStyle);
|
||||
}
|
||||
|
||||
let rx = 0;
|
||||
// PID
|
||||
screen.setText(rx, y, String(proc.pid).padStart(6), pidStyle);
|
||||
rx += 7;
|
||||
// USER
|
||||
screen.setText(rx, y, proc.user.slice(0, 9).padEnd(10), rowStyle);
|
||||
rx += 10;
|
||||
// %CPU
|
||||
screen.setText(rx, y, proc.cpu.toFixed(1).padStart(6), cpuStyle);
|
||||
rx += 7;
|
||||
// %MEM
|
||||
screen.setText(rx, y, proc.mem.toFixed(1).padStart(6), memStyle);
|
||||
rx += 7;
|
||||
// RSS (in MB)
|
||||
const rssMB = (proc.rss / 1024).toFixed(0);
|
||||
screen.setText(rx, y, `${rssMB} MB`.padStart(9), rowStyle);
|
||||
rx += 10;
|
||||
// COMMAND
|
||||
const cmdW = cols - rx - 1;
|
||||
if (cmdW > 0) {
|
||||
let cmd = proc.command;
|
||||
if (cmd.length > cmdW) cmd = cmd.slice(0, cmdW - 1) + "\u2026";
|
||||
screen.setText(rx, y, cmd, rowStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Footer ---
|
||||
const footerY = rows - 1;
|
||||
screen.fill(0, footerY, cols, 1, " ", st.footer);
|
||||
const keys = [
|
||||
["P", "CPU"],
|
||||
["M", "Mem"],
|
||||
["N", "Name"],
|
||||
["/", "Filter"],
|
||||
["Space", "Pause"],
|
||||
["q", "Quit"],
|
||||
];
|
||||
let fx = 0;
|
||||
for (const [key, label] of keys) {
|
||||
screen.setText(fx, footerY, key, st.footerKey);
|
||||
fx += key.length;
|
||||
screen.setText(fx, footerY, label + " ", st.footerLabel);
|
||||
fx += label.length + 1;
|
||||
}
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
|
||||
const { name, ctrl, alt } = event;
|
||||
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (filterMode) {
|
||||
switch (name) {
|
||||
case "enter":
|
||||
case "escape":
|
||||
filterMode = false;
|
||||
if (name === "escape") filterText = "";
|
||||
break;
|
||||
case "backspace":
|
||||
if (filterText.length > 0) filterText = filterText.slice(0, -1);
|
||||
else filterMode = false;
|
||||
break;
|
||||
default:
|
||||
if (!ctrl && !alt && name.length === 1) filterText += name;
|
||||
break;
|
||||
}
|
||||
selectedIndex = 0;
|
||||
scrollOffset = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "q":
|
||||
cleanup();
|
||||
return;
|
||||
case "up":
|
||||
case "k":
|
||||
if (selectedIndex > 0) selectedIndex--;
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
if (selectedIndex < getFilteredProcesses().length - 1) selectedIndex++;
|
||||
break;
|
||||
case "pageup":
|
||||
selectedIndex = Math.max(0, selectedIndex - (rows - 6));
|
||||
break;
|
||||
case "pagedown":
|
||||
selectedIndex = Math.min(getFilteredProcesses().length - 1, selectedIndex + (rows - 6));
|
||||
break;
|
||||
case "home":
|
||||
selectedIndex = 0;
|
||||
break;
|
||||
case "end":
|
||||
selectedIndex = getFilteredProcesses().length - 1;
|
||||
break;
|
||||
case "p":
|
||||
sortBy = "cpu";
|
||||
sortAsc = false;
|
||||
sortProcesses();
|
||||
break;
|
||||
case "m":
|
||||
sortBy = "mem";
|
||||
sortAsc = false;
|
||||
sortProcesses();
|
||||
break;
|
||||
case "n":
|
||||
sortBy = "name";
|
||||
sortAsc = true;
|
||||
sortProcesses();
|
||||
break;
|
||||
case "/":
|
||||
filterMode = true;
|
||||
filterText = "";
|
||||
break;
|
||||
case " ":
|
||||
paused = !paused;
|
||||
break;
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearInterval(timer);
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
await refreshProcesses();
|
||||
render();
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
if (!paused) {
|
||||
await refreshProcesses();
|
||||
}
|
||||
render();
|
||||
}, 2000);
|
||||
378
test/js/bun/tui/demos/demo-hyperlink.ts
Normal file
378
test/js/bun/tui/demos/demo-hyperlink.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* demo-hyperlink.ts — Terminal Hyperlinks & Unicode Showcase
|
||||
*
|
||||
* Demonstrates OSC 8 terminal hyperlinks (clickable URLs), CJK wide characters,
|
||||
* emoji with ZWJ sequences, combining marks, and the full Unicode handling
|
||||
* powered by Ghostty's grapheme clustering.
|
||||
*
|
||||
* Demonstrates: hyperlink(url), setHyperlink(x, y, id), setText with CJK/emoji,
|
||||
* style (fg/bg/bold/italic/underline), drawBox, TUITerminalWriter, TUIKeyReader,
|
||||
* alt screen, resize handling.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-hyperlink.ts
|
||||
* Controls: j/k scroll, Tab switch section, Q quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
|
||||
header: screen.style({ fg: 0x61afef, bold: true }),
|
||||
subheader: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
text: screen.style({ fg: 0xabb2bf }),
|
||||
link: screen.style({ fg: 0x61afef, underline: "single", bold: true }),
|
||||
linkDesc: screen.style({ fg: 0xabb2bf }),
|
||||
cjk: screen.style({ fg: 0xe5c07b }),
|
||||
emoji: screen.style({ fg: 0xffffff }),
|
||||
code: screen.style({ fg: 0x98c379, bg: 0x21252b }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
tabActive: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
|
||||
tabInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
|
||||
accent: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
wide: screen.style({ fg: 0xe06c75, bg: 0x21252b }),
|
||||
combining: screen.style({ fg: 0x98c379 }),
|
||||
};
|
||||
|
||||
// --- Link data ---
|
||||
const links = [
|
||||
{ url: "https://bun.sh", text: "bun.sh", desc: "Bun - JavaScript runtime & toolkit" },
|
||||
{ url: "https://bun.sh/docs", text: "bun.sh/docs", desc: "Bun Documentation" },
|
||||
{ url: "https://github.com/oven-sh/bun", text: "github.com/oven-sh/bun", desc: "Bun on GitHub" },
|
||||
{ url: "https://ghostty.org", text: "ghostty.org", desc: "Ghostty Terminal Emulator" },
|
||||
{ url: "https://github.com/ghostty-org/ghostty", text: "github.com/ghostty-org/ghostty", desc: "Ghostty on GitHub" },
|
||||
{ url: "https://ziglang.org", text: "ziglang.org", desc: "Zig Programming Language" },
|
||||
{ url: "https://developer.mozilla.org/en-US/docs/Web/API", text: "MDN Web APIs", desc: "Mozilla Developer Network" },
|
||||
{ url: "https://nodejs.org", text: "nodejs.org", desc: "Node.js Runtime" },
|
||||
];
|
||||
|
||||
// --- Unicode showcase data ---
|
||||
const cjkSamples = [
|
||||
{ text: "\u5FEB\u901F", label: "Fast (Chinese)" },
|
||||
{ text: "\u30D0\u30F3", label: "Bun (Japanese Katakana)" },
|
||||
{ text: "\uD55C\uAD6D\uC5B4", label: "Korean" },
|
||||
{ text: "\u6027\u80FD", label: "Performance (Chinese)" },
|
||||
{ text: "\u30BF\u30FC\u30DF\u30CA\u30EB", label: "Terminal (Japanese)" },
|
||||
{ text: "\uC548\uB155", label: "Hello (Korean)" },
|
||||
];
|
||||
|
||||
const emojiSamples = [
|
||||
{ text: "\u{1F680}", label: "Rocket" },
|
||||
{ text: "\u{1F525}", label: "Fire" },
|
||||
{ text: "\u26A1", label: "Lightning" },
|
||||
{ text: "\u{1F4E6}", label: "Package" },
|
||||
{ text: "\u{1F3AF}", label: "Bullseye" },
|
||||
{ text: "\u{2728}", label: "Sparkles" },
|
||||
{ text: "\u{1F40D}", label: "Snake" },
|
||||
{ text: "\u{1F980}", label: "Crab" },
|
||||
{ text: "\u{1F439}", label: "Hamster" },
|
||||
{ text: "\u2615", label: "Coffee" },
|
||||
{ text: "\u{1F4BB}", label: "Laptop" },
|
||||
{ text: "\u{1F310}", label: "Globe" },
|
||||
];
|
||||
|
||||
const zwjSamples = [
|
||||
{ text: "\u{1F468}\u200D\u{1F4BB}", label: "Man Technologist (ZWJ)" },
|
||||
{ text: "\u{1F469}\u200D\u{1F52C}", label: "Woman Scientist (ZWJ)" },
|
||||
{ text: "\u{1F3F3}\uFE0F\u200D\u{1F308}", label: "Rainbow Flag (ZWJ)" },
|
||||
{ text: "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}", label: "Family (ZWJ)" },
|
||||
];
|
||||
|
||||
const boxDrawing = [
|
||||
{ chars: "\u250C\u2500\u2510\u2502\u2514\u2518", label: "Single box" },
|
||||
{ chars: "\u2554\u2550\u2557\u2551\u255A\u255D", label: "Double box" },
|
||||
{ chars: "\u256D\u2500\u256E\u2502\u2570\u256F", label: "Rounded box" },
|
||||
{ chars: "\u2501\u2503\u250F\u2513\u2517\u251B", label: "Heavy box" },
|
||||
{ chars: "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588", label: "Block elements" },
|
||||
{ chars: "\u2591\u2592\u2593\u2588", label: "Shade blocks" },
|
||||
];
|
||||
|
||||
// --- State ---
|
||||
let activeTab = 0;
|
||||
let scrollY = 0;
|
||||
const tabs = ["Hyperlinks", "CJK & Emoji", "Box Drawing"];
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Hyperlinks & Unicode ", st.titleBar);
|
||||
|
||||
// Tabs
|
||||
let tx = 2;
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const label = ` ${i + 1}:${tabs[i]} `;
|
||||
screen.setText(tx, 1, label, i === activeTab ? st.tabActive : st.tabInactive);
|
||||
tx += label.length + 1;
|
||||
}
|
||||
|
||||
const contentY = 3;
|
||||
const contentH = rows - contentY - 1;
|
||||
|
||||
if (activeTab === 0) {
|
||||
renderHyperlinks(contentY, contentH);
|
||||
} else if (activeTab === 1) {
|
||||
renderUnicode(contentY, contentH);
|
||||
} else {
|
||||
renderBoxDrawing(contentY, contentH);
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerText = " j/k:Scroll | 1-3:Section | Tab:Next | q:Quit ";
|
||||
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
function renderHyperlinks(startY: number, height: number) {
|
||||
let y = startY - scrollY;
|
||||
|
||||
// Section: Clickable Hyperlinks
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Clickable Terminal Hyperlinks (OSC 8)", st.header);
|
||||
}
|
||||
y += 2;
|
||||
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Click any link below in a supporting terminal:", st.dim);
|
||||
}
|
||||
y += 2;
|
||||
|
||||
for (const link of links) {
|
||||
if (y >= startY && y < startY + height) {
|
||||
// Register hyperlink
|
||||
const hid = screen.hyperlink(link.url);
|
||||
|
||||
// Draw the link text
|
||||
const linkLen = screen.setText(4, y, link.text, st.link);
|
||||
|
||||
// Apply hyperlink to the cells
|
||||
for (let x = 0; x < linkLen; x++) {
|
||||
screen.setHyperlink(4 + x, y, hid);
|
||||
}
|
||||
|
||||
// Description
|
||||
screen.setText(4 + linkLen + 2, y, link.desc, st.linkDesc);
|
||||
}
|
||||
y += 2;
|
||||
}
|
||||
|
||||
// Code example
|
||||
y += 1;
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Usage in code:", st.subheader);
|
||||
}
|
||||
y += 1;
|
||||
|
||||
const codeLines = [
|
||||
"const hid = screen.hyperlink('https://bun.sh');",
|
||||
"screen.setText(0, 0, 'Click me!', linkStyle);",
|
||||
"for (let x = 0; x < 9; x++)",
|
||||
" screen.setHyperlink(x, 0, hid);",
|
||||
];
|
||||
for (const line of codeLines) {
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(4, y, line, st.code);
|
||||
}
|
||||
y++;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUnicode(startY: number, height: number) {
|
||||
let y = startY - scrollY;
|
||||
|
||||
// CJK
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "CJK Wide Characters (2 cells each)", st.header);
|
||||
}
|
||||
y += 2;
|
||||
|
||||
for (const sample of cjkSamples) {
|
||||
if (y >= startY && y < startY + height) {
|
||||
const w = screen.setText(4, y, sample.text, st.cjk);
|
||||
screen.setText(4 + w + 2, y, sample.label, st.dim);
|
||||
}
|
||||
y++;
|
||||
}
|
||||
|
||||
// Emoji
|
||||
y += 1;
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Emoji (wide characters)", st.header);
|
||||
}
|
||||
y += 2;
|
||||
|
||||
let ex = 4;
|
||||
let ey = y;
|
||||
for (const sample of emojiSamples) {
|
||||
if (ex + 14 > cols - 4) {
|
||||
ex = 4;
|
||||
ey += 2;
|
||||
}
|
||||
if (ey >= startY && ey < startY + height) {
|
||||
const w = screen.setText(ex, ey, sample.text, st.emoji);
|
||||
screen.setText(ex + w, ey, ` ${sample.label}`, st.dim);
|
||||
}
|
||||
ex += sample.label.length + 5;
|
||||
}
|
||||
y = ey + 3;
|
||||
|
||||
// ZWJ sequences
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "ZWJ Sequences (Grapheme Clustering)", st.header);
|
||||
}
|
||||
y += 2;
|
||||
|
||||
for (const sample of zwjSamples) {
|
||||
if (y >= startY && y < startY + height) {
|
||||
const w = screen.setText(4, y, sample.text, st.emoji);
|
||||
screen.setText(4 + w + 2, y, sample.label, st.dim);
|
||||
}
|
||||
y++;
|
||||
}
|
||||
|
||||
y += 1;
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Powered by Ghostty's grapheme clustering engine", st.accent);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBoxDrawing(startY: number, height: number) {
|
||||
let y = startY - scrollY;
|
||||
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Box Drawing Characters", st.header);
|
||||
}
|
||||
y += 2;
|
||||
|
||||
for (const sample of boxDrawing) {
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(4, y, sample.chars, st.text);
|
||||
screen.setText(4 + sample.chars.length + 2, y, sample.label, st.dim);
|
||||
}
|
||||
y += 2;
|
||||
}
|
||||
|
||||
// Live box drawing examples
|
||||
y += 1;
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Live Box Styles", st.header);
|
||||
}
|
||||
y += 1;
|
||||
|
||||
const boxStyles = ["single", "double", "rounded", "heavy", "ascii"];
|
||||
let bx = 2;
|
||||
for (const style of boxStyles) {
|
||||
if (bx + 14 < cols && y + 4 < startY + height) {
|
||||
screen.drawBox(bx, y, 14, 5, { style, styleId: st.border, fill: true });
|
||||
const labelX = bx + Math.floor((14 - style.length) / 2);
|
||||
screen.setText(labelX, y + 2, style, st.accent);
|
||||
}
|
||||
bx += 16;
|
||||
}
|
||||
y += 6;
|
||||
|
||||
// Braille patterns
|
||||
if (y >= startY && y < startY + height) {
|
||||
screen.setText(2, y, "Braille Patterns (U+2800-U+28FF)", st.header);
|
||||
}
|
||||
y += 2;
|
||||
|
||||
if (y >= startY && y < startY + height) {
|
||||
let bpx = 4;
|
||||
for (let i = 0; i < 64 && bpx < cols - 4; i++) {
|
||||
const cp = 0x2800 + i;
|
||||
screen.setText(bpx, y, String.fromCodePoint(cp), st.text);
|
||||
bpx += 2;
|
||||
}
|
||||
}
|
||||
y++;
|
||||
if (y >= startY && y < startY + height) {
|
||||
let bpx = 4;
|
||||
for (let i = 64; i < 128 && bpx < cols - 4; i++) {
|
||||
const cp = 0x2800 + i;
|
||||
screen.setText(bpx, y, String.fromCodePoint(cp), st.text);
|
||||
bpx += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
scrollY = Math.max(0, scrollY - 1);
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
scrollY++;
|
||||
break;
|
||||
case "pageup":
|
||||
scrollY = Math.max(0, scrollY - (rows - 5));
|
||||
break;
|
||||
case "pagedown":
|
||||
scrollY += rows - 5;
|
||||
break;
|
||||
case "1":
|
||||
activeTab = 0;
|
||||
scrollY = 0;
|
||||
break;
|
||||
case "2":
|
||||
activeTab = 1;
|
||||
scrollY = 0;
|
||||
break;
|
||||
case "3":
|
||||
activeTab = 2;
|
||||
scrollY = 0;
|
||||
break;
|
||||
case "tab":
|
||||
activeTab = (activeTab + 1) % tabs.length;
|
||||
scrollY = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
render();
|
||||
178
test/js/bun/tui/demos/demo-inline.ts
Normal file
178
test/js/bun/tui/demos/demo-inline.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* demo-inline.ts — Inline TUI Rendering (No Alt Screen)
|
||||
*
|
||||
* Renders styled content directly into the terminal's scrollback buffer
|
||||
* without entering alt screen mode. Useful for CLI tools that want to
|
||||
* output rich formatted content (tables, trees, status bars) inline.
|
||||
*
|
||||
* Demonstrates: rendering without alt screen, multiple sequential renders
|
||||
* to the same region, writer.clear() to reset diff state, small fixed-size
|
||||
* screens, setText, fill, style, drawBox, TUITerminalWriter, TUIScreen.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-inline.ts
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
|
||||
// --- Inline render: small screens rendered directly into scrollback ---
|
||||
|
||||
// 1. Styled banner
|
||||
{
|
||||
const screen = new Bun.TUIScreen(60, 3);
|
||||
const bannerBg = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
|
||||
screen.fill(0, 0, 60, 3, " ", bannerBg);
|
||||
screen.setText(4, 1, "\u26A1 Bun TUI — Inline Rendering Demo \u26A1", bannerBg);
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
// Print a blank line after
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// 2. Dependency tree
|
||||
{
|
||||
const w = 50;
|
||||
const screen = new Bun.TUIScreen(w, 10);
|
||||
const header = screen.style({ fg: 0x61afef, bold: true });
|
||||
const pkg = screen.style({ fg: 0x98c379 });
|
||||
const ver = screen.style({ fg: 0xe5c07b });
|
||||
const tree = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
screen.setText(0, 0, "Dependencies:", header);
|
||||
screen.setText(0, 1, "\u251C\u2500\u2500 ", tree);
|
||||
screen.setText(4, 1, "typescript", pkg);
|
||||
screen.setText(15, 1, "^5.7.2", ver);
|
||||
screen.setText(0, 2, "\u251C\u2500\u2500 ", tree);
|
||||
screen.setText(4, 2, "esbuild", pkg);
|
||||
screen.setText(15, 2, "^0.24.0", ver);
|
||||
screen.setText(0, 3, "\u251C\u2500\u2500 ", tree);
|
||||
screen.setText(4, 3, "@types/node", pkg);
|
||||
screen.setText(15, 3, "^22.10.0", ver);
|
||||
screen.setText(0, 4, "\u2502 \u2514\u2500\u2500 ", tree);
|
||||
screen.setText(8, 4, "undici-types", pkg);
|
||||
screen.setText(21, 4, "~6.20.0", ver);
|
||||
screen.setText(0, 5, "\u251C\u2500\u2500 ", tree);
|
||||
screen.setText(4, 5, "prettier", pkg);
|
||||
screen.setText(15, 5, "^3.4.2", ver);
|
||||
screen.setText(0, 6, "\u2514\u2500\u2500 ", tree);
|
||||
screen.setText(4, 6, "vitest", pkg);
|
||||
screen.setText(15, 6, "^2.1.8", ver);
|
||||
screen.setText(0, 8, "5 dependencies, 1 nested", screen.style({ fg: 0x5c6370, italic: true }));
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// 3. Inline table
|
||||
{
|
||||
const w = 65;
|
||||
const screen = new Bun.TUIScreen(w, 8);
|
||||
const headerBg = screen.style({ fg: 0x000000, bg: 0x3e4451, bold: true });
|
||||
const row = screen.style({ fg: 0xabb2bf });
|
||||
const rowAlt = screen.style({ fg: 0xabb2bf, bg: 0x21252b });
|
||||
const num = screen.style({ fg: 0xe5c07b });
|
||||
const numAlt = screen.style({ fg: 0xe5c07b, bg: 0x21252b });
|
||||
const pass = screen.style({ fg: 0x98c379, bold: true });
|
||||
const fail = screen.style({ fg: 0xe06c75, bold: true });
|
||||
const border = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
screen.drawBox(0, 0, w, 8, { style: "rounded", styleId: border });
|
||||
screen.setText(2, 0, " Test Results ", screen.style({ fg: 0x61afef, bold: true }));
|
||||
|
||||
// Header
|
||||
screen.fill(1, 1, w - 2, 1, " ", headerBg);
|
||||
screen.setText(2, 1, "Suite", headerBg);
|
||||
screen.setText(22, 1, "Tests", headerBg);
|
||||
screen.setText(32, 1, "Pass", headerBg);
|
||||
screen.setText(42, 1, "Fail", headerBg);
|
||||
screen.setText(52, 1, "Time", headerBg);
|
||||
|
||||
// Rows
|
||||
const data = [
|
||||
["screen.test.ts", "105", "105", "0", "680ms"],
|
||||
["writer.test.ts", "83", "83", "0", "643ms"],
|
||||
["key-reader.test.ts", "28", "28", "0", "12.3s"],
|
||||
["e2e.test.ts", "27", "27", "0", "1.16s"],
|
||||
["bench.test.ts", "14", "14", "0", "657ms"],
|
||||
];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const d = data[i];
|
||||
const y = 2 + i;
|
||||
const isAlt = i % 2 === 1;
|
||||
const rs = isAlt ? rowAlt : row;
|
||||
const ns = isAlt ? numAlt : num;
|
||||
if (isAlt) screen.fill(1, y, w - 2, 1, " ", rowAlt);
|
||||
screen.setText(2, y, d[0], rs);
|
||||
screen.setText(22, y, d[1], ns);
|
||||
screen.setText(32, y, d[2], pass);
|
||||
screen.setText(42, y, d[3], parseInt(d[3]) > 0 ? fail : pass);
|
||||
screen.setText(52, y, d[4], rs);
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// 4. Status bars / progress
|
||||
{
|
||||
const w = 60;
|
||||
const screen = new Bun.TUIScreen(w, 4);
|
||||
const label = screen.style({ fg: 0xabb2bf });
|
||||
const barFill = screen.style({ bg: 0x98c379 });
|
||||
const barEmpty = screen.style({ bg: 0x2c313a });
|
||||
const pct = screen.style({ fg: 0x98c379, bold: true });
|
||||
const done = screen.style({ fg: 0x98c379, bold: true });
|
||||
|
||||
screen.setText(0, 0, "Build:", label);
|
||||
screen.fill(8, 0, 40, 1, " ", barFill);
|
||||
screen.setText(49, 0, "100%", pct);
|
||||
screen.setText(54, 0, "\u2713", done);
|
||||
|
||||
screen.setText(0, 1, "Tests:", label);
|
||||
screen.fill(8, 1, 34, 1, " ", barFill);
|
||||
screen.fill(42, 1, 6, 1, " ", barEmpty);
|
||||
screen.setText(49, 1, " 85%", screen.style({ fg: 0xe5c07b, bold: true }));
|
||||
|
||||
screen.setText(0, 2, "Lint:", label);
|
||||
screen.fill(8, 2, 20, 1, " ", barFill);
|
||||
screen.fill(28, 2, 20, 1, " ", barEmpty);
|
||||
screen.setText(49, 2, " 50%", screen.style({ fg: 0xe5c07b }));
|
||||
|
||||
screen.setText(0, 3, "\u2714 Build complete \u25cf 257 tests \u26A0 3 warnings", screen.style({ fg: 0x5c6370 }));
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// 5. Colored text art
|
||||
{
|
||||
const screen = new Bun.TUIScreen(40, 5);
|
||||
const colors = [0xff5555, 0xff8800, 0xffff55, 0x55ff55, 0x55ffff, 0x5555ff, 0xff55ff];
|
||||
const text = "Bun TUI";
|
||||
const artLines = [
|
||||
" ____ _____ _ _ ___ ",
|
||||
"| __ ) _ _ _ __ |_ _|| | | ||_ _| ",
|
||||
"| _ \\| | | | '_ \\ | | | | | | | | ",
|
||||
"| |_) | |_| | | | | | | | |_| | | | ",
|
||||
"|____/ \\__,_|_| |_| |_| \\___/ |___| ",
|
||||
];
|
||||
|
||||
for (let y = 0; y < artLines.length; y++) {
|
||||
const line = artLines[y];
|
||||
for (let x = 0; x < line.length && x < 40; x++) {
|
||||
const colorIdx = Math.floor((x / 40) * colors.length);
|
||||
const color = colors[colorIdx % colors.length];
|
||||
screen.setText(x, y, line[x], screen.style({ fg: color, bold: true }));
|
||||
}
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
writer.close();
|
||||
389
test/js/bun/tui/demos/demo-interactive-list.ts
Normal file
389
test/js/bun/tui/demos/demo-interactive-list.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* demo-interactive-list.ts — Interactive Scrollable List
|
||||
*
|
||||
* A filterable, scrollable list with keyboard navigation.
|
||||
* Demonstrates: TUIKeyReader (arrows, enter, escape, typing), style
|
||||
* (fg/bg/bold/inverse), setText, fill, clearRect, drawBox, alt screen,
|
||||
* and search filtering.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-interactive-list.ts
|
||||
* Exit: Escape or Ctrl+C
|
||||
*/
|
||||
|
||||
// --- Item data ---
|
||||
interface ListItem {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const ALL_ITEMS: ListItem[] = [
|
||||
{ name: "Bun.serve()", description: "Start an HTTP server", category: "HTTP" },
|
||||
{ name: "Bun.file()", description: "Reference a file on disk", category: "File I/O" },
|
||||
{ name: "Bun.write()", description: "Write data to a file", category: "File I/O" },
|
||||
{ name: "Bun.spawn()", description: "Spawn a child process", category: "Process" },
|
||||
{ name: "Bun.build()", description: "Bundle JavaScript/TypeScript", category: "Bundler" },
|
||||
{ name: "Bun.password", description: "Hash and verify passwords", category: "Crypto" },
|
||||
{ name: "Bun.hash()", description: "Fast non-cryptographic hashing", category: "Crypto" },
|
||||
{ name: "Bun.Transpiler", description: "JavaScript/TypeScript transpiler", category: "Bundler" },
|
||||
{ name: "Bun.sleep()", description: "Async sleep for given duration", category: "Utilities" },
|
||||
{ name: "Bun.which()", description: "Find an executable in PATH", category: "Process" },
|
||||
{ name: "Bun.peek()", description: "Read a promise without awaiting", category: "Utilities" },
|
||||
{ name: "Bun.gzipSync()", description: "Synchronous gzip compression", category: "Compression" },
|
||||
{ name: "Bun.deflateSync()", description: "Synchronous deflate compression", category: "Compression" },
|
||||
{ name: "Bun.inflateSync()", description: "Synchronous inflate decompression", category: "Compression" },
|
||||
{ name: "Bun.gunzipSync()", description: "Synchronous gunzip decompression", category: "Compression" },
|
||||
{ name: "Bun.color()", description: "Parse and convert colors", category: "Utilities" },
|
||||
{ name: "Bun.semver", description: "Semantic versioning utilities", category: "Utilities" },
|
||||
{ name: "Bun.dns", description: "DNS resolution", category: "Network" },
|
||||
{ name: "Bun.connect()", description: "TCP/TLS client connection", category: "Network" },
|
||||
{ name: "Bun.listen()", description: "TCP/TLS server listener", category: "Network" },
|
||||
{ name: "Bun.udpSocket()", description: "UDP socket creation", category: "Network" },
|
||||
{ name: "Bun.sql", description: "SQL database client (Postgres)", category: "Database" },
|
||||
{ name: "Bun.redis", description: "Redis client", category: "Database" },
|
||||
{ name: "Bun.s3()", description: "S3-compatible object storage", category: "Cloud" },
|
||||
{ name: "Bun.Glob", description: "File pattern matching", category: "File I/O" },
|
||||
{ name: "Bun.stringWidth()", description: "Display width of a string", category: "Utilities" },
|
||||
{ name: "Bun.TUIScreen", description: "Terminal screen buffer", category: "TUI" },
|
||||
{ name: "Bun.TUITerminalWriter", description: "Terminal ANSI writer", category: "TUI" },
|
||||
{ name: "Bun.TUIKeyReader", description: "Terminal input reader", category: "TUI" },
|
||||
{ name: "Bun.TUIBufferWriter", description: "Buffer-backed ANSI writer", category: "TUI" },
|
||||
];
|
||||
|
||||
// --- State ---
|
||||
let selectedIndex = 0;
|
||||
let scrollOffset = 0;
|
||||
let searchQuery = "";
|
||||
let searchMode = false;
|
||||
let filteredItems = [...ALL_ITEMS];
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const styles = {
|
||||
title: screen.style({ fg: 0x61afef, bold: true }),
|
||||
searchLabel: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
searchText: screen.style({ fg: 0xffffff }),
|
||||
searchPlaceholder: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
itemName: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
itemDesc: screen.style({ fg: 0xabb2bf }),
|
||||
itemCategory: screen.style({ fg: 0x56b6c2, italic: true }),
|
||||
selectedName: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
selectedDesc: screen.style({ fg: 0x000000, bg: 0x61afef }),
|
||||
selectedCategory: screen.style({ fg: 0x000000, bg: 0x61afef, italic: true }),
|
||||
selectedBg: screen.style({ bg: 0x61afef }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
scrollIndicator: screen.style({ fg: 0x61afef, bold: true }),
|
||||
footer: screen.style({ fg: 0x5c6370 }),
|
||||
count: screen.style({ fg: 0xe5c07b }),
|
||||
detailHeader: screen.style({ fg: 0x61afef, bold: true }),
|
||||
detailLabel: screen.style({ fg: 0xabb2bf }),
|
||||
detailValue: screen.style({ fg: 0xe5c07b }),
|
||||
noResults: screen.style({ fg: 0xe06c75, italic: true }),
|
||||
};
|
||||
|
||||
// --- Filter logic ---
|
||||
function filterItems() {
|
||||
if (searchQuery.length === 0) {
|
||||
filteredItems = [...ALL_ITEMS];
|
||||
} else {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filteredItems = ALL_ITEMS.filter(
|
||||
item =>
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.description.toLowerCase().includes(q) ||
|
||||
item.category.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
// Reset selection if out of range
|
||||
if (selectedIndex >= filteredItems.length) {
|
||||
selectedIndex = Math.max(0, filteredItems.length - 1);
|
||||
}
|
||||
scrollOffset = 0;
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
const listWidth = Math.min(60, cols - 4);
|
||||
const listX = 2;
|
||||
const headerY = 0;
|
||||
|
||||
// Title
|
||||
const titleText = "Bun API Explorer";
|
||||
screen.setText(listX, headerY, titleText, styles.title);
|
||||
|
||||
// Search bar
|
||||
const searchY = headerY + 2;
|
||||
const searchLabel = searchMode ? "> " : "/ ";
|
||||
screen.setText(listX, searchY, searchLabel, styles.searchLabel);
|
||||
if (searchQuery.length > 0) {
|
||||
screen.setText(listX + 2, searchY, searchQuery.slice(0, listWidth - 4), styles.searchText);
|
||||
// Cursor indicator in search mode
|
||||
if (searchMode) {
|
||||
const curX = listX + 2 + searchQuery.length;
|
||||
if (curX < listX + listWidth) {
|
||||
screen.setText(curX, searchY, "_", styles.searchText);
|
||||
}
|
||||
}
|
||||
} else if (!searchMode) {
|
||||
screen.setText(listX + 2, searchY, "Type '/' to search...", styles.searchPlaceholder);
|
||||
} else {
|
||||
screen.setText(listX + 2, searchY, "Type to filter...", styles.searchPlaceholder);
|
||||
}
|
||||
|
||||
// Item count
|
||||
const countText = `${filteredItems.length}/${ALL_ITEMS.length} items`;
|
||||
if (cols > listWidth + countText.length + 4) {
|
||||
screen.setText(cols - countText.length - 2, searchY, countText, styles.count);
|
||||
}
|
||||
|
||||
// Separator
|
||||
const sepY = searchY + 1;
|
||||
const sepChar = "\u2500"; // horizontal line
|
||||
for (let i = 0; i < listWidth; i++) {
|
||||
screen.setText(listX + i, sepY, sepChar, styles.border);
|
||||
}
|
||||
|
||||
// List area
|
||||
const listY = sepY + 1;
|
||||
const maxVisibleItems = rows - listY - 2; // leave room for footer
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
screen.setText(listX + 2, listY + 1, "No matching items found.", styles.noResults);
|
||||
} else {
|
||||
// Ensure selected item is visible
|
||||
if (selectedIndex < scrollOffset) {
|
||||
scrollOffset = selectedIndex;
|
||||
} else if (selectedIndex >= scrollOffset + maxVisibleItems) {
|
||||
scrollOffset = selectedIndex - maxVisibleItems + 1;
|
||||
}
|
||||
|
||||
const visibleCount = Math.min(maxVisibleItems, filteredItems.length - scrollOffset);
|
||||
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const itemIdx = scrollOffset + i;
|
||||
const item = filteredItems[itemIdx];
|
||||
const y = listY + i;
|
||||
const isSelected = itemIdx === selectedIndex;
|
||||
|
||||
if (isSelected) {
|
||||
// Highlight entire row
|
||||
screen.fill(listX, y, listWidth, 1, " ", styles.selectedBg);
|
||||
}
|
||||
|
||||
// Arrow indicator for selected item
|
||||
const arrow = isSelected ? "\u25b6 " : " ";
|
||||
screen.setText(listX, y, arrow, isSelected ? styles.selectedName : styles.itemName);
|
||||
|
||||
// Item name
|
||||
const nameWidth = Math.min(item.name.length, Math.floor(listWidth * 0.4));
|
||||
screen.setText(listX + 2, y, item.name.slice(0, nameWidth), isSelected ? styles.selectedName : styles.itemName);
|
||||
|
||||
// Description
|
||||
const descX = listX + 2 + nameWidth + 1;
|
||||
const descWidth = listWidth - nameWidth - 3;
|
||||
if (descWidth > 0) {
|
||||
screen.setText(
|
||||
descX,
|
||||
y,
|
||||
item.description.slice(0, descWidth),
|
||||
isSelected ? styles.selectedDesc : styles.itemDesc,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
if (scrollOffset > 0) {
|
||||
screen.setText(listX + listWidth - 1, listY, "\u25b2", styles.scrollIndicator); // up arrow
|
||||
}
|
||||
if (scrollOffset + maxVisibleItems < filteredItems.length) {
|
||||
screen.setText(listX + listWidth - 1, listY + visibleCount - 1, "\u25bc", styles.scrollIndicator); // down arrow
|
||||
}
|
||||
}
|
||||
|
||||
// Detail panel (right side, if room)
|
||||
const detailX = listX + listWidth + 2;
|
||||
const detailWidth = cols - detailX - 2;
|
||||
if (detailWidth > 20 && filteredItems.length > 0) {
|
||||
const item = filteredItems[selectedIndex];
|
||||
const detailY = listY;
|
||||
screen.drawBox(detailX, detailY - 1, detailWidth, 8, {
|
||||
style: "rounded",
|
||||
styleId: styles.border,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(detailX + 2, detailY - 1, " Details ", styles.detailHeader);
|
||||
|
||||
let dy = detailY;
|
||||
screen.setText(detailX + 2, dy, "Name:", styles.detailLabel);
|
||||
screen.setText(detailX + 12, dy, item.name.slice(0, detailWidth - 14), styles.detailValue);
|
||||
dy++;
|
||||
|
||||
screen.setText(detailX + 2, dy, "Category:", styles.detailLabel);
|
||||
screen.setText(detailX + 12, dy, item.category, styles.itemCategory);
|
||||
dy++;
|
||||
|
||||
dy++;
|
||||
screen.setText(detailX + 2, dy, "Description:", styles.detailLabel);
|
||||
dy++;
|
||||
// Word-wrap description
|
||||
const words = item.description.split(" ");
|
||||
let line = "";
|
||||
for (const word of words) {
|
||||
if (line.length + word.length + 1 > detailWidth - 4) {
|
||||
screen.setText(detailX + 2, dy, line, styles.itemDesc);
|
||||
dy++;
|
||||
line = word;
|
||||
} else {
|
||||
line = line.length > 0 ? line + " " + word : word;
|
||||
}
|
||||
}
|
||||
if (line.length > 0) {
|
||||
screen.setText(detailX + 2, dy, line, styles.itemDesc);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
const footerParts = ["\u2191\u2193 Navigate", "Enter Select", "/ Search", "Esc Cancel", "Ctrl+C Quit"];
|
||||
const footerText = " " + footerParts.join(" | ") + " ";
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), styles.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input handling ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; shift: boolean; alt: boolean; sequence: string }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
// Ctrl+C always quits
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchMode) {
|
||||
// Search mode input handling
|
||||
switch (name) {
|
||||
case "escape":
|
||||
searchMode = false;
|
||||
searchQuery = "";
|
||||
filterItems();
|
||||
break;
|
||||
case "enter":
|
||||
searchMode = false;
|
||||
break;
|
||||
case "backspace":
|
||||
if (searchQuery.length > 0) {
|
||||
searchQuery = searchQuery.slice(0, -1);
|
||||
filterItems();
|
||||
}
|
||||
break;
|
||||
case "up":
|
||||
searchMode = false;
|
||||
if (selectedIndex > 0) selectedIndex--;
|
||||
break;
|
||||
case "down":
|
||||
searchMode = false;
|
||||
if (selectedIndex < filteredItems.length - 1) selectedIndex++;
|
||||
break;
|
||||
default:
|
||||
// Printable character
|
||||
if (!ctrl && !event.alt && name.length === 1) {
|
||||
searchQuery += name;
|
||||
filterItems();
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Normal mode input handling
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
if (selectedIndex > 0) selectedIndex--;
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
if (selectedIndex < filteredItems.length - 1) selectedIndex++;
|
||||
break;
|
||||
case "home":
|
||||
selectedIndex = 0;
|
||||
break;
|
||||
case "end":
|
||||
selectedIndex = Math.max(0, filteredItems.length - 1);
|
||||
break;
|
||||
case "pageup": {
|
||||
const pageSize = rows - 6;
|
||||
selectedIndex = Math.max(0, selectedIndex - pageSize);
|
||||
break;
|
||||
}
|
||||
case "pagedown": {
|
||||
const pageSize = rows - 6;
|
||||
selectedIndex = Math.min(filteredItems.length - 1, selectedIndex + pageSize);
|
||||
break;
|
||||
}
|
||||
case "/":
|
||||
searchMode = true;
|
||||
searchQuery = "";
|
||||
break;
|
||||
case "escape":
|
||||
if (searchQuery.length > 0) {
|
||||
searchQuery = "";
|
||||
filterItems();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
break;
|
||||
case "enter":
|
||||
// Could do something with selected item; for now just flash
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Handle paste into search ---
|
||||
reader.onpaste = (text: string) => {
|
||||
if (searchMode) {
|
||||
// Add pasted text to search (first line only, no newlines)
|
||||
const firstLine = text.split("\n")[0];
|
||||
searchQuery += firstLine;
|
||||
filterItems();
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handle resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Initial render ---
|
||||
render();
|
||||
426
test/js/bun/tui/demos/demo-logs.ts
Normal file
426
test/js/bun/tui/demos/demo-logs.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* demo-logs.ts — Real-time Log Viewer
|
||||
*
|
||||
* Simulated real-time log stream with color-coded log levels, timestamp
|
||||
* formatting, level filtering, search, follow mode, and log count stats.
|
||||
*
|
||||
* Demonstrates: auto-scrolling append-only list, color-coded categories,
|
||||
* level-based filtering, search highlighting, setText, fill, style
|
||||
* (fg/bg/bold/italic/faint), TUITerminalWriter, TUIKeyReader, alt screen, resize.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-logs.ts
|
||||
* Controls: j/k scroll, F follow, 1-5 filter level, / search, C clear, Q quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
|
||||
timestamp: screen.style({ fg: 0x5c6370 }),
|
||||
debug: screen.style({ fg: 0x5c6370 }),
|
||||
debugLabel: screen.style({ fg: 0x5c6370, bold: true }),
|
||||
info: screen.style({ fg: 0x61afef }),
|
||||
infoLabel: screen.style({ fg: 0x61afef, bold: true }),
|
||||
warn: screen.style({ fg: 0xe5c07b }),
|
||||
warnLabel: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
error: screen.style({ fg: 0xe06c75 }),
|
||||
errorLabel: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
fatal: screen.style({ fg: 0xffffff, bg: 0xe06c75, bold: true }),
|
||||
fatalLabel: screen.style({ fg: 0xffffff, bg: 0xe06c75, bold: true }),
|
||||
statusBar: screen.style({ fg: 0x000000, bg: 0x3e4451 }),
|
||||
statusLabel: screen.style({ fg: 0xabb2bf, bg: 0x3e4451 }),
|
||||
statusValue: screen.style({ fg: 0xe5c07b, bg: 0x3e4451, bold: true }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
filterActive: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
filterInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
|
||||
follow: screen.style({ fg: 0x98c379, bold: true }),
|
||||
paused: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
searchBar: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
|
||||
searchLabel: screen.style({ fg: 0xe5c07b, bg: 0x2c313a, bold: true }),
|
||||
highlight: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
|
||||
source: screen.style({ fg: 0xc678dd }),
|
||||
dim: screen.style({ fg: 0x3e4451 }),
|
||||
};
|
||||
|
||||
// --- Log levels ---
|
||||
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL";
|
||||
const LEVELS: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR", "FATAL"];
|
||||
|
||||
interface LogEntry {
|
||||
time: Date;
|
||||
level: LogLevel;
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
const logs: LogEntry[] = [];
|
||||
const MAX_LOGS = 5000;
|
||||
let scrollOffset = 0;
|
||||
let following = true;
|
||||
let minLevel: LogLevel = "DEBUG";
|
||||
let searchMode = false;
|
||||
let searchText = "";
|
||||
let paused = false;
|
||||
|
||||
// --- Log sources and message templates ---
|
||||
const sources = ["http", "db", "auth", "cache", "worker", "scheduler", "api", "ws"];
|
||||
|
||||
const templates: Record<LogLevel, string[]> = {
|
||||
DEBUG: [
|
||||
"Connection pool size: {n} active, {n} idle",
|
||||
"Cache hit for key: user:{n}",
|
||||
"Query executed in {n}ms",
|
||||
"Middleware chain: {n} handlers",
|
||||
"GC pause: {n}ms",
|
||||
"Event loop latency: {n}ms",
|
||||
],
|
||||
INFO: [
|
||||
"Request completed: GET /api/users - 200 ({n}ms)",
|
||||
"Request completed: POST /api/data - 201 ({n}ms)",
|
||||
"User authenticated: user_{n}@example.com",
|
||||
"WebSocket client connected from 10.0.{n}.{n}",
|
||||
"Background job completed: sync_data_{n}",
|
||||
"Server listening on port {n}",
|
||||
"Database migration applied: v{n}",
|
||||
],
|
||||
WARN: [
|
||||
"Slow query detected: SELECT * FROM users ({n}ms)",
|
||||
"Rate limit approaching: {n}/1000 requests",
|
||||
"Memory usage high: {n}% of allocated",
|
||||
"Connection pool exhausted, queuing request",
|
||||
"Deprecated API called: /v1/legacy/users",
|
||||
"Certificate expires in {n} days",
|
||||
],
|
||||
ERROR: [
|
||||
"Database connection failed: ETIMEDOUT after {n}ms",
|
||||
"Request failed: GET /api/data - 500 Internal Server Error",
|
||||
"Unhandled promise rejection in worker #{n}",
|
||||
"Failed to parse JSON payload: Unexpected token at position {n}",
|
||||
"Redis connection lost, reconnecting in {n}s",
|
||||
],
|
||||
FATAL: [
|
||||
"Out of memory: heap limit reached ({n} MB)",
|
||||
"Database cluster unreachable, all replicas down",
|
||||
"CRITICAL: Data corruption detected in shard {n}",
|
||||
],
|
||||
};
|
||||
|
||||
function randomLog(): LogEntry {
|
||||
// Weight towards lower severity
|
||||
const r = Math.random();
|
||||
let level: LogLevel;
|
||||
if (r < 0.3) level = "DEBUG";
|
||||
else if (r < 0.7) level = "INFO";
|
||||
else if (r < 0.88) level = "WARN";
|
||||
else if (r < 0.97) level = "ERROR";
|
||||
else level = "FATAL";
|
||||
|
||||
const msgs = templates[level];
|
||||
let msg = msgs[Math.floor(Math.random() * msgs.length)];
|
||||
msg = msg.replace(/\{n\}/g, () => String(Math.floor(Math.random() * 999) + 1));
|
||||
|
||||
return {
|
||||
time: new Date(),
|
||||
level,
|
||||
source: sources[Math.floor(Math.random() * sources.length)],
|
||||
message: msg,
|
||||
};
|
||||
}
|
||||
|
||||
// Seed with some initial logs
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const entry = randomLog();
|
||||
entry.time = new Date(Date.now() - (30 - i) * 1000);
|
||||
logs.push(entry);
|
||||
}
|
||||
|
||||
// --- Filtering ---
|
||||
function levelIndex(l: LogLevel): number {
|
||||
return LEVELS.indexOf(l);
|
||||
}
|
||||
|
||||
function getFilteredLogs(): LogEntry[] {
|
||||
let filtered = logs.filter(l => levelIndex(l.level) >= levelIndex(minLevel));
|
||||
if (searchText.length > 0) {
|
||||
const q = searchText.toLowerCase();
|
||||
filtered = filtered.filter(l => l.message.toLowerCase().includes(q) || l.source.toLowerCase().includes(q));
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// --- Style helpers ---
|
||||
function levelStyle(level: LogLevel): number {
|
||||
switch (level) {
|
||||
case "DEBUG":
|
||||
return st.debug;
|
||||
case "INFO":
|
||||
return st.info;
|
||||
case "WARN":
|
||||
return st.warn;
|
||||
case "ERROR":
|
||||
return st.error;
|
||||
case "FATAL":
|
||||
return st.fatal;
|
||||
}
|
||||
}
|
||||
|
||||
function levelLabelStyle(level: LogLevel): number {
|
||||
switch (level) {
|
||||
case "DEBUG":
|
||||
return st.debugLabel;
|
||||
case "INFO":
|
||||
return st.infoLabel;
|
||||
case "WARN":
|
||||
return st.warnLabel;
|
||||
case "ERROR":
|
||||
return st.errorLabel;
|
||||
case "FATAL":
|
||||
return st.fatalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTs(d: Date): string {
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const m = String(d.getMinutes()).padStart(2, "0");
|
||||
const s = String(d.getSeconds()).padStart(2, "0");
|
||||
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${h}:${m}:${s}.${ms}`;
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
const filtered = getFilteredLogs();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Log Viewer ", st.titleBar);
|
||||
const modeText = following ? " FOLLOW " : paused ? " PAUSED " : "";
|
||||
if (modeText) {
|
||||
screen.setText(cols - modeText.length - 2, 0, modeText, following ? st.follow : st.paused);
|
||||
}
|
||||
|
||||
// Level filter tabs
|
||||
let tx = 1;
|
||||
for (const level of LEVELS) {
|
||||
const count = logs.filter(l => l.level === level).length;
|
||||
const label = ` ${level}(${count}) `;
|
||||
const isActive = levelIndex(level) >= levelIndex(minLevel);
|
||||
screen.setText(tx, 1, label, isActive ? st.filterActive : st.filterInactive);
|
||||
tx += label.length + 1;
|
||||
}
|
||||
|
||||
// Search bar
|
||||
if (searchMode) {
|
||||
screen.setText(1, 2, "/ ", st.searchLabel);
|
||||
screen.setText(3, 2, searchText + "_", st.searchBar);
|
||||
} else if (searchText.length > 0) {
|
||||
screen.setText(1, 2, `Search: "${searchText}" (${filtered.length} matches)`, st.searchBar);
|
||||
}
|
||||
|
||||
// Log area
|
||||
const logY = 3;
|
||||
const logH = rows - logY - 2;
|
||||
|
||||
// Auto-scroll when following
|
||||
if (following) {
|
||||
scrollOffset = Math.max(0, filtered.length - logH);
|
||||
}
|
||||
|
||||
// Clamp scroll
|
||||
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, filtered.length - logH)));
|
||||
|
||||
const visibleCount = Math.min(logH, filtered.length - scrollOffset);
|
||||
const tsW = 12; // HH:MM:SS.mmm
|
||||
const levelW = 6; // [XXXXX]
|
||||
const srcW = 10;
|
||||
|
||||
for (let vi = 0; vi < visibleCount; vi++) {
|
||||
const entry = filtered[scrollOffset + vi];
|
||||
const y = logY + vi;
|
||||
let x = 1;
|
||||
|
||||
// Timestamp
|
||||
screen.setText(x, y, formatTs(entry.time), st.timestamp);
|
||||
x += tsW + 1;
|
||||
|
||||
// Level badge
|
||||
const lvl = entry.level.padEnd(5);
|
||||
screen.setText(x, y, lvl, levelLabelStyle(entry.level));
|
||||
x += levelW;
|
||||
|
||||
// Source
|
||||
screen.setText(x, y, entry.source.padEnd(srcW).slice(0, srcW), st.source);
|
||||
x += srcW;
|
||||
|
||||
// Separator
|
||||
screen.setText(x, y, "\u2502", st.dim);
|
||||
x += 2;
|
||||
|
||||
// Message
|
||||
const msgW = cols - x - 1;
|
||||
const msg = entry.message.slice(0, msgW);
|
||||
screen.setText(x, y, msg, levelStyle(entry.level));
|
||||
}
|
||||
|
||||
// Scroll position indicator
|
||||
if (filtered.length > logH) {
|
||||
const barH = Math.max(1, Math.floor((logH * logH) / filtered.length));
|
||||
const barPos = Math.floor((scrollOffset / Math.max(1, filtered.length - logH)) * (logH - barH));
|
||||
for (let i = 0; i < logH; i++) {
|
||||
const ch = i >= barPos && i < barPos + barH ? "\u2588" : "\u2502";
|
||||
const style = i >= barPos && i < barPos + barH ? st.infoLabel : st.dim;
|
||||
screen.setText(cols - 1, logY + i, ch, style);
|
||||
}
|
||||
}
|
||||
|
||||
// Status bar
|
||||
const statusY = rows - 2;
|
||||
screen.fill(0, statusY, cols, 1, " ", st.statusBar);
|
||||
screen.setText(1, statusY, `Total: `, st.statusLabel);
|
||||
screen.setText(8, statusY, `${logs.length}`, st.statusValue);
|
||||
screen.setText(14, statusY, ` Showing: `, st.statusLabel);
|
||||
screen.setText(24, statusY, `${filtered.length}`, st.statusValue);
|
||||
screen.setText(30, statusY, ` Level: `, st.statusLabel);
|
||||
screen.setText(38, statusY, `>=${minLevel}`, st.statusValue);
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
const footerText = " j/k:Scroll F:Follow 1-5:Level /:Search C:Clear Space:Pause q:Quit ";
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
|
||||
const { name, ctrl, alt } = event;
|
||||
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchMode) {
|
||||
switch (name) {
|
||||
case "enter":
|
||||
case "escape":
|
||||
searchMode = false;
|
||||
if (name === "escape") searchText = "";
|
||||
break;
|
||||
case "backspace":
|
||||
if (searchText.length > 0) searchText = searchText.slice(0, -1);
|
||||
else searchMode = false;
|
||||
break;
|
||||
default:
|
||||
if (!ctrl && !alt && name.length === 1) searchText += name;
|
||||
break;
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "q":
|
||||
cleanup();
|
||||
return;
|
||||
case "up":
|
||||
case "k":
|
||||
following = false;
|
||||
scrollOffset = Math.max(0, scrollOffset - 1);
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
scrollOffset++;
|
||||
break;
|
||||
case "pageup":
|
||||
following = false;
|
||||
scrollOffset = Math.max(0, scrollOffset - (rows - 6));
|
||||
break;
|
||||
case "pagedown":
|
||||
scrollOffset += rows - 6;
|
||||
break;
|
||||
case "home":
|
||||
following = false;
|
||||
scrollOffset = 0;
|
||||
break;
|
||||
case "end":
|
||||
case "f":
|
||||
following = true;
|
||||
break;
|
||||
case "1":
|
||||
minLevel = "DEBUG";
|
||||
break;
|
||||
case "2":
|
||||
minLevel = "INFO";
|
||||
break;
|
||||
case "3":
|
||||
minLevel = "WARN";
|
||||
break;
|
||||
case "4":
|
||||
minLevel = "ERROR";
|
||||
break;
|
||||
case "5":
|
||||
minLevel = "FATAL";
|
||||
break;
|
||||
case "/":
|
||||
searchMode = true;
|
||||
searchText = "";
|
||||
break;
|
||||
case "c":
|
||||
logs.length = 0;
|
||||
scrollOffset = 0;
|
||||
break;
|
||||
case " ":
|
||||
paused = !paused;
|
||||
following = !paused && following;
|
||||
break;
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearInterval(timer);
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Log generation loop ---
|
||||
const timer = setInterval(() => {
|
||||
if (!paused) {
|
||||
// Add 1-3 log entries per tick
|
||||
const count = 1 + Math.floor(Math.random() * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
logs.push(randomLog());
|
||||
if (logs.length > MAX_LOGS) logs.shift();
|
||||
}
|
||||
}
|
||||
render();
|
||||
}, 300);
|
||||
|
||||
render();
|
||||
95
test/js/bun/tui/demos/demo-mandelbrot.ts
Normal file
95
test/js/bun/tui/demos/demo-mandelbrot.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* demo-mandelbrot.ts — Render a section of the Mandelbrot set using Unicode
|
||||
* half-block characters and true color for double vertical resolution.
|
||||
* Computes the fractal mathematically.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const cols = Math.min(writer.columns || 80, 72);
|
||||
const pixelRows = 40; // Pixel rows (doubled via half-blocks)
|
||||
const screenRows = Math.ceil(pixelRows / 2) + 3;
|
||||
const screen = new Bun.TUIScreen(cols, screenRows);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
// Mandelbrot viewport
|
||||
const xMin = -2.2;
|
||||
const xMax = 0.8;
|
||||
const yMin = -1.2;
|
||||
const yMax = 1.2;
|
||||
|
||||
const maxIter = 80;
|
||||
|
||||
function mandelbrot(cx: number, cy: number): number {
|
||||
let zx = 0;
|
||||
let zy = 0;
|
||||
let i = 0;
|
||||
while (i < maxIter && zx * zx + zy * zy < 4) {
|
||||
const tmp = zx * zx - zy * zy + cx;
|
||||
zy = 2 * zx * zy + cy;
|
||||
zx = tmp;
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
// Color palette based on iteration count
|
||||
function iterToColor(iter: number): number {
|
||||
if (iter === maxIter) return 0x000000; // Inside the set: black
|
||||
|
||||
// Smooth coloring using sinusoidal palette
|
||||
const t = iter / maxIter;
|
||||
const r = Math.round(9 * (1 - t) * t * t * t * 255);
|
||||
const g = Math.round(15 * (1 - t) * (1 - t) * t * t * 255);
|
||||
const b = Math.round(8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255);
|
||||
return (Math.min(255, r) << 16) | (Math.min(255, g) << 8) | Math.min(255, b);
|
||||
}
|
||||
|
||||
// Compute the fractal
|
||||
const pixels: number[][] = []; // [row][col] = color
|
||||
for (let py = 0; py < pixelRows; py++) {
|
||||
const row: number[] = [];
|
||||
const cy = yMin + (py / (pixelRows - 1)) * (yMax - yMin);
|
||||
for (let px = 0; px < cols; px++) {
|
||||
const cx = xMin + (px / (cols - 1)) * (xMax - xMin);
|
||||
const iter = mandelbrot(cx, cy);
|
||||
row.push(iterToColor(iter));
|
||||
}
|
||||
pixels.push(row);
|
||||
}
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "Mandelbrot Set", titleStyle);
|
||||
|
||||
// Render using half-blocks for double vertical resolution
|
||||
const renderY = 2;
|
||||
for (let py = 0; py < pixelRows; py += 2) {
|
||||
for (let px = 0; px < cols; px++) {
|
||||
const topColor = pixels[py][px];
|
||||
const bottomColor = py + 1 < pixelRows ? pixels[py + 1][px] : 0x000000;
|
||||
|
||||
const y = renderY + Math.floor(py / 2);
|
||||
|
||||
if (topColor === bottomColor) {
|
||||
// Both same color: use full block with fg
|
||||
const s = screen.style({ fg: topColor });
|
||||
screen.setText(px, y, "\u2588", s);
|
||||
} else {
|
||||
// Upper half block: fg = top color, bg = bottom color
|
||||
const s = screen.style({ fg: topColor, bg: bottomColor });
|
||||
screen.setText(px, y, "\u2580", s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Info line
|
||||
const infoY = renderY + Math.ceil(pixelRows / 2);
|
||||
const info = `${cols}x${pixelRows}px x:[${xMin}, ${xMax}] y:[${yMin}, ${yMax}] max_iter:${maxIter}`;
|
||||
screen.setText(2, infoY, info, dimStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
95
test/js/bun/tui/demos/demo-matrix-inline.ts
Normal file
95
test/js/bun/tui/demos/demo-matrix-inline.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* demo-matrix-inline.ts — A single frame of matrix-style katakana rain
|
||||
* rendered inline (just one static snapshot, not animated).
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 64);
|
||||
const height = 20;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Katakana range: U+30A0 to U+30FF
|
||||
const katakana: string[] = [];
|
||||
for (let cp = 0x30a0; cp <= 0x30ff; cp++) {
|
||||
katakana.push(String.fromCodePoint(cp));
|
||||
}
|
||||
|
||||
// Also include some digits and symbols for variety
|
||||
const extras = "0123456789@#$%&*=+<>".split("");
|
||||
const chars = [...katakana, ...extras];
|
||||
|
||||
// Seeded pseudo-random
|
||||
let seed = 42;
|
||||
function rand() {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
}
|
||||
|
||||
function randChar(): string {
|
||||
return chars[Math.floor(rand() * chars.length)];
|
||||
}
|
||||
|
||||
// Create columns with "streams" of different lengths and positions
|
||||
interface Stream {
|
||||
x: number;
|
||||
startY: number;
|
||||
length: number;
|
||||
speed: number; // how bright the tail is
|
||||
}
|
||||
|
||||
const streams: Stream[] = [];
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (rand() < 0.7) {
|
||||
// 70% chance of having a stream in this column
|
||||
const numStreams = rand() < 0.3 ? 2 : 1;
|
||||
for (let s = 0; s < numStreams; s++) {
|
||||
streams.push({
|
||||
x,
|
||||
startY: Math.floor(rand() * height),
|
||||
length: 3 + Math.floor(rand() * (height - 3)),
|
||||
speed: 0.5 + rand() * 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the screen with the background
|
||||
const bgStyle = screen.style({ fg: 0x003300 });
|
||||
screen.fill(0, 0, width, height, " ", bgStyle);
|
||||
|
||||
// Render each stream
|
||||
for (const stream of streams) {
|
||||
for (let i = 0; i < stream.length; i++) {
|
||||
const y = (stream.startY + i) % height;
|
||||
const ch = randChar();
|
||||
|
||||
let style: number;
|
||||
if (i === stream.length - 1) {
|
||||
// Head of the stream: bright white-green
|
||||
style = screen.style({ fg: 0xffffff, bold: true });
|
||||
} else if (i >= stream.length - 3) {
|
||||
// Near head: bright green
|
||||
style = screen.style({ fg: 0x00ff00, bold: true });
|
||||
} else {
|
||||
// Tail: fading green based on position
|
||||
const fade = (i / stream.length) * stream.speed;
|
||||
const g = Math.max(30, Math.round(200 * (1 - fade)));
|
||||
style = screen.style({ fg: (0 << 16) | (g << 8) | 0 });
|
||||
}
|
||||
|
||||
screen.setText(stream.x, y, ch, style);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a few bright "sparkle" characters
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const x = Math.floor(rand() * width);
|
||||
const y = Math.floor(rand() * height);
|
||||
const sparkle = screen.style({ fg: 0xccffcc, bold: true });
|
||||
screen.setText(x, y, randChar(), sparkle);
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
375
test/js/bun/tui/demos/demo-mouse.ts
Normal file
375
test/js/bun/tui/demos/demo-mouse.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* demo-mouse.ts — Mouse Interaction Demo
|
||||
*
|
||||
* Enables mouse tracking and allows the user to:
|
||||
* - Click to place colored markers on the screen
|
||||
* - Click and drag to draw lines
|
||||
* - See live mouse coordinates
|
||||
* - Scroll to cycle through marker colors
|
||||
* - Right-click to erase markers
|
||||
*
|
||||
* Demonstrates: enableMouseTracking, disableMouseTracking, onmouse,
|
||||
* TUIKeyReader, TUITerminalWriter, style (fg/bg), fill, setText,
|
||||
* drawBox, alt screen, and cursor options.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-mouse.ts
|
||||
* Exit: Press 'q' or Ctrl+C
|
||||
*/
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
writer.enableMouseTracking();
|
||||
|
||||
// --- State ---
|
||||
|
||||
// Canvas stores placed markers: key is "x,y", value is color index
|
||||
const canvas = new Map<string, number>();
|
||||
|
||||
// Current mouse position
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
|
||||
// Is the mouse button currently held down (for drag drawing)?
|
||||
let isDragging = false;
|
||||
|
||||
// Color palette for markers
|
||||
const markerColors = [
|
||||
0xff5555, // red
|
||||
0xff8800, // orange
|
||||
0xffff55, // yellow
|
||||
0x55ff55, // green
|
||||
0x55ffff, // cyan
|
||||
0x5555ff, // blue
|
||||
0xff55ff, // magenta
|
||||
0xffffff, // white
|
||||
];
|
||||
let colorIndex = 0;
|
||||
|
||||
// Event log (most recent events shown in the info panel)
|
||||
const eventLog: string[] = [];
|
||||
function logEvent(msg: string) {
|
||||
eventLog.push(msg);
|
||||
if (eventLog.length > 100) eventLog.shift();
|
||||
}
|
||||
|
||||
// --- Styles ---
|
||||
const titleStyle = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
|
||||
const infoLabel = screen.style({ fg: 0xabb2bf });
|
||||
const infoValue = screen.style({ fg: 0xe5c07b });
|
||||
const coordsStyle = screen.style({ fg: 0x98c379, bold: true });
|
||||
const borderStyle = screen.style({ fg: 0x5c6370 });
|
||||
const footerStyle = screen.style({ fg: 0x5c6370, italic: true });
|
||||
const logStyle = screen.style({ fg: 0x5c6370 });
|
||||
const canvasBg = screen.style({ bg: 0x1e2127 });
|
||||
const crosshairH = screen.style({ fg: 0x3e4451 });
|
||||
const crosshairV = screen.style({ fg: 0x3e4451 });
|
||||
|
||||
// --- Layout ---
|
||||
const HEADER_HEIGHT = 1;
|
||||
const SIDEBAR_WIDTH = 28;
|
||||
const FOOTER_HEIGHT = 1;
|
||||
|
||||
function canvasLeft() {
|
||||
return 0;
|
||||
}
|
||||
function canvasTop() {
|
||||
return HEADER_HEIGHT;
|
||||
}
|
||||
function canvasWidth() {
|
||||
return Math.max(1, cols - SIDEBAR_WIDTH);
|
||||
}
|
||||
function canvasHeight() {
|
||||
return Math.max(1, rows - HEADER_HEIGHT - FOOTER_HEIGHT);
|
||||
}
|
||||
|
||||
// --- Drawing helpers ---
|
||||
|
||||
/** Place a marker on the canvas at the given position. */
|
||||
function placeMarker(x: number, y: number) {
|
||||
const cx = x - canvasLeft();
|
||||
const cy = y - canvasTop();
|
||||
if (cx >= 0 && cx < canvasWidth() && cy >= 0 && cy < canvasHeight()) {
|
||||
canvas.set(`${cx},${cy}`, colorIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/** Erase a marker from the canvas. */
|
||||
function eraseMarker(x: number, y: number) {
|
||||
const cx = x - canvasLeft();
|
||||
const cy = y - canvasTop();
|
||||
canvas.delete(`${cx},${cy}`);
|
||||
}
|
||||
|
||||
/** Draw a line between two points using Bresenham's algorithm. */
|
||||
function drawLine(x0: number, y0: number, x1: number, y1: number) {
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let cx = x0;
|
||||
let cy = y0;
|
||||
|
||||
while (true) {
|
||||
placeMarker(cx, cy);
|
||||
if (cx === x1 && cy === y1) break;
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
cx += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
cy += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track last drag position for continuous line drawing
|
||||
let lastDragX = -1;
|
||||
let lastDragY = -1;
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, HEADER_HEIGHT, " ", titleStyle);
|
||||
const title = " Mouse Demo - Click to Draw ";
|
||||
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, titleStyle);
|
||||
|
||||
// Canvas area background
|
||||
const cw = canvasWidth();
|
||||
const ch = canvasHeight();
|
||||
const cl = canvasLeft();
|
||||
const ct = canvasTop();
|
||||
screen.fill(cl, ct, cw, ch, " ", canvasBg);
|
||||
|
||||
// Draw crosshairs at mouse position
|
||||
const relMx = mouseX - cl;
|
||||
const relMy = mouseY - ct;
|
||||
if (relMx >= 0 && relMx < cw && relMy >= 0 && relMy < ch) {
|
||||
// Horizontal crosshair
|
||||
for (let x = 0; x < cw; x++) {
|
||||
if (x !== relMx) {
|
||||
screen.setText(cl + x, mouseY, "\u00b7", crosshairH); // middle dot
|
||||
}
|
||||
}
|
||||
// Vertical crosshair
|
||||
for (let y = 0; y < ch; y++) {
|
||||
if (y !== relMy) {
|
||||
screen.setText(mouseX, ct + y, "\u00b7", crosshairV); // middle dot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw all markers
|
||||
for (const [key, ci] of canvas) {
|
||||
const [cx, cy] = key.split(",").map(Number);
|
||||
if (cx < cw && cy < ch) {
|
||||
const color = markerColors[ci % markerColors.length];
|
||||
const sid = screen.style({ fg: color, bold: true });
|
||||
screen.setText(cl + cx, ct + cy, "\u2588", sid); // full block
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
const sx = cols - SIDEBAR_WIDTH;
|
||||
screen.drawBox(sx, ct, SIDEBAR_WIDTH, ch, {
|
||||
style: "rounded",
|
||||
styleId: borderStyle,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(sx + 2, ct, " Info ", screen.style({ fg: 0x61afef, bold: true }));
|
||||
|
||||
let sy = ct + 1;
|
||||
|
||||
// Mouse coordinates
|
||||
screen.setText(sx + 2, sy, "Position:", infoLabel);
|
||||
screen.setText(sx + 12, sy, `(${mouseX}, ${mouseY})`, coordsStyle);
|
||||
sy++;
|
||||
|
||||
// Canvas-relative coordinates
|
||||
screen.setText(sx + 2, sy, "Canvas:", infoLabel);
|
||||
screen.setText(sx + 12, sy, `(${relMx}, ${relMy})`, infoValue);
|
||||
sy++;
|
||||
|
||||
// Dragging state
|
||||
screen.setText(sx + 2, sy, "Dragging:", infoLabel);
|
||||
screen.setText(sx + 12, sy, isDragging ? "Yes" : "No", infoValue);
|
||||
sy++;
|
||||
|
||||
// Marker count
|
||||
screen.setText(sx + 2, sy, "Markers:", infoLabel);
|
||||
screen.setText(sx + 12, sy, `${canvas.size}`, infoValue);
|
||||
sy += 2;
|
||||
|
||||
// Current color
|
||||
screen.setText(sx + 2, sy, "Color:", infoLabel);
|
||||
const currentColor = markerColors[colorIndex];
|
||||
const colorSwatch = screen.style({ bg: currentColor });
|
||||
screen.fill(sx + 9, sy, 3, 1, " ", colorSwatch);
|
||||
const hexStr = `#${currentColor.toString(16).padStart(6, "0")}`;
|
||||
screen.setText(sx + 13, sy, hexStr, infoValue);
|
||||
sy += 2;
|
||||
|
||||
// Color palette
|
||||
screen.setText(sx + 2, sy, "Palette (scroll):", infoLabel);
|
||||
sy++;
|
||||
for (let i = 0; i < markerColors.length; i++) {
|
||||
const c = markerColors[i];
|
||||
const sw = screen.style({ bg: c });
|
||||
const indicator = i === colorIndex ? "\u25b6" : " ";
|
||||
const indStyle = i === colorIndex ? screen.style({ fg: 0xe5c07b, bold: true }) : infoLabel;
|
||||
screen.setText(sx + 2, sy + i, indicator, indStyle);
|
||||
screen.fill(sx + 4, sy + i, 2, 1, " ", sw);
|
||||
const hex = `#${c.toString(16).padStart(6, "0")}`;
|
||||
screen.setText(sx + 7, sy + i, hex, i === colorIndex ? infoValue : infoLabel);
|
||||
}
|
||||
sy += markerColors.length + 1;
|
||||
|
||||
// Recent events
|
||||
if (sy + 2 < ct + ch - 1) {
|
||||
screen.setText(sx + 2, sy, "Events:", infoLabel);
|
||||
sy++;
|
||||
const maxEvents = Math.min(eventLog.length, ct + ch - 1 - sy);
|
||||
const startIdx = Math.max(0, eventLog.length - maxEvents);
|
||||
for (let i = startIdx; i < eventLog.length; i++) {
|
||||
if (sy >= ct + ch - 1) break;
|
||||
const msg = eventLog[i].slice(0, SIDEBAR_WIDTH - 4);
|
||||
screen.setText(sx + 2, sy, msg, logStyle);
|
||||
sy++;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
const footerText = " Click: Draw | Drag: Line | Right-click: Erase | Scroll: Color | c: Clear | q: Quit ";
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), footerStyle);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Mouse event handling ---
|
||||
reader.onmouse = (event: {
|
||||
type: string;
|
||||
button: number;
|
||||
x: number;
|
||||
y: number;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
ctrl: boolean;
|
||||
}) => {
|
||||
mouseX = event.x;
|
||||
mouseY = event.y;
|
||||
|
||||
switch (event.type) {
|
||||
case "down":
|
||||
if (event.button === 0) {
|
||||
// Left click
|
||||
isDragging = true;
|
||||
lastDragX = mouseX;
|
||||
lastDragY = mouseY;
|
||||
placeMarker(mouseX, mouseY);
|
||||
logEvent(`click (${mouseX},${mouseY})`);
|
||||
} else if (event.button === 2) {
|
||||
// Right click - erase
|
||||
eraseMarker(mouseX, mouseY);
|
||||
logEvent(`erase (${mouseX},${mouseY})`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "up":
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
lastDragX = -1;
|
||||
lastDragY = -1;
|
||||
logEvent(`release (${mouseX},${mouseY})`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "drag":
|
||||
if (isDragging) {
|
||||
if (lastDragX >= 0 && lastDragY >= 0) {
|
||||
drawLine(lastDragX, lastDragY, mouseX, mouseY);
|
||||
}
|
||||
lastDragX = mouseX;
|
||||
lastDragY = mouseY;
|
||||
}
|
||||
break;
|
||||
|
||||
case "move":
|
||||
// Just update coordinates
|
||||
break;
|
||||
|
||||
case "scrollUp":
|
||||
colorIndex = (colorIndex - 1 + markerColors.length) % markerColors.length;
|
||||
logEvent(`color: ${colorIndex}`);
|
||||
break;
|
||||
|
||||
case "scrollDown":
|
||||
colorIndex = (colorIndex + 1) % markerColors.length;
|
||||
logEvent(`color: ${colorIndex}`);
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Keyboard ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "c" && !ctrl) {
|
||||
// Clear canvas
|
||||
canvas.clear();
|
||||
logEvent("canvas cleared");
|
||||
}
|
||||
|
||||
// Number keys 1-8 to select color directly
|
||||
const num = parseInt(name);
|
||||
if (num >= 1 && num <= markerColors.length) {
|
||||
colorIndex = num - 1;
|
||||
logEvent(`color: ${colorIndex}`);
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
writer.disableMouseTracking();
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Initial render ---
|
||||
render();
|
||||
127
test/js/bun/tui/demos/demo-network.ts
Normal file
127
test/js/bun/tui/demos/demo-network.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* demo-network.ts — Simulated network request waterfall.
|
||||
* Shows GET/POST requests with status codes, timing bars, and response sizes.
|
||||
* Color-coded by status (green 200, yellow 3xx, red 4xx/5xx).
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 78);
|
||||
const height = 20;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
|
||||
const getStyle = screen.style({ fg: 0x61afef, bold: true });
|
||||
const postStyle = screen.style({ fg: 0xc678dd, bold: true });
|
||||
const s200 = screen.style({ fg: 0x98c379 });
|
||||
const s200bar = screen.style({ fg: 0x98c379, bg: 0x2d4a2d });
|
||||
const s301 = screen.style({ fg: 0xe5c07b });
|
||||
const s301bar = screen.style({ fg: 0xe5c07b, bg: 0x4a3d1a });
|
||||
const s404 = screen.style({ fg: 0xe06c75 });
|
||||
const s404bar = screen.style({ fg: 0xe06c75, bg: 0x4a2025 });
|
||||
const s500 = screen.style({ fg: 0xe06c75, bold: true });
|
||||
const s500bar = screen.style({ fg: 0xe06c75, bg: 0x4a2025 });
|
||||
const sizeStyle = screen.style({ fg: 0xabb2bf });
|
||||
const urlStyle = screen.style({ fg: 0xabb2bf });
|
||||
|
||||
interface Request {
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
size: string;
|
||||
}
|
||||
|
||||
const requests: Request[] = [
|
||||
{ method: "GET", url: "/", status: 200, startMs: 0, durationMs: 45, size: "4.2K" },
|
||||
{ method: "GET", url: "/api/users", status: 200, startMs: 50, durationMs: 120, size: "12.8K" },
|
||||
{ method: "GET", url: "/styles.css", status: 200, startMs: 55, durationMs: 30, size: "8.1K" },
|
||||
{ method: "GET", url: "/app.js", status: 200, startMs: 60, durationMs: 85, size: "42.5K" },
|
||||
{ method: "POST", url: "/api/auth", status: 200, startMs: 180, durationMs: 200, size: "0.3K" },
|
||||
{ method: "GET", url: "/old-page", status: 301, startMs: 200, durationMs: 15, size: "0.1K" },
|
||||
{ method: "GET", url: "/api/data", status: 200, startMs: 390, durationMs: 340, size: "98.4K" },
|
||||
{ method: "GET", url: "/missing.png", status: 404, startMs: 400, durationMs: 25, size: "0.2K" },
|
||||
{ method: "POST", url: "/api/submit", status: 500, startMs: 420, durationMs: 150, size: "0.5K" },
|
||||
{ method: "GET", url: "/api/config", status: 200, startMs: 440, durationMs: 55, size: "1.1K" },
|
||||
];
|
||||
|
||||
function statusStyle(s: number) {
|
||||
if (s >= 500) return s500;
|
||||
if (s >= 400) return s404;
|
||||
if (s >= 300) return s301;
|
||||
return s200;
|
||||
}
|
||||
|
||||
function barStyle(s: number) {
|
||||
if (s >= 500) return s500bar;
|
||||
if (s >= 400) return s404bar;
|
||||
if (s >= 300) return s301bar;
|
||||
return s200bar;
|
||||
}
|
||||
|
||||
// Title
|
||||
screen.setText(1, 0, "Network Waterfall", titleStyle);
|
||||
screen.setText(1, 1, "\u2500".repeat(width - 2), dimStyle);
|
||||
|
||||
// Column headers
|
||||
const methodCol = 1;
|
||||
const statusCol = 7;
|
||||
const urlCol = 12;
|
||||
const waterfallCol = 36;
|
||||
const sizeCol = width - 8;
|
||||
|
||||
let y = 2;
|
||||
screen.setText(methodCol, y, "Meth", headerStyle);
|
||||
screen.setText(statusCol, y, "Code", headerStyle);
|
||||
screen.setText(urlCol, y, "URL", headerStyle);
|
||||
screen.setText(waterfallCol, y, "Timeline", headerStyle);
|
||||
screen.setText(sizeCol, y, "Size", headerStyle);
|
||||
y++;
|
||||
|
||||
// Compute waterfall scale
|
||||
const maxEnd = Math.max(...requests.map(r => r.startMs + r.durationMs));
|
||||
const waterfallWidth = sizeCol - waterfallCol - 2;
|
||||
|
||||
for (const req of requests) {
|
||||
const ms = screen.style({ fg: req.method === "POST" ? 0xc678dd : 0x61afef, bold: true });
|
||||
screen.setText(methodCol, y, req.method.padEnd(5), ms);
|
||||
screen.setText(statusCol, y, String(req.status), statusStyle(req.status));
|
||||
|
||||
const shortUrl = req.url.length > 22 ? req.url.slice(0, 20) + ".." : req.url;
|
||||
screen.setText(urlCol, y, shortUrl, urlStyle);
|
||||
|
||||
// Waterfall bar
|
||||
const barOffset = Math.round((req.startMs / maxEnd) * waterfallWidth);
|
||||
const barLen = Math.max(1, Math.round((req.durationMs / maxEnd) * waterfallWidth));
|
||||
// Light background track
|
||||
screen.fill(waterfallCol, y, waterfallWidth, 1, "\u2500", dimStyle);
|
||||
// Active bar
|
||||
screen.fill(waterfallCol + barOffset, y, barLen, 1, "\u2588", barStyle(req.status));
|
||||
|
||||
// Duration label
|
||||
const durStr = `${req.durationMs}ms`;
|
||||
if (barOffset + barLen + durStr.length + 1 < waterfallWidth) {
|
||||
screen.setText(waterfallCol + barOffset + barLen + 1, y, durStr, dimStyle);
|
||||
}
|
||||
|
||||
screen.setText(sizeCol, y, req.size.padStart(6), sizeStyle);
|
||||
y++;
|
||||
}
|
||||
|
||||
// Summary
|
||||
y++;
|
||||
const total200 = requests.filter(r => r.status < 300).length;
|
||||
const total3xx = requests.filter(r => r.status >= 300 && r.status < 400).length;
|
||||
const totalErr = requests.filter(r => r.status >= 400).length;
|
||||
screen.setText(1, y, `${requests.length} requests`, dimStyle);
|
||||
screen.setText(16, y, `\u2714 ${total200} OK`, s200);
|
||||
screen.setText(24, y, `\u2192 ${total3xx} redirect`, s301);
|
||||
screen.setText(38, y, `\u2718 ${totalErr} errors`, s404);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
109
test/js/bun/tui/demos/demo-periodic.ts
Normal file
109
test/js/bun/tui/demos/demo-periodic.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* demo-periodic.ts — A section of the periodic table rendered with colored boxes
|
||||
* per element group. Shows elements 1-18 with symbol, atomic number, colored by group.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 78);
|
||||
const height = 18;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
// Element groups with colors
|
||||
const groups = {
|
||||
nonmetal: { color: 0x98c379, name: "Nonmetal" },
|
||||
noble: { color: 0xc678dd, name: "Noble Gas" },
|
||||
alkali: { color: 0xe06c75, name: "Alkali Metal" },
|
||||
alkaline: { color: 0xe5c07b, name: "Alkaline Earth" },
|
||||
metalloid: { color: 0x56b6c2, name: "Metalloid" },
|
||||
post: { color: 0x61afef, name: "Post-Trans. Metal" },
|
||||
halogen: { color: 0xd19a66, name: "Halogen" },
|
||||
};
|
||||
|
||||
interface Element {
|
||||
num: number;
|
||||
sym: string;
|
||||
group: keyof typeof groups;
|
||||
col: number; // 0-indexed column in periodic table
|
||||
row: number; // 0-indexed row (period - 1)
|
||||
}
|
||||
|
||||
const elements: Element[] = [
|
||||
// Period 1
|
||||
{ num: 1, sym: "H", group: "nonmetal", col: 0, row: 0 },
|
||||
{ num: 2, sym: "He", group: "noble", col: 17, row: 0 },
|
||||
// Period 2
|
||||
{ num: 3, sym: "Li", group: "alkali", col: 0, row: 1 },
|
||||
{ num: 4, sym: "Be", group: "alkaline", col: 1, row: 1 },
|
||||
{ num: 5, sym: "B", group: "metalloid", col: 12, row: 1 },
|
||||
{ num: 6, sym: "C", group: "nonmetal", col: 13, row: 1 },
|
||||
{ num: 7, sym: "N", group: "nonmetal", col: 14, row: 1 },
|
||||
{ num: 8, sym: "O", group: "nonmetal", col: 15, row: 1 },
|
||||
{ num: 9, sym: "F", group: "halogen", col: 16, row: 1 },
|
||||
{ num: 10, sym: "Ne", group: "noble", col: 17, row: 1 },
|
||||
// Period 3
|
||||
{ num: 11, sym: "Na", group: "alkali", col: 0, row: 2 },
|
||||
{ num: 12, sym: "Mg", group: "alkaline", col: 1, row: 2 },
|
||||
{ num: 13, sym: "Al", group: "post", col: 12, row: 2 },
|
||||
{ num: 14, sym: "Si", group: "metalloid", col: 13, row: 2 },
|
||||
{ num: 15, sym: "P", group: "nonmetal", col: 14, row: 2 },
|
||||
{ num: 16, sym: "S", group: "nonmetal", col: 15, row: 2 },
|
||||
{ num: 17, sym: "Cl", group: "halogen", col: 16, row: 2 },
|
||||
{ num: 18, sym: "Ar", group: "noble", col: 17, row: 2 },
|
||||
];
|
||||
|
||||
// Each element cell is 4 chars wide, 3 rows tall
|
||||
const cellW = 4;
|
||||
const cellH = 3;
|
||||
const gridX = 1;
|
||||
const gridY = 3;
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "Periodic Table (Elements 1-18)", titleStyle);
|
||||
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
|
||||
|
||||
// Render elements
|
||||
for (const el of elements) {
|
||||
const grp = groups[el.group];
|
||||
const x = gridX + el.col * cellW;
|
||||
const y = gridY + el.row * cellH;
|
||||
|
||||
// Only render if it fits
|
||||
if (x + cellW > width) continue;
|
||||
|
||||
const borderS = screen.style({ fg: grp.color });
|
||||
const numS = screen.style({ fg: grp.color, faint: true });
|
||||
const symS = screen.style({ fg: grp.color, bold: true });
|
||||
|
||||
// Draw mini box
|
||||
screen.drawBox(x, y, cellW, cellH, { style: "single", styleId: borderS });
|
||||
|
||||
// Atomic number (top-left inside)
|
||||
const numStr = String(el.num);
|
||||
screen.setText(x + 1, y, numStr, numS);
|
||||
|
||||
// Symbol (center)
|
||||
screen.setText(x + 1, y + 1, el.sym, symS);
|
||||
}
|
||||
|
||||
// Legend
|
||||
const legendY = gridY + 3 * cellH + 1;
|
||||
let lx = 2;
|
||||
for (const [key, grp] of Object.entries(groups)) {
|
||||
const s = screen.style({ fg: grp.color });
|
||||
screen.setText(lx, legendY, "\u2588", s);
|
||||
screen.setText(lx + 2, legendY, grp.name, screen.style({ fg: grp.color, faint: true }));
|
||||
lx += grp.name.length + 4;
|
||||
if (lx > width - 15) {
|
||||
// Wrap to next line if needed
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
103
test/js/bun/tui/demos/demo-piano.ts
Normal file
103
test/js/bun/tui/demos/demo-piano.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* demo-piano.ts — ASCII piano keyboard (2 octaves) with black and white keys
|
||||
* using box-drawing characters and half-blocks. Labels the notes.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 72);
|
||||
const height = 14;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const whiteKey = screen.style({ fg: 0xffffff, bg: 0xffffff });
|
||||
const whiteKeyBorder = screen.style({ fg: 0x888888, bg: 0xffffff });
|
||||
const blackKey = screen.style({ fg: 0x222222, bg: 0x222222 });
|
||||
const labelStyle = screen.style({ fg: 0x333333, bg: 0xffffff });
|
||||
const blackLabel = screen.style({ fg: 0x888888 });
|
||||
const borderDark = screen.style({ fg: 0x444444 });
|
||||
|
||||
// Piano layout: 2 octaves
|
||||
// White keys: C D E F G A B (7 per octave = 14 total)
|
||||
// Black keys pattern: C# D# _ F# G# A# _ (5 per octave = 10 total)
|
||||
|
||||
const whiteNotes = ["C", "D", "E", "F", "G", "A", "B", "C", "D", "E", "F", "G", "A", "B"];
|
||||
const octaveMarks = [4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5];
|
||||
|
||||
// Black key positions relative to white keys (0-indexed)
|
||||
// After C(0): C#, After D(1): D#, skip E(2), After F(3): F#, After G(4): G#, After A(5): A#, skip B(6)
|
||||
const blackKeyAfter = [0, 1, 3, 4, 5]; // Indices of white keys that have a sharp
|
||||
|
||||
const keyWidth = 4;
|
||||
const totalKeys = whiteNotes.length;
|
||||
const pianoWidth = totalKeys * keyWidth + 1;
|
||||
const startX = Math.max(1, Math.floor((width - pianoWidth) / 2));
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "Piano Keyboard (2 Octaves)", titleStyle);
|
||||
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
|
||||
|
||||
const pianoTop = 3;
|
||||
const whiteKeyHeight = 7;
|
||||
const blackKeyHeight = 4;
|
||||
|
||||
// Draw white keys (background)
|
||||
for (let i = 0; i < totalKeys; i++) {
|
||||
const x = startX + i * keyWidth;
|
||||
// Fill white key area
|
||||
screen.fill(x, pianoTop, keyWidth, whiteKeyHeight, " ", whiteKey);
|
||||
// Right border of each key
|
||||
for (let row = 0; row < whiteKeyHeight; row++) {
|
||||
screen.setText(x + keyWidth, pianoTop + row, "\u2502", borderDark);
|
||||
}
|
||||
}
|
||||
|
||||
// Left border
|
||||
for (let row = 0; row < whiteKeyHeight; row++) {
|
||||
screen.setText(startX, pianoTop + row, "\u2502", borderDark);
|
||||
}
|
||||
|
||||
// Top border
|
||||
screen.fill(startX, pianoTop, pianoWidth, 1, "\u2500", borderDark);
|
||||
// Bottom border
|
||||
screen.fill(startX, pianoTop + whiteKeyHeight, pianoWidth, 1, "\u2500", borderDark);
|
||||
|
||||
// White key note labels (at the bottom of keys)
|
||||
for (let i = 0; i < totalKeys; i++) {
|
||||
const x = startX + i * keyWidth + 1;
|
||||
const label = whiteNotes[i] + octaveMarks[i];
|
||||
screen.setText(x, pianoTop + whiteKeyHeight - 1, label, labelStyle);
|
||||
}
|
||||
|
||||
// Draw black keys (on top of white keys)
|
||||
for (let oct = 0; oct < 2; oct++) {
|
||||
for (const bk of blackKeyAfter) {
|
||||
const whiteIdx = oct * 7 + bk;
|
||||
if (whiteIdx >= totalKeys - 1) continue;
|
||||
|
||||
// Black key sits between two white keys
|
||||
const x = startX + whiteIdx * keyWidth + keyWidth - 1;
|
||||
const bkWidth = keyWidth - 1;
|
||||
|
||||
// Draw black key body
|
||||
screen.fill(x, pianoTop + 1, bkWidth, blackKeyHeight, "\u2588", blackKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Black key labels above piano
|
||||
const sharpNames = ["C#", "D#", "F#", "G#", "A#"];
|
||||
for (let oct = 0; oct < 2; oct++) {
|
||||
for (let bi = 0; bi < blackKeyAfter.length; bi++) {
|
||||
const whiteIdx = oct * 7 + blackKeyAfter[bi];
|
||||
if (whiteIdx >= totalKeys - 1) continue;
|
||||
const x = startX + whiteIdx * keyWidth + keyWidth - 1;
|
||||
const label = sharpNames[bi] + (oct + 4);
|
||||
screen.setText(x, pianoTop + blackKeyHeight + 1, label, blackLabel);
|
||||
}
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
466
test/js/bun/tui/demos/demo-progress.ts
Normal file
466
test/js/bun/tui/demos/demo-progress.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* demo-progress.ts — Progress Bars and Spinners
|
||||
*
|
||||
* Multiple animated progress bars with different visual styles,
|
||||
* a spinner, status text, and a task queue simulation.
|
||||
* Demonstrates: setInterval animation loop, style (fg/bg/bold),
|
||||
* setText, fill, drawBox, TUITerminalWriter, TUIKeyReader, alt screen.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-progress.ts
|
||||
* Exit: Press 'q' or Ctrl+C
|
||||
*/
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const styles = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
header: screen.style({ fg: 0x61afef, bold: true }),
|
||||
label: screen.style({ fg: 0xabb2bf }),
|
||||
labelBold: screen.style({ fg: 0xabb2bf, bold: true }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
success: screen.style({ fg: 0x98c379, bold: true }),
|
||||
error: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
warning: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
info: screen.style({ fg: 0x61afef }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
// Progress bar styles
|
||||
barGreen: screen.style({ bg: 0x98c379 }),
|
||||
barBlue: screen.style({ bg: 0x61afef }),
|
||||
barYellow: screen.style({ bg: 0xe5c07b }),
|
||||
barRed: screen.style({ bg: 0xe06c75 }),
|
||||
barCyan: screen.style({ bg: 0x56b6c2 }),
|
||||
barMagenta: screen.style({ bg: 0xc678dd }),
|
||||
barEmpty: screen.style({ fg: 0x3e4451 }),
|
||||
barEmptyBg: screen.style({ bg: 0x2c313a }),
|
||||
// Percent text
|
||||
pctHigh: screen.style({ fg: 0x98c379, bold: true }),
|
||||
pctMid: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
pctLow: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
// Spinner
|
||||
spinnerStyle: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
};
|
||||
|
||||
// --- Spinner frames ---
|
||||
const spinnerFrames = [
|
||||
"\u280b",
|
||||
"\u2819",
|
||||
"\u2839",
|
||||
"\u2838",
|
||||
"\u283c",
|
||||
"\u2834",
|
||||
"\u2826",
|
||||
"\u2827",
|
||||
"\u2807",
|
||||
"\u280f",
|
||||
];
|
||||
// Alternative: ["\\", "|", "/", "-"]
|
||||
let spinnerIdx = 0;
|
||||
|
||||
// --- Task simulation state ---
|
||||
interface Task {
|
||||
name: string;
|
||||
progress: number; // 0.0 - 1.0
|
||||
speed: number; // progress increment per tick
|
||||
barStyle: number; // style ID for filled portion
|
||||
status: "pending" | "running" | "complete" | "error";
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
const tasks: Task[] = [
|
||||
{
|
||||
name: "Installing dependencies",
|
||||
progress: 0,
|
||||
speed: 0.008 + Math.random() * 0.005,
|
||||
barStyle: styles.barGreen,
|
||||
status: "running",
|
||||
statusText: "Resolving packages...",
|
||||
},
|
||||
{
|
||||
name: "Compiling TypeScript",
|
||||
progress: 0,
|
||||
speed: 0.012 + Math.random() * 0.005,
|
||||
barStyle: styles.barBlue,
|
||||
status: "pending",
|
||||
statusText: "Waiting...",
|
||||
},
|
||||
{
|
||||
name: "Running tests",
|
||||
progress: 0,
|
||||
speed: 0.006 + Math.random() * 0.003,
|
||||
barStyle: styles.barCyan,
|
||||
status: "pending",
|
||||
statusText: "Waiting...",
|
||||
},
|
||||
{
|
||||
name: "Building bundle",
|
||||
progress: 0,
|
||||
speed: 0.015 + Math.random() * 0.008,
|
||||
barStyle: styles.barYellow,
|
||||
status: "pending",
|
||||
statusText: "Waiting...",
|
||||
},
|
||||
{
|
||||
name: "Optimizing assets",
|
||||
progress: 0,
|
||||
speed: 0.005 + Math.random() * 0.003,
|
||||
barStyle: styles.barMagenta,
|
||||
status: "pending",
|
||||
statusText: "Waiting...",
|
||||
},
|
||||
{
|
||||
name: "Deploying to production",
|
||||
progress: 0,
|
||||
speed: 0.01 + Math.random() * 0.005,
|
||||
barStyle: styles.barRed,
|
||||
status: "pending",
|
||||
statusText: "Waiting...",
|
||||
},
|
||||
];
|
||||
|
||||
// Status messages for each task (cycled during progress)
|
||||
const statusMessages: Record<string, string[]> = {
|
||||
"Installing dependencies": [
|
||||
"Resolving packages...",
|
||||
"Downloading modules...",
|
||||
"Linking dependencies...",
|
||||
"Verifying tree...",
|
||||
],
|
||||
"Compiling TypeScript": [
|
||||
"Parsing source files...",
|
||||
"Type checking...",
|
||||
"Emitting declarations...",
|
||||
"Generating output...",
|
||||
],
|
||||
"Running tests": ["test/unit/core...", "test/unit/api...", "test/integration...", "test/e2e..."],
|
||||
"Building bundle": ["Scanning entry points...", "Tree shaking...", "Minifying...", "Writing output..."],
|
||||
"Optimizing assets": ["Compressing images...", "Inlining CSS...", "Hashing filenames...", "Generating manifest..."],
|
||||
"Deploying to production": [
|
||||
"Uploading artifacts...",
|
||||
"Updating DNS...",
|
||||
"Invalidating cache...",
|
||||
"Health checking...",
|
||||
],
|
||||
};
|
||||
|
||||
// Overall stats
|
||||
let completedCount = 0;
|
||||
let totalTicks = 0;
|
||||
let paused = false;
|
||||
let allDone = false;
|
||||
|
||||
// --- Animation tick ---
|
||||
function tick() {
|
||||
if (paused || allDone) return;
|
||||
|
||||
totalTicks++;
|
||||
spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
|
||||
|
||||
// Process tasks sequentially: start next when current is at a threshold
|
||||
let activeFound = false;
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
|
||||
if (task.status === "complete" || task.status === "error") continue;
|
||||
|
||||
if (task.status === "pending") {
|
||||
// Start this task if the previous one is at least 30% done or complete
|
||||
if (i === 0 || tasks[i - 1].progress >= 0.3 || tasks[i - 1].status === "complete") {
|
||||
task.status = "running";
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (task.status === "running") {
|
||||
activeFound = true;
|
||||
task.progress += task.speed * (0.8 + Math.random() * 0.4);
|
||||
|
||||
// Update status text based on progress
|
||||
const msgs = statusMessages[task.name];
|
||||
if (msgs) {
|
||||
const msgIdx = Math.min(Math.floor(task.progress * msgs.length), msgs.length - 1);
|
||||
task.statusText = msgs[msgIdx];
|
||||
}
|
||||
|
||||
if (task.progress >= 1.0) {
|
||||
task.progress = 1.0;
|
||||
task.status = "complete";
|
||||
task.statusText = "Done!";
|
||||
completedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeFound && completedCount === tasks.length) {
|
||||
allDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render helpers ---
|
||||
|
||||
function percentStyle(ratio: number): number {
|
||||
if (ratio >= 0.8) return styles.pctHigh;
|
||||
if (ratio >= 0.4) return styles.pctMid;
|
||||
return styles.pctLow;
|
||||
}
|
||||
|
||||
function statusIcon(status: string): { icon: string; style: number } {
|
||||
switch (status) {
|
||||
case "complete":
|
||||
return { icon: "\u2714", style: styles.success }; // checkmark
|
||||
case "error":
|
||||
return { icon: "\u2718", style: styles.error }; // cross
|
||||
case "running":
|
||||
return { icon: spinnerFrames[spinnerIdx], style: styles.spinnerStyle };
|
||||
default:
|
||||
return { icon: "\u25cb", style: styles.dim }; // empty circle
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw a progress bar with filled/empty sections. */
|
||||
function drawProgressBar(x: number, y: number, width: number, ratio: number, fillStyle: number) {
|
||||
const filled = Math.round(ratio * width);
|
||||
const empty = width - filled;
|
||||
|
||||
if (filled > 0) {
|
||||
screen.fill(x, y, filled, 1, " ", fillStyle);
|
||||
}
|
||||
if (empty > 0) {
|
||||
screen.fill(x + filled, y, empty, 1, " ", styles.barEmptyBg);
|
||||
}
|
||||
|
||||
// Overlay percentage text centered in the bar
|
||||
const pctText = `${Math.round(ratio * 100)}%`;
|
||||
const pctX = x + Math.floor((width - pctText.length) / 2);
|
||||
const pctStyle =
|
||||
ratio > 0.5
|
||||
? screen.style({
|
||||
fg: 0x000000,
|
||||
bg: ratio >= 1.0 ? 0x98c379 : fillStyle === styles.barGreen ? 0x98c379 : 0x61afef,
|
||||
bold: true,
|
||||
})
|
||||
: screen.style({ fg: 0xffffff, bg: 0x2c313a, bold: true });
|
||||
// Just write the percentage without styling to keep it simple
|
||||
screen.setText(pctX, y, pctText, percentStyle(ratio));
|
||||
}
|
||||
|
||||
/** Draw a block-character progress bar (alternative style). */
|
||||
function drawBlockBar(x: number, y: number, width: number, ratio: number) {
|
||||
const blocks = [" ", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"];
|
||||
const totalUnits = width * 8;
|
||||
const filledUnits = Math.round(ratio * totalUnits);
|
||||
const fullBlocks = Math.floor(filledUnits / 8);
|
||||
const partialBlock = filledUnits % 8;
|
||||
|
||||
const barColor = screen.style({ fg: 0x61afef });
|
||||
for (let i = 0; i < width; i++) {
|
||||
if (i < fullBlocks) {
|
||||
screen.setText(x + i, y, "\u2588", barColor);
|
||||
} else if (i === fullBlocks && partialBlock > 0) {
|
||||
screen.setText(x + i, y, blocks[partialBlock], barColor);
|
||||
} else {
|
||||
screen.setText(x + i, y, "\u2500", styles.barEmpty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", styles.titleBar);
|
||||
const title = " Progress Bars & Spinners ";
|
||||
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, styles.titleBar);
|
||||
|
||||
let y = 2;
|
||||
const leftMargin = 2;
|
||||
const contentWidth = Math.min(cols - 4, 90);
|
||||
|
||||
// --- Overall progress ---
|
||||
const overallProgress = tasks.reduce((sum, t) => sum + t.progress, 0) / tasks.length;
|
||||
screen.setText(leftMargin, y, "Overall Progress", styles.header);
|
||||
y++;
|
||||
|
||||
const overallBarWidth = Math.min(contentWidth - 10, 60);
|
||||
drawBlockBar(leftMargin, y, overallBarWidth, overallProgress);
|
||||
const overallPct = `${Math.round(overallProgress * 100)}%`;
|
||||
screen.setText(leftMargin + overallBarWidth + 2, y, overallPct, percentStyle(overallProgress));
|
||||
screen.setText(leftMargin + overallBarWidth + 8, y, `${completedCount}/${tasks.length} tasks`, styles.label);
|
||||
y += 2;
|
||||
|
||||
// Separator
|
||||
for (let i = 0; i < contentWidth; i++) {
|
||||
screen.setText(leftMargin + i, y, "\u2500", styles.dim);
|
||||
}
|
||||
y++;
|
||||
|
||||
// --- Individual tasks ---
|
||||
screen.setText(leftMargin, y, "Task Queue", styles.header);
|
||||
y++;
|
||||
|
||||
const barWidth = Math.min(contentWidth - 40, 40);
|
||||
const taskNameWidth = 26;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (y >= rows - 4) break;
|
||||
|
||||
// Status icon
|
||||
const { icon, style: iconStyle } = statusIcon(task.status);
|
||||
screen.setText(leftMargin, y, icon, iconStyle);
|
||||
|
||||
// Task name
|
||||
const nameStyle =
|
||||
task.status === "complete" ? styles.success : task.status === "running" ? styles.labelBold : styles.dim;
|
||||
screen.setText(leftMargin + 2, y, task.name.padEnd(taskNameWidth).slice(0, taskNameWidth), nameStyle);
|
||||
|
||||
// Progress bar
|
||||
const barX = leftMargin + 2 + taskNameWidth + 1;
|
||||
if (task.status === "running" || task.status === "complete") {
|
||||
drawProgressBar(barX, y, barWidth, task.progress, task.barStyle);
|
||||
} else {
|
||||
// Show empty bar for pending
|
||||
screen.fill(barX, y, barWidth, 1, " ", styles.barEmptyBg);
|
||||
}
|
||||
|
||||
// Status text
|
||||
const statusX = barX + barWidth + 2;
|
||||
const statusWidth = cols - statusX - 2;
|
||||
if (statusWidth > 0) {
|
||||
const st = task.statusText.slice(0, statusWidth);
|
||||
const stStyle = task.status === "complete" ? styles.success : task.status === "error" ? styles.error : styles.dim;
|
||||
screen.setText(statusX, y, st, stStyle);
|
||||
}
|
||||
|
||||
y += 2; // spacing between tasks
|
||||
}
|
||||
|
||||
// --- Spinner showcase ---
|
||||
y = Math.max(y, rows - 8);
|
||||
if (y < rows - 4) {
|
||||
screen.setText(leftMargin, y, "Spinner Styles", styles.header);
|
||||
y++;
|
||||
|
||||
const spinnerTypes = [
|
||||
{
|
||||
frames: ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"],
|
||||
label: "Dots",
|
||||
},
|
||||
{ frames: ["|", "/", "-", "\\"], label: "Line" },
|
||||
{ frames: ["\u25dc", "\u25dd", "\u25de", "\u25df"], label: "Arc" },
|
||||
{ frames: ["\u2596", "\u2598", "\u259d", "\u2597"], label: "Block" },
|
||||
{ frames: ["\u25a0", "\u25a1"], label: "Square" },
|
||||
];
|
||||
|
||||
for (let i = 0; i < spinnerTypes.length; i++) {
|
||||
const sp = spinnerTypes[i];
|
||||
const frame = sp.frames[spinnerIdx % sp.frames.length];
|
||||
const spX = leftMargin + i * 14;
|
||||
if (spX + 12 < cols) {
|
||||
screen.setText(spX, y, frame, styles.spinnerStyle);
|
||||
screen.setText(spX + 2, y, sp.label, styles.label);
|
||||
}
|
||||
}
|
||||
y += 2;
|
||||
}
|
||||
|
||||
// --- Completion message ---
|
||||
if (allDone) {
|
||||
const doneMsg = " All tasks completed successfully! ";
|
||||
const doneX = Math.max(leftMargin, Math.floor((cols - doneMsg.length) / 2));
|
||||
const doneY = Math.min(y, rows - 3);
|
||||
screen.setText(doneX, doneY, doneMsg, styles.success);
|
||||
}
|
||||
|
||||
// --- Footer ---
|
||||
const footerY = rows - 1;
|
||||
const footerParts: string[] = [];
|
||||
if (paused) {
|
||||
footerParts.push("PAUSED");
|
||||
}
|
||||
footerParts.push("p: Pause/Resume");
|
||||
footerParts.push("r: Restart");
|
||||
footerParts.push("q: Quit");
|
||||
const footerText = " " + footerParts.join(" | ") + " ";
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), paused ? styles.warning : styles.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Reset tasks ---
|
||||
function resetTasks() {
|
||||
for (const task of tasks) {
|
||||
task.progress = 0;
|
||||
task.speed = 0.005 + Math.random() * 0.01;
|
||||
task.status = "pending";
|
||||
task.statusText = "Waiting...";
|
||||
}
|
||||
tasks[0].status = "running";
|
||||
tasks[0].statusText = statusMessages[tasks[0].name]?.[0] ?? "Starting...";
|
||||
completedCount = 0;
|
||||
totalTicks = 0;
|
||||
allDone = false;
|
||||
paused = false;
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "p":
|
||||
paused = !paused;
|
||||
break;
|
||||
case "r":
|
||||
resetTasks();
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearInterval(timer);
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Animation loop ---
|
||||
const timer = setInterval(() => {
|
||||
tick();
|
||||
render();
|
||||
}, 80); // ~12.5 fps
|
||||
|
||||
// --- Initial render ---
|
||||
render();
|
||||
123
test/js/bun/tui/demos/demo-qrcode.ts
Normal file
123
test/js/bun/tui/demos/demo-qrcode.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* demo-qrcode.ts — A QR-code-like pattern rendered using block and space characters.
|
||||
* Generates a deterministic pattern that visually resembles a QR code.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const qrSize = 25; // 25x25 modules (standard QR version 2 size)
|
||||
const width = Math.max(qrSize + 4, 40);
|
||||
const height = Math.ceil(qrSize / 2) + 5; // Half-block rendering doubles vertical resolution
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
|
||||
// QR code data: true = dark module, false = light module
|
||||
const grid: boolean[][] = Array.from({ length: qrSize }, () => Array(qrSize).fill(false));
|
||||
|
||||
// Finder patterns (7x7 squares in 3 corners)
|
||||
function drawFinder(cx: number, cy: number) {
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
// Outer border
|
||||
if (y === 0 || y === 6 || x === 0 || x === 6) {
|
||||
grid[cy + y][cx + x] = true;
|
||||
}
|
||||
// Inner 3x3 block
|
||||
else if (y >= 2 && y <= 4 && x >= 2 && x <= 4) {
|
||||
grid[cy + y][cx + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawFinder(0, 0); // Top-left
|
||||
drawFinder(qrSize - 7, 0); // Top-right
|
||||
drawFinder(0, qrSize - 7); // Bottom-left
|
||||
|
||||
// Timing patterns (alternating dots between finders)
|
||||
for (let i = 8; i < qrSize - 8; i++) {
|
||||
grid[6][i] = i % 2 === 0;
|
||||
grid[i][6] = i % 2 === 0;
|
||||
}
|
||||
|
||||
// Alignment pattern (5x5 at position 18,18 for version 2+)
|
||||
const apx = 18,
|
||||
apy = 18;
|
||||
for (let y = -2; y <= 2; y++) {
|
||||
for (let x = -2; x <= 2; x++) {
|
||||
if (Math.abs(x) === 2 || Math.abs(y) === 2 || (x === 0 && y === 0)) {
|
||||
grid[apy + y][apx + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separator patterns (white border around finders)
|
||||
// Already false by default
|
||||
|
||||
// Fill data area with pseudo-random but deterministic pattern
|
||||
let seed = 12345;
|
||||
function prng() {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
}
|
||||
|
||||
for (let y = 0; y < qrSize; y++) {
|
||||
for (let x = 0; x < qrSize; x++) {
|
||||
// Skip finder patterns and timing
|
||||
if (x < 9 && y < 9) continue; // top-left finder + separator
|
||||
if (x >= qrSize - 8 && y < 9) continue; // top-right finder + separator
|
||||
if (x < 9 && y >= qrSize - 8) continue; // bottom-left finder + separator
|
||||
if (y === 6 || x === 6) continue; // timing patterns
|
||||
if (Math.abs(x - apx) <= 2 && Math.abs(y - apy) <= 2) continue; // alignment
|
||||
|
||||
grid[y][x] = prng() > 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "QR Code Pattern", titleStyle);
|
||||
|
||||
// Render using half-blocks for double vertical resolution
|
||||
// Upper-half block: top pixel dark, bottom pixel light = \u2580 (fg=dark)
|
||||
// Lower-half block: top pixel light, bottom pixel dark = \u2584 (fg=dark)
|
||||
// Full block: both dark = \u2588
|
||||
// Space: both light = " "
|
||||
|
||||
const qrDark = 0x222222;
|
||||
const qrLight = 0xffffff;
|
||||
const startX = 2;
|
||||
const startY = 2;
|
||||
|
||||
for (let row = 0; row < qrSize; row += 2) {
|
||||
for (let col = 0; col < qrSize; col++) {
|
||||
const top = grid[row][col];
|
||||
const bottom = row + 1 < qrSize ? grid[row + 1][col] : false;
|
||||
const x = startX + col;
|
||||
const y = startY + Math.floor(row / 2);
|
||||
|
||||
if (top && bottom) {
|
||||
const s = screen.style({ fg: qrDark });
|
||||
screen.setText(x, y, "\u2588", s);
|
||||
} else if (top && !bottom) {
|
||||
const s = screen.style({ fg: qrDark, bg: qrLight });
|
||||
screen.setText(x, y, "\u2580", s);
|
||||
} else if (!top && bottom) {
|
||||
const s = screen.style({ fg: qrDark, bg: qrLight });
|
||||
screen.setText(x, y, "\u2584", s);
|
||||
} else {
|
||||
const s = screen.style({ fg: qrLight });
|
||||
screen.setText(x, y, " ", s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Caption
|
||||
const captionY = startY + Math.ceil(qrSize / 2) + 1;
|
||||
screen.setText(2, captionY, "Scan me! (decorative only)", dimStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
102
test/js/bun/tui/demos/demo-report.ts
Normal file
102
test/js/bun/tui/demos/demo-report.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* demo-report.ts — Test report summary with header box, pass/fail counts,
|
||||
* mini bar chart of test durations, and a footer.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 72);
|
||||
const height = 22;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const headerBg = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
|
||||
const passStyle = screen.style({ fg: 0x98c379, bold: true });
|
||||
const failStyle = screen.style({ fg: 0xe06c75, bold: true });
|
||||
const skipStyle = screen.style({ fg: 0xe5c07b });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const labelStyle = screen.style({ fg: 0xabb2bf });
|
||||
const barPass = screen.style({ fg: 0x98c379 });
|
||||
const barFail = screen.style({ fg: 0xe06c75 });
|
||||
const borderStyle = screen.style({ fg: 0x3e4452 });
|
||||
const totalStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
|
||||
// Header
|
||||
screen.drawBox(0, 0, width, 3, { style: "rounded", styleId: borderStyle, fill: true, fillChar: " " });
|
||||
screen.fill(1, 1, width - 2, 1, " ", headerBg);
|
||||
const title = " Test Report \u2014 my-project ";
|
||||
screen.setText(Math.floor((width - title.length) / 2), 1, title, headerBg);
|
||||
|
||||
// Summary counts
|
||||
const passed = 142;
|
||||
const failed = 3;
|
||||
const skipped = 7;
|
||||
const total = passed + failed + skipped;
|
||||
|
||||
let y = 4;
|
||||
screen.setText(2, y, "Results:", totalStyle);
|
||||
y++;
|
||||
screen.setText(4, y, `\u2714 Passed: ${passed}`, passStyle);
|
||||
y++;
|
||||
screen.setText(4, y, `\u2718 Failed: ${failed}`, failStyle);
|
||||
y++;
|
||||
screen.setText(4, y, `\u25CB Skipped: ${skipped}`, skipStyle);
|
||||
y++;
|
||||
screen.setText(4, y, ` Total: ${total}`, dimStyle);
|
||||
|
||||
// Summary bar
|
||||
y += 2;
|
||||
screen.setText(2, y, "Pass Rate:", labelStyle);
|
||||
const barWidth = width - 16;
|
||||
const passWidth = Math.round((passed / total) * barWidth);
|
||||
const failWidth = Math.round((failed / total) * barWidth);
|
||||
const skipWidth = barWidth - passWidth - failWidth;
|
||||
let bx = 14;
|
||||
screen.fill(bx, y, passWidth, 1, "\u2588", barPass);
|
||||
bx += passWidth;
|
||||
screen.fill(bx, y, failWidth, 1, "\u2588", barFail);
|
||||
bx += failWidth;
|
||||
screen.fill(bx, y, skipWidth, 1, "\u2591", skipStyle);
|
||||
const pct = ((passed / total) * 100).toFixed(1);
|
||||
screen.setText(bx + skipWidth + 1, y, `${pct}%`, passStyle);
|
||||
|
||||
// Test duration chart
|
||||
y += 2;
|
||||
screen.setText(2, y, "Test Durations (ms):", labelStyle);
|
||||
y++;
|
||||
|
||||
const suites = [
|
||||
{ name: "http/serve.test.ts", time: 1240, pass: true },
|
||||
{ name: "crypto/hash.test.ts", time: 890, pass: true },
|
||||
{ name: "fs/readFile.test.ts", time: 650, pass: true },
|
||||
{ name: "shell/exec.test.ts", time: 2100, pass: false },
|
||||
{ name: "fetch/client.test.ts", time: 430, pass: true },
|
||||
{ name: "sqlite/query.test.ts", time: 780, pass: true },
|
||||
];
|
||||
|
||||
const maxTime = Math.max(...suites.map(s => s.time));
|
||||
const nameCol = 4;
|
||||
const chartStart = 28;
|
||||
const chartWidth = width - chartStart - 8;
|
||||
|
||||
for (const suite of suites) {
|
||||
const nameStyle = suite.pass ? labelStyle : failStyle;
|
||||
const shortName = suite.name.length > 22 ? suite.name.slice(0, 20) + ".." : suite.name;
|
||||
screen.setText(nameCol, y, shortName, nameStyle);
|
||||
|
||||
const barLen = Math.max(1, Math.round((suite.time / maxTime) * chartWidth));
|
||||
const bStyle = suite.pass ? barPass : barFail;
|
||||
screen.fill(chartStart, y, barLen, 1, "\u2588", bStyle);
|
||||
screen.setText(chartStart + barLen + 1, y, `${suite.time}`, dimStyle);
|
||||
y++;
|
||||
}
|
||||
|
||||
// Footer
|
||||
y++;
|
||||
screen.drawBox(0, y, width, 3, { style: "rounded", styleId: borderStyle, fill: true, fillChar: " " });
|
||||
const footer = `Completed in 6.09s \u2022 ${new Date().toLocaleTimeString()}`;
|
||||
screen.setText(Math.floor((width - footer.length) / 2), y + 1, footer, dimStyle);
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
355
test/js/bun/tui/demos/demo-snake.ts
Normal file
355
test/js/bun/tui/demos/demo-snake.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* demo-snake.ts — Snake Game
|
||||
*
|
||||
* Classic snake game with food collection, growing tail, collision detection,
|
||||
* speed increase, score tracking, and game over / restart.
|
||||
*
|
||||
* Demonstrates: setInterval game loop, keyboard input, dynamic cell rendering,
|
||||
* fill, setText, style (fg/bg/bold), drawBox, alt screen, TUITerminalWriter,
|
||||
* TUIKeyReader, and resize handling.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-snake.ts
|
||||
* Controls: Arrow keys / WASD to move, R to restart, Q / Ctrl+C to quit
|
||||
*/
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const styles = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
snakeHead: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
|
||||
snakeBody: screen.style({ fg: 0x000000, bg: 0x61afef }),
|
||||
food: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
empty: screen.style({ bg: 0x1e2127 }),
|
||||
score: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
label: screen.style({ fg: 0xabb2bf }),
|
||||
gameOver: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
gameOverBg: screen.style({ fg: 0xffffff, bg: 0xe06c75, bold: true }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
highScore: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
speed: screen.style({ fg: 0x56b6c2 }),
|
||||
};
|
||||
|
||||
// --- Game constants ---
|
||||
const HEADER_H = 1;
|
||||
const FOOTER_H = 1;
|
||||
const SIDEBAR_W = 20;
|
||||
|
||||
function fieldWidth() {
|
||||
return Math.max(10, cols - SIDEBAR_W - 2);
|
||||
}
|
||||
function fieldHeight() {
|
||||
return Math.max(6, rows - HEADER_H - FOOTER_H - 2);
|
||||
}
|
||||
function fieldX() {
|
||||
return 1;
|
||||
}
|
||||
function fieldY() {
|
||||
return HEADER_H + 1;
|
||||
}
|
||||
|
||||
// --- Game state ---
|
||||
type Point = { x: number; y: number };
|
||||
type Direction = "up" | "down" | "left" | "right";
|
||||
|
||||
let snake: Point[] = [];
|
||||
let direction: Direction = "right";
|
||||
let nextDirection: Direction = "right";
|
||||
let food: Point = { x: 0, y: 0 };
|
||||
let score = 0;
|
||||
let highScore = 0;
|
||||
let gameOver = false;
|
||||
let paused = false;
|
||||
let tickCount = 0;
|
||||
let baseInterval = 120; // ms per tick
|
||||
let currentInterval = baseInterval;
|
||||
|
||||
function resetGame() {
|
||||
const fw = fieldWidth();
|
||||
const fh = fieldHeight();
|
||||
const startX = Math.floor(fw / 2);
|
||||
const startY = Math.floor(fh / 2);
|
||||
snake = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX - 1, y: startY },
|
||||
{ x: startX - 2, y: startY },
|
||||
];
|
||||
direction = "right";
|
||||
nextDirection = "right";
|
||||
score = 0;
|
||||
gameOver = false;
|
||||
paused = false;
|
||||
tickCount = 0;
|
||||
currentInterval = baseInterval;
|
||||
spawnFood();
|
||||
}
|
||||
|
||||
function spawnFood() {
|
||||
const fw = fieldWidth();
|
||||
const fh = fieldHeight();
|
||||
let attempts = 0;
|
||||
do {
|
||||
food = {
|
||||
x: Math.floor(Math.random() * fw),
|
||||
y: Math.floor(Math.random() * fh),
|
||||
};
|
||||
attempts++;
|
||||
} while (snake.some(s => s.x === food.x && s.y === food.y) && attempts < 1000);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (gameOver || paused) return;
|
||||
tickCount++;
|
||||
|
||||
direction = nextDirection;
|
||||
|
||||
// Move head
|
||||
const head = snake[0];
|
||||
let nx = head.x;
|
||||
let ny = head.y;
|
||||
switch (direction) {
|
||||
case "up":
|
||||
ny--;
|
||||
break;
|
||||
case "down":
|
||||
ny++;
|
||||
break;
|
||||
case "left":
|
||||
nx--;
|
||||
break;
|
||||
case "right":
|
||||
nx++;
|
||||
break;
|
||||
}
|
||||
|
||||
const fw = fieldWidth();
|
||||
const fh = fieldHeight();
|
||||
|
||||
// Wall collision
|
||||
if (nx < 0 || nx >= fw || ny < 0 || ny >= fh) {
|
||||
gameOver = true;
|
||||
if (score > highScore) highScore = score;
|
||||
return;
|
||||
}
|
||||
|
||||
// Self collision
|
||||
if (snake.some(s => s.x === nx && s.y === ny)) {
|
||||
gameOver = true;
|
||||
if (score > highScore) highScore = score;
|
||||
return;
|
||||
}
|
||||
|
||||
snake.unshift({ x: nx, y: ny });
|
||||
|
||||
// Food collision
|
||||
if (nx === food.x && ny === food.y) {
|
||||
score += 10;
|
||||
// Speed up every 50 points
|
||||
if (score % 50 === 0 && currentInterval > 50) {
|
||||
currentInterval = Math.max(50, currentInterval - 10);
|
||||
restartTimer();
|
||||
}
|
||||
spawnFood();
|
||||
} else {
|
||||
snake.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
const fx = fieldX();
|
||||
const fy = fieldY();
|
||||
const fw = fieldWidth();
|
||||
const fh = fieldHeight();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, HEADER_H, " ", styles.titleBar);
|
||||
const title = " Snake Game ";
|
||||
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, styles.titleBar);
|
||||
|
||||
// Game field background
|
||||
screen.fill(fx, fy, fw, fh, " ", styles.empty);
|
||||
|
||||
// Border around field
|
||||
screen.drawBox(fx - 1, fy - 1, fw + 2, fh + 2, { style: "rounded", styleId: styles.border });
|
||||
|
||||
// Draw food
|
||||
screen.setText(fx + food.x, fy + food.y, "\u2665", styles.food); // heart
|
||||
|
||||
// Draw snake
|
||||
for (let i = snake.length - 1; i >= 0; i--) {
|
||||
const seg = snake[i];
|
||||
if (seg.x >= 0 && seg.x < fw && seg.y >= 0 && seg.y < fh) {
|
||||
if (i === 0) {
|
||||
// Head - directional character
|
||||
let headChar = "\u25cf"; // filled circle
|
||||
switch (direction) {
|
||||
case "up":
|
||||
headChar = "\u25b2";
|
||||
break; // ▲
|
||||
case "down":
|
||||
headChar = "\u25bc";
|
||||
break; // ▼
|
||||
case "left":
|
||||
headChar = "\u25c0";
|
||||
break; // ◀
|
||||
case "right":
|
||||
headChar = "\u25b6";
|
||||
break; // ▶
|
||||
}
|
||||
screen.setText(fx + seg.x, fy + seg.y, headChar, styles.snakeHead);
|
||||
} else {
|
||||
screen.setText(fx + seg.x, fy + seg.y, "\u2588", styles.snakeBody); // full block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
const sx = fx + fw + 2;
|
||||
const sw = cols - sx - 1;
|
||||
if (sw > 8) {
|
||||
let sy = fy;
|
||||
|
||||
screen.setText(sx, sy, "Score", styles.label);
|
||||
sy++;
|
||||
screen.setText(sx, sy, `${score}`, styles.score);
|
||||
sy += 2;
|
||||
|
||||
screen.setText(sx, sy, "High Score", styles.label);
|
||||
sy++;
|
||||
screen.setText(sx, sy, `${highScore}`, styles.highScore);
|
||||
sy += 2;
|
||||
|
||||
screen.setText(sx, sy, "Length", styles.label);
|
||||
sy++;
|
||||
screen.setText(sx, sy, `${snake.length}`, styles.label);
|
||||
sy += 2;
|
||||
|
||||
screen.setText(sx, sy, "Speed", styles.label);
|
||||
sy++;
|
||||
const speedPct = Math.round(((baseInterval - currentInterval) / (baseInterval - 50)) * 100);
|
||||
screen.setText(sx, sy, `${speedPct}%`, styles.speed);
|
||||
sy += 2;
|
||||
|
||||
if (paused) {
|
||||
screen.setText(sx, sy, "PAUSED", styles.gameOver);
|
||||
}
|
||||
}
|
||||
|
||||
// Game over overlay
|
||||
if (gameOver) {
|
||||
const msgW = 24;
|
||||
const msgH = 5;
|
||||
const mx = Math.floor((fw - msgW) / 2) + fx;
|
||||
const my = Math.floor((fh - msgH) / 2) + fy;
|
||||
screen.drawBox(mx, my, msgW, msgH, { style: "double", styleId: styles.gameOver, fill: true });
|
||||
screen.setText(mx + Math.floor((msgW - 9) / 2), my + 1, "GAME OVER", styles.gameOverBg);
|
||||
const scoreText = `Score: ${score}`;
|
||||
screen.setText(mx + Math.floor((msgW - scoreText.length) / 2), my + 2, scoreText, styles.score);
|
||||
screen.setText(mx + Math.floor((msgW - 16) / 2), my + 3, "R to restart", styles.label);
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
const footerText = " Arrows/WASD: Move | P: Pause | R: Restart | Q: Quit ";
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), styles.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "r") {
|
||||
resetGame();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "p" && !gameOver) {
|
||||
paused = !paused;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameOver || paused) return;
|
||||
|
||||
// Direction changes — prevent 180-degree reversal
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "w":
|
||||
case "k":
|
||||
if (direction !== "down") nextDirection = "up";
|
||||
break;
|
||||
case "down":
|
||||
case "s":
|
||||
case "j":
|
||||
if (direction !== "up") nextDirection = "down";
|
||||
break;
|
||||
case "left":
|
||||
case "a":
|
||||
case "h":
|
||||
if (direction !== "right") nextDirection = "left";
|
||||
break;
|
||||
case "right":
|
||||
case "d":
|
||||
case "l":
|
||||
if (direction !== "left") nextDirection = "right";
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Timer management ---
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
|
||||
function restartTimer() {
|
||||
clearInterval(timer);
|
||||
timer = setInterval(() => {
|
||||
tick();
|
||||
render();
|
||||
}, currentInterval);
|
||||
}
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
clearInterval(timer);
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
resetGame();
|
||||
render();
|
||||
restartTimer();
|
||||
103
test/js/bun/tui/demos/demo-sparkline-inline.ts
Normal file
103
test/js/bun/tui/demos/demo-sparkline-inline.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* demo-sparkline-inline.ts — Renders several inline sparklines with labels
|
||||
* showing CPU, Memory, Network, and Disk time series data.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = Math.min(writer.columns || 80, 72);
|
||||
const height = 14;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const dimStyle = screen.style({ fg: 0x5c6370 });
|
||||
const labelStyle = screen.style({ fg: 0xabb2bf });
|
||||
const valueStyle = screen.style({ fg: 0xe5c07b, bold: true });
|
||||
|
||||
const sparkColors = {
|
||||
cpu: screen.style({ fg: 0xe06c75 }),
|
||||
mem: screen.style({ fg: 0x61afef }),
|
||||
net: screen.style({ fg: 0x98c379 }),
|
||||
disk: screen.style({ fg: 0xc678dd }),
|
||||
latency: screen.style({ fg: 0xe5c07b }),
|
||||
};
|
||||
|
||||
const sparkChars = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
||||
|
||||
// Seeded pseudo-random for reproducibility
|
||||
let seed = 1337;
|
||||
function rand() {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
}
|
||||
|
||||
function generateSeries(len: number, min: number, max: number, smooth = true): number[] {
|
||||
const data: number[] = [];
|
||||
let val = min + rand() * (max - min);
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (smooth) {
|
||||
val += (rand() - 0.5) * (max - min) * 0.3;
|
||||
val = Math.max(min, Math.min(max, val));
|
||||
} else {
|
||||
val = min + rand() * (max - min);
|
||||
}
|
||||
data.push(val);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function sparkline(data: number[], min: number, max: number): string {
|
||||
return data
|
||||
.map(v => {
|
||||
const idx = Math.round(((v - min) / (max - min)) * (sparkChars.length - 1));
|
||||
return sparkChars[Math.max(0, Math.min(sparkChars.length - 1, idx))];
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
const sparkWidth = width - 30;
|
||||
|
||||
interface MetricDef {
|
||||
label: string;
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
color: number;
|
||||
key: keyof typeof sparkColors;
|
||||
}
|
||||
|
||||
const metrics: MetricDef[] = [
|
||||
{ label: "CPU Usage", unit: "%", min: 0, max: 100, color: 0xe06c75, key: "cpu" },
|
||||
{ label: "Memory", unit: "GB", min: 2, max: 16, color: 0x61afef, key: "mem" },
|
||||
{ label: "Network I/O", unit: "MB/s", min: 0, max: 500, color: 0x98c379, key: "net" },
|
||||
{ label: "Disk I/O", unit: "MB/s", min: 0, max: 200, color: 0xc678dd, key: "disk" },
|
||||
{ label: "Latency", unit: "ms", min: 1, max: 50, color: 0xe5c07b, key: "latency" },
|
||||
];
|
||||
|
||||
// Title
|
||||
screen.setText(2, 0, "System Metrics (last 60s)", titleStyle);
|
||||
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
|
||||
|
||||
let y = 3;
|
||||
for (const metric of metrics) {
|
||||
const data = generateSeries(sparkWidth, metric.min, metric.max);
|
||||
const lastVal = data[data.length - 1];
|
||||
const spark = sparkline(data, metric.min, metric.max);
|
||||
|
||||
screen.setText(2, y, metric.label.padEnd(12), labelStyle);
|
||||
|
||||
// Draw sparkline chars individually with color
|
||||
const sparkStr = spark;
|
||||
screen.setText(15, y, sparkStr, sparkColors[metric.key]);
|
||||
|
||||
// Current value
|
||||
const valStr = `${lastVal.toFixed(1)} ${metric.unit}`;
|
||||
screen.setText(15 + sparkWidth + 1, y, valStr, valueStyle);
|
||||
|
||||
y += 2;
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
136
test/js/bun/tui/demos/demo-spinner.ts
Normal file
136
test/js/bun/tui/demos/demo-spinner.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* demo-spinner.ts — Inline Spinner & Status (No Alt Screen)
|
||||
*
|
||||
* A CLI-style spinner that renders inline without alt screen, updating
|
||||
* in place. Shows a sequence of tasks with spinners that complete one
|
||||
* by one — like npm install or a build tool output.
|
||||
*
|
||||
* Demonstrates: inline rendering without alt screen, in-place updates via
|
||||
* diff rendering, small fixed-height screens, sequential task simulation,
|
||||
* setText, fill, style (fg/bold), TUITerminalWriter, TUIScreen.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-spinner.ts
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
|
||||
const SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
||||
|
||||
interface Task {
|
||||
name: string;
|
||||
duration: number; // ms
|
||||
status: "pending" | "running" | "done" | "error";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const tasks: Task[] = [
|
||||
{ name: "Resolving dependencies", duration: 800, status: "pending", detail: "package.json" },
|
||||
{ name: "Downloading packages", duration: 1200, status: "pending", detail: "48 packages" },
|
||||
{ name: "Linking modules", duration: 600, status: "pending", detail: "node_modules" },
|
||||
{ name: "Compiling TypeScript", duration: 1500, status: "pending", detail: "src/**/*.ts" },
|
||||
{ name: "Running tests", duration: 2000, status: "pending", detail: "257 tests" },
|
||||
{ name: "Building bundle", duration: 1000, status: "pending", detail: "dist/index.js" },
|
||||
{ name: "Generating types", duration: 700, status: "pending", detail: "dist/index.d.ts" },
|
||||
];
|
||||
|
||||
// Render height: 1 line per task + 1 summary line
|
||||
const SCREEN_H = tasks.length + 2;
|
||||
const SCREEN_W = 70;
|
||||
const screen = new Bun.TUIScreen(SCREEN_W, SCREEN_H);
|
||||
|
||||
// Styles
|
||||
const stSpin = screen.style({ fg: 0xc678dd, bold: true });
|
||||
const stName = screen.style({ fg: 0xabb2bf });
|
||||
const stNameDone = screen.style({ fg: 0x5c6370 });
|
||||
const stDetail = screen.style({ fg: 0x5c6370 });
|
||||
const stDone = screen.style({ fg: 0x98c379, bold: true });
|
||||
const stError = screen.style({ fg: 0xe06c75, bold: true });
|
||||
const stPending = screen.style({ fg: 0x3e4451 });
|
||||
const stSummary = screen.style({ fg: 0x61afef, bold: true });
|
||||
const stTime = screen.style({ fg: 0xe5c07b });
|
||||
|
||||
let currentTask = 0;
|
||||
let spinFrame = 0;
|
||||
let taskStartTime = Date.now();
|
||||
let totalStartTime = Date.now();
|
||||
let done = false;
|
||||
|
||||
function renderTasks() {
|
||||
screen.clear();
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const y = i;
|
||||
|
||||
switch (task.status) {
|
||||
case "pending":
|
||||
screen.setText(0, y, " \u25CB", stPending); // ○
|
||||
screen.setText(4, y, task.name, stPending);
|
||||
break;
|
||||
case "running": {
|
||||
const ch = SPIN[spinFrame % SPIN.length];
|
||||
screen.setText(0, y, ` ${ch}`, stSpin);
|
||||
screen.setText(4, y, task.name, stName);
|
||||
screen.setText(4 + task.name.length + 1, y, task.detail, stDetail);
|
||||
const elapsed = ((Date.now() - taskStartTime) / 1000).toFixed(1);
|
||||
screen.setText(SCREEN_W - 6, y, `${elapsed}s`, stTime);
|
||||
break;
|
||||
}
|
||||
case "done":
|
||||
screen.setText(0, y, " \u2714", stDone); // ✔
|
||||
screen.setText(4, y, task.name, stNameDone);
|
||||
break;
|
||||
case "error":
|
||||
screen.setText(0, y, " \u2718", stError); // ✘
|
||||
screen.setText(4, y, task.name, stError);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary line
|
||||
const summaryY = tasks.length + 1;
|
||||
if (done) {
|
||||
const totalTime = ((Date.now() - totalStartTime) / 1000).toFixed(1);
|
||||
screen.setText(0, summaryY, `\u2728 All tasks completed in ${totalTime}s`, stSummary);
|
||||
} else {
|
||||
const completed = tasks.filter(t => t.status === "done").length;
|
||||
screen.setText(0, summaryY, ` ${completed}/${tasks.length} tasks complete`, stDetail);
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
}
|
||||
|
||||
// Run tasks sequentially
|
||||
function startNextTask() {
|
||||
if (currentTask >= tasks.length) {
|
||||
done = true;
|
||||
renderTasks();
|
||||
clearInterval(spinTimer);
|
||||
// Final newlines to push content into scrollback
|
||||
setTimeout(() => {
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
tasks[currentTask].status = "running";
|
||||
taskStartTime = Date.now();
|
||||
|
||||
setTimeout(() => {
|
||||
tasks[currentTask].status = "done";
|
||||
currentTask++;
|
||||
startNextTask();
|
||||
}, tasks[currentTask].duration);
|
||||
}
|
||||
|
||||
// Spinner animation
|
||||
const spinTimer = setInterval(() => {
|
||||
spinFrame++;
|
||||
renderTasks();
|
||||
}, 80);
|
||||
|
||||
// Start
|
||||
renderTasks();
|
||||
startNextTask();
|
||||
424
test/js/bun/tui/demos/demo-split.ts
Normal file
424
test/js/bun/tui/demos/demo-split.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* demo-split.ts — Split Pane Layout
|
||||
*
|
||||
* Demonstrates a composable split-pane layout with multiple independent
|
||||
* panels: a sidebar navigation, a main content area, and a bottom status/log
|
||||
* panel. Each pane has its own scroll state and focus behavior.
|
||||
*
|
||||
* Demonstrates: clipping (clip/unclip), copy between screens, multi-pane
|
||||
* layouts, independent scroll states, focus tracking, drawBox, setText, fill,
|
||||
* style, TUITerminalWriter, TUIKeyReader, alt screen, resize.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-split.ts
|
||||
* Controls: Tab switch pane, j/k scroll active pane, Enter select, Q quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
borderFocused: screen.style({ fg: 0x61afef, bold: true }),
|
||||
borderUnfocused: screen.style({ fg: 0x3e4451 }),
|
||||
panelTitle: screen.style({ fg: 0x61afef, bold: true }),
|
||||
panelTitleFocused: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
item: screen.style({ fg: 0xabb2bf }),
|
||||
itemSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
itemActive: screen.style({ fg: 0x98c379, bold: true }),
|
||||
header: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
text: screen.style({ fg: 0xabb2bf }),
|
||||
code: screen.style({ fg: 0x98c379, bg: 0x21252b }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
logInfo: screen.style({ fg: 0x61afef }),
|
||||
logWarn: screen.style({ fg: 0xe5c07b }),
|
||||
logError: screen.style({ fg: 0xe06c75 }),
|
||||
logTime: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
focusIndicator: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
};
|
||||
|
||||
// --- Content data ---
|
||||
interface NavItem {
|
||||
name: string;
|
||||
icon: string;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
name: "Getting Started",
|
||||
icon: "\u{1F680}",
|
||||
content: [
|
||||
"Getting Started with Bun TUI",
|
||||
"",
|
||||
"The Bun TUI library provides low-level primitives for",
|
||||
"building terminal user interfaces. It uses Ghostty's",
|
||||
"cell grid internally for efficient rendering.",
|
||||
"",
|
||||
"Quick Start:",
|
||||
"",
|
||||
" const screen = new Bun.TUIScreen(80, 24);",
|
||||
" const writer = new Bun.TUITerminalWriter(Bun.stdout);",
|
||||
" const reader = new Bun.TUIKeyReader();",
|
||||
"",
|
||||
" screen.setText(0, 0, 'Hello, World!');",
|
||||
" writer.render(screen);",
|
||||
"",
|
||||
"The screen is a grid of cells, each with a codepoint",
|
||||
"and a style ID. Styles are interned for efficiency.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Screen API",
|
||||
icon: "\u{1F4FA}",
|
||||
content: [
|
||||
"TuiScreen API Reference",
|
||||
"",
|
||||
"Constructor:",
|
||||
" new Bun.TUIScreen(cols, rows)",
|
||||
"",
|
||||
"Methods:",
|
||||
" setText(x, y, text, styleId?)",
|
||||
" fill(x, y, w, h, char, styleId?)",
|
||||
" clear()",
|
||||
" clearRect(x, y, w, h)",
|
||||
" resize(cols, rows)",
|
||||
" copy(src, sx, sy, dx, dy, w, h)",
|
||||
" style({ fg, bg, bold, ... })",
|
||||
" drawBox(x, y, w, h, options?)",
|
||||
" clip(x1, y1, x2, y2)",
|
||||
" unclip()",
|
||||
" getCell(x, y)",
|
||||
" hyperlink(url)",
|
||||
" setHyperlink(x, y, id)",
|
||||
"",
|
||||
"Properties:",
|
||||
" width - column count",
|
||||
" height - row count",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Writer API",
|
||||
icon: "\u{270D}",
|
||||
content: [
|
||||
"TuiTerminalWriter API Reference",
|
||||
"",
|
||||
"Constructor:",
|
||||
" new Bun.TUITerminalWriter(Bun.stdout)",
|
||||
"",
|
||||
"Methods:",
|
||||
" render(screen, options?)",
|
||||
" clear()",
|
||||
" close() / end()",
|
||||
" enterAltScreen() / exitAltScreen()",
|
||||
" enableMouseTracking()",
|
||||
" disableMouseTracking()",
|
||||
" enableFocusTracking()",
|
||||
" disableFocusTracking()",
|
||||
" enableBracketedPaste()",
|
||||
" disableBracketedPaste()",
|
||||
" write(string)",
|
||||
"",
|
||||
"Properties:",
|
||||
" columns / rows - terminal dimensions",
|
||||
" onresize - resize callback",
|
||||
"",
|
||||
"The writer does cell-level diffing between frames,",
|
||||
"only emitting ANSI for changed cells.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Key Reader",
|
||||
icon: "\u{2328}",
|
||||
content: [
|
||||
"TuiKeyReader API Reference",
|
||||
"",
|
||||
"Constructor:",
|
||||
" new Bun.TUIKeyReader()",
|
||||
"",
|
||||
"Callbacks:",
|
||||
" onkeypress = (event) => { ... }",
|
||||
" event: { name, sequence, ctrl, shift, alt }",
|
||||
"",
|
||||
" onmouse = (event) => { ... }",
|
||||
" event: { type, button, x, y, shift, alt, ctrl }",
|
||||
" types: down, up, drag, move, scrollUp, scrollDown",
|
||||
"",
|
||||
" onpaste = (text) => { ... }",
|
||||
" onfocus = () => { ... }",
|
||||
" onblur = () => { ... }",
|
||||
"",
|
||||
"Methods:",
|
||||
" close() - restore terminal, stop reading",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Styling",
|
||||
icon: "\u{1F3A8}",
|
||||
content: [
|
||||
"Style System",
|
||||
"",
|
||||
"Styles are interned objects with numeric IDs:",
|
||||
"",
|
||||
" const id = screen.style({",
|
||||
" fg: 0xff0000, // RGB foreground",
|
||||
" bg: 0x000088, // RGB background",
|
||||
" bold: true,",
|
||||
" italic: true,",
|
||||
" underline: 'curly', // single|double|curly|dotted|dashed",
|
||||
" underlineColor: 0xffff00,",
|
||||
" strikethrough: true,",
|
||||
" overline: true,",
|
||||
" faint: true,",
|
||||
" blink: true,",
|
||||
" inverse: true,",
|
||||
" });",
|
||||
"",
|
||||
"Style 0 is always the default (no styling).",
|
||||
"Up to 4096 unique styles per screen.",
|
||||
"",
|
||||
"Colors can be specified as:",
|
||||
" - Number: 0xff0000",
|
||||
" - Hex string: '#ff0000'",
|
||||
" - Object: { r: 255, g: 0, b: 0 }",
|
||||
" - Palette: { palette: 196 }",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// --- Log entries ---
|
||||
interface LogMsg {
|
||||
time: string;
|
||||
level: "info" | "warn" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
const logMessages: LogMsg[] = [
|
||||
{ time: "12:00:01", level: "info", text: "Application started" },
|
||||
{ time: "12:00:01", level: "info", text: "TUI screen initialized (80x24)" },
|
||||
{ time: "12:00:02", level: "info", text: "Key reader started in raw mode" },
|
||||
{ time: "12:00:03", level: "info", text: "Alt screen entered" },
|
||||
{ time: "12:00:05", level: "warn", text: "Terminal does not support true color, falling back" },
|
||||
{ time: "12:00:10", level: "info", text: "First render completed in 2ms" },
|
||||
{ time: "12:00:15", level: "info", text: "Diff render: 0 changed cells" },
|
||||
{ time: "12:00:20", level: "error", text: "Style capacity warning: 3800/4096 used" },
|
||||
{ time: "12:00:25", level: "info", text: "Resize detected: 120x45" },
|
||||
{ time: "12:00:30", level: "info", text: "Styles migrated after resize: 42 styles" },
|
||||
];
|
||||
|
||||
// --- State ---
|
||||
let focusedPane = 0; // 0=sidebar, 1=content, 2=logs
|
||||
let sidebarSelected = 0;
|
||||
let sidebarScroll = 0;
|
||||
let contentScroll = 0;
|
||||
let logScroll = 0;
|
||||
|
||||
// --- Layout ---
|
||||
function sidebarW() {
|
||||
return Math.min(28, Math.floor(cols * 0.25));
|
||||
}
|
||||
function contentW() {
|
||||
return cols - sidebarW();
|
||||
}
|
||||
function contentH() {
|
||||
return rows - 1 - logPanelH();
|
||||
} // -1 for title
|
||||
function logPanelH() {
|
||||
return Math.max(4, Math.floor(rows * 0.25));
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
const sw = sidebarW();
|
||||
const cw = contentW();
|
||||
const ch = contentH();
|
||||
const lh = logPanelH();
|
||||
|
||||
// Title bar
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Split Pane Demo ", st.titleBar);
|
||||
const paneNames = ["Sidebar", "Content", "Logs"];
|
||||
screen.setText(cols - paneNames[focusedPane].length - 12, 0, `Focus: ${paneNames[focusedPane]}`, st.titleBar);
|
||||
|
||||
// --- Sidebar ---
|
||||
const sbBorder = focusedPane === 0 ? st.borderFocused : st.borderUnfocused;
|
||||
const sbTitle = focusedPane === 0 ? st.panelTitleFocused : st.panelTitle;
|
||||
screen.drawBox(0, 1, sw, ch, { style: "rounded", styleId: sbBorder });
|
||||
screen.setText(2, 1, " Navigation ", sbTitle);
|
||||
|
||||
screen.clip(1, 2, sw - 1, ch);
|
||||
const sbVisH = ch - 2;
|
||||
if (sidebarSelected < sidebarScroll) sidebarScroll = sidebarSelected;
|
||||
if (sidebarSelected >= sidebarScroll + sbVisH) sidebarScroll = sidebarSelected - sbVisH + 1;
|
||||
|
||||
for (let i = 0; i < sbVisH; i++) {
|
||||
const idx = sidebarScroll + i;
|
||||
if (idx >= navItems.length) break;
|
||||
const item = navItems[idx];
|
||||
const y = 2 + i;
|
||||
const isSel = idx === sidebarSelected;
|
||||
|
||||
if (isSel) {
|
||||
screen.fill(1, y, sw - 2, 1, " ", st.itemSelected);
|
||||
screen.setText(2, y, `${item.icon} ${item.name}`, st.itemSelected);
|
||||
} else {
|
||||
screen.setText(2, y, `${item.icon} ${item.name}`, st.item);
|
||||
}
|
||||
}
|
||||
screen.unclip();
|
||||
|
||||
// --- Content pane ---
|
||||
const cx = sw;
|
||||
const cBorder = focusedPane === 1 ? st.borderFocused : st.borderUnfocused;
|
||||
const cTitle = focusedPane === 1 ? st.panelTitleFocused : st.panelTitle;
|
||||
screen.drawBox(cx, 1, cw, ch, { style: "rounded", styleId: cBorder });
|
||||
screen.setText(cx + 2, 1, ` ${navItems[sidebarSelected].name} `, cTitle);
|
||||
|
||||
screen.clip(cx + 1, 2, cx + cw - 1, ch);
|
||||
const content = navItems[sidebarSelected].content;
|
||||
const contentVisH = ch - 2;
|
||||
if (contentScroll > Math.max(0, content.length - contentVisH)) {
|
||||
contentScroll = Math.max(0, content.length - contentVisH);
|
||||
}
|
||||
|
||||
for (let i = 0; i < contentVisH; i++) {
|
||||
const lineIdx = contentScroll + i;
|
||||
if (lineIdx >= content.length) break;
|
||||
const line = content[lineIdx];
|
||||
const y = 2 + i;
|
||||
|
||||
if (lineIdx === 0) {
|
||||
screen.setText(cx + 2, y, line.slice(0, cw - 4), st.header);
|
||||
} else if (line.startsWith(" ")) {
|
||||
screen.setText(cx + 2, y, line.slice(0, cw - 4), st.code);
|
||||
} else {
|
||||
screen.setText(cx + 2, y, line.slice(0, cw - 4), st.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
if (content.length > contentVisH) {
|
||||
const pct = Math.round((contentScroll / Math.max(1, content.length - contentVisH)) * 100);
|
||||
screen.setText(cx + cw - 6, 1, ` ${pct}% `, st.dim);
|
||||
}
|
||||
screen.unclip();
|
||||
|
||||
// --- Log panel ---
|
||||
const ly = 1 + ch;
|
||||
const lBorder = focusedPane === 2 ? st.borderFocused : st.borderUnfocused;
|
||||
const lTitle = focusedPane === 2 ? st.panelTitleFocused : st.panelTitle;
|
||||
screen.drawBox(0, ly, cols, lh, { style: "rounded", styleId: lBorder });
|
||||
screen.setText(2, ly, " Output ", lTitle);
|
||||
|
||||
screen.clip(1, ly + 1, cols - 1, ly + lh - 1);
|
||||
const logVisH = lh - 2;
|
||||
const maxLogScroll = Math.max(0, logMessages.length - logVisH);
|
||||
if (logScroll > maxLogScroll) logScroll = maxLogScroll;
|
||||
|
||||
for (let i = 0; i < logVisH; i++) {
|
||||
const idx = logScroll + i;
|
||||
if (idx >= logMessages.length) break;
|
||||
const msg = logMessages[idx];
|
||||
const y = ly + 1 + i;
|
||||
|
||||
screen.setText(1, y, msg.time, st.logTime);
|
||||
const lvlStyle = msg.level === "error" ? st.logError : msg.level === "warn" ? st.logWarn : st.logInfo;
|
||||
screen.setText(11, y, `[${msg.level.toUpperCase().padEnd(5)}]`, lvlStyle);
|
||||
screen.setText(19, y, msg.text.slice(0, cols - 21), st.text);
|
||||
}
|
||||
screen.unclip();
|
||||
|
||||
// Focus indicators (dots next to focused pane)
|
||||
const focusY = [Math.floor(ch / 2) + 1, Math.floor(ch / 2) + 1, ly + Math.floor(lh / 2)];
|
||||
const focusX = [0, cx, 0];
|
||||
screen.setText(focusX[focusedPane], focusY[focusedPane], "\u25b6", st.focusIndicator);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "tab":
|
||||
focusedPane = (focusedPane + 1) % 3;
|
||||
break;
|
||||
case "up":
|
||||
case "k":
|
||||
if (focusedPane === 0) {
|
||||
if (sidebarSelected > 0) sidebarSelected--;
|
||||
contentScroll = 0; // reset content scroll on nav change
|
||||
} else if (focusedPane === 1) {
|
||||
contentScroll = Math.max(0, contentScroll - 1);
|
||||
} else {
|
||||
logScroll = Math.max(0, logScroll - 1);
|
||||
}
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
if (focusedPane === 0) {
|
||||
if (sidebarSelected < navItems.length - 1) sidebarSelected++;
|
||||
contentScroll = 0;
|
||||
} else if (focusedPane === 1) {
|
||||
contentScroll++;
|
||||
} else {
|
||||
logScroll++;
|
||||
}
|
||||
break;
|
||||
case "pageup":
|
||||
if (focusedPane === 1) contentScroll = Math.max(0, contentScroll - 10);
|
||||
else if (focusedPane === 2) logScroll = Math.max(0, logScroll - 5);
|
||||
break;
|
||||
case "pagedown":
|
||||
if (focusedPane === 1) contentScroll += 10;
|
||||
else if (focusedPane === 2) logScroll += 5;
|
||||
break;
|
||||
case "enter":
|
||||
if (focusedPane === 0) {
|
||||
focusedPane = 1; // switch to content pane
|
||||
contentScroll = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
render();
|
||||
309
test/js/bun/tui/demos/demo-table.ts
Normal file
309
test/js/bun/tui/demos/demo-table.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* demo-table.ts — Sortable Data Table
|
||||
*
|
||||
* An interactive data table with column headers, sorting, row selection,
|
||||
* scrolling, and a detail panel.
|
||||
*
|
||||
* Demonstrates: tabular layout, column alignment, sort state, setText, fill,
|
||||
* style (fg/bg/bold/italic/inverse), drawBox, TUITerminalWriter, TUIKeyReader,
|
||||
* alt screen, and resize handling.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-table.ts
|
||||
* Controls: j/k or arrows to navigate, 1-5 to sort by column, Tab to toggle
|
||||
* sort direction, Q / Ctrl+C to quit
|
||||
*/
|
||||
|
||||
// --- Data ---
|
||||
interface Row {
|
||||
name: string;
|
||||
language: string;
|
||||
stars: number;
|
||||
version: string;
|
||||
license: string;
|
||||
}
|
||||
|
||||
const DATA: Row[] = [
|
||||
{ name: "Bun", language: "Zig", stars: 75200, version: "1.3.9", license: "MIT" },
|
||||
{ name: "Node.js", language: "C++", stars: 109000, version: "22.11.0", license: "MIT" },
|
||||
{ name: "Deno", language: "Rust", stars: 97800, version: "2.1.4", license: "MIT" },
|
||||
{ name: "esbuild", language: "Go", stars: 38500, version: "0.24.0", license: "MIT" },
|
||||
{ name: "swc", language: "Rust", stars: 31800, version: "1.9.3", license: "Apache-2.0" },
|
||||
{ name: "Vite", language: "TypeScript", stars: 70100, version: "6.0.3", license: "MIT" },
|
||||
{ name: "webpack", language: "JavaScript", stars: 64900, version: "5.97.1", license: "MIT" },
|
||||
{ name: "Rollup", language: "JavaScript", stars: 25400, version: "4.28.1", license: "MIT" },
|
||||
{ name: "Parcel", language: "JavaScript", stars: 43500, version: "2.13.2", license: "MIT" },
|
||||
{ name: "Turbopack", language: "Rust", stars: 26100, version: "2.3.3", license: "MPL-2.0" },
|
||||
{ name: "Rome", language: "Rust", stars: 23900, version: "12.1.3", license: "MIT" },
|
||||
{ name: "Biome", language: "Rust", stars: 16200, version: "1.9.4", license: "MIT" },
|
||||
{ name: "Rspack", language: "Rust", stars: 10300, version: "1.1.8", license: "MIT" },
|
||||
{ name: "tsup", language: "TypeScript", stars: 9400, version: "8.3.5", license: "MIT" },
|
||||
{ name: "unbuild", language: "TypeScript", stars: 2400, version: "2.0.0", license: "MIT" },
|
||||
{ name: "tsx", language: "TypeScript", stars: 9800, version: "4.19.2", license: "MIT" },
|
||||
{ name: "Oxc", language: "Rust", stars: 12600, version: "0.40.0", license: "MIT" },
|
||||
{ name: "Prettier", language: "JavaScript", stars: 49800, version: "3.4.2", license: "MIT" },
|
||||
{ name: "ESLint", language: "JavaScript", stars: 25200, version: "9.16.0", license: "MIT" },
|
||||
{ name: "TypeScript", language: "TypeScript", stars: 101000, version: "5.7.2", license: "Apache-2.0" },
|
||||
];
|
||||
|
||||
type ColKey = keyof Row;
|
||||
const COLUMNS: { key: ColKey; label: string; width: number; align: "left" | "right" }[] = [
|
||||
{ key: "name", label: "Name", width: 16, align: "left" },
|
||||
{ key: "language", label: "Language", width: 14, align: "left" },
|
||||
{ key: "stars", label: "Stars", width: 10, align: "right" },
|
||||
{ key: "version", label: "Version", width: 12, align: "left" },
|
||||
{ key: "license", label: "License", width: 14, align: "left" },
|
||||
];
|
||||
|
||||
// --- State ---
|
||||
let selectedIndex = 0;
|
||||
let scrollOffset = 0;
|
||||
let sortColumn: ColKey = "stars";
|
||||
let sortAscending = false;
|
||||
let sortedData = sortData();
|
||||
|
||||
function sortData(): Row[] {
|
||||
const copy = [...DATA];
|
||||
copy.sort((a, b) => {
|
||||
const av = a[sortColumn];
|
||||
const bv = b[sortColumn];
|
||||
let cmp: number;
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
cmp = av - bv;
|
||||
} else {
|
||||
cmp = String(av).localeCompare(String(bv));
|
||||
}
|
||||
return sortAscending ? cmp : -cmp;
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const s = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
headerBg: screen.style({ fg: 0xffffff, bg: 0x3e4451, bold: true }),
|
||||
headerSort: screen.style({ fg: 0xe5c07b, bg: 0x3e4451, bold: true }),
|
||||
rowEven: screen.style({ fg: 0xabb2bf }),
|
||||
rowOdd: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
|
||||
rowSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
number: screen.style({ fg: 0xe5c07b }),
|
||||
numberOdd: screen.style({ fg: 0xe5c07b, bg: 0x21252b }),
|
||||
numberSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
detailLabel: screen.style({ fg: 0xabb2bf }),
|
||||
detailValue: screen.style({ fg: 0xe5c07b }),
|
||||
detailHeader: screen.style({ fg: 0x61afef, bold: true }),
|
||||
summary: screen.style({ fg: 0x98c379 }),
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
|
||||
// Title
|
||||
screen.fill(0, 0, cols, 1, " ", s.titleBar);
|
||||
const title = " JS Runtime & Build Tool Comparison ";
|
||||
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, s.titleBar);
|
||||
|
||||
const tableX = 1;
|
||||
const tableY = 2;
|
||||
const tableW = COLUMNS.reduce((sum, c) => sum + c.width + 1, 0) + 1;
|
||||
const maxVisibleRows = rows - tableY - 4; // header + footer + detail
|
||||
|
||||
// Ensure selected is visible
|
||||
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
||||
if (selectedIndex >= scrollOffset + maxVisibleRows) scrollOffset = selectedIndex - maxVisibleRows + 1;
|
||||
|
||||
// --- Column headers ---
|
||||
const headerY = tableY;
|
||||
screen.fill(tableX, headerY, Math.min(tableW, cols - tableX), 1, " ", s.headerBg);
|
||||
let hx = tableX;
|
||||
for (let ci = 0; ci < COLUMNS.length; ci++) {
|
||||
const col = COLUMNS[ci];
|
||||
const isSorted = col.key === sortColumn;
|
||||
const arrow = isSorted ? (sortAscending ? " \u25b2" : " \u25bc") : "";
|
||||
const label = `${ci + 1}:${col.label}${arrow}`;
|
||||
const headerStyle = isSorted ? s.headerSort : s.headerBg;
|
||||
if (col.align === "right") {
|
||||
const padded = label.padStart(col.width);
|
||||
screen.setText(hx, headerY, padded.slice(0, col.width), headerStyle);
|
||||
} else {
|
||||
screen.setText(hx, headerY, label.slice(0, col.width), headerStyle);
|
||||
}
|
||||
hx += col.width + 1;
|
||||
}
|
||||
|
||||
// --- Separator ---
|
||||
const sepY = headerY + 1;
|
||||
for (let i = 0; i < Math.min(tableW, cols - tableX); i++) {
|
||||
screen.setText(tableX + i, sepY, "\u2500", s.border);
|
||||
}
|
||||
|
||||
// --- Data rows ---
|
||||
const visibleCount = Math.min(maxVisibleRows, sortedData.length - scrollOffset);
|
||||
for (let vi = 0; vi < visibleCount; vi++) {
|
||||
const dataIdx = scrollOffset + vi;
|
||||
const row = sortedData[dataIdx];
|
||||
const rowY = sepY + 1 + vi;
|
||||
const isSelected = dataIdx === selectedIndex;
|
||||
const isOdd = vi % 2 === 1;
|
||||
|
||||
// Row background
|
||||
const rowStyle = isSelected ? s.rowSelected : isOdd ? s.rowOdd : s.rowEven;
|
||||
const numStyle = isSelected ? s.numberSelected : isOdd ? s.numberOdd : s.number;
|
||||
|
||||
if (isSelected) {
|
||||
screen.fill(tableX, rowY, Math.min(tableW, cols - tableX), 1, " ", s.rowSelected);
|
||||
} else if (isOdd) {
|
||||
screen.fill(tableX, rowY, Math.min(tableW, cols - tableX), 1, " ", s.rowOdd);
|
||||
}
|
||||
|
||||
let rx = tableX;
|
||||
for (const col of COLUMNS) {
|
||||
const val = String(row[col.key]);
|
||||
const isNum = col.key === "stars";
|
||||
const cellStyle = isNum ? numStyle : rowStyle;
|
||||
if (col.align === "right") {
|
||||
const formatted = isNum ? Number(row[col.key]).toLocaleString() : val;
|
||||
const padded = formatted.padStart(col.width);
|
||||
screen.setText(rx, rowY, padded.slice(0, col.width), cellStyle);
|
||||
} else {
|
||||
screen.setText(rx, rowY, val.slice(0, col.width), cellStyle);
|
||||
}
|
||||
rx += col.width + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detail panel (right side) ---
|
||||
const detailX = tableX + tableW + 1;
|
||||
const detailW = cols - detailX - 1;
|
||||
if (detailW > 16 && selectedIndex < sortedData.length) {
|
||||
const sel = sortedData[selectedIndex];
|
||||
const detailY = tableY;
|
||||
screen.drawBox(detailX, detailY, detailW, 10, {
|
||||
style: "rounded",
|
||||
styleId: s.border,
|
||||
fill: true,
|
||||
});
|
||||
screen.setText(detailX + 2, detailY, " Details ", s.detailHeader);
|
||||
|
||||
let dy = detailY + 1;
|
||||
const pairs: [string, string][] = [
|
||||
["Name:", sel.name],
|
||||
["Language:", sel.language],
|
||||
["Stars:", Number(sel.stars).toLocaleString()],
|
||||
["Version:", sel.version],
|
||||
["License:", sel.license],
|
||||
];
|
||||
for (const [label, value] of pairs) {
|
||||
screen.setText(detailX + 2, dy, label, s.detailLabel);
|
||||
screen.setText(detailX + 12, dy, value.slice(0, detailW - 14), s.detailValue);
|
||||
dy++;
|
||||
}
|
||||
|
||||
// Summary
|
||||
dy++;
|
||||
const avgStars = Math.round(sortedData.reduce((sum, r) => sum + r.stars, 0) / sortedData.length);
|
||||
screen.setText(detailX + 2, dy, `Avg: ${avgStars.toLocaleString()} stars`, s.summary);
|
||||
}
|
||||
|
||||
// --- Footer ---
|
||||
const footerY = rows - 1;
|
||||
const sortInfo = `Sorted by ${sortColumn} ${sortAscending ? "\u25b2" : "\u25bc"}`;
|
||||
const navInfo = `${selectedIndex + 1}/${sortedData.length}`;
|
||||
const footerText = ` \u2191\u2193/jk: Navigate | 1-5: Sort column | Tab: Toggle direction | ${sortInfo} | ${navInfo} | q: Quit `;
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), s.footer);
|
||||
|
||||
writer.render(screen, { cursorVisible: false });
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
|
||||
const { name, ctrl } = event;
|
||||
|
||||
if (name === "q" || (ctrl && name === "c")) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
if (selectedIndex > 0) selectedIndex--;
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
if (selectedIndex < sortedData.length - 1) selectedIndex++;
|
||||
break;
|
||||
case "home":
|
||||
case "g":
|
||||
selectedIndex = 0;
|
||||
break;
|
||||
case "end":
|
||||
selectedIndex = sortedData.length - 1;
|
||||
break;
|
||||
case "pageup":
|
||||
selectedIndex = Math.max(0, selectedIndex - (rows - 6));
|
||||
break;
|
||||
case "pagedown":
|
||||
selectedIndex = Math.min(sortedData.length - 1, selectedIndex + (rows - 6));
|
||||
break;
|
||||
case "tab":
|
||||
sortAscending = !sortAscending;
|
||||
sortedData = sortData();
|
||||
break;
|
||||
case "1":
|
||||
case "2":
|
||||
case "3":
|
||||
case "4":
|
||||
case "5": {
|
||||
const idx = parseInt(name) - 1;
|
||||
if (idx < COLUMNS.length) {
|
||||
if (sortColumn === COLUMNS[idx].key) {
|
||||
sortAscending = !sortAscending;
|
||||
} else {
|
||||
sortColumn = COLUMNS[idx].key;
|
||||
sortAscending = sortColumn === "name" || sortColumn === "language";
|
||||
}
|
||||
sortedData = sortData();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Initial render ---
|
||||
render();
|
||||
320
test/js/bun/tui/demos/demo-text-editor.ts
Normal file
320
test/js/bun/tui/demos/demo-text-editor.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* demo-text-editor.ts — Simple Text Editor
|
||||
*
|
||||
* A minimal text editor with cursor movement, typing, backspace, enter,
|
||||
* line numbers, scrolling, and a status bar.
|
||||
* Demonstrates: alt screen, TUIKeyReader (arrow keys, printable chars,
|
||||
* ctrl keys, shift), TUITerminalWriter, cursor rendering, setText, fill,
|
||||
* style, clearRect, drawBox, clipboard paste.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-text-editor.ts
|
||||
* Exit: Ctrl+Q
|
||||
*/
|
||||
|
||||
// --- Editor state ---
|
||||
let lines: string[] = [
|
||||
"Welcome to the Bun TUI text editor!",
|
||||
"",
|
||||
"This is a minimal editor built with Bun.TUIScreen",
|
||||
"and Bun.TUIKeyReader. Try the following:",
|
||||
"",
|
||||
" - Type to insert text at the cursor",
|
||||
" - Arrow keys to move the cursor",
|
||||
" - Enter to create a new line",
|
||||
" - Backspace / Delete to remove characters",
|
||||
" - Home / End to jump to line start / end",
|
||||
" - Page Up / Page Down to scroll",
|
||||
" - Ctrl+Q to quit",
|
||||
"",
|
||||
"The editor supports basic scrolling when the",
|
||||
"document is taller than the screen.",
|
||||
"",
|
||||
"Have fun!",
|
||||
];
|
||||
let cursorRow = 0;
|
||||
let cursorCol = 0;
|
||||
let scrollY = 0; // first visible line index
|
||||
|
||||
// --- Setup ---
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Layout constants ---
|
||||
const GUTTER_WIDTH = 5; // width reserved for line numbers
|
||||
const STATUS_HEIGHT = 2; // status bar height at bottom
|
||||
function editableRows() {
|
||||
return rows - STATUS_HEIGHT;
|
||||
}
|
||||
|
||||
// --- Styles ---
|
||||
const styles = {
|
||||
lineNumber: screen.style({ fg: 0x5c6370 }),
|
||||
lineNumberCurrent: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
text: 0, // default style
|
||||
statusBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
statusBarRight: screen.style({ fg: 0x000000, bg: 0x61afef }),
|
||||
helpBar: screen.style({ fg: 0x5c6370, bg: 0x282c34 }),
|
||||
gutter: screen.style({ fg: 0x5c6370, bg: 0x21252b }),
|
||||
cursor: screen.style({ fg: 0x000000, bg: 0x61afef }),
|
||||
};
|
||||
|
||||
// --- Ensure cursor is visible by adjusting scroll ---
|
||||
function ensureCursorVisible() {
|
||||
const maxRow = editableRows();
|
||||
if (cursorRow < scrollY) {
|
||||
scrollY = cursorRow;
|
||||
} else if (cursorRow >= scrollY + maxRow) {
|
||||
scrollY = cursorRow - maxRow + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Clamp cursor to valid positions ---
|
||||
function clampCursor() {
|
||||
if (cursorRow < 0) cursorRow = 0;
|
||||
if (cursorRow >= lines.length) cursorRow = lines.length - 1;
|
||||
if (cursorCol < 0) cursorCol = 0;
|
||||
if (cursorCol > lines[cursorRow].length) cursorCol = lines[cursorRow].length;
|
||||
}
|
||||
|
||||
// --- Render the editor ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
const maxRow = editableRows();
|
||||
const textWidth = cols - GUTTER_WIDTH;
|
||||
|
||||
// Draw each visible line
|
||||
for (let i = 0; i < maxRow; i++) {
|
||||
const lineIdx = scrollY + i;
|
||||
const y = i;
|
||||
|
||||
// Gutter background
|
||||
screen.fill(0, y, GUTTER_WIDTH - 1, 1, " ", styles.gutter);
|
||||
|
||||
if (lineIdx < lines.length) {
|
||||
// Line number
|
||||
const numStr = String(lineIdx + 1).padStart(GUTTER_WIDTH - 2, " ") + " ";
|
||||
const numStyle = lineIdx === cursorRow ? styles.lineNumberCurrent : styles.lineNumber;
|
||||
screen.setText(0, y, numStr, numStyle);
|
||||
|
||||
// Line content (clipped to available width)
|
||||
const line = lines[lineIdx];
|
||||
if (line.length > 0) {
|
||||
const displayLine = line.slice(0, textWidth);
|
||||
screen.setText(GUTTER_WIDTH, y, displayLine, styles.text);
|
||||
}
|
||||
} else {
|
||||
// Tilde for lines beyond the buffer
|
||||
screen.setText(GUTTER_WIDTH - 2, y, "~", styles.lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Status bar ---
|
||||
const statusY = rows - STATUS_HEIGHT;
|
||||
screen.fill(0, statusY, cols, 1, " ", styles.statusBar);
|
||||
|
||||
// Left side: mode and file info
|
||||
const modeText = " EDIT ";
|
||||
screen.setText(0, statusY, modeText, styles.statusBar);
|
||||
const fileInfo = " [scratch buffer] ";
|
||||
screen.setText(modeText.length, statusY, fileInfo, styles.statusBarRight);
|
||||
|
||||
// Right side: cursor position
|
||||
const posText = `Ln ${cursorRow + 1}, Col ${cursorCol + 1} Lines: ${lines.length} `;
|
||||
if (cols > posText.length + modeText.length + fileInfo.length) {
|
||||
screen.setText(cols - posText.length, statusY, posText, styles.statusBarRight);
|
||||
}
|
||||
|
||||
// Help bar
|
||||
const helpY = rows - 1;
|
||||
screen.fill(0, helpY, cols, 1, " ", styles.helpBar);
|
||||
const helpText = " Ctrl+Q: Quit | Arrows: Move | Enter: New Line | Backspace: Delete ";
|
||||
screen.setText(0, helpY, helpText.slice(0, cols), styles.helpBar);
|
||||
|
||||
// Render with cursor
|
||||
const cursorScreenY = cursorRow - scrollY;
|
||||
const cursorScreenX = GUTTER_WIDTH + cursorCol;
|
||||
writer.render(screen, {
|
||||
cursorX: cursorScreenX,
|
||||
cursorY: cursorScreenY,
|
||||
cursorVisible: true,
|
||||
cursorStyle: "line",
|
||||
cursorBlinking: true,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Handle keyboard input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; shift: boolean; alt: boolean; sequence: string }) => {
|
||||
const { name, ctrl, shift } = event;
|
||||
|
||||
// Ctrl+Q to quit
|
||||
if (ctrl && name === "q") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
// Ctrl+C also quits
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "up":
|
||||
cursorRow--;
|
||||
clampCursor();
|
||||
break;
|
||||
case "down":
|
||||
cursorRow++;
|
||||
clampCursor();
|
||||
break;
|
||||
case "left":
|
||||
if (cursorCol > 0) {
|
||||
cursorCol--;
|
||||
} else if (cursorRow > 0) {
|
||||
// Wrap to end of previous line
|
||||
cursorRow--;
|
||||
cursorCol = lines[cursorRow].length;
|
||||
}
|
||||
break;
|
||||
case "right":
|
||||
if (cursorCol < lines[cursorRow].length) {
|
||||
cursorCol++;
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
// Wrap to start of next line
|
||||
cursorRow++;
|
||||
cursorCol = 0;
|
||||
}
|
||||
break;
|
||||
case "home":
|
||||
cursorCol = 0;
|
||||
break;
|
||||
case "end":
|
||||
cursorCol = lines[cursorRow].length;
|
||||
break;
|
||||
case "pageup": {
|
||||
const pageSize = editableRows() - 1;
|
||||
cursorRow = Math.max(0, cursorRow - pageSize);
|
||||
clampCursor();
|
||||
break;
|
||||
}
|
||||
case "pagedown": {
|
||||
const pageSize = editableRows() - 1;
|
||||
cursorRow = Math.min(lines.length - 1, cursorRow + pageSize);
|
||||
clampCursor();
|
||||
break;
|
||||
}
|
||||
case "enter": {
|
||||
// Split current line at cursor
|
||||
const currentLine = lines[cursorRow];
|
||||
const before = currentLine.slice(0, cursorCol);
|
||||
const after = currentLine.slice(cursorCol);
|
||||
lines[cursorRow] = before;
|
||||
lines.splice(cursorRow + 1, 0, after);
|
||||
cursorRow++;
|
||||
cursorCol = 0;
|
||||
break;
|
||||
}
|
||||
case "backspace": {
|
||||
if (cursorCol > 0) {
|
||||
// Delete character before cursor
|
||||
const line = lines[cursorRow];
|
||||
lines[cursorRow] = line.slice(0, cursorCol - 1) + line.slice(cursorCol);
|
||||
cursorCol--;
|
||||
} else if (cursorRow > 0) {
|
||||
// Merge with previous line
|
||||
const prevLen = lines[cursorRow - 1].length;
|
||||
lines[cursorRow - 1] += lines[cursorRow];
|
||||
lines.splice(cursorRow, 1);
|
||||
cursorRow--;
|
||||
cursorCol = prevLen;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const line = lines[cursorRow];
|
||||
if (cursorCol < line.length) {
|
||||
// Delete character at cursor
|
||||
lines[cursorRow] = line.slice(0, cursorCol) + line.slice(cursorCol + 1);
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
// Merge with next line
|
||||
lines[cursorRow] += lines[cursorRow + 1];
|
||||
lines.splice(cursorRow + 1, 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tab": {
|
||||
// Insert two spaces
|
||||
const line = lines[cursorRow];
|
||||
lines[cursorRow] = line.slice(0, cursorCol) + " " + line.slice(cursorCol);
|
||||
cursorCol += 2;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Insert printable character
|
||||
if (!ctrl && !event.alt && name.length === 1) {
|
||||
const line = lines[cursorRow];
|
||||
lines[cursorRow] = line.slice(0, cursorCol) + name + line.slice(cursorCol);
|
||||
cursorCol++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ensureCursorVisible();
|
||||
render();
|
||||
};
|
||||
|
||||
// Handle paste
|
||||
reader.onpaste = (text: string) => {
|
||||
// Insert pasted text at cursor, splitting on newlines
|
||||
const pasteLines = text.split("\n");
|
||||
for (let i = 0; i < pasteLines.length; i++) {
|
||||
if (i > 0) {
|
||||
// Insert a newline
|
||||
const currentLine = lines[cursorRow];
|
||||
const before = currentLine.slice(0, cursorCol);
|
||||
const after = currentLine.slice(cursorCol);
|
||||
lines[cursorRow] = before;
|
||||
lines.splice(cursorRow + 1, 0, after);
|
||||
cursorRow++;
|
||||
cursorCol = 0;
|
||||
}
|
||||
// Insert the text
|
||||
const insertText = pasteLines[i];
|
||||
const line = lines[cursorRow];
|
||||
lines[cursorRow] = line.slice(0, cursorCol) + insertText + line.slice(cursorCol);
|
||||
cursorCol += insertText.length;
|
||||
}
|
||||
ensureCursorVisible();
|
||||
render();
|
||||
};
|
||||
|
||||
// Handle resize
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
ensureCursorVisible();
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Initial render ---
|
||||
render();
|
||||
461
test/js/bun/tui/demos/demo-todo.ts
Normal file
461
test/js/bun/tui/demos/demo-todo.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* demo-todo.ts — Todo List Manager
|
||||
*
|
||||
* A fully interactive todo app with checkboxes, priorities, categories,
|
||||
* inline editing, and persistence to a temp file.
|
||||
*
|
||||
* Demonstrates: checkbox toggling, inline text editing, priority styling,
|
||||
* category filtering, setText, fill, style (fg/bg/bold/italic/strikethrough),
|
||||
* drawBox, TUITerminalWriter, TUIKeyReader, alt screen, resize, onpaste.
|
||||
*
|
||||
* Run: bun run test/js/bun/tui/demos/demo-todo.ts
|
||||
* Controls: j/k navigate, Space toggle, a add, e edit, d delete, 1-4 priority,
|
||||
* Tab cycle filter, Q/Ctrl+C quit
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
let cols = writer.columns || 80;
|
||||
let rows = writer.rows || 24;
|
||||
let screen = new Bun.TUIScreen(cols, rows);
|
||||
|
||||
writer.enterAltScreen();
|
||||
|
||||
// --- Styles ---
|
||||
const st = {
|
||||
titleBar: screen.style({ fg: 0x000000, bg: 0xc678dd, bold: true }),
|
||||
header: screen.style({ fg: 0xc678dd, bold: true }),
|
||||
label: screen.style({ fg: 0xabb2bf }),
|
||||
dim: screen.style({ fg: 0x5c6370 }),
|
||||
footer: screen.style({ fg: 0x5c6370, italic: true }),
|
||||
border: screen.style({ fg: 0x5c6370 }),
|
||||
selected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
selectedDone: screen.style({ fg: 0x000000, bg: 0x61afef, strikethrough: true }),
|
||||
done: screen.style({ fg: 0x5c6370, strikethrough: true }),
|
||||
checkOn: screen.style({ fg: 0x98c379, bold: true }),
|
||||
checkOff: screen.style({ fg: 0x5c6370 }),
|
||||
checkOnSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
checkOffSel: screen.style({ fg: 0x000000, bg: 0x61afef }),
|
||||
priHigh: screen.style({ fg: 0xe06c75, bold: true }),
|
||||
priMed: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
priLow: screen.style({ fg: 0x56b6c2 }),
|
||||
priNone: screen.style({ fg: 0x5c6370 }),
|
||||
priHighSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
|
||||
catWork: screen.style({ fg: 0x61afef, italic: true }),
|
||||
catPersonal: screen.style({ fg: 0xc678dd, italic: true }),
|
||||
catHealth: screen.style({ fg: 0x98c379, italic: true }),
|
||||
catOther: screen.style({ fg: 0xe5c07b, italic: true }),
|
||||
statsLabel: screen.style({ fg: 0xabb2bf }),
|
||||
statsValue: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
inputBg: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
|
||||
inputLabel: screen.style({ fg: 0xe5c07b, bold: true }),
|
||||
filterActive: screen.style({ fg: 0x000000, bg: 0xc678dd, bold: true }),
|
||||
filterInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
|
||||
};
|
||||
|
||||
// --- Data ---
|
||||
type Priority = "high" | "medium" | "low" | "none";
|
||||
type Category = "work" | "personal" | "health" | "other";
|
||||
|
||||
interface Todo {
|
||||
text: string;
|
||||
done: boolean;
|
||||
priority: Priority;
|
||||
category: Category;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ text: "Ship the TUI library", done: false, priority: "high", category: "work", createdAt: new Date() },
|
||||
{ text: "Write documentation", done: false, priority: "medium", category: "work", createdAt: new Date() },
|
||||
{ text: "Add more demo apps", done: true, priority: "medium", category: "work", createdAt: new Date() },
|
||||
{ text: "Review pull requests", done: false, priority: "high", category: "work", createdAt: new Date() },
|
||||
{ text: "Buy groceries", done: false, priority: "low", category: "personal", createdAt: new Date() },
|
||||
{ text: "Go for a run", done: true, priority: "medium", category: "health", createdAt: new Date() },
|
||||
{ text: "Read a book", done: false, priority: "low", category: "personal", createdAt: new Date() },
|
||||
{ text: "Meditate", done: false, priority: "medium", category: "health", createdAt: new Date() },
|
||||
{ text: "Clean the kitchen", done: false, priority: "low", category: "personal", createdAt: new Date() },
|
||||
{ text: "Benchmark TUI rendering", done: false, priority: "high", category: "work", createdAt: new Date() },
|
||||
];
|
||||
|
||||
// --- State ---
|
||||
let selectedIndex = 0;
|
||||
let scrollOffset = 0;
|
||||
let editMode = false;
|
||||
let editText = "";
|
||||
let editCursor = 0;
|
||||
let addMode = false;
|
||||
let filterCategory: Category | "all" = "all";
|
||||
const categories: (Category | "all")[] = ["all", "work", "personal", "health", "other"];
|
||||
|
||||
function getFilteredTodos(): Todo[] {
|
||||
if (filterCategory === "all") return todos;
|
||||
return todos.filter(t => t.category === filterCategory);
|
||||
}
|
||||
|
||||
// --- Priority helpers ---
|
||||
function priLabel(p: Priority): string {
|
||||
switch (p) {
|
||||
case "high":
|
||||
return "!!!";
|
||||
case "medium":
|
||||
return "!! ";
|
||||
case "low":
|
||||
return "! ";
|
||||
default:
|
||||
return " ";
|
||||
}
|
||||
}
|
||||
function priStyle(p: Priority, sel: boolean): number {
|
||||
if (sel) return st.priHighSel;
|
||||
switch (p) {
|
||||
case "high":
|
||||
return st.priHigh;
|
||||
case "medium":
|
||||
return st.priMed;
|
||||
case "low":
|
||||
return st.priLow;
|
||||
default:
|
||||
return st.priNone;
|
||||
}
|
||||
}
|
||||
|
||||
function catStyle(c: Category): number {
|
||||
switch (c) {
|
||||
case "work":
|
||||
return st.catWork;
|
||||
case "personal":
|
||||
return st.catPersonal;
|
||||
case "health":
|
||||
return st.catHealth;
|
||||
default:
|
||||
return st.catOther;
|
||||
}
|
||||
}
|
||||
|
||||
function cyclePriority(p: Priority): Priority {
|
||||
switch (p) {
|
||||
case "none":
|
||||
return "low";
|
||||
case "low":
|
||||
return "medium";
|
||||
case "medium":
|
||||
return "high";
|
||||
case "high":
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
function render() {
|
||||
screen.clear();
|
||||
const filtered = getFilteredTodos();
|
||||
|
||||
// Title
|
||||
screen.fill(0, 0, cols, 1, " ", st.titleBar);
|
||||
screen.setText(2, 0, " Todo List ", st.titleBar);
|
||||
|
||||
// Filter tabs
|
||||
let tx = 2;
|
||||
const tabY = 2;
|
||||
for (const cat of categories) {
|
||||
const label = ` ${cat === "all" ? "All" : cat.charAt(0).toUpperCase() + cat.slice(1)} `;
|
||||
const style = cat === filterCategory ? st.filterActive : st.filterInactive;
|
||||
screen.setText(tx, tabY, label, style);
|
||||
tx += label.length + 1;
|
||||
}
|
||||
|
||||
// Stats
|
||||
const totalCount = filterCategory === "all" ? todos.length : filtered.length;
|
||||
const doneCount = filtered.filter(t => t.done).length;
|
||||
const statsText = `${doneCount}/${totalCount} done`;
|
||||
screen.setText(cols - statsText.length - 2, tabY, statsText, st.statsValue);
|
||||
|
||||
// List
|
||||
const listY = 4;
|
||||
const listH = rows - listY - 2;
|
||||
|
||||
// Ensure selected is visible
|
||||
if (selectedIndex >= filtered.length) selectedIndex = Math.max(0, filtered.length - 1);
|
||||
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
||||
if (selectedIndex >= scrollOffset + listH) scrollOffset = selectedIndex - listH + 1;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
screen.setText(4, listY + 1, "No items. Press 'a' to add one.", st.dim);
|
||||
}
|
||||
|
||||
const visibleCount = Math.min(listH, filtered.length - scrollOffset);
|
||||
for (let vi = 0; vi < visibleCount; vi++) {
|
||||
const idx = scrollOffset + vi;
|
||||
const todo = filtered[idx];
|
||||
const y = listY + vi;
|
||||
const isSel = idx === selectedIndex;
|
||||
const isEditing = isSel && editMode;
|
||||
|
||||
// Selection highlight
|
||||
if (isSel) {
|
||||
screen.fill(1, y, cols - 2, 1, " ", st.selected);
|
||||
}
|
||||
|
||||
// Checkbox
|
||||
const checkChar = todo.done ? "[\u2713]" : "[ ]";
|
||||
screen.setText(
|
||||
2,
|
||||
y,
|
||||
checkChar,
|
||||
isSel ? (todo.done ? st.checkOnSel : st.checkOffSel) : todo.done ? st.checkOn : st.checkOff,
|
||||
);
|
||||
|
||||
// Priority
|
||||
screen.setText(6, y, priLabel(todo.priority), priStyle(todo.priority, isSel));
|
||||
|
||||
// Text
|
||||
const textX = 10;
|
||||
const maxTextW = cols - textX - 16;
|
||||
if (isEditing) {
|
||||
// Edit mode: show editable text with cursor
|
||||
const displayText = editText.slice(0, maxTextW);
|
||||
screen.setText(textX, y, displayText, st.inputBg);
|
||||
// Fill remaining
|
||||
if (displayText.length < maxTextW) {
|
||||
screen.fill(textX + displayText.length, y, maxTextW - displayText.length, 1, " ", st.inputBg);
|
||||
}
|
||||
} else {
|
||||
const textStyle = isSel ? (todo.done ? st.selectedDone : st.selected) : todo.done ? st.done : st.label;
|
||||
const displayText = todo.text.slice(0, maxTextW);
|
||||
screen.setText(textX, y, displayText, textStyle);
|
||||
}
|
||||
|
||||
// Category tag
|
||||
const tagX = cols - 12;
|
||||
screen.setText(tagX, y, todo.category.slice(0, 8), isSel ? st.selected : catStyle(todo.category));
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
if (scrollOffset > 0) {
|
||||
screen.setText(cols - 2, listY, "\u25b2", st.statsValue);
|
||||
}
|
||||
if (scrollOffset + listH < filtered.length) {
|
||||
screen.setText(cols - 2, listY + listH - 1, "\u25bc", st.statsValue);
|
||||
}
|
||||
|
||||
// Add mode input
|
||||
if (addMode) {
|
||||
const addY = rows - 3;
|
||||
screen.fill(1, addY, cols - 2, 1, " ", st.inputBg);
|
||||
screen.setText(2, addY, "New: ", st.inputLabel);
|
||||
screen.setText(7, addY, editText + "_", st.inputBg);
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerY = rows - 1;
|
||||
let footerText: string;
|
||||
if (editMode) {
|
||||
footerText = " Enter: Save | Esc: Cancel | Editing... ";
|
||||
} else if (addMode) {
|
||||
footerText = " Enter: Add | Esc: Cancel | Type your todo... ";
|
||||
} else {
|
||||
footerText = " j/k:\u2195 Space:\u2713 a:Add e:Edit d:Del p:Priority Tab:Filter q:Quit ";
|
||||
}
|
||||
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
|
||||
|
||||
writer.render(screen, {
|
||||
cursorVisible: editMode || addMode,
|
||||
cursorX: editMode ? 10 + editCursor : addMode ? 7 + editCursor : 0,
|
||||
cursorY: editMode ? listY + (selectedIndex - scrollOffset) : addMode ? rows - 3 : 0,
|
||||
cursorStyle: "line",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
|
||||
const { name, ctrl, alt } = event;
|
||||
|
||||
if (ctrl && name === "c") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = getFilteredTodos();
|
||||
|
||||
if (editMode) {
|
||||
switch (name) {
|
||||
case "enter":
|
||||
if (editText.trim().length > 0) {
|
||||
filtered[selectedIndex].text = editText.trim();
|
||||
}
|
||||
editMode = false;
|
||||
break;
|
||||
case "escape":
|
||||
editMode = false;
|
||||
break;
|
||||
case "backspace":
|
||||
if (editCursor > 0) {
|
||||
editText = editText.slice(0, editCursor - 1) + editText.slice(editCursor);
|
||||
editCursor--;
|
||||
}
|
||||
break;
|
||||
case "left":
|
||||
if (editCursor > 0) editCursor--;
|
||||
break;
|
||||
case "right":
|
||||
if (editCursor < editText.length) editCursor++;
|
||||
break;
|
||||
case "home":
|
||||
editCursor = 0;
|
||||
break;
|
||||
case "end":
|
||||
editCursor = editText.length;
|
||||
break;
|
||||
default:
|
||||
if (!ctrl && !alt && name.length === 1) {
|
||||
editText = editText.slice(0, editCursor) + name + editText.slice(editCursor);
|
||||
editCursor++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (addMode) {
|
||||
switch (name) {
|
||||
case "enter":
|
||||
if (editText.trim().length > 0) {
|
||||
todos.push({
|
||||
text: editText.trim(),
|
||||
done: false,
|
||||
priority: "none",
|
||||
category: filterCategory === "all" ? "other" : filterCategory,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
selectedIndex = getFilteredTodos().length - 1;
|
||||
}
|
||||
addMode = false;
|
||||
editText = "";
|
||||
break;
|
||||
case "escape":
|
||||
addMode = false;
|
||||
editText = "";
|
||||
break;
|
||||
case "backspace":
|
||||
if (editCursor > 0) {
|
||||
editText = editText.slice(0, editCursor - 1) + editText.slice(editCursor);
|
||||
editCursor--;
|
||||
}
|
||||
break;
|
||||
case "left":
|
||||
if (editCursor > 0) editCursor--;
|
||||
break;
|
||||
case "right":
|
||||
if (editCursor < editText.length) editCursor++;
|
||||
break;
|
||||
default:
|
||||
if (!ctrl && !alt && name.length === 1) {
|
||||
editText = editText.slice(0, editCursor) + name + editText.slice(editCursor);
|
||||
editCursor++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode
|
||||
if (name === "q") {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case "up":
|
||||
case "k":
|
||||
if (selectedIndex > 0) selectedIndex--;
|
||||
break;
|
||||
case "down":
|
||||
case "j":
|
||||
if (selectedIndex < filtered.length - 1) selectedIndex++;
|
||||
break;
|
||||
case " ":
|
||||
if (filtered[selectedIndex]) {
|
||||
filtered[selectedIndex].done = !filtered[selectedIndex].done;
|
||||
}
|
||||
break;
|
||||
case "a":
|
||||
addMode = true;
|
||||
editText = "";
|
||||
editCursor = 0;
|
||||
break;
|
||||
case "e":
|
||||
if (filtered[selectedIndex]) {
|
||||
editMode = true;
|
||||
editText = filtered[selectedIndex].text;
|
||||
editCursor = editText.length;
|
||||
}
|
||||
break;
|
||||
case "d":
|
||||
if (filtered[selectedIndex]) {
|
||||
const realIdx = todos.indexOf(filtered[selectedIndex]);
|
||||
if (realIdx >= 0) todos.splice(realIdx, 1);
|
||||
if (selectedIndex >= getFilteredTodos().length) {
|
||||
selectedIndex = Math.max(0, getFilteredTodos().length - 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "p":
|
||||
if (filtered[selectedIndex]) {
|
||||
filtered[selectedIndex].priority = cyclePriority(filtered[selectedIndex].priority);
|
||||
}
|
||||
break;
|
||||
case "tab": {
|
||||
const ci = categories.indexOf(filterCategory);
|
||||
filterCategory = categories[(ci + 1) % categories.length];
|
||||
selectedIndex = 0;
|
||||
scrollOffset = 0;
|
||||
break;
|
||||
}
|
||||
case "home":
|
||||
selectedIndex = 0;
|
||||
break;
|
||||
case "end":
|
||||
selectedIndex = Math.max(0, filtered.length - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Paste ---
|
||||
reader.onpaste = (text: string) => {
|
||||
if (editMode || addMode) {
|
||||
const firstLine = text.split("\n")[0];
|
||||
editText = editText.slice(0, editCursor) + firstLine + editText.slice(editCursor);
|
||||
editCursor += firstLine.length;
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Resize ---
|
||||
writer.onresize = (newCols: number, newRows: number) => {
|
||||
cols = newCols;
|
||||
rows = newRows;
|
||||
screen.resize(cols, rows);
|
||||
render();
|
||||
};
|
||||
|
||||
// --- Cleanup ---
|
||||
let cleanedUp = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
reader.close();
|
||||
writer.exitAltScreen();
|
||||
writer.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// --- Start ---
|
||||
render();
|
||||
130
test/js/bun/tui/demos/demo-tree.ts
Normal file
130
test/js/bun/tui/demos/demo-tree.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* demo-tree.ts — Renders a directory tree structure with icons.
|
||||
* Uses box-drawing characters with file-type coloring.
|
||||
*/
|
||||
|
||||
const writer = new Bun.TUITerminalWriter(Bun.stdout);
|
||||
const width = writer.columns || 80;
|
||||
|
||||
interface TreeEntry {
|
||||
name: string;
|
||||
type: "dir" | "ts" | "json" | "md" | "zig" | "file" | "lock";
|
||||
children?: TreeEntry[];
|
||||
}
|
||||
|
||||
const tree: TreeEntry = {
|
||||
name: "my-project/",
|
||||
type: "dir",
|
||||
children: [
|
||||
{
|
||||
name: "src/",
|
||||
type: "dir",
|
||||
children: [
|
||||
{ name: "index.ts", type: "ts" },
|
||||
{ name: "server.ts", type: "ts" },
|
||||
{
|
||||
name: "utils/",
|
||||
type: "dir",
|
||||
children: [
|
||||
{ name: "helpers.ts", type: "ts" },
|
||||
{ name: "constants.ts", type: "ts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "routes/",
|
||||
type: "dir",
|
||||
children: [
|
||||
{ name: "api.ts", type: "ts" },
|
||||
{ name: "auth.ts", type: "ts" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "test/",
|
||||
type: "dir",
|
||||
children: [
|
||||
{ name: "server.test.ts", type: "ts" },
|
||||
{ name: "helpers.test.ts", type: "ts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "lib/",
|
||||
type: "dir",
|
||||
children: [{ name: "native.zig", type: "zig" }],
|
||||
},
|
||||
{ name: "package.json", type: "json" },
|
||||
{ name: "tsconfig.json", type: "json" },
|
||||
{ name: "bun.lock", type: "lock" },
|
||||
{ name: "README.md", type: "md" },
|
||||
],
|
||||
};
|
||||
|
||||
// Collect all lines
|
||||
const lines: { indent: string; name: string; type: string }[] = [];
|
||||
|
||||
function walk(node: TreeEntry, prefix: string, isLast: boolean, isRoot: boolean) {
|
||||
const connector = isRoot ? "" : isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
||||
lines.push({ indent: prefix + connector, name: node.name, type: node.type });
|
||||
if (node.children) {
|
||||
const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "\u2502 ");
|
||||
node.children.forEach((child, i) => {
|
||||
walk(child, childPrefix, i === node.children!.length - 1, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
walk(tree, "", true, true);
|
||||
|
||||
const height = lines.length + 3;
|
||||
const screen = new Bun.TUIScreen(width, height);
|
||||
|
||||
// Styles
|
||||
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
|
||||
const treeChars = screen.style({ fg: 0x555555 });
|
||||
const dirStyle = screen.style({ fg: 0x61afef, bold: true });
|
||||
const tsStyle = screen.style({ fg: 0x98c379 });
|
||||
const jsonStyle = screen.style({ fg: 0xe5c07b });
|
||||
const mdStyle = screen.style({ fg: 0xc678dd });
|
||||
const zigStyle = screen.style({ fg: 0xf0a030 });
|
||||
const fileStyle = screen.style({ fg: 0xabb2bf });
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
dir: "\uD83D\uDCC1 ",
|
||||
ts: "\uD83D\uDCC4 ",
|
||||
json: "\u2699\uFE0F ",
|
||||
md: "\uD83D\uDCD6 ",
|
||||
zig: "\u26A1 ",
|
||||
file: "\uD83D\uDCC4 ",
|
||||
lock: "\uD83D\uDD12 ",
|
||||
};
|
||||
|
||||
const typeStyles: Record<string, number> = {
|
||||
dir: dirStyle,
|
||||
ts: tsStyle,
|
||||
json: jsonStyle,
|
||||
md: mdStyle,
|
||||
zig: zigStyle,
|
||||
file: fileStyle,
|
||||
lock: fileStyle,
|
||||
};
|
||||
|
||||
// Title
|
||||
screen.setText(1, 0, "Project Structure", titleStyle);
|
||||
const sep = "\u2500".repeat(Math.min(40, width - 2));
|
||||
screen.setText(1, 1, sep, screen.style({ fg: 0x444444 }));
|
||||
|
||||
// Render tree lines
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const y = i + 2;
|
||||
let x = 1;
|
||||
// Draw tree connector chars
|
||||
x += screen.setText(x, y, line.indent, treeChars);
|
||||
// Draw name with type coloring
|
||||
screen.setText(x, y, line.name, typeStyles[line.type] ?? fileStyle);
|
||||
}
|
||||
|
||||
writer.render(screen);
|
||||
writer.clear();
|
||||
writer.write("\r\n");
|
||||
writer.close();
|
||||
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();
|
||||
});
|
||||
});
|
||||
1249
test/js/bun/tui/key-reader.test.ts
Normal file
1249
test/js/bun/tui/key-reader.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1279
test/js/bun/tui/screen.test.ts
Normal file
1279
test/js/bun/tui/screen.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1576
test/js/bun/tui/writer.test.ts
Normal file
1576
test/js/bun/tui/writer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
77
test/regression/issue/26851.test.ts
Normal file
77
test/regression/issue/26851.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDir } from "harness";
|
||||
import { join } from "path";
|
||||
|
||||
test("--bail writes JUnit reporter outfile", async () => {
|
||||
using dir = tempDir("bail-junit", {
|
||||
"fail.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
test("failing test", () => { expect(1).toBe(2); });
|
||||
`,
|
||||
});
|
||||
|
||||
const outfile = join(String(dir), "results.xml");
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`, "fail.test.ts"],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
// The test should fail and bail
|
||||
expect(exitCode).not.toBe(0);
|
||||
|
||||
// The JUnit report file should still be written despite bail
|
||||
const file = Bun.file(outfile);
|
||||
expect(await file.exists()).toBe(true);
|
||||
|
||||
const xml = await file.text();
|
||||
expect(xml).toContain("<?xml");
|
||||
expect(xml).toContain("<testsuites");
|
||||
expect(xml).toContain("</testsuites>");
|
||||
expect(xml).toContain("failing test");
|
||||
});
|
||||
|
||||
test("--bail writes JUnit reporter outfile with multiple files", async () => {
|
||||
using dir = tempDir("bail-junit-multi", {
|
||||
"a_pass.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
test("passing test", () => { expect(1).toBe(1); });
|
||||
`,
|
||||
"b_fail.test.ts": `
|
||||
import { test, expect } from "bun:test";
|
||||
test("another failing test", () => { expect(1).toBe(2); });
|
||||
`,
|
||||
});
|
||||
|
||||
const outfile = join(String(dir), "results.xml");
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`],
|
||||
env: bunEnv,
|
||||
cwd: String(dir),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
// The test should fail and bail
|
||||
expect(exitCode).not.toBe(0);
|
||||
|
||||
// The JUnit report file should still be written despite bail
|
||||
const file = Bun.file(outfile);
|
||||
expect(await file.exists()).toBe(true);
|
||||
|
||||
const xml = await file.text();
|
||||
expect(xml).toContain("<?xml");
|
||||
expect(xml).toContain("<testsuites");
|
||||
expect(xml).toContain("</testsuites>");
|
||||
// Both the passing and failing tests should be recorded
|
||||
expect(xml).toContain("passing test");
|
||||
expect(xml).toContain("another failing test");
|
||||
});
|
||||
Reference in New Issue
Block a user