mirror of
https://github.com/oven-sh/bun
synced 2026-02-13 20:39:05 +00:00
Add TUI APIs for ink migration: setAnsiText, key reader mode sequences, inline rendering
Phase 0 of the ink→Bun.TUI migration. Adds three new capabilities to the native TUI primitives: - TUIScreen.setAnsiText(x, y, text): feeds ANSI text through Ghostty's VT parser, interprets SGR sequences to track style, writes styled cells directly - TUIKeyReader constructor options (bracketedPaste, focusEvents, kittyKeyboard): writes mode-enabling sequences to stdout on construction, disables on close() - TUITerminalWriter inline rendering mode: uses LF instead of CUD for viewport scrolling, tracks scrollback depth, detects unreachable dirty rows for full redraw Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
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
|
||||
@@ -14,6 +14,10 @@ export default [
|
||||
fn: "setText",
|
||||
length: 4,
|
||||
},
|
||||
setAnsiText: {
|
||||
fn: "setAnsiText",
|
||||
length: 3,
|
||||
},
|
||||
style: {
|
||||
fn: "style",
|
||||
length: 1,
|
||||
|
||||
@@ -78,6 +78,8 @@ pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe
|
||||
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| {
|
||||
@@ -97,6 +99,12 @@ pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe
|
||||
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
|
||||
@@ -108,7 +116,11 @@ pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe
|
||||
return globalThis.throw("render: ArrayBuffer is empty", .{});
|
||||
|
||||
this.output.clearRetainingCapacity();
|
||||
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
|
||||
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);
|
||||
|
||||
@@ -52,14 +52,20 @@ const Flags = packed struct {
|
||||
esc_pending: bool = false,
|
||||
/// Set when we're accumulating an SGR mouse event sequence.
|
||||
in_mouse: bool = false,
|
||||
_padding: u1 = 0,
|
||||
/// 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, _: *jsc.CallFrame) bun.JSError!*TuiKeyReader {
|
||||
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.
|
||||
@@ -69,12 +75,34 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSErr
|
||||
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 },
|
||||
.flags = .{
|
||||
.is_tty = is_tty,
|
||||
.bracketed_paste = want_bracketed_paste,
|
||||
.focus_events = want_focus_events,
|
||||
.kitty_keyboard = want_kitty_keyboard,
|
||||
},
|
||||
});
|
||||
this.reader.setParent(this);
|
||||
|
||||
@@ -91,6 +119,9 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSErr
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -101,6 +132,7 @@ fn deinit(this: *TuiKeyReader) void {
|
||||
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();
|
||||
@@ -112,6 +144,7 @@ fn deinit(this: *TuiKeyReader) void {
|
||||
|
||||
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();
|
||||
@@ -154,6 +187,7 @@ pub fn onReaderError(this: *TuiKeyReader, _: bun.sys.Error) void {
|
||||
|
||||
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();
|
||||
@@ -230,6 +264,62 @@ pub fn getOnBlur(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.J
|
||||
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 {
|
||||
@@ -568,6 +658,7 @@ fn emitMouse(this: *TuiKeyReader, button_code: u16, px: u16, py: u16, is_release
|
||||
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});
|
||||
}
|
||||
@@ -588,7 +679,7 @@ fn emitKeypress(this: *TuiKeyReader, name: []const u8, sequence: []const u8, ctr
|
||||
const callback = this.onkeypress_callback.get() orelse return;
|
||||
const globalThis = this.globalThis;
|
||||
|
||||
const event = jsc.JSValue.createEmptyObject(globalThis, 5);
|
||||
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);
|
||||
@@ -596,6 +687,7 @@ fn emitKeypress(this: *TuiKeyReader, name: []const u8, sequence: []const u8, ctr
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,16 @@ 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,
|
||||
@@ -51,14 +61,53 @@ pub fn render(
|
||||
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);
|
||||
|
||||
const need_full = !this.has_rendered or this.prev_page == null or
|
||||
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) {
|
||||
@@ -102,6 +151,8 @@ pub fn clear(this: *TuiRenderer) void {
|
||||
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;
|
||||
@@ -113,23 +164,56 @@ pub fn deinit(this: *TuiRenderer) void {
|
||||
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) {
|
||||
if (this.cursor_y > 0) {
|
||||
this.emitCSI(this.cursor_y, 'A');
|
||||
// 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 = 0;
|
||||
this.cursor_y = reachable_top;
|
||||
}
|
||||
this.current_style_id = 0;
|
||||
|
||||
var y: size.CellCountInt = 0;
|
||||
var first_visible = true;
|
||||
var y: size.CellCountInt = start_y;
|
||||
while (y < screen.page.size.rows) : (y += 1) {
|
||||
if (y > 0) this.emit("\r\n");
|
||||
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;
|
||||
@@ -165,6 +249,7 @@ fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void {
|
||||
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) {
|
||||
@@ -177,6 +262,18 @@ fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void {
|
||||
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 {
|
||||
@@ -260,7 +357,26 @@ fn swapScreens(this: *TuiRenderer, screen: *const TuiScreen) void {
|
||||
|
||||
fn moveTo(this: *TuiRenderer, x: size.CellCountInt, y: size.CellCountInt) void {
|
||||
if (y > this.cursor_y) {
|
||||
this.emitCSI(y - this.cursor_y, 'B');
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -298,6 +298,220 @@ pub fn setText(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *js
|
||||
return jsc.JSValue.jsNumber(@as(i32, @intCast(col - start_x)));
|
||||
}
|
||||
|
||||
/// setAnsiText(x, y, text) — parse ANSI escape sequences in `text` and write styled characters.
|
||||
/// Interprets SGR (CSI … m) sequences to set the current style, writes printable characters
|
||||
/// into cells at (x, y), advancing the column after each character.
|
||||
/// CR resets column to start_x; LF advances row and resets column.
|
||||
/// Returns the column count written (on the last row).
|
||||
pub fn setAnsiText(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
||||
const arguments = callframe.arguments();
|
||||
if (arguments.len < 3) return globalThis.throw("setAnsiText requires (x, y, text)", .{});
|
||||
|
||||
if (!arguments[0].isNumber() or !arguments[1].isNumber())
|
||||
return globalThis.throw("setAnsiText: x and y must be numbers", .{});
|
||||
if (!arguments[2].isString()) return globalThis.throw("setAnsiText: text must be a string", .{});
|
||||
|
||||
const start_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols() -| 1)));
|
||||
var y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows() -| 1)));
|
||||
|
||||
const str = try arguments[2].toSliceClone(globalThis);
|
||||
defer str.deinit();
|
||||
const text = str.slice();
|
||||
|
||||
var parser = Parser.init();
|
||||
defer parser.deinit();
|
||||
var current_style = Style{};
|
||||
var current_style_id: size.StyleCountInt = 0;
|
||||
var col = start_x;
|
||||
const cols = this.getCols();
|
||||
|
||||
// Apply clipping
|
||||
var clip_max_col: size.CellCountInt = cols;
|
||||
var clip_min_y: size.CellCountInt = 0;
|
||||
var clip_max_y: size.CellCountInt = this.getRows();
|
||||
if (this.clip_depth > 0) {
|
||||
const cr = this.clip_stack[this.clip_depth - 1];
|
||||
clip_max_col = cr.x2;
|
||||
clip_min_y = cr.y1;
|
||||
clip_max_y = cr.y2;
|
||||
}
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < text.len) {
|
||||
const byte = text[i];
|
||||
|
||||
// Handle UTF-8 multi-byte sequences directly (parser only handles ASCII).
|
||||
if (byte >= 0xC0) {
|
||||
const seq_len = bun.strings.utf8ByteSequenceLength(byte);
|
||||
if (i + seq_len <= text.len) {
|
||||
const seq = text[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 cp = bun.strings.decodeWTF8RuneT(&seq_bytes, seq_len, u21, 0xFFFD);
|
||||
if (cp != 0xFFFD) {
|
||||
this.writeAnsiChar(cp, &col, y, current_style_id, clip_max_col, clip_min_y, clip_max_y);
|
||||
}
|
||||
i += seq_len;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip stray continuation bytes.
|
||||
if (byte >= 0x80) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Feed ASCII through the VT parser.
|
||||
const actions = parser.next(byte);
|
||||
for (&actions) |maybe_action| {
|
||||
const action = maybe_action orelse continue;
|
||||
switch (action) {
|
||||
.print => |cp| {
|
||||
this.writeAnsiChar(@intCast(cp), &col, y, current_style_id, clip_max_col, clip_min_y, clip_max_y);
|
||||
},
|
||||
.execute => |c| {
|
||||
switch (c) {
|
||||
'\r' => col = start_x,
|
||||
'\n' => {
|
||||
y +|= 1;
|
||||
col = start_x;
|
||||
},
|
||||
'\t' => {
|
||||
// Advance to next tab stop (every 8 columns).
|
||||
col = @min((col +| 8) & ~@as(size.CellCountInt, 7), cols);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
.csi_dispatch => |csi| {
|
||||
if (csi.final == 'm') {
|
||||
// Parse SGR attributes and update current style.
|
||||
var sgr_parser = sgr.Parser{
|
||||
.params = csi.params,
|
||||
.params_sep = csi.params_sep,
|
||||
};
|
||||
while (sgr_parser.next()) |attr| {
|
||||
applyAnsiAttribute(¤t_style, attr);
|
||||
}
|
||||
// Intern the updated style.
|
||||
if (current_style.default()) {
|
||||
current_style_id = 0;
|
||||
} else {
|
||||
current_style_id = this.internStyle(current_style);
|
||||
}
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return jsc.JSValue.jsNumber(@as(i32, @intCast(col -| start_x)));
|
||||
}
|
||||
|
||||
/// Write a single codepoint at (col, y) with the given style, advancing col.
|
||||
/// Used by setAnsiText to place characters parsed from ANSI input.
|
||||
fn writeAnsiChar(
|
||||
this: *TuiScreen,
|
||||
cp: u21,
|
||||
col: *size.CellCountInt,
|
||||
y: size.CellCountInt,
|
||||
sid: size.StyleCountInt,
|
||||
clip_max_col: size.CellCountInt,
|
||||
clip_min_y: size.CellCountInt,
|
||||
clip_max_y: size.CellCountInt,
|
||||
) void {
|
||||
if (y < clip_min_y or y >= clip_max_y or y >= this.getRows()) return;
|
||||
|
||||
const effective_cols = clip_max_col;
|
||||
const width: u2 = @intCast(@min(bun.strings.visibleCodepointWidth(@intCast(cp), false), 2));
|
||||
|
||||
if (width == 0) {
|
||||
// Zero-width: append as grapheme extension to preceding cell.
|
||||
if (col.* > 0) {
|
||||
const rc = this.getRowCells(y);
|
||||
const target = graphemeTarget(rc.cells, col.*, 0);
|
||||
this.page.appendGrapheme(rc.row, &rc.cells[target], @intCast(cp)) catch {};
|
||||
rc.row.dirty = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (col.* >= effective_cols) return;
|
||||
if (width == 2 and col.* + 1 >= effective_cols) return;
|
||||
|
||||
const rc = this.getRowCells(y);
|
||||
rc.cells[col.*] = .{
|
||||
.content_tag = .codepoint,
|
||||
.content = .{ .codepoint = @intCast(cp) },
|
||||
.style_id = sid,
|
||||
.wide = if (width == 2) .wide else .narrow,
|
||||
};
|
||||
rc.row.dirty = true;
|
||||
if (sid != 0) rc.row.styled = true;
|
||||
col.* += 1;
|
||||
|
||||
if (width == 2 and col.* < effective_cols) {
|
||||
rc.cells[col.*] = .{ .content_tag = .codepoint, .content = .{ .codepoint = ' ' }, .style_id = sid, .wide = .spacer_tail };
|
||||
col.* += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Intern a Style into the page's style set, using the cache to avoid duplicates.
|
||||
fn internStyle(this: *TuiScreen, s: Style) size.StyleCountInt {
|
||||
const key = StyleKey.fromStyle(s);
|
||||
if (this.style_cache.get(key)) |cached_id| return cached_id;
|
||||
const id = this.page.styles.add(this.page.memory, s) catch return 0;
|
||||
this.style_cache.put(bun.default_allocator, key, id) catch {};
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Apply an SGR attribute to a Style, modifying it in-place.
|
||||
fn applyAnsiAttribute(s: *Style, attr: sgr.Attribute) void {
|
||||
switch (attr) {
|
||||
.unset => s.* = Style{},
|
||||
.bold => s.flags.bold = true,
|
||||
.reset_bold => {
|
||||
s.flags.bold = false;
|
||||
s.flags.faint = false;
|
||||
},
|
||||
.italic => s.flags.italic = true,
|
||||
.reset_italic => s.flags.italic = false,
|
||||
.faint => s.flags.faint = true,
|
||||
.underline => |ul| s.flags.underline = ul,
|
||||
.blink => s.flags.blink = true,
|
||||
.reset_blink => s.flags.blink = false,
|
||||
.inverse => s.flags.inverse = true,
|
||||
.reset_inverse => s.flags.inverse = false,
|
||||
.invisible => s.flags.invisible = true,
|
||||
.reset_invisible => s.flags.invisible = false,
|
||||
.strikethrough => s.flags.strikethrough = true,
|
||||
.reset_strikethrough => s.flags.strikethrough = false,
|
||||
.overline => s.flags.overline = true,
|
||||
.reset_overline => s.flags.overline = false,
|
||||
.direct_color_fg => |rgb| s.fg_color = .{ .rgb = rgb },
|
||||
.direct_color_bg => |rgb| s.bg_color = .{ .rgb = rgb },
|
||||
.@"8_fg" => |name| s.fg_color = .{ .palette = @intFromEnum(name) },
|
||||
.@"8_bg" => |name| s.bg_color = .{ .palette = @intFromEnum(name) },
|
||||
.@"8_bright_fg" => |name| s.fg_color = .{ .palette = @intFromEnum(name) },
|
||||
.@"8_bright_bg" => |name| s.bg_color = .{ .palette = @intFromEnum(name) },
|
||||
.@"256_fg" => |idx| s.fg_color = .{ .palette = idx },
|
||||
.@"256_bg" => |idx| s.bg_color = .{ .palette = idx },
|
||||
.reset_fg => s.fg_color = .none,
|
||||
.reset_bg => s.bg_color = .none,
|
||||
.underline_color => |rgb| s.underline_color = .{ .rgb = rgb },
|
||||
.@"256_underline_color" => |idx| s.underline_color = .{ .palette = idx },
|
||||
.reset_underline_color => s.underline_color = .none,
|
||||
.unknown => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// style({ fg, bg, bold, italic, underline, ... }) → styleId
|
||||
pub fn style(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
||||
const arguments = callframe.arguments();
|
||||
@@ -869,6 +1083,9 @@ fn parseColor(globalThis: *jsc.JSGlobalObject, val: jsc.JSValue) bun.JSError!Sty
|
||||
return .none;
|
||||
}
|
||||
|
||||
const Parser = ghostty.Parser;
|
||||
const color = ghostty.color;
|
||||
|
||||
const bun = @import("bun");
|
||||
const std = @import("std");
|
||||
const jsc = bun.jsc;
|
||||
|
||||
@@ -230,6 +230,7 @@ pub fn render(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callfra
|
||||
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| {
|
||||
@@ -249,15 +250,34 @@ pub fn render(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callfra
|
||||
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();
|
||||
this.renderer.render(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
|
||||
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();
|
||||
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
|
||||
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;
|
||||
|
||||
@@ -1036,4 +1036,214 @@ describe("Bun.TUIKeyReader", () => {
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes option field as alias for alt on keypress events", async () => {
|
||||
using dir = tempDir("tui-keyreader-option", {
|
||||
"test.ts": `
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
const events: any[] = [];
|
||||
reader.onkeypress = (event: any) => {
|
||||
events.push({ name: event.name, alt: event.alt, option: event.option });
|
||||
if (events.length >= 2) {
|
||||
reader.close();
|
||||
console.log(JSON.stringify(events));
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), join(String(dir), "test.ts")],
|
||||
env: bunEnv,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// Send 'a' (no alt) then alt+b (\x1bb)
|
||||
proc.stdin.write("a\x1bb");
|
||||
proc.stdin.end();
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).toBe("");
|
||||
const events = JSON.parse(stdout.trim());
|
||||
expect(events).toEqual([
|
||||
{ name: "a", alt: false, option: false },
|
||||
{ name: "b", alt: true, option: true },
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Constructor options (mode sequences) ──────────────────────
|
||||
|
||||
describe("constructor options", () => {
|
||||
test("accepts options object without crashing", async () => {
|
||||
using dir = tempDir("tui-keyreader-opts", {
|
||||
"test.ts": `
|
||||
const reader = new Bun.TUIKeyReader({
|
||||
bracketedPaste: true,
|
||||
focusEvents: true,
|
||||
kittyKeyboard: true,
|
||||
});
|
||||
reader.close();
|
||||
console.log("ok");
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), join(String(dir), "test.ts")],
|
||||
env: bunEnv,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.end();
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
// stdout contains the mode sequences + "ok\n"
|
||||
expect(stdout).toContain("ok");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("writes bracketed paste enable/disable sequences", async () => {
|
||||
using dir = tempDir("tui-keyreader-bp", {
|
||||
"test.ts": `
|
||||
const reader = new Bun.TUIKeyReader({ bracketedPaste: true });
|
||||
// Immediately close to get both enable and disable sequences
|
||||
reader.close();
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), join(String(dir), "test.ts")],
|
||||
env: bunEnv,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.end();
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
// Enable: CSI ?2004h, Disable: CSI ?2004l
|
||||
expect(stdout).toContain("\x1b[?2004h");
|
||||
expect(stdout).toContain("\x1b[?2004l");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("writes focus events enable/disable sequences", async () => {
|
||||
using dir = tempDir("tui-keyreader-fe", {
|
||||
"test.ts": `
|
||||
const reader = new Bun.TUIKeyReader({ focusEvents: true });
|
||||
reader.close();
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), join(String(dir), "test.ts")],
|
||||
env: bunEnv,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.end();
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
// Enable: CSI ?1004h, Disable: CSI ?1004l
|
||||
expect(stdout).toContain("\x1b[?1004h");
|
||||
expect(stdout).toContain("\x1b[?1004l");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("writes kitty keyboard enable/disable sequences", async () => {
|
||||
using dir = tempDir("tui-keyreader-kk", {
|
||||
"test.ts": `
|
||||
const reader = new Bun.TUIKeyReader({ kittyKeyboard: true });
|
||||
reader.close();
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), join(String(dir), "test.ts")],
|
||||
env: bunEnv,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.end();
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
// Enable: CSI >1u, Disable: CSI <u
|
||||
expect(stdout).toContain("\x1b[>1u");
|
||||
expect(stdout).toContain("\x1b[<u");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("no options means no mode sequences", async () => {
|
||||
using dir = tempDir("tui-keyreader-noopts", {
|
||||
"test.ts": `
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
reader.close();
|
||||
console.log("ok");
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), join(String(dir), "test.ts")],
|
||||
env: bunEnv,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
proc.stdin.end();
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
// Should just have "ok" and no escape sequences
|
||||
expect(stdout.trim()).toBe("ok");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes option field as alias for alt on mouse events", async () => {
|
||||
using dir = tempDir("tui-keyreader-mouse-option", {
|
||||
"test.ts": `
|
||||
const reader = new Bun.TUIKeyReader();
|
||||
reader.onmouse = (event: any) => {
|
||||
reader.close();
|
||||
console.log(JSON.stringify({ alt: event.alt, option: event.option, type: event.type }));
|
||||
process.exit(0);
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), join(String(dir), "test.ts")],
|
||||
env: bunEnv,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// SGR mouse: alt+click at (1,1) — button 0 with alt modifier (bit 3 = 8)
|
||||
proc.stdin.write("\x1b[<8;1;1M");
|
||||
proc.stdin.end();
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).toBe("");
|
||||
const event = JSON.parse(stdout.trim());
|
||||
expect(event).toEqual({ alt: true, option: true, type: "down" });
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1024,4 +1024,256 @@ describe("Bun.TUIScreen", () => {
|
||||
expect(screen.getCell(10, 5).char).toBe(".");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setAnsiText ────────────────────────────────────────────────
|
||||
|
||||
describe("setAnsiText", () => {
|
||||
test("writes plain text without ANSI codes", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
const cols = screen.setAnsiText(0, 0, "Hello");
|
||||
expect(cols).toBe(5);
|
||||
expect(screen.getCell(0, 0).char).toBe("H");
|
||||
expect(screen.getCell(4, 0).char).toBe("o");
|
||||
});
|
||||
|
||||
test("applies bold SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// ESC[1m = bold on, then "Hi", then ESC[0m = reset
|
||||
screen.setAnsiText(0, 0, "\x1b[1mHi\x1b[0m");
|
||||
const boldId = screen.style({ bold: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("H");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(boldId);
|
||||
expect(screen.getCell(1, 0).char).toBe("i");
|
||||
expect(screen.getCell(1, 0).styleId).toBe(boldId);
|
||||
});
|
||||
|
||||
test("applies foreground color SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// ESC[38;2;255;0;0m = red foreground
|
||||
screen.setAnsiText(0, 0, "\x1b[38;2;255;0;0mRed\x1b[0m");
|
||||
const redId = screen.style({ fg: 0xff0000 });
|
||||
expect(screen.getCell(0, 0).char).toBe("R");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(redId);
|
||||
});
|
||||
|
||||
test("applies background color SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// ESC[48;2;0;255;0m = green background
|
||||
screen.setAnsiText(0, 0, "\x1b[48;2;0;255;0mGrn\x1b[0m");
|
||||
const greenBgId = screen.style({ bg: 0x00ff00 });
|
||||
expect(screen.getCell(0, 0).char).toBe("G");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(greenBgId);
|
||||
});
|
||||
|
||||
test("applies 8-color named foreground", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// ESC[31m = red foreground (palette index 1)
|
||||
screen.setAnsiText(0, 0, "\x1b[31mX\x1b[0m");
|
||||
const redId = screen.style({ fg: { palette: 1 } });
|
||||
expect(screen.getCell(0, 0).char).toBe("X");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(redId);
|
||||
});
|
||||
|
||||
test("applies 256-color foreground", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// ESC[38;5;196m = 256-color palette index 196
|
||||
screen.setAnsiText(0, 0, "\x1b[38;5;196mX\x1b[0m");
|
||||
const id = screen.style({ fg: { palette: 196 } });
|
||||
expect(screen.getCell(0, 0).char).toBe("X");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(id);
|
||||
});
|
||||
|
||||
test("applies multiple attributes", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// ESC[1;3m = bold + italic
|
||||
screen.setAnsiText(0, 0, "\x1b[1;3mBI\x1b[0m");
|
||||
const id = screen.style({ bold: true, italic: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("B");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(id);
|
||||
expect(screen.getCell(1, 0).styleId).toBe(id);
|
||||
});
|
||||
|
||||
test("reset SGR clears all attributes", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// Bold on, write "A", reset, write "B"
|
||||
screen.setAnsiText(0, 0, "\x1b[1mA\x1b[0mB");
|
||||
const boldId = screen.style({ bold: true });
|
||||
expect(screen.getCell(0, 0).styleId).toBe(boldId);
|
||||
expect(screen.getCell(1, 0).styleId).toBe(0); // default style
|
||||
});
|
||||
|
||||
test("handles CR (carriage return)", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "Hello\rWorld");
|
||||
// CR resets column to start, "World" overwrites "Hello"
|
||||
expect(screen.getCell(0, 0).char).toBe("W");
|
||||
expect(screen.getCell(4, 0).char).toBe("d");
|
||||
});
|
||||
|
||||
test("handles LF (line feed)", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "Line1\nLine2");
|
||||
expect(screen.getCell(0, 0).char).toBe("L");
|
||||
expect(screen.getCell(4, 0).char).toBe("1");
|
||||
expect(screen.getCell(0, 1).char).toBe("L");
|
||||
expect(screen.getCell(4, 1).char).toBe("2");
|
||||
});
|
||||
|
||||
test("handles tab characters", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "A\tB");
|
||||
expect(screen.getCell(0, 0).char).toBe("A");
|
||||
// Tab advances to column 8
|
||||
expect(screen.getCell(8, 0).char).toBe("B");
|
||||
});
|
||||
|
||||
test("handles CJK wide characters in ANSI text", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
const cols = screen.setAnsiText(0, 0, "\x1b[1m世界\x1b[0m");
|
||||
expect(cols).toBe(4);
|
||||
const boldId = screen.style({ bold: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("世");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(boldId);
|
||||
expect(screen.getCell(0, 0).wide).toBe(1);
|
||||
expect(screen.getCell(1, 0).wide).toBe(2); // spacer_tail
|
||||
});
|
||||
|
||||
test("handles emoji in ANSI text", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "😀");
|
||||
expect(screen.getCell(0, 0).char).toBe("😀");
|
||||
expect(screen.getCell(0, 0).wide).toBe(1);
|
||||
expect(screen.getCell(1, 0).wide).toBe(2);
|
||||
});
|
||||
|
||||
test("clips at screen boundary", () => {
|
||||
const screen = new Bun.TUIScreen(5, 1);
|
||||
const cols = screen.setAnsiText(0, 0, "Hello World!");
|
||||
expect(cols).toBe(5);
|
||||
expect(screen.getCell(0, 0).char).toBe("H");
|
||||
expect(screen.getCell(4, 0).char).toBe("o");
|
||||
});
|
||||
|
||||
test("respects clip rect", () => {
|
||||
const screen = new Bun.TUIScreen(20, 5);
|
||||
screen.clip(5, 0, 10, 5);
|
||||
screen.setAnsiText(5, 0, "Hello World");
|
||||
expect(screen.getCell(5, 0).char).toBe("H");
|
||||
expect(screen.getCell(9, 0).char).toBe("o");
|
||||
// Beyond clip rect should be empty
|
||||
expect(screen.getCell(10, 0).char).toBe(" ");
|
||||
screen.unclip();
|
||||
});
|
||||
|
||||
test("writes at offset position", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(10, 5, "Test");
|
||||
expect(screen.getCell(10, 5).char).toBe("T");
|
||||
expect(screen.getCell(13, 5).char).toBe("t");
|
||||
});
|
||||
|
||||
test("empty string returns 0", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
const cols = screen.setAnsiText(0, 0, "");
|
||||
expect(cols).toBe(0);
|
||||
});
|
||||
|
||||
test("ANSI-only text (no printable chars) returns 0", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
const cols = screen.setAnsiText(0, 0, "\x1b[1m\x1b[0m");
|
||||
expect(cols).toBe(0);
|
||||
});
|
||||
|
||||
test("applies italic SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "\x1b[3mI\x1b[0m");
|
||||
const italicId = screen.style({ italic: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("I");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(italicId);
|
||||
});
|
||||
|
||||
test("applies underline SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "\x1b[4mU\x1b[0m");
|
||||
const ulId = screen.style({ underline: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("U");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(ulId);
|
||||
});
|
||||
|
||||
test("applies strikethrough SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "\x1b[9mS\x1b[0m");
|
||||
const stId = screen.style({ strikethrough: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("S");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(stId);
|
||||
});
|
||||
|
||||
test("applies dim/faint SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "\x1b[2mD\x1b[0m");
|
||||
const faintId = screen.style({ faint: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("D");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(faintId);
|
||||
});
|
||||
|
||||
test("applies inverse SGR", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
screen.setAnsiText(0, 0, "\x1b[7mV\x1b[0m");
|
||||
const invId = screen.style({ inverse: true });
|
||||
expect(screen.getCell(0, 0).char).toBe("V");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(invId);
|
||||
});
|
||||
|
||||
test("style transitions within text", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// "A" bold, "B" italic, "C" plain
|
||||
screen.setAnsiText(0, 0, "\x1b[1mA\x1b[0m\x1b[3mB\x1b[0mC");
|
||||
const boldId = screen.style({ bold: true });
|
||||
const italicId = screen.style({ italic: true });
|
||||
expect(screen.getCell(0, 0).styleId).toBe(boldId);
|
||||
expect(screen.getCell(1, 0).styleId).toBe(italicId);
|
||||
expect(screen.getCell(2, 0).styleId).toBe(0);
|
||||
});
|
||||
|
||||
test("throws with too few arguments", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
expect(() => (screen as any).setAnsiText(0, 0)).toThrow();
|
||||
expect(() => (screen as any).setAnsiText(0)).toThrow();
|
||||
expect(() => (screen as any).setAnsiText()).toThrow();
|
||||
});
|
||||
|
||||
test("throws with non-string text", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
expect(() => (screen as any).setAnsiText(0, 0, 42)).toThrow();
|
||||
});
|
||||
|
||||
test("complex ANSI with multiple lines and styles", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// Bold red "ERR" on line 0, then normal "ok" on line 1
|
||||
screen.setAnsiText(0, 0, "\x1b[1;38;2;255;0;0mERR\x1b[0m\nok");
|
||||
const boldRedId = screen.style({ bold: true, fg: 0xff0000 });
|
||||
expect(screen.getCell(0, 0).char).toBe("E");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(boldRedId);
|
||||
expect(screen.getCell(2, 0).char).toBe("R");
|
||||
expect(screen.getCell(0, 1).char).toBe("o");
|
||||
expect(screen.getCell(0, 1).styleId).toBe(0);
|
||||
});
|
||||
|
||||
test("ignores non-SGR CSI sequences", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// CSI 2J = clear screen (non-SGR), should be ignored
|
||||
screen.setAnsiText(0, 0, "A\x1b[2JB");
|
||||
expect(screen.getCell(0, 0).char).toBe("A");
|
||||
expect(screen.getCell(1, 0).char).toBe("B");
|
||||
});
|
||||
|
||||
test("bright foreground colors", () => {
|
||||
const screen = new Bun.TUIScreen(80, 24);
|
||||
// ESC[91m = bright red foreground (palette index 9)
|
||||
screen.setAnsiText(0, 0, "\x1b[91mX\x1b[0m");
|
||||
const brightRedId = screen.style({ fg: { palette: 9 } });
|
||||
expect(screen.getCell(0, 0).char).toBe("X");
|
||||
expect(screen.getCell(0, 0).styleId).toBe(brightRedId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1509,4 +1509,68 @@ describe("Bun.TUIBufferWriter", () => {
|
||||
expect(writer.byteOffset).toBe(0);
|
||||
expect(writer.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Inline mode ─────────────────────────────────────────────────
|
||||
|
||||
describe("inline mode via TUIBufferWriter", () => {
|
||||
test("render with inline option uses LF instead of CUD", () => {
|
||||
const buf = new ArrayBuffer(65536);
|
||||
const writer = new Bun.TUIBufferWriter(buf);
|
||||
const screen = new Bun.TUIScreen(10, 3);
|
||||
screen.setText(0, 0, "Line1");
|
||||
screen.setText(0, 1, "Line2");
|
||||
screen.setText(0, 2, "Line3");
|
||||
|
||||
writer.render(screen, { inline: true, viewportHeight: 24 });
|
||||
|
||||
const output = new TextDecoder().decode(new Uint8Array(buf, 0, writer.byteOffset));
|
||||
// Should contain BSU/ESU
|
||||
expect(output).toContain("\x1b[?2026h");
|
||||
expect(output).toContain("\x1b[?2026l");
|
||||
// Should contain content
|
||||
expect(output).toContain("Line1");
|
||||
expect(output).toContain("Line2");
|
||||
expect(output).toContain("Line3");
|
||||
writer.close();
|
||||
});
|
||||
|
||||
test("inline mode first render has CR+LF between rows", () => {
|
||||
const buf = new ArrayBuffer(65536);
|
||||
const writer = new Bun.TUIBufferWriter(buf);
|
||||
const screen = new Bun.TUIScreen(10, 2);
|
||||
screen.setText(0, 0, "A");
|
||||
screen.setText(0, 1, "B");
|
||||
|
||||
writer.render(screen, { inline: true, viewportHeight: 24 });
|
||||
|
||||
const output = new TextDecoder().decode(new Uint8Array(buf, 0, writer.byteOffset));
|
||||
// Between rows, renderFull emits \r\n
|
||||
expect(output).toContain("\r\n");
|
||||
expect(output).toContain("A");
|
||||
expect(output).toContain("B");
|
||||
writer.close();
|
||||
});
|
||||
|
||||
test("inline diff uses LF for downward movement", () => {
|
||||
const buf = new ArrayBuffer(65536);
|
||||
const writer = new Bun.TUIBufferWriter(buf);
|
||||
const screen = new Bun.TUIScreen(10, 3);
|
||||
screen.setText(0, 0, "A");
|
||||
screen.setText(0, 1, "B");
|
||||
screen.setText(0, 2, "C");
|
||||
|
||||
// First render (full)
|
||||
writer.render(screen, { inline: true, viewportHeight: 24 });
|
||||
const firstLen = writer.byteOffset;
|
||||
|
||||
// Change only row 2
|
||||
screen.setText(0, 2, "X");
|
||||
writer.render(screen, { inline: true, viewportHeight: 24 });
|
||||
|
||||
const output = new TextDecoder().decode(new Uint8Array(buf, 0, writer.byteOffset));
|
||||
// Diff render should contain the changed cell
|
||||
expect(output).toContain("X");
|
||||
writer.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user