///|
/// I/O implementation for JavaScript/Node.js target
///|
/// Read a single key from stdin (raw mode)
extern "js" fn js_read_key() -> @js_async.Promise[String] =
#| async () => {
#| if (typeof process === 'undefined') return 'q';
#|
#| if (process.stdin.isTTY) {
#| process.stdin.setRawMode(true);
#| }
#| process.stdin.resume();
#| process.stdin.setEncoding('utf8');
#|
#| return new Promise((resolve) => {
#| const onData = (data) => {
#| process.stdin.removeListener('data', onData);
#| resolve(data);
#| };
#| process.stdin.on('data', onData);
#| });
#| }
///|
/// Print to stdout without newline
extern "js" fn js_print_raw(s : String) =
#| (s) => process.stdout.write(s)
///|
/// Debug print to stderr (won't interfere with TUI)
extern "js" fn js_debug_stderr(s : String) =
#| (s) => {
#| process.stderr.write('[DEBUG] ' + s + '\n');
#| }
///|
/// Debug print to stderr (public)
pub fn debug_stderr(s : String) -> Unit {
js_debug_stderr(s)
}
///|
/// Debug stdin state
extern "js" fn js_debug_stdin_state(label : String) =
#| async (label) => {
#| const { stdin } = await import('node:process');
#| console.log(`[STDIN ${label}] isTTY=${stdin.isTTY}, isRaw=${stdin.isRaw}, isPaused=${stdin.isPaused()}, readable=${stdin.readable}, listenerCount(data)=${stdin.listenerCount('data')}`);
#| }
///|
/// Debug stdin state (public)
pub fn debug_stdin_state(label : String) -> Unit {
js_debug_stdin_state(label)
}
///|
/// Cleanup stdin (turn off raw mode)
extern "js" fn js_cleanup_stdin() =
#| () => {
#| if (typeof process !== 'undefined' && process.stdin.isTTY) {
#| process.stdin.setRawMode(false);
#| process.stdin.pause();
#| }
#| }
///|
/// Enable raw mode
extern "js" fn js_enable_raw_mode() =
#| () => {
#| if (typeof process !== 'undefined' && process.stdin.isTTY) {
#| process.stdin.setRawMode(true);
#| process.stdin.resume();
#| }
#| }
///|
/// Get terminal columns
extern "js" fn js_get_terminal_columns() -> Int =
#| () => {
#| if (typeof process === 'undefined') return 80;
#| return process.stdout.columns || 80;
#| }
///|
/// Get terminal rows
extern "js" fn js_get_terminal_rows() -> Int =
#| () => {
#| if (typeof process === 'undefined') return 24;
#| return process.stdout.rows || 24;
#| }
///|
/// Sleep for milliseconds
extern "js" fn js_sleep(ms : Int) -> @js_async.Promise[Unit] =
#| (ms) => new Promise(r => setTimeout(r, ms))
///|
/// Keep the process alive without blocking (non-blocking)
/// This schedules a timeout but doesn't wait - keeps event loop active
extern "js" fn js_keep_alive_nonblocking(ms : Int) =
#| (ms) => {
#| // Simple timer keeps the process alive
#| setTimeout(() => {}, ms);
#| }
///|
/// Keep process alive without blocking the event loop
pub fn keep_alive_nonblocking(ms : Int) -> Unit {
js_keep_alive_nonblocking(ms)
}
///|
/// Start a keypress listener (event-driven, non-blocking)
/// Uses raw data events for reliability
extern "js" fn js_start_keypress_listener(callback : (String) -> Unit) =
#| async (callback) => {
#| const { stdin } = await import('node:process');
#|
#| // Clean up any existing listeners
#| stdin.removeAllListeners('keypress');
#| stdin.removeAllListeners('data');
#|
#| if (stdin.isTTY) {
#| stdin.setRawMode(true);
#| }
#| stdin.resume();
#| stdin.setEncoding('utf8');
#|
#| const onData = (data) => callback(data);
#|
#| stdin.__dataHandler = onData;
#| stdin.on('data', onData);
#| }
///|
/// Stop the keypress listener
extern "js" fn js_stop_keypress_listener() =
#| async () => {
#| const { stdin } = await import('node:process');
#| if (stdin.__dataHandler) {
#| stdin.removeListener('data', stdin.__dataHandler);
#| stdin.__dataHandler = null;
#| }
#| if (stdin.isTTY) {
#| stdin.setRawMode(false);
#| }
#| stdin.pause();
#| }
///|
/// Start listening for keypresses (event-driven)
pub fn start_keypress_listener(callback : (String) -> Unit) -> Unit {
js_start_keypress_listener(callback)
}
///|
/// Stop listening for keypresses
pub fn stop_keypress_listener() -> Unit {
js_stop_keypress_listener()
}
///|
/// Start an inplace input session with raw mode
/// Stays in alternate screen, edits at specified position
/// row/col are 1-indexed ANSI coordinates
/// Enter confirms, Esc cancels
/// Supports arrow keys, Home, End, Delete for cursor movement
extern "js" fn js_start_inplace_input(
row : Int,
col : Int,
width : Int,
height : Int,
multiline : Bool,
initial : String,
on_confirmed : (String) -> Unit,
on_cancelled : () -> Unit,
) -> Unit =
#| async (row, col, width, height, multiline, initial, onConfirmed, onCancelled) => {
#| const { stdin, stdout } = await import('node:process');
#|
#| // Helper to get display width of a character
#| const getCharWidth = (char) => {
#| const code = char.codePointAt(0);
#| if (code >= 0x1100 && (
#| code <= 0x115F ||
#| code === 0x2329 || code === 0x232A ||
#| (code >= 0x2E80 && code <= 0x303E) ||
#| (code >= 0x3040 && code <= 0x33FF) ||
#| (code >= 0x3400 && code <= 0x4DBF) ||
#| (code >= 0x4E00 && code <= 0x9FFF) ||
#| (code >= 0xAC00 && code <= 0xD7A3) ||
#| (code >= 0xF900 && code <= 0xFAFF) ||
#| (code >= 0xFE10 && code <= 0xFE1F) ||
#| (code >= 0xFE30 && code <= 0xFE6F) ||
#| (code >= 0xFF00 && code <= 0xFF60) ||
#| (code >= 0xFFE0 && code <= 0xFFE6) ||
#| (code >= 0x20000 && code <= 0x2FFFF)
#| )) {
#| return 2;
#| }
#| return 1;
#| };
#|
#| const getStringWidth = (str) => {
#| let w = 0;
#| for (const char of str) w += getCharWidth(char);
#| return w;
#| };
#|
#| // Wrap text into lines based on width, handling newlines
#| const wrapText = (chars) => {
#| const lines = [];
#| let currentLine = [];
#| let currentWidth = 0;
#| let lineStartIdx = 0;
#|
#| for (let i = 0; i < chars.length; i++) {
#| const char = chars[i];
#|
#| // Handle explicit newline character
#| if (char === '\n') {
#| lines.push({ chars: currentLine, startIdx: lineStartIdx });
#| currentLine = [];
#| currentWidth = 0;
#| lineStartIdx = i + 1; // Next line starts after the newline
#| continue;
#| }
#|
#| const charWidth = getCharWidth(char);
#|
#| if (currentWidth + charWidth > width) {
#| lines.push({ chars: currentLine, startIdx: lineStartIdx });
#| currentLine = [char];
#| currentWidth = charWidth;
#| lineStartIdx = i;
#| } else {
#| currentLine.push(char);
#| currentWidth += charWidth;
#| }
#| }
#| lines.push({ chars: currentLine, startIdx: lineStartIdx });
#| return lines;
#| };
#|
#| // Find cursor position in wrapped lines
#| const findCursorPos = (lines, cursor) => {
#| for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
#| const line = lines[lineIdx];
#| const lineEnd = line.startIdx + line.chars.length;
#| if (cursor <= lineEnd) {
#| const posInLine = cursor - line.startIdx;
#| let colOffset = 0;
#| for (let i = 0; i < posInLine; i++) {
#| colOffset += getCharWidth(line.chars[i]);
#| }
#| return { lineIdx, colOffset };
#| }
#| }
#| // Cursor at end
#| const lastLine = lines[lines.length - 1];
#| let colOffset = 0;
#| for (const char of lastLine.chars) {
#| colOffset += getCharWidth(char);
#| }
#| return { lineIdx: lines.length - 1, colOffset };
#| };
#|
#| // Clean up any existing handlers
#| if (stdin.__dataHandler) {
#| stdin.removeListener('data', stdin.__dataHandler);
#| stdin.__dataHandler = null;
#| }
#|
#| // Keep raw mode for character-by-character input
#| if (stdin.isTTY) {
#| stdin.setRawMode(true);
#| }
#| stdin.resume();
#| stdin.setEncoding('utf8');
#|
#| let chars = [...initial];
#| let cursor = chars.length;
#| let done = false;
#| let scrollOffset = 0; // First visible line index
#|
#| const redraw = () => {
#| const lines = wrapText(chars);
#| const cursorPos = findCursorPos(lines, cursor);
#|
#| // Adjust scroll to keep cursor visible
#| if (cursorPos.lineIdx < scrollOffset) {
#| scrollOffset = cursorPos.lineIdx;
#| } else if (cursorPos.lineIdx >= scrollOffset + height) {
#| scrollOffset = cursorPos.lineIdx - height + 1;
#| }
#|
#| // Clear all lines
#| for (let i = 0; i < height; i++) {
#| stdout.write(`\x1b[${row + i};${col}H`);
#| stdout.write(' '.repeat(width));
#| }
#|
#| // Determine if we need ellipsis
#| const hasMoreAbove = scrollOffset > 0;
#| const hasMoreBelow = lines.length > scrollOffset + height;
#|
#| // Helper to truncate text to fit within width
#| const truncateToWidth = (text, maxWidth) => {
#| let w = 0;
#| let result = '';
#| for (const char of text) {
#| const cw = getCharWidth(char);
#| if (w + cw > maxWidth) break;
#| result += char;
#| w += cw;
#| }
#| return result;
#| };
#|
#| // Draw visible lines
#| for (let i = 0; i < height; i++) {
#| const lineIdx = scrollOffset + i;
#| if (lineIdx >= lines.length) break;
#|
#| const line = lines[lineIdx];
#| let lineText = line.chars.join('');
#|
#| stdout.write(`\x1b[${row + i};${col}H`);
#|
#| // Show ellipsis for overflow
#| if (i === 0 && hasMoreAbove) {
#| // First visible line with content above
#| const displayWidth = getStringWidth(lineText);
#| if (displayWidth > width - 1) {
#| // Truncate and add ellipsis at start
#| let w = 0;
#| let startIdx = 0;
#| for (let j = line.chars.length - 1; j >= 0; j--) {
#| const cw = getCharWidth(line.chars[j]);
#| if (w + cw > width - 1) break;
#| w += cw;
#| startIdx = j;
#| }
#| lineText = '…' + line.chars.slice(startIdx).join('');
#| }
#| } else if (i === height - 1 && hasMoreBelow) {
#| // Last visible line with content below - show ellipsis at end
#| const displayWidth = getStringWidth(lineText);
#| if (displayWidth > width - 1) {
#| let w = 0;
#| let endIdx = line.chars.length;
#| for (let j = 0; j < line.chars.length; j++) {
#| const cw = getCharWidth(line.chars[j]);
#| if (w + cw > width - 1) {
#| endIdx = j;
#| break;
#| }
#| w += cw;
#| }
#| lineText = line.chars.slice(0, endIdx).join('') + '…';
#| } else {
#| lineText = lineText + '…';
#| }
#| }
#|
#| // Ensure text doesn't exceed width (safety measure)
#| lineText = truncateToWidth(lineText, width);
#| stdout.write(lineText);
#| }
#|
#| // Position cursor (clamped to valid range)
#| const cursorLineOffset = cursorPos.lineIdx - scrollOffset;
#| const displayRow = row + Math.max(0, Math.min(cursorLineOffset, height - 1));
#| const displayCol = col + Math.max(0, Math.min(cursorPos.colOffset, width - 1));
#| stdout.write(`\x1b[${displayRow};${displayCol}H`);
#| stdout.write('\x1b[?25h');
#| };
#|
#| // Initial display
#| redraw();
#|
#| const cleanup = () => {
#| if (stdin.__inplaceHandler) {
#| stdin.removeListener('data', stdin.__inplaceHandler);
#| stdin.__inplaceHandler = null;
#| }
#| stdout.write('\x1b[?25l');
#| };
#|
#| const finish = (result) => {
#| if (done) return;
#| done = true;
#| cleanup();
#| setImmediate(() => onConfirmed(result));
#| };
#|
#| const cancel = () => {
#| if (done) return;
#| done = true;
#| cleanup();
#| setImmediate(() => onCancelled());
#| };
#|
#| const onData = (chunk) => {
#| if (done) return;
#|
#| // Handle escape sequences
#| if (chunk === '\x1b') {
#| cancel();
#| return;
#| }
#| if (chunk === '\x1b[A') {
#| // Up arrow - move to previous line
#| const lines = wrapText(chars);
#| const pos = findCursorPos(lines, cursor);
#| if (pos.lineIdx > 0) {
#| const prevLine = lines[pos.lineIdx - 1];
#| // Find position in previous line at same column offset
#| let targetCol = pos.colOffset;
#| let newCursor = prevLine.startIdx;
#| let w = 0;
#| for (let i = 0; i < prevLine.chars.length; i++) {
#| const cw = getCharWidth(prevLine.chars[i]);
#| if (w + cw > targetCol) break;
#| w += cw;
#| newCursor = prevLine.startIdx + i + 1;
#| }
#| cursor = Math.min(newCursor, prevLine.startIdx + prevLine.chars.length);
#| redraw();
#| }
#| return;
#| }
#| if (chunk === '\x1b[B') {
#| // Down arrow - move to next line
#| const lines = wrapText(chars);
#| const pos = findCursorPos(lines, cursor);
#| if (pos.lineIdx < lines.length - 1) {
#| const nextLine = lines[pos.lineIdx + 1];
#| let targetCol = pos.colOffset;
#| let newCursor = nextLine.startIdx;
#| let w = 0;
#| for (let i = 0; i < nextLine.chars.length; i++) {
#| const cw = getCharWidth(nextLine.chars[i]);
#| if (w + cw > targetCol) break;
#| w += cw;
#| newCursor = nextLine.startIdx + i + 1;
#| }
#| cursor = Math.min(newCursor, nextLine.startIdx + nextLine.chars.length);
#| redraw();
#| }
#| return;
#| }
#| if (chunk === '\x1b[D') {
#| if (cursor > 0) {
#| cursor--;
#| redraw();
#| }
#| return;
#| }
#| if (chunk === '\x1b[C') {
#| if (cursor < chars.length) {
#| cursor++;
#| redraw();
#| }
#| return;
#| }
#| if (chunk === '\x1b[H' || chunk === '\x1b[1~') {
#| cursor = 0;
#| redraw();
#| return;
#| }
#| if (chunk === '\x1b[F' || chunk === '\x1b[4~') {
#| cursor = chars.length;
#| redraw();
#| return;
#| }
#| if (chunk === '\x1b[3~') {
#| if (cursor < chars.length) {
#| chars.splice(cursor, 1);
#| redraw();
#| }
#| return;
#| }
#| // Check for Shift+Enter or Alt+Enter
#| if (chunk === '\x1b[13;2u' || chunk === '\x1b[27;2;13~' || chunk === '\x1bOM' || chunk === '\x1b\r' || chunk === '\x1b\n') {
#| // Shift+Enter or Alt+Enter - insert newline
#| chars.splice(cursor, 0, '\n');
#| cursor++;
#| redraw();
#| return;
#| }
#| if (chunk.startsWith('\x1b[')) {
#| return;
#| }
#|
#| for (const char of chunk) {
#| const code = char.charCodeAt(0);
#|
#| if (code === 3) {
#| // Ctrl+C - cancel
#| cancel();
#| return;
#| } else if (code === 4 || code === 10) {
#| // Ctrl+D or Ctrl+J - insert newline in multiline mode
#| if (multiline) {
#| chars.splice(cursor, 0, '\n');
#| cursor++;
#| redraw();
#| }
#| } else if (code === 1) {
#| // Ctrl+A - Home
#| cursor = 0;
#| redraw();
#| } else if (code === 5) {
#| // Ctrl+E - End
#| cursor = chars.length;
#| redraw();
#| } else if (code === 11) {
#| // Ctrl+K - delete from cursor to end
#| chars.splice(cursor, chars.length - cursor);
#| redraw();
#| } else if (code === 21) {
#| // Ctrl+U - delete from start to cursor
#| chars.splice(0, cursor);
#| cursor = 0;
#| redraw();
#| } else if (code === 13) {
#| // Enter always confirms
#| finish(chars.join(''));
#| return;
#| } else if (code === 127 || code === 8) {
#| if (cursor > 0) {
#| chars.splice(cursor - 1, 1);
#| cursor--;
#| redraw();
#| }
#| } else if (code >= 32) {
#| chars.splice(cursor, 0, char);
#| cursor++;
#| redraw();
#| }
#| }
#| };
#|
#| stdin.__inplaceHandler = onData;
#| stdin.on('data', onData);
#| }
///|
/// Start an inline input session with cooked mode (IME enabled)
/// Exits alternate screen temporarily for proper IME support
/// Enter confirms, Ctrl+C cancels
extern "js" fn js_start_inline_input_cooked(
field_name : String,
initial : String,
on_confirmed : (String) -> Unit,
on_cancelled : () -> Unit,
) -> Unit =
#| async (fieldName, initial, onConfirmed, onCancelled) => {
#| const { stdin, stdout } = await import('node:process');
#|
#| // Exit alternate screen
#| stdout.write('\x1b[?1049l');
#| // Show cursor
#| stdout.write('\x1b[?25h');
#| // Clear screen and move to top
#| stdout.write('\x1b[2J\x1b[H');
#|
#| // Show prompt
#| console.log(`\n Editing: ${fieldName}`);
#| console.log(` Current: "${initial}"`);
#| console.log(` (Enter to confirm, Ctrl+C to cancel)\n`);
#| stdout.write(' > ');
#|
#| // Clean up any existing handlers
#| if (stdin.__dataHandler) {
#| stdin.removeListener('data', stdin.__dataHandler);
#| stdin.__dataHandler = null;
#| }
#|
#| // Set cooked mode for IME support
#| if (stdin.isTTY) {
#| stdin.setRawMode(false);
#| }
#| stdin.resume();
#| stdin.setEncoding('utf8');
#|
#| let buffer = '';
#| let done = false;
#|
#| const cleanup = () => {
#| if (stdin.__cookedHandler) {
#| stdin.removeListener('data', stdin.__cookedHandler);
#| stdin.__cookedHandler = null;
#| }
#| };
#|
#| const finish = (result) => {
#| if (done) return;
#| done = true;
#| cleanup();
#| // Re-enter alternate screen immediately before callback
#| stdout.write('\x1b[?1049h');
#| setImmediate(() => onConfirmed(result));
#| };
#|
#| const cancel = () => {
#| if (done) return;
#| done = true;
#| cleanup();
#| // Re-enter alternate screen immediately before callback
#| stdout.write('\x1b[?1049h');
#| setImmediate(() => onCancelled());
#| };
#|
#| // Handle input directly without readline
#| const onData = (chunk) => {
#| if (done) return;
#|
#| for (const char of chunk) {
#| const code = char.charCodeAt(0);
#|
#| if (code === 3) {
#| // Ctrl+C
#| cancel();
#| return;
#| } else if (code === 13 || code === 10) {
#| // Enter
#| stdout.write('\n');
#| const result = buffer.length > 0 ? buffer : initial;
#| finish(result);
#| return;
#| } else if (code === 127 || code === 8) {
#| // Backspace
#| if (buffer.length > 0) {
#| buffer = buffer.slice(0, -1);
#| stdout.write('\b \b');
#| }
#| } else if (code >= 32) {
#| // Printable
#| buffer += char;
#| stdout.write(char);
#| }
#| }
#| };
#|
#| stdin.__cookedHandler = onData;
#| stdin.on('data', onData);
#| }
///|
/// Start an inline input session (stays in current screen)
/// Uses raw mode for reliable input handling
/// Enter confirms, Escape cancels, Backspace deletes
/// Supports text wrapping within max_width
extern "js" fn js_start_inline_input(
label : String,
initial : String,
start_row : Int,
start_col : Int,
max_width : Int,
max_height : Int,
on_confirmed : (String) -> Unit,
on_cancelled : () -> Unit,
on_tab_next : (String) -> Unit,
on_tab_prev : (String) -> Unit,
) -> Unit =
#| async (label, initial, startRow, startCol, maxWidth, maxHeight, onConfirmed, onCancelled, onTabNext, onTabPrev) => {
#| const { stdin, stdout } = await import('node:process');
#|
#| // Helper to get display width of a character (1 for half-width, 2 for full-width)
#| const getCharWidth = (char) => {
#| const code = char.codePointAt(0);
#| if (code >= 0x1100 && (
#| code <= 0x115F ||
#| code === 0x2329 || code === 0x232A ||
#| (code >= 0x2E80 && code <= 0x303E) ||
#| (code >= 0x3040 && code <= 0x33FF) ||
#| (code >= 0x3400 && code <= 0x4DBF) ||
#| (code >= 0x4E00 && code <= 0x9FFF) ||
#| (code >= 0xAC00 && code <= 0xD7A3) ||
#| (code >= 0xF900 && code <= 0xFAFF) ||
#| (code >= 0xFE10 && code <= 0xFE1F) ||
#| (code >= 0xFE30 && code <= 0xFE6F) ||
#| (code >= 0xFF00 && code <= 0xFF60) ||
#| (code >= 0xFFE0 && code <= 0xFFE6) ||
#| (code >= 0x20000 && code <= 0x2FFFF)
#| )) {
#| return 2;
#| }
#| return 1;
#| };
#|
#| // Get total display width of a string
#| const getStringWidth = (str) => {
#| let width = 0;
#| for (const char of str) {
#| width += getCharWidth(char);
#| }
#| return width;
#| };
#|
#| // Render buffer with wrapping, returns cursor position
#| const renderBuffer = (buf) => {
#| let row = startRow;
#| let col = startCol;
#| let output = '';
#|
#| // Move to start position and clear area
#| output += `\x1b[${startRow};${startCol}H`;
#| for (let r = 0; r < maxHeight; r++) {
#| output += `\x1b[${startRow + r};${startCol}H`;
#| output += ' '.repeat(maxWidth);
#| }
#| output += `\x1b[${startRow};${startCol}H`;
#|
#| // Render text with wrapping
#| let lineWidth = 0;
#| for (const char of buf) {
#| const charWidth = getCharWidth(char);
#| if (lineWidth + charWidth > maxWidth) {
#| // Wrap to next line
#| row++;
#| col = startCol;
#| lineWidth = 0;
#| output += `\x1b[${row};${col}H`;
#| }
#| output += char;
#| lineWidth += charWidth;
#| col += charWidth;
#| }
#|
#| stdout.write(output);
#| return { row, col };
#| };
#|
#| // Clear all listeners for clean state
#| stdin.removeAllListeners('keypress');
#| stdin.removeAllListeners('data');
#| stdin.__dataHandler = null;
#|
#| // Use raw mode for character-by-character input
#| if (stdin.isTTY) {
#| stdin.setRawMode(true);
#| }
#| stdin.resume();
#| stdin.setEncoding('utf8');
#|
#| // Display initial value
#| let buffer = initial || '';
#| renderBuffer(buffer);
#|
#| let done = false;
#|
#| const cleanup = () => {
#| if (done) return;
#| done = true;
#| stdin.removeListener('data', onData);
#| };
#|
#| const onData = (chunk) => {
#| if (done) return;
#|
#| // Check for escape sequences first (they come as multi-char chunks)
#| if (chunk === '\x1b[Z') {
#| // Shift+Tab - confirm and go to previous
#| cleanup();
#| onTabPrev(buffer);
#| return;
#| }
#| if (chunk === '\x1b' || chunk.startsWith('\x1b[')) {
#| // Escape or other escape sequence - cancel
#| cleanup();
#| onCancelled();
#| return;
#| }
#|
#| let needsRender = false;
#| for (const char of chunk) {
#| const code = char.charCodeAt(0);
#|
#| if (code === 9) {
#| // Tab - confirm and go to next
#| cleanup();
#| onTabNext(buffer);
#| return;
#| } else if (code === 13) {
#| // Enter - confirm
#| cleanup();
#| onConfirmed(buffer);
#| return;
#| } else if (code === 3) {
#| // Ctrl+C - cancel
#| cleanup();
#| onCancelled();
#| return;
#| } else if (code === 127 || code === 8) {
#| // Backspace - delete last character
#| if (buffer.length > 0) {
#| const chars = [...buffer];
#| chars.pop();
#| buffer = chars.join('');
#| needsRender = true;
#| }
#| } else if (code >= 32) {
#| // Printable character - check if within bounds
#| const newWidth = getStringWidth(buffer + char);
#| const maxTotalWidth = maxWidth * maxHeight;
#| if (newWidth <= maxTotalWidth) {
#| buffer += char;
#| needsRender = true;
#| }
#| }
#| }
#|
#| if (needsRender) {
#| renderBuffer(buffer);
#| }
#| };
#|
#| stdin.on('data', onData);
#| }
///|
/// Start an inline input session with cooked mode (for IME support)
/// Temporarily exits alternate screen for proper IME input
/// field_name: Display name for the field being edited
/// initial: Current value to pre-fill
pub fn start_inline_input_cooked(
field_name : String,
initial : String,
on_result : (InputResult) -> Unit,
) -> Unit {
js_start_inline_input_cooked(
field_name,
initial,
fn(value) { on_result(InputResult::Confirmed(value)) },
fn() { on_result(InputResult::Cancelled) },
)
}
///|
/// Start an inplace input session with cooked mode (IME enabled)
/// Stays in alternate screen, edits at specified position
/// row/col are 1-indexed ANSI coordinates
/// width is the max display width for the input
pub fn start_inplace_input(
row : Int,
col : Int,
width : Int,
height : Int,
multiline : Bool,
initial : String,
on_result : (InputResult) -> Unit,
on_lines_change~ : ((Int) -> Int)? = None,
) -> Unit {
// Note: on_lines_change is currently ignored in JS version (uses scroll)
let _ = on_lines_change
js_start_inplace_input(
row,
col,
width,
height,
multiline,
initial,
fn(value) { on_result(InputResult::Confirmed(value)) },
fn() { on_result(InputResult::Cancelled) },
)
}
///|
/// Start an inline input session (for editing in place)
/// row/col are 1-indexed ANSI coordinates
/// max_width/max_height define the input area for text wrapping
pub fn start_inline_input(
label : String,
initial : String,
row : Int,
col : Int,
max_width : Int,
max_height : Int,
on_result : (InputResult) -> Unit,
) -> Unit {
js_start_inline_input(
label,
initial,
row,
col,
max_width,
max_height,
fn(value) { on_result(InputResult::Confirmed(value)) },
fn() { on_result(InputResult::Cancelled) },
fn(value) { on_result(InputResult::TabNext(value)) },
fn(value) { on_result(InputResult::TabPrev(value)) },
)
}
///|
/// Read a key asynchronously
pub async fn read_key() -> String {
@js_async.Promise::wait(js_read_key())
}
///|
/// Print string to stdout (no newline)
pub fn print_raw(s : String) -> Unit {
js_print_raw(s)
}
///|
/// Cleanup stdin state
pub fn cleanup_stdin() -> Unit {
js_cleanup_stdin()
raw_mode_flag.val = false
}
///|
/// Enable raw mode
pub fn enable_raw_mode() -> Unit {
js_enable_raw_mode()
raw_mode_flag.val = true
}
///|
/// Get terminal size (columns, rows)
pub fn get_terminal_size() -> (Int, Int) {
(js_get_terminal_columns(), js_get_terminal_rows())
}
///|
/// Sleep for milliseconds
pub async fn sleep(ms : Int) -> Unit {
@js_async.Promise::wait(js_sleep(ms))
}
// --- Input Session for IME support ---
///|
/// Start an input session with callback (non-blocking)
extern "js" fn js_start_input_session_callback(
initial : String,
on_confirmed : (String) -> Unit,
on_cancelled : () -> Unit,
) -> Unit =
#| async (initial, onConfirmed, onCancelled) => {
#| const rl = await import('node:readline/promises');
#| const { stdin, stdout } = await import('node:process');
#|
#| // Show cursor
#| stdout.write('\x1b[?25h');
#|
#| // Reset stdin state for readline
#| stdin.removeAllListeners('keypress');
#| if (stdin.isTTY) {
#| stdin.setRawMode(false);
#| }
#|
#| const readline = rl.createInterface({
#| input: stdin,
#| output: stdout,
#| });
#|
#| try {
#| // Pre-fill and get answer
#| const answer = await readline.question('> ' + initial);
#| readline.close();
#| onConfirmed(answer);
#| } catch (err) {
#| readline.close();
#| onCancelled();
#| }
#| }
///|
/// Legacy Promise-based version (kept for reference, may not work properly)
extern "js" fn js_start_input_session(
x : Int,
y : Int,
initial : String,
) -> @js_async.Promise[String] =
#| async (x, y, initial) => {
#| return new Promise((resolve) => {
#| // This version may block the event loop - use callback version instead
#| resolve('cancelled:');
#| });
#| }
///|
/// Abort the current input session
extern "js" fn js_abort_input_session() =
#| () => {
#| if (globalThis.__inputSession && globalThis.__inputSession.active) {
#| globalThis.__inputSession.active = false;
#| if (globalThis.__inputSession.rl) {
#| globalThis.__inputSession.rl.close();
#| }
#| if (globalThis.__inputSession.resolve) {
#| globalThis.__inputSession.resolve('cancelled:');
#| }
#| }
#| }
///|
/// Start a render ticker (setInterval)
extern "js" fn js_start_render_ticker(ms : Int, callback : () -> Unit) -> Int =
#| (ms, cb) => setInterval(cb, ms)
///|
/// Stop a render ticker
extern "js" fn js_stop_render_ticker(ticker_id : Int) =
#| (id) => clearInterval(id)
///|
/// Start an input session (callback-based, non-blocking)
/// This is the recommended way to read input - it doesn't block the event loop
pub fn start_input_session_cb(
initial : String,
on_result : (InputResult) -> Unit,
) -> Unit {
js_start_input_session_callback(
initial,
fn(value) { on_result(InputResult::Confirmed(value)) },
fn() { on_result(InputResult::Cancelled) },
)
}
///|
/// Start an input session that returns a Promise (for async/await)
/// This wraps the callback-based API in a Promise created on the JS side
extern "js" fn js_start_input_session_promise(
initial : String,
) -> @js_async.Promise[InputResult] =
#| async (initial) => {
#| const rl = await import('node:readline/promises');
#| const { stdin, stdout } = await import('node:process');
#|
#| // Show cursor
#| stdout.write('\x1b[?25h');
#|
#| // Reset stdin state for readline
#| stdin.removeAllListeners('keypress');
#| if (stdin.isTTY) {
#| stdin.setRawMode(false);
#| }
#|
#| const readline = rl.createInterface({
#| input: stdin,
#| output: stdout,
#| });
#|
#| try {
#| const answer = await readline.question('> ' + initial);
#| readline.close();
#| return { $tag: "Confirmed", _0: answer };
#| } catch (err) {
#| readline.close();
#| return { $tag: "Cancelled" };
#| }
#| }
///|
/// Start an input session that returns a Promise (for async/await)
/// Use with @js_async.Promise::wait() in async functions
pub fn start_input_session_promise(
initial : String,
) -> @js_async.Promise[InputResult] {
js_start_input_session_promise(initial)
}
///|
/// Start an input session (async version - may not work with raw mode)
/// WARNING: This uses Promise::wait which can block the Node.js event loop
/// Use start_input_session_cb instead for reliable operation
pub async fn start_input_session(
x : Int,
y : Int,
initial : String,
) -> InputResult {
// This version doesn't work properly after raw mode - returns cancelled immediately
let result = @js_async.Promise::wait(js_start_input_session(x, y, initial))
if result.has_prefix("confirmed:") {
InputResult::Confirmed(result[10:].to_string())
} else {
InputResult::Cancelled
}
}
///|
/// Abort the current input session
pub fn abort_input_session() -> Unit {
js_abort_input_session()
}
///|
/// Start a render ticker that calls the callback every `ms` milliseconds
pub fn start_render_ticker(ms : Int, callback : () -> Unit) -> Int {
js_start_render_ticker(ms, callback)
}
///|
/// Stop a render ticker
pub fn stop_render_ticker(ticker_id : Int) -> Unit {
js_stop_render_ticker(ticker_id)
}
// --- Functions for native compatibility ---
///|
/// Check if raw mode is enabled (always true after enable_raw_mode in JS)
let raw_mode_flag : Ref[Bool] = { val: false }
///|
pub fn is_raw_mode() -> Bool {
raw_mode_flag.val
}
///|
/// Check if stdin is a TTY (always true in JS terminal context)
pub fn is_tty() -> Bool {
true
}
///|
/// Poll for keypress (not applicable in JS - use start_keypress_listener instead)
/// Returns false as JS uses event-driven model
pub fn poll_keypress() -> Bool {
// JS uses event-driven model, not polling
false
}