diff --git a/examples/demo/nano.js b/examples/demo/nano.js new file mode 100755 index 0000000..7c88a11 --- /dev/null +++ b/examples/demo/nano.js @@ -0,0 +1,489 @@ +#!/usr/bin/env ant + +import fs from 'node:fs'; +import path from 'node:path'; + +const { stdin, stdout } = process; +let file = process.argv[2]; +let highlight = file ? /\.(c|m)?(j|t)s$/.test(path.extname(file)) : false; + +let lines = ['']; +let modified = false; + +let row = 0; +let col = 0; + +let scrollY = 0; +let scrollX = 0; + +let message = ''; +let msgTimer = null; + +let mode = 'edit'; +let prompt = ''; +let promptCb = null; +let promptBuf = ''; +let promptKeys = null; +let searchTerm = ''; + +if (file) { + try { + const content = fs.readFileSync(file, 'utf8'); + lines = content.split('\n'); + if (lines[lines.length - 1] === '') lines.pop(); + if (!lines.length) lines = ['']; + setMessage(`[ Read ${lines.length} line${lines.length !== 1 ? 's' : ''} ]`); + } catch { + setMessage('[ New File ]'); + } +} else { + setMessage('[ New File ]'); +} + +function setMessage(msg) { + if (msgTimer) clearTimeout(msgTimer); + message = msg; + msgTimer = setTimeout(() => { + message = ''; + draw(); + }, 1500); +} + +function rows() { + return stdout.rows - 4; +} + +function cols() { + return stdout.columns; +} + +function clamp() { + if (row < 0) row = 0; + if (row >= lines.length) row = lines.length - 1; + if (col < 0) col = 0; + if (col > lines[row].length) col = lines[row].length; + if (row < scrollY) scrollY = row; + if (row >= scrollY + rows()) scrollY = row - rows() + 1; + if (col < scrollX) scrollX = col; + if (col >= scrollX + cols()) scrollX = col - cols() + 1; +} + +function shortcutRow(shortcuts, w, totalCols) { + const cols = totalCols || shortcuts.length; + const cellW = Math.floor(w / cols); + let out = ''; + + for (let i = 0; i < shortcuts.length; i++) { + const [key, label] = shortcuts[i]; + if (!key && !label) { + out += ' '.repeat(cellW); + continue; + } + const cell = `\x1b[7m${key}\x1b[0m ${label}`; + const padLen = cellW - key.length - label.length - 1; + out += cell + (padLen > 0 ? ' '.repeat(padLen) : ''); + } + + return out; +} + +function draw() { + const h = rows(); + const w = cols(); + let out = '\x1b[H'; + + const titleL = ` ANT nano ${Ant.version.match(/^\d+\.\d+\.\d+/)[0]}`; + const titleC = file || 'New Buffer'; + const titleR = modified ? 'Modified' : ''; + + const cpad = Math.max(0, Math.floor((w - titleC.length) / 2) - titleL.length); + const rpad = Math.max(0, w - titleL.length - cpad - titleC.length - titleR.length); + out += `\x1b[7m${titleL}${' '.repeat(cpad)}${titleC}${' '.repeat(rpad)}${titleR}\x1b[0m`; + + const visible = lines.slice(scrollY, scrollY + h); + const raw = visible.map(l => l.slice(scrollX, scrollX + w)); + let body; + + if (highlight) { + const joined = raw.join('\n'); + body = Ant.highlight(joined).split('\n'); + } else { + body = raw; + } + + if (searchTerm) { + const visRow = row - scrollY; + const matchCol = col - scrollX; + const matchEnd = matchCol + searchTerm.length; + if (visRow >= 0 && visRow < body.length && body[visRow] && matchCol >= 0) { + const rendered = body[visRow]; + let result = ''; + let visIdx = 0; + let inHL = false; + for (let bi = 0; bi < rendered.length; ) { + if (rendered[bi] === '\x1b') { + const end = rendered.indexOf('m', bi); + if (end !== -1) { + result += rendered.slice(bi, end + 1); + bi = end + 1; + continue; + } + } + if (visIdx === matchCol && !inHL) { + result += '\x1b[43;30m'; + inHL = true; + } + if (visIdx === matchEnd && inHL) { + result += '\x1b[0m'; + inHL = false; + } + result += rendered[bi++]; + visIdx++; + } + if (inHL) result += '\x1b[0m'; + body[visRow] = result; + } + } + + for (let i = 0; i < h; i++) { + out += `\x1b[${i + 2};1H\x1b[2K`; + out += body[i] || ''; + } + + const statusRow = h + 2; + out += `\x1b[${statusRow};1H\x1b[2K`; + + if (mode === 'yesno') { + const q = 'Save modified buffer?'; + out += `\x1b[7m${q}${' '.repeat(Math.max(0, w - q.length))}\x1b[0m`; + } else if (mode === 'prompt') { + const promptText = prompt + promptBuf; + out += `\x1b[7m${promptText}${' '.repeat(Math.max(0, w - promptText.length))}\x1b[0m`; + } else if (message) { + const pad = Math.max(0, Math.floor((w - message.length) / 2)); + out += ' '.repeat(pad) + `\x1b[7m${message}\x1b[0m`; + } + + let row1, row2; + + if ((mode === 'prompt' || mode === 'yesno') && promptKeys) { + row1 = promptKeys[0]; + row2 = promptKeys[1]; + } else { + row1 = [ + ['^G', 'Help'], + ['^X', 'Exit'], + ['^O', 'Write'], + ['^W', 'Search'], + ['^K', 'Cut'], + ['^U', 'Paste'] + ]; + row2 = [ + ['^A', 'Home'], + ['^E', 'End'], + ['^\\', 'Replace'], + ['^T', 'Execute'], + ['^J', 'Justify'], + ['^C', 'Location'] + ]; + } + + out += `\x1b[${h + 3};1H\x1b[2K`; + out += shortcutRow(row1, w); + out += `\x1b[${h + 4};1H\x1b[2K`; + out += shortcutRow(row2, w); + + if (mode === 'yesno') { + out += `\x1b[${statusRow};${'Save modified buffer?'.length + 2}H`; + } else if (mode === 'prompt') { + out += `\x1b[${statusRow};${prompt.length + promptBuf.length + 1}H`; + } else { + const cy = row - scrollY + 2; + const cx = col - scrollX + 1; + out += `\x1b[${cy};${cx}H`; + } + + stdout.write(out); +} + +let cutBuf = []; + +const writePromptKeys = [ + [ + ['^G', 'Help'], + ['M-D', 'DOS Format'], + ['M-A', 'Append'], + ['M-B', 'Backup File'], + ['^T', 'Browse'] + ], + [ + ['^C', 'Cancel'], + ['M-M', 'Mac Format'], + ['M-P', 'Prepend'], + ['^Q', 'Discard buffer'], + ['', ''] + ] +]; + +function handleYesNo(buf) { + const ch = buf[0]; + if (ch === 0x79 || ch === 0x59) { + mode = 'edit'; + promptKeys = null; + if (file) { + save(file); + quit(); + return; + } + startPrompt( + 'Write to File: ', + name => { + if (!name) { + setMessage('[ No file name given ]'); + return; + } + save(name); + quit(); + }, + writePromptKeys + ); + } else if (ch === 0x6e || ch === 0x4e) { + mode = 'edit'; + promptKeys = null; + quit(); + } else if (ch === 0x03) { + mode = 'edit'; + promptKeys = null; + setMessage('[ Cancelled ]'); + } +} + +function handleKey(buf) { + if (mode === 'yesno') { + handleYesNo(buf); + return; + } + + if (mode === 'prompt') { + handlePrompt(buf); + return; + } + + message = ''; + const ch = buf[0]; + + if (ch === 0x1b && buf[1] === 0x5b) { + const code = buf[2]; + if (code === 0x41) row--; + else if (code === 0x42) row++; + else if (code === 0x43) col++; + else if (code === 0x44) col--; + else if (code === 0x48) col = 0; + else if (code === 0x46) col = lines[row].length; + else if (code === 0x35 && buf[3] === 0x7e) { + row -= rows(); + } else if (code === 0x36 && buf[3] === 0x7e) { + row += rows(); + } + clamp(); + return; + } + + if (ch === 0x18) { + if (modified) { + mode = 'yesno'; + promptKeys = [ + [ + ['Y', 'Yes'], + ['', ''], + ['', ''], + ['', ''], + ['', ''], + ['', ''] + ], + [ + ['N', 'No'], + ['', ''], + ['^C', 'Cancel'], + ['', ''], + ['', ''], + ['', ''] + ] + ]; + } else quit(); + return; + } + + if (ch === 0x0f) { + startPrompt( + 'Write to File: ', + name => { + const target = name || file; + if (!target) { + setMessage('[ No file name given ]'); + return; + } + save(target); + setMessage(`[ Wrote ${lines.length} line${lines.length !== 1 ? 's' : ''} ]`); + }, + writePromptKeys + ); + return; + } + + if (ch === 0x17) { + startPrompt( + 'Search: ', + term => { + if (term) searchTerm = term; + if (!searchTerm) return; + for (let i = row; i < lines.length; i++) { + const idx = lines[i].indexOf(searchTerm, i === row ? col + 1 : 0); + if (idx !== -1) { + row = i; + col = idx; + clamp(); + return; + } + } + for (let i = 0; i <= row; i++) { + const idx = lines[i].indexOf(searchTerm); + if (idx !== -1) { + row = i; + col = idx; + clamp(); + return; + } + } + setMessage(`[ "${searchTerm}" not found ]`); + }, + null, + searchTerm + ); + return; + } + + if (ch === 0x0b) { + cutBuf = [lines[row]]; + lines.splice(row, 1); + if (!lines.length) lines = ['']; + modified = true; + clamp(); + return; + } + + if (ch === 0x15) { + if (cutBuf.length) { + lines.splice(row, 0, ...cutBuf); + modified = true; + clamp(); + } + return; + } + + if (ch === 0x01) { + col = 0; + return; + } + if (ch === 0x05) { + col = lines[row].length; + return; + } + + if (ch === 0x7f) { + if (col > 0) { + lines[row] = lines[row].slice(0, col - 1) + lines[row].slice(col); + col--; + } else if (row > 0) { + col = lines[row - 1].length; + lines[row - 1] += lines[row]; + lines.splice(row, 1); + row--; + } + modified = true; + clamp(); + return; + } + + if (ch === 0x0d) { + const rest = lines[row].slice(col); + lines[row] = lines[row].slice(0, col); + lines.splice(row + 1, 0, rest); + row++; + col = 0; + modified = true; + clamp(); + return; + } + + if (ch === 0x09 || ch >= 0x20) { + const c = ch === 0x09 ? '\t' : String.fromCharCode(ch); + lines[row] = lines[row].slice(0, col) + c + lines[row].slice(col); + col++; + modified = true; + clamp(); + return; + } +} + +function startPrompt(msg, cb, keys, prefill) { + mode = 'prompt'; + prompt = msg; + promptBuf = prefill || ''; + promptCb = cb; + promptKeys = keys || null; +} + +function handlePrompt(buf) { + const ch = buf[0]; + if (ch === 0x0d) { + mode = 'edit'; + promptKeys = null; + const cb = promptCb; + promptCb = null; + cb(promptBuf); + return; + } + if (ch === 0x03) { + mode = 'edit'; + promptCb = null; + promptKeys = null; + setMessage('[ Cancelled ]'); + return; + } + if (ch === 0x1b) return; + if (ch === 0x7f) { + promptBuf = promptBuf.slice(0, -1); + return; + } + if (ch >= 0x20) { + promptBuf += String.fromCharCode(ch); + } +} + +function save(target) { + file = target; + highlight = /\.(c|m)?(j|t)s$/.test(path.extname(file)); + fs.writeFileSync(file, lines.join('\n') + '\n'); + modified = false; +} + +function quit() { + stdin.setRawMode(false); + stdout.write('\x1b[?1049l'); + stdout.write('\x1b[?25h'); + process.exit(0); +} + +stdout.write('\x1b[?1049h'); +stdout.write('\x1b[?25h'); +stdin.setRawMode(true); +stdin.resume(); +draw(); + +stdin.on('data', buf => { + handleKey(buf); + draw(); +}); + +stdout.on('resize', () => draw()); diff --git a/include/modules/tty.h b/include/modules/tty.h index d6e6d7e..0555760 100644 --- a/include/modules/tty.h +++ b/include/modules/tty.h @@ -6,4 +6,7 @@ void init_tty_module(void); ant_value_t tty_library(ant_t *js); +bool tty_set_raw_mode(int fd, bool enable); +bool tty_is_raw_mode(int fd); + #endif diff --git a/src/modules/process.c b/src/modules/process.c index bfabd98..6e53831 100644 --- a/src/modules/process.c +++ b/src/modules/process.c @@ -38,6 +38,7 @@ #include "gc/modules.h" #include "modules/process.h" +#include "modules/tty.h" #include "modules/symbol.h" #include "modules/buffer.h" #include "modules/napi.h" @@ -95,8 +96,6 @@ static stdin_state_t stdin_state = {0}; static uint64_t process_start_time = 0; #ifndef _WIN32 -static struct termios stdin_saved_termios; -static bool stdin_raw_mode = false; static uv_signal_t sigwinch_handle; static bool sigwinch_initialized = false; #endif @@ -624,31 +623,10 @@ static void get_tty_size(int fd, int *rows, int *cols) { if (cols) *cols = out_cols; } -#ifndef _WIN32 static bool stdin_set_raw_mode(bool enable) { if (!stdin_is_tty()) return false; - if (enable) { - if (stdin_raw_mode) return true; - if (tcgetattr(STDIN_FILENO, &stdin_saved_termios) == -1) return false; - struct termios raw = stdin_saved_termios; - raw.c_lflag &= ~(ICANON | ECHO | ISIG); - raw.c_cc[VMIN] = 1; - raw.c_cc[VTIME] = 0; - if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == -1) return false; - stdin_raw_mode = true; - return true; - } - if (!stdin_raw_mode) return true; - if (tcsetattr(STDIN_FILENO, TCSANOW, &stdin_saved_termios) == -1) return false; - stdin_raw_mode = false; - return true; -} -#else -static bool stdin_set_raw_mode(bool enable) { - (void)enable; - return false; + return tty_set_raw_mode(STDIN_FILENO, enable); } -#endif static void stdin_alloc_buffer(uv_handle_t *handle, size_t suggested_size, uv_buf_t *buf) { (void)handle; @@ -680,13 +658,13 @@ static void stdin_start_reading(void) { uv_loop_t *loop = uv_default_loop(); if (uv_tty_init(loop, &stdin_state.tty, STDIN_FILENO, 1) != 0) return; #ifndef _WIN32 - uv_tty_set_mode(&stdin_state.tty, stdin_raw_mode ? UV_TTY_MODE_RAW : UV_TTY_MODE_NORMAL); + uv_tty_set_mode(&stdin_state.tty, tty_is_raw_mode(STDIN_FILENO) ? UV_TTY_MODE_RAW : UV_TTY_MODE_NORMAL); #endif stdin_state.tty.data = NULL; stdin_state.tty_initialized = true; } else { #ifndef _WIN32 - uv_tty_set_mode(&stdin_state.tty, stdin_raw_mode ? UV_TTY_MODE_RAW : UV_TTY_MODE_NORMAL); + uv_tty_set_mode(&stdin_state.tty, tty_is_raw_mode(STDIN_FILENO) ? UV_TTY_MODE_RAW : UV_TTY_MODE_NORMAL); #endif } stdin_state.reading = true; diff --git a/src/modules/tty.c b/src/modules/tty.c index 33d8f0e..c1d11ba 100644 --- a/src/modules/tty.c +++ b/src/modules/tty.c @@ -266,7 +266,7 @@ static struct { } raw_state = { .fd = -1, .active = false }; #endif -static bool set_raw_mode_fd(int fd, bool enable) { +bool tty_set_raw_mode(int fd, bool enable) { #ifdef _WIN32 intptr_t os_handle = _get_osfhandle(fd); if (os_handle == -1) return false; @@ -296,7 +296,12 @@ static bool set_raw_mode_fd(int fd, bool enable) { raw.c_iflag &= ~(IXON | ICRNL); raw.c_cc[VMIN] = 1; raw.c_cc[VTIME] = 0; - +#ifdef VDISCARD + raw.c_cc[VDISCARD] = _POSIX_VDISABLE; +#endif +#ifdef VLNEXT + raw.c_cc[VLNEXT] = _POSIX_VDISABLE; +#endif if (tcsetattr(fd, TCSANOW, &raw) == -1) return false; raw_state.fd = fd; raw_state.saved = saved; @@ -312,6 +317,14 @@ static bool set_raw_mode_fd(int fd, bool enable) { #endif } +bool tty_is_raw_mode(int fd) { +#ifdef _WIN32 + return false; +#else + return raw_state.active && raw_state.fd == fd; +#endif +} + static ant_value_t get_process_stream(ant_t *js, const char *name) { ant_value_t process_obj = js_get(js, js_glob(js), "process"); if (!is_special_object(process_obj)) return js_mkundef(); @@ -548,27 +561,8 @@ static ant_value_t tty_read_stream_set_raw_mode(ant_t *js, ant_value_t *args, in } bool enable = nargs > 0 ? js_truthy(js, args[0]) : true; - - ant_value_t native_fn = js_get(js, this_obj, "__antNativeSetRawMode"); - if (is_callable(native_fn)) { - ant_value_t call_args[1]; - int call_nargs = 0; - if (nargs > 0) { - call_args[0] = args[0]; - call_nargs = 1; - } - ant_value_t result = sv_vm_call( - js->vm, js, native_fn, this_obj, - call_nargs > 0 ? call_args : NULL, call_nargs, - NULL, false - ); - if (vtype(result) == T_ERR) return result; - js_set(js, this_obj, "isRaw", js_bool(js_truthy(js, result) && enable)); - return this_obj; - } - int fd = stream_fd_from_this(js, ANT_STDIN_FD); - if (!set_raw_mode_fd(fd, enable)) { + if (!tty_set_raw_mode(fd, enable)) { return js_mkerr_typed(js, JS_ERR_GENERIC, "Failed to set raw mode for fd %d", fd); } js_set(js, this_obj, "isRaw", js_bool(enable)); @@ -691,10 +685,6 @@ void init_tty_module(void) { ant_value_t stdin_proto = js_get_proto(js, stdin_obj); if (is_special_object(stdin_proto)) { - ant_value_t native_set_raw = js_get(js, stdin_proto, "setRawMode"); - if (is_callable(native_set_raw)) { - js_set(js, stdin_proto, "__antNativeSetRawMode", native_set_raw); - } js_set_proto_init(stdin_proto, stream_readable_prototype(js)); setup_readstream_proto(js, stdin_proto); }