diff --git a/cmake/targets/BuildCares.cmake b/cmake/targets/BuildCares.cmake index c2f1401417..e6d4e21006 100644 --- a/cmake/targets/BuildCares.cmake +++ b/cmake/targets/BuildCares.cmake @@ -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 diff --git a/cmake/targets/BuildLibArchive.cmake b/cmake/targets/BuildLibArchive.cmake index 3a7058683a..9d4624a410 100644 --- a/cmake/targets/BuildLibArchive.cmake +++ b/cmake/targets/BuildLibArchive.cmake @@ -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 diff --git a/patches/zlib/fix-macos-target-os-mac.patch b/patches/zlib/fix-macos-target-os-mac.patch new file mode 100644 index 0000000000..929232e811 --- /dev/null +++ b/patches/zlib/fix-macos-target-os-mac.patch @@ -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 diff --git a/src/bun.js/api/tui/TUIScreen.classes.ts b/src/bun.js/api/tui/TUIScreen.classes.ts index 8d27cfeafa..b3983bf54a 100644 --- a/src/bun.js/api/tui/TUIScreen.classes.ts +++ b/src/bun.js/api/tui/TUIScreen.classes.ts @@ -14,6 +14,10 @@ export default [ fn: "setText", length: 4, }, + setAnsiText: { + fn: "setAnsiText", + length: 3, + }, style: { fn: "style", length: 1, diff --git a/src/bun.js/api/tui/buffer_writer.zig b/src/bun.js/api/tui/buffer_writer.zig index e13068aff3..a118451d9c 100644 --- a/src/bun.js/api/tui/buffer_writer.zig +++ b/src/bun.js/api/tui/buffer_writer.zig @@ -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); diff --git a/src/bun.js/api/tui/key_reader.zig b/src/bun.js/api/tui/key_reader.zig index e4e9ba9552..78bf27bc8a 100644 --- a/src/bun.js/api/tui/key_reader.zig +++ b/src/bun.js/api/tui/key_reader.zig @@ -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[ 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}); } diff --git a/src/bun.js/api/tui/renderer.zig b/src/bun.js/api/tui/renderer.zig index 1811d2a65d..88cf1877ab 100644 --- a/src/bun.js/api/tui/renderer.zig +++ b/src/bun.js/api/tui/renderer.zig @@ -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'); } diff --git a/src/bun.js/api/tui/screen.zig b/src/bun.js/api/tui/screen.zig index a817e07381..ed4e7e05eb 100644 --- a/src/bun.js/api/tui/screen.zig +++ b/src/bun.js/api/tui/screen.zig @@ -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; diff --git a/src/bun.js/api/tui/terminal_writer.zig b/src/bun.js/api/tui/terminal_writer.zig index 9b80ec2e8b..3bee868d7a 100644 --- a/src/bun.js/api/tui/terminal_writer.zig +++ b/src/bun.js/api/tui/terminal_writer.zig @@ -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; diff --git a/test/js/bun/tui/key-reader.test.ts b/test/js/bun/tui/key-reader.test.ts index 8a71ad734e..b763acca67 100644 --- a/test/js/bun/tui/key-reader.test.ts +++ b/test/js/bun/tui/key-reader.test.ts @@ -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 1u"); + expect(stdout).toContain("\x1b[ { + 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); + }); }); diff --git a/test/js/bun/tui/screen.test.ts b/test/js/bun/tui/screen.test.ts index 441e7698ea..b0121a52f9 100644 --- a/test/js/bun/tui/screen.test.ts +++ b/test/js/bun/tui/screen.test.ts @@ -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); + }); + }); }); diff --git a/test/js/bun/tui/writer.test.ts b/test/js/bun/tui/writer.test.ts index 156b6d521a..75781af3ae 100644 --- a/test/js/bun/tui/writer.test.ts +++ b/test/js/bun/tui/writer.test.ts @@ -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(); + }); + }); });