diff --git a/examples/tui/docker.js b/examples/tui/docker.js new file mode 100644 index 0000000..84a1d15 --- /dev/null +++ b/examples/tui/docker.js @@ -0,0 +1,293 @@ +import { Screen, List, Input, colors, box, keys, codes, confirm, alert, pad, padCenter, truncate } from './tuey.js'; +import { $ } from 'ant:shell'; + +const screen = new Screen({ fullscreen: true, hideCursor: true }); + +let containers = []; + +const state = { + searchMode: false, + searchQuery: '', + lastError: '' +}; + +const searchInput = new Input({ width: 30, placeholder: 'Search containers...' }); + +const containerList = new List({ + items: [], + x: 2, + y: 6, + width: 60, + height: 10, + selectedStyle: colors.bgBlue + colors.bold + colors.white, + renderItem: container => formatContainer(container) +}); + +function runDocker(command) { + const result = $(command); + if (result.exitCode !== 0) { + const output = result.text().trim(); + state.lastError = output || `Command failed: ${command}`; + return null; + } + return result.text(); +} + +function isRunning(container) { + return container.status && container.status.startsWith('Up'); +} + +function formatStatus(container) { + if (!container.status) { + return colors.dim + 'UNKNOWN' + codes.reset; + } + if (isRunning(container)) { + return colors.green + 'UP' + codes.reset; + } + if (container.status.startsWith('Exited')) { + return colors.red + 'EXIT' + codes.reset; + } + return colors.yellow + truncate(container.status.split(' ')[0].toUpperCase(), 6) + codes.reset; +} + +function formatContainer(container) { + const width = containerList.width; + const nameWidth = 20; + const imageWidth = 20; + const statusWidth = 8; + const portsWidth = Math.max(10, width - nameWidth - imageWidth - statusWidth - 6); + + const name = pad(truncate(container.name || '', nameWidth), nameWidth); + const image = pad(truncate(container.image || '', imageWidth), imageWidth); + const ports = pad(truncate(container.ports || '', portsWidth), portsWidth); + + return `${name} ${image} ${formatStatus(container)} ${ports}`; +} + +function parseContainers(output) { + const lines = output.trim() ? output.trim().split('\n') : []; + return lines.map(line => { + const [id, name, image, status, ports] = line.split('\t'); + return { + id: id || '', + name: name || '', + image: image || '', + status: status || '', + ports: ports || '' + }; + }); +} + +function applyFilter() { + let filtered = containers; + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase(); + filtered = containers.filter(container => { + return container.name.toLowerCase().includes(q) || container.image.toLowerCase().includes(q) || container.id.toLowerCase().includes(q); + }); + } + containerList.setItems(filtered); +} + +function refreshContainers() { + state.lastError = ''; + const output = runDocker("docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"); + if (output === null) { + containers = []; + containerList.setItems([]); + return; + } + containers = parseContainers(output); + applyFilter(); +} + +function drawHeader() { + const title = ' Docker TUI - Containers '; + const w = screen.width; + screen.write(0, 0, colors.bgBlue + colors.bold + colors.white + padCenter(title, w) + codes.reset); + + const statusLine = state.lastError + ? colors.bgRed + colors.white + pad(` Error: ${truncate(state.lastError, w - 8)} `, w) + codes.reset + : colors.bgGray + colors.white + pad(' Connected to docker CLI ', w) + codes.reset; + screen.write(0, 1, statusLine); +} + +function drawFooter() { + const w = screen.width; + const help = ' Up/Down:Navigate Enter:Start/Stop s:Start t:Stop R:Restart r:Refresh /:Search q:Quit '; + screen.write(0, screen.height - 1, colors.bgGray + colors.white + pad(help, w) + codes.reset); +} + +function drawList() { + const listWidth = screen.width > 95 ? 62 : Math.max(40, screen.width - 4); + containerList.width = listWidth; + containerList.height = Math.max(5, screen.height - 8); + containerList.x = 2; + containerList.y = state.searchMode ? 7 : 6; + + const heading = pad('Name', 20) + ' ' + pad('Image', 20) + ' ' + pad('State', 8) + ' ' + pad('Ports', listWidth - 20 - 20 - 8 - 3); + screen.write(2, 5, colors.bold + heading + codes.reset); + + if (state.searchMode) { + screen.write(2, 3, colors.cyan + 'Search: ' + codes.reset + searchInput.render()); + } else { + screen.write(2, 3, colors.bold + `Containers - ${containerList.items.length} total` + codes.reset); + } + + containerList.render(screen); + + if (screen.width > 95) { + drawDetailsPanel(listWidth + 4); + } +} + +function drawDetailsPanel(x) { + const selected = containerList.getSelected(); + const width = screen.width - x - 2; + if (!selected || width < 20) return; + + const panelHeight = 10; + screen.box(x, 5, width, panelHeight, box.rounded, 'Details', colors.bold + colors.cyan); + screen.write(x + 2, 7, `${colors.bold}Name:${codes.reset} ${truncate(selected.name, width - 12)}`); + screen.write(x + 2, 8, `${colors.bold}ID:${codes.reset} ${truncate(selected.id, width - 10)}`); + screen.write(x + 2, 9, `${colors.bold}Image:${codes.reset} ${truncate(selected.image, width - 12)}`); + screen.write(x + 2, 10, `${colors.bold}Status:${codes.reset} ${truncate(selected.status, width - 13)}`); + screen.write(x + 2, 11, `${colors.bold}Ports:${codes.reset} ${truncate(selected.ports || '-', width - 12)}`); +} + +function render() { + screen.clear(); + drawHeader(); + drawList(); + drawFooter(); + screen.render(); +} + +function runAction(action, container) { + const verb = action.charAt(0).toUpperCase() + action.slice(1); + confirm(screen, { + title: `${verb} Container`, + message: `Run: docker ${action} ${container.name}?` + }).then(confirmed => { + if (!confirmed) { + render(); + return; + } + + const output = runDocker(`docker ${action} ${container.id}`); + if (output === null) { + alert(screen, { + title: 'Docker Error', + message: state.lastError || 'Docker command failed.' + }).then(() => { + refreshContainers(); + render(); + }); + return; + } + + refreshContainers(); + render(); + }); +} + +function toggleStartStop(container) { + if (isRunning(container)) { + runAction('stop', container); + } else { + runAction('start', container); + } +} + +function handleKey(key) { + if (screen.hasModal()) return; + + if (state.searchMode) { + if (key === keys.ESCAPE) { + state.searchMode = false; + state.searchQuery = ''; + searchInput.clear(); + applyFilter(); + } else if (key === keys.ENTER) { + state.searchMode = false; + state.searchQuery = searchInput.value; + applyFilter(); + } else { + searchInput.handleKey(key); + state.searchQuery = searchInput.value; + applyFilter(); + } + render(); + return; + } + + switch (key) { + case 'q': + case keys.CTRL_C: + confirm(screen, { + title: 'Quit', + message: 'Exit Docker TUI?' + }).then(confirmed => { + if (confirmed) { + screen.exit(0); + } + render(); + }); + return; + case '/': + state.searchMode = true; + searchInput.clear(); + render(); + return; + case 'r': + refreshContainers(); + render(); + return; + case keys.ENTER: { + const selected = containerList.getSelected(); + if (selected) { + toggleStartStop(selected); + } + return; + } + case 's': { + const selected = containerList.getSelected(); + if (selected) { + runAction('start', selected); + } + return; + } + case 't': { + const selected = containerList.getSelected(); + if (selected) { + runAction('stop', selected); + } + return; + } + case 'R': { + const selected = containerList.getSelected(); + if (selected) { + runAction('restart', selected); + } + return; + } + } + + if (containerList.handleKey(key)) { + render(); + } +} + +screen.onKey(handleKey); +screen.onResize(() => render()); + +screen.start(); +refreshContainers(); +render(); + +setInterval(() => { + if (!screen.hasModal()) { + refreshContainers(); + render(); + } +}, 4000); diff --git a/examples/tui/index.js b/examples/tui/index.js new file mode 100644 index 0000000..fcee2b5 --- /dev/null +++ b/examples/tui/index.js @@ -0,0 +1,526 @@ +import { Screen, List, ProgressBar, Input, Table, colors, box, keys, codes, modal, confirm, pad, padCenter, truncate } from './tuey.js'; + +const screen = new Screen({ fullscreen: true, hideCursor: true }); + +const tasks = [ + { id: 1, name: 'Build TUI library', status: 'done', priority: 'high' }, + { id: 2, name: 'Add modal support', status: 'done', priority: 'high' }, + { id: 3, name: 'Implement themes', status: 'in_progress', priority: 'medium' }, + { id: 4, name: 'Write documentation', status: 'todo', priority: 'low' }, + { id: 5, name: 'Add animation support', status: 'todo', priority: 'low' }, + { id: 6, name: 'Create widget system', status: 'in_progress', priority: 'medium' }, + { id: 7, name: 'Performance optimization', status: 'todo', priority: 'high' }, + { id: 8, name: 'Add mouse support', status: 'todo', priority: 'low' }, + { id: 9, name: 'Create color picker', status: 'todo', priority: 'medium' }, + { id: 10, name: 'Build file browser', status: 'in_progress', priority: 'high' } +]; + +const logs = [ + { time: '10:23:45', level: 'INFO', message: 'Application started' }, + { time: '10:23:46', level: 'DEBUG', message: 'Loading configuration...' }, + { time: '10:23:47', level: 'INFO', message: 'Config loaded successfully' }, + { time: '10:23:48', level: 'WARN', message: 'Cache directory not found, creating...' }, + { time: '10:23:49', level: 'INFO', message: 'Cache initialized' }, + { time: '10:24:01', level: 'DEBUG', message: 'Connecting to database...' }, + { time: '10:24:02', level: 'INFO', message: 'Database connection established' }, + { time: '10:24:05', level: 'ERROR', message: 'Failed to load plugin: missing-plugin' }, + { time: '10:24:06', level: 'WARN', message: 'Running with reduced functionality' }, + { time: '10:24:10', level: 'INFO', message: 'Ready to accept connections' } +]; + +let state = { + view: 'dashboard', + taskFilter: 'all', + searchMode: false, + searchQuery: '', + cpuUsage: 45, + memUsage: 62, + diskUsage: 78, + networkIn: 0, + networkOut: 0 +}; + +const taskList = new List({ + items: tasks, + x: 2, + y: 5, + width: 50, + height: 12, + selectedStyle: colors.bgBlue + colors.bold + colors.white, + renderItem: task => { + const statusIcon = + task.status === 'done' + ? `${colors.green}✓${codes.reset}` + : task.status === 'in_progress' + ? `${colors.yellow}◐${codes.reset}` + : `${colors.gray}○${codes.reset}`; + const priorityColor = task.priority === 'high' ? colors.red : task.priority === 'medium' ? colors.yellow : colors.gray; + return ` ${statusIcon} ${task.name} ${priorityColor}[${task.priority}]${codes.reset}`; + } +}); + +const logList = new List({ + items: logs, + x: 2, + y: 5, + width: screen.width - 4, + height: 15, + selectedStyle: colors.bgGray + colors.white, + renderItem: log => { + const levelColor = log.level === 'ERROR' ? colors.red : log.level === 'WARN' ? colors.yellow : log.level === 'DEBUG' ? colors.cyan : colors.green; + return `${colors.dim}${log.time}${codes.reset} ${levelColor}${pad(log.level, 5)}${codes.reset} ${log.message}`; + } +}); + +const cpuBar = new ProgressBar({ width: 25, filledStyle: colors.green, showPercent: true }); +const memBar = new ProgressBar({ width: 25, filledStyle: colors.blue, showPercent: true }); +const diskBar = new ProgressBar({ width: 25, filledStyle: colors.yellow, showPercent: true }); + +const searchInput = new Input({ width: 30, placeholder: 'Search tasks...' }); + +function filterTasks() { + let filtered = tasks; + if (state.taskFilter !== 'all') { + filtered = filtered.filter(t => t.status === state.taskFilter); + } + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase(); + filtered = filtered.filter(t => t.name.toLowerCase().includes(q)); + } + taskList.setItems(filtered); +} + +function drawHeader() { + const title = ' TUI Demo - Press ? for help '; + const w = screen.width; + + screen.write(0, 0, colors.bgBlue + colors.bold + colors.white + padCenter(title, w) + codes.reset); + + const tabs = [ + { key: '1', name: 'Dashboard', view: 'dashboard' }, + { key: '2', name: 'Tasks', view: 'tasks' }, + { key: '3', name: 'Logs', view: 'logs' }, + { key: '4', name: 'Settings', view: 'settings' } + ]; + + let tabLine = ' '; + for (const tab of tabs) { + const isActive = state.view === tab.view; + const style = isActive ? colors.bgWhite + colors.black + colors.bold : colors.dim; + tabLine += `${style} ${tab.key}:${tab.name} ${codes.reset} `; + } + screen.write(0, 1, tabLine); +} + +function drawFooter() { + const w = screen.width; + const h = screen.height; + + const help = + state.view === 'tasks' ? ' ↑↓:Navigate Enter:Toggle a:All t:Todo p:Progress d:Done /:Search q:Quit ' : ' 1-4:Switch tabs ?:Help q:Quit '; + + screen.write(0, h - 1, colors.bgGray + colors.white + pad(help, w) + codes.reset); +} + +function drawDashboard() { + const w = screen.width; + + screen.write(2, 3, colors.bold + colors.cyan + '┌─ System Status ─────────────────────────┐' + codes.reset); + + cpuBar.setValue(state.cpuUsage); + memBar.setValue(state.memUsage); + diskBar.setValue(state.diskUsage); + + screen.write(2, 4, colors.cyan + '│' + codes.reset); + screen.write(4, 4, `CPU: ${cpuBar.render()}`); + screen.write(45, 4, colors.cyan + '│' + codes.reset); + + screen.write(2, 5, colors.cyan + '│' + codes.reset); + screen.write(4, 5, `Memory: ${memBar.render()}`); + screen.write(45, 5, colors.cyan + '│' + codes.reset); + + screen.write(2, 6, colors.cyan + '│' + codes.reset); + screen.write(4, 6, `Disk: ${diskBar.render()}`); + screen.write(45, 6, colors.cyan + '│' + codes.reset); + + screen.write(2, 7, colors.cyan + '└──────────────────────────────────────────┘' + codes.reset); + + screen.write(2, 9, colors.bold + colors.yellow + '┌─ Task Summary ──────────────────────────┐' + codes.reset); + + const done = tasks.filter(t => t.status === 'done').length; + const inProgress = tasks.filter(t => t.status === 'in_progress').length; + const todo = tasks.filter(t => t.status === 'todo').length; + + screen.write(2, 10, colors.yellow + '│' + codes.reset); + screen.write( + 4, + 10, + `${colors.green}✓ Completed:${codes.reset} ${done} ${colors.yellow}◐ In Progress:${codes.reset} ${inProgress} ${colors.gray}○ Todo:${codes.reset} ${todo}` + ); + screen.write(45, 10, colors.yellow + '│' + codes.reset); + + const progress = new ProgressBar({ + value: done, + max: tasks.length, + width: 35, + filledStyle: colors.green, + showPercent: true + }); + + screen.write(2, 11, colors.yellow + '│' + codes.reset); + screen.write(4, 11, `Progress: ${progress.render()}`); + screen.write(45, 11, colors.yellow + '│' + codes.reset); + + screen.write(2, 12, colors.yellow + '└──────────────────────────────────────────┘' + codes.reset); + + screen.write(2, 14, colors.bold + colors.magenta + '┌─ Recent Activity ───────────────────────┐' + codes.reset); + + for (let i = 0; i < Math.min(5, logs.length); i++) { + const log = logs[logs.length - 1 - i]; + const levelColor = log.level === 'ERROR' ? colors.red : log.level === 'WARN' ? colors.yellow : colors.green; + screen.write(2, 15 + i, colors.magenta + '│' + codes.reset); + screen.write(4, 15 + i, `${colors.dim}${log.time}${codes.reset} ${levelColor}${log.level}${codes.reset} ${truncate(log.message, 30)}`); + screen.write(45, 15 + i, colors.magenta + '│' + codes.reset); + } + + screen.write(2, 20, colors.magenta + '└──────────────────────────────────────────┘' + codes.reset); + + if (w > 55) { + screen.box(50, 3, 30, 10, box.rounded, 'Quick Stats', colors.bold + colors.green); + screen.write(52, 5, `${colors.bold}Uptime:${codes.reset} 2h 34m`); + screen.write(52, 6, `${colors.bold}Network In:${codes.reset} ${state.networkIn} KB/s`); + screen.write(52, 7, `${colors.bold}Network Out:${codes.reset} ${state.networkOut} KB/s`); + screen.write(52, 8, `${colors.bold}Active Users:${codes.reset} 42`); + screen.write(52, 9, `${colors.bold}Requests/s:${codes.reset} 1,234`); + screen.write(52, 10, `${colors.bold}Errors:${codes.reset} ${colors.red}3${codes.reset}`); + } +} + +function drawTasks() { + const filterLabel = + state.taskFilter === 'all' ? 'All' : state.taskFilter === 'todo' ? 'Todo' : state.taskFilter === 'in_progress' ? 'In Progress' : 'Done'; + + screen.write(2, 3, colors.bold + `Tasks [${filterLabel}] - ${taskList.items.length} items` + codes.reset); + + if (state.searchMode) { + screen.write(2, 4, colors.cyan + 'Search: ' + codes.reset + searchInput.render()); + } + + taskList.y = state.searchMode ? 6 : 5; + taskList.height = state.searchMode ? screen.height - 9 : screen.height - 8; + taskList.width = Math.min(60, screen.width - 4); + taskList.render(screen); + + const selected = taskList.getSelected(); + if (selected && screen.width > 65) { + const detailX = taskList.width + 5; + screen.box(detailX, 5, 35, 12, box.rounded, 'Details', colors.bold + colors.cyan); + + screen.write(detailX + 2, 7, `${colors.bold}ID:${codes.reset} ${selected.id}`); + screen.write(detailX + 2, 8, `${colors.bold}Name:${codes.reset} ${truncate(selected.name, 25)}`); + screen.write(detailX + 2, 9, `${colors.bold}Status:${codes.reset} ${selected.status}`); + screen.write(detailX + 2, 10, `${colors.bold}Priority:${codes.reset} ${selected.priority}`); + + screen.write(detailX + 2, 12, colors.dim + 'Enter to toggle status' + codes.reset); + screen.write(detailX + 2, 13, colors.dim + 'D to delete task' + codes.reset); + screen.write(detailX + 2, 14, colors.dim + 'N to add new task' + codes.reset); + } +} + +function drawLogs() { + screen.write(2, 3, colors.bold + `System Logs - ${logs.length} entries` + codes.reset); + + logList.width = screen.width - 4; + logList.height = screen.height - 8; + logList.render(screen); +} + +function drawSettings() { + screen.write(2, 3, colors.bold + colors.cyan + 'Settings' + codes.reset); + + const table = new Table({ + x: 2, + y: 5, + width: 60, + columns: [ + { key: 'setting', header: 'Setting' }, + { key: 'value', header: 'Value' }, + { key: 'description', header: 'Description' } + ], + rows: [ + { setting: 'Theme', value: 'Dark', description: 'UI color scheme' }, + { setting: 'Refresh Rate', value: '1000ms', description: 'Dashboard update interval' }, + { setting: 'Log Level', value: 'INFO', description: 'Minimum log level to display' }, + { setting: 'Notifications', value: 'Enabled', description: 'Show system notifications' }, + { setting: 'Auto-save', value: 'On', description: 'Automatically save changes' } + ], + borderStyle: box.rounded, + headerStyle: colors.bold + colors.cyan + }); + + table.render(screen); + + screen.write(2, 15, colors.dim + 'Press Enter on a setting to modify it' + codes.reset); +} + +function render() { + screen.clear(); + drawHeader(); + + switch (state.view) { + case 'dashboard': + drawDashboard(); + break; + case 'tasks': + drawTasks(); + break; + case 'logs': + drawLogs(); + break; + case 'settings': + drawSettings(); + break; + } + + drawFooter(); + screen.render(); +} + +function showHelp() { + modal(screen, { + id: 'help', + width: 50, + height: 18, + title: 'Keyboard Shortcuts', + titleStyle: colors.bold + colors.cyan, + borderStyle: box.double, + onKey: key => { + if (key === keys.ESCAPE || key === keys.ENTER || key === '?') { + screen.popModal('help'); + render(); + } + return false; + }, + render: (buf, w, _h, ox, oy) => { + const shortcuts = [ + ['1-4', 'Switch between tabs'], + ['↑/↓ or j/k', 'Navigate lists'], + ['Enter', 'Select/Toggle item'], + ['/', 'Search (in Tasks)'], + ['Escape', 'Cancel/Close'], + ['a', 'Show all tasks'], + ['t', 'Filter: Todo only'], + ['p', 'Filter: In Progress'], + ['d', 'Filter: Done'], + ['m', 'Show memory stats'], + ['?', 'Show this help'], + ['q', 'Quit application'] + ]; + + for (let i = 0; i < shortcuts.length; i++) { + const [key, desc] = shortcuts[i]; + buf.writeStyled(ox, oy + i + 1, ` ${colors.cyan}${pad(key, 12)}${codes.reset} ${desc}`); + } + + buf.writeStyled(ox, oy + shortcuts.length + 2, colors.dim + padCenter('Press Escape to close', w) + codes.reset); + } + }); + screen.render(); +} + +function showMemoryModal() { + const fmt = bytes => { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; + return (bytes / 1024 / 1024).toFixed(2) + ' MB'; + }; + + modal(screen, { + id: 'memory', + width: 40, + height: 14, + title: 'Memory Usage', + titleStyle: colors.bold + colors.yellow, + borderStyle: box.rounded, + onKey: key => { + if (key === 'm' || key === keys.ESCAPE) { + screen.popModal('memory'); + render(); + } else if (key === 'g') { + Ant.gc(); + screen.popModal('memory'); + showMemoryModal(); + } + return false; + }, + render: (buf, _w, _h, ox, oy) => { + const mem = Ant.stats(); + buf.writeStyled(ox, oy + 1, `${colors.cyan}Arena${codes.reset}`); + buf.writeStyled(ox, oy + 2, ` Used: ${colors.bold}${fmt(mem.arenaUsed)}${codes.reset}`); + buf.writeStyled(ox, oy + 3, ` Size: ${colors.bold}${fmt(mem.arenaSize)}${codes.reset}`); + buf.writeStyled(ox, oy + 5, `${colors.cyan}Process${codes.reset}`); + buf.writeStyled(ox, oy + 6, ` RSS: ${colors.bold}${fmt(mem.rss)}${codes.reset}`); + buf.writeStyled(ox, oy + 8, `${colors.cyan}C Stack${codes.reset}`); + buf.writeStyled(ox, oy + 9, ` Max: ${colors.bold}${fmt(mem.cstack)}${codes.reset}`); + buf.writeStyled(ox, oy + 11, colors.dim + ' g: Run GC m/Esc: Close' + codes.reset); + } + }); + screen.render(); +} + +function handleKey(key) { + if (screen.hasModal()) return; + + if (state.searchMode) { + if (key === keys.ESCAPE) { + state.searchMode = false; + state.searchQuery = ''; + searchInput.clear(); + filterTasks(); + } else if (key === keys.ENTER) { + state.searchMode = false; + state.searchQuery = searchInput.value; + filterTasks(); + } else { + searchInput.handleKey(key); + state.searchQuery = searchInput.value; + filterTasks(); + } + render(); + return; + } + + switch (key) { + case 'q': + case keys.CTRL_C: + confirm(screen, { + title: 'Quit', + message: 'Are you sure you want to quit?' + }).then(confirmed => { + if (confirmed) screen.exit(0); + render(); + }); + return; + + case '1': + state.view = 'dashboard'; + render(); + break; + case '2': + state.view = 'tasks'; + render(); + break; + case '3': + state.view = 'logs'; + render(); + break; + case '4': + state.view = 'settings'; + render(); + break; + + case '?': + showHelp(); + break; + case 'm': + showMemoryModal(); + break; + + case keys.UP: + case 'k': + if (state.view === 'tasks') taskList.selectPrev(); + else if (state.view === 'logs') logList.selectPrev(); + render(); + break; + + case keys.DOWN: + case 'j': + if (state.view === 'tasks') taskList.selectNext(); + else if (state.view === 'logs') logList.selectNext(); + render(); + break; + + case keys.PAGE_UP: + if (state.view === 'tasks') taskList.pageUp(); + else if (state.view === 'logs') logList.pageUp(); + render(); + break; + + case keys.PAGE_DOWN: + if (state.view === 'tasks') taskList.pageDown(); + else if (state.view === 'logs') logList.pageDown(); + render(); + break; + + case keys.ENTER: + if (state.view === 'tasks') { + const task = taskList.getSelected(); + if (task) { + task.status = task.status === 'done' ? 'todo' : task.status === 'todo' ? 'in_progress' : 'done'; + filterTasks(); + } + } + render(); + break; + + case '/': + if (state.view === 'tasks') { + state.searchMode = true; + searchInput.clear(); + } + render(); + break; + + case 'a': + if (state.view === 'tasks') { + state.taskFilter = 'all'; + filterTasks(); + render(); + } + break; + + case 't': + if (state.view === 'tasks') { + state.taskFilter = 'todo'; + filterTasks(); + render(); + } + break; + + case 'p': + if (state.view === 'tasks') { + state.taskFilter = 'in_progress'; + filterTasks(); + render(); + } + break; + + case 'd': + if (state.view === 'tasks') { + state.taskFilter = 'done'; + filterTasks(); + render(); + } + break; + } +} + +screen.onKey(handleKey); + +screen.onResize(() => { + render(); +}); + +setInterval(() => { + state.cpuUsage = Math.max(5, Math.min(95, state.cpuUsage + (Math.random() - 0.5) * 10)); + state.memUsage = Math.max(20, Math.min(90, state.memUsage + (Math.random() - 0.5) * 5)); + state.networkIn = Math.floor(Math.random() * 500); + state.networkOut = Math.floor(Math.random() * 200); + + if (state.view === 'dashboard' && !screen.hasModal()) { + render(); + } +}, 1000); + +screen.start(); +render(); diff --git a/examples/tui/test.js b/examples/tui/test.js new file mode 100644 index 0000000..5c001be --- /dev/null +++ b/examples/tui/test.js @@ -0,0 +1,368 @@ +import { + codes, + colors, + box, + keys, + stripAnsi, + visibleLength, + pad, + padStart, + padCenter, + truncate, + wrap, + Buffer, + List, + ProgressBar, + Input, + Table +} from './tuey.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(`${colors.green}✓${colors.reset} ${name}`); + passed++; + } catch (e) { + console.log(`${colors.red}✗${colors.reset} ${name}`); + console.log(` ${colors.dim}${e.message}${colors.reset}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +console.log(`${colors.bold}${colors.blue}═══════════════════════════════════════${colors.reset}`); +console.log(`${colors.bold} TUI Library Tests${colors.reset}`); +console.log(`${colors.bold}${colors.blue}═══════════════════════════════════════${colors.reset}\n`); + +console.log(`${colors.cyan}ANSI Code Generation${colors.reset}`); + +test('codes.fg generates correct 256-color code', () => { + assertEqual(codes.fg(196), '\x1b[38;5;196m'); +}); + +test('codes.bg generates correct 256-color code', () => { + assertEqual(codes.bg(24), '\x1b[48;5;24m'); +}); + +test('codes.rgb generates correct 24-bit color code', () => { + assertEqual(codes.rgb(255, 128, 0), '\x1b[38;2;255;128;0m'); +}); + +test('codes.moveTo generates correct cursor position', () => { + assertEqual(codes.moveTo(10, 5), '\x1b[6;11H'); +}); + +console.log(`\n${colors.cyan}String Utilities${colors.reset}`); + +test('stripAnsi removes ANSI codes', () => { + const input = `${colors.red}hello${colors.reset} ${colors.bold}world${colors.reset}`; + assertEqual(stripAnsi(input), 'hello world'); +}); + +test('visibleLength calculates correct length with ANSI', () => { + const input = `${colors.green}test${colors.reset}`; + assertEqual(visibleLength(input), 4); +}); + +test('pad right-pads to target length', () => { + assertEqual(pad('hello', 10), 'hello '); +}); + +test('pad handles strings longer than target', () => { + assertEqual(pad('hello world', 5), 'hello world'); +}); + +test('pad works with ANSI codes', () => { + const input = `${colors.red}hi${colors.reset}`; + const result = pad(input, 5); + assertEqual(visibleLength(result), 5); + assert(result.includes('\x1b[38;5;196m'), 'Should contain red color code'); +}); + +test('padStart left-pads to target length', () => { + assertEqual(padStart('123', 6), ' 123'); +}); + +test('padCenter centers text', () => { + assertEqual(padCenter('hi', 6), ' hi '); +}); + +test('truncate shortens long strings', () => { + const result = truncate('hello world', 8, '.'); + assertEqual(stripAnsi(result), 'hello w.'); +}); + +test('truncate preserves short strings', () => { + assertEqual(truncate('hi', 10), 'hi'); +}); + +test('truncate handles ANSI codes correctly', () => { + const input = `${colors.red}hello world${colors.reset}`; + const result = truncate(input, 8); + assertEqual(visibleLength(result), 8); +}); + +test('wrap splits text at word boundaries', () => { + const result = wrap('hello world foo bar', 12); + assertEqual(result.length, 2); + assertEqual(result[0], 'hello world'); +}); + +console.log(`\n${colors.cyan}Buffer${colors.reset}`); + +test('Buffer initializes with correct dimensions', () => { + const buf = new Buffer(20, 10); + assertEqual(buf.width, 20); + assertEqual(buf.height, 10); + assertEqual(buf.lines.length, 10); +}); + +test('Buffer.write writes text at position', () => { + const buf = new Buffer(20, 5); + buf.write(5, 2, 'hello'); + assert(buf.lines[2].includes('hello'), 'Buffer should contain "hello"'); +}); + +test('Buffer.clear resets all lines', () => { + const buf = new Buffer(10, 5); + buf.write(0, 0, 'test'); + buf.clear(); + assertEqual(buf.lines[0], ' '.repeat(10)); +}); + +test('Buffer.resize changes dimensions', () => { + const buf = new Buffer(10, 5); + buf.write(0, 0, 'hello'); + buf.resize(20, 10); + assertEqual(buf.width, 20); + assertEqual(buf.height, 10); + assert(buf.lines[0].includes('hello'), 'Content should be preserved'); +}); + +test('Buffer.clone creates independent copy', () => { + const buf = new Buffer(10, 5); + buf.write(0, 0, 'original'); + const clone = buf.clone(); + buf.write(0, 0, 'modified'); + assert(clone.lines[0].includes('original'), 'Clone should be independent'); +}); + +test('Buffer.fill fills rectangular region', () => { + const buf = new Buffer(10, 5); + buf.fill(2, 1, 3, 2, 'X'); + assert(buf.lines[1].includes('XXX'), 'Should contain filled region'); + assert(buf.lines[2].includes('XXX'), 'Should contain filled region'); +}); + +test('Buffer.box draws box characters', () => { + const buf = new Buffer(20, 10); + buf.box(0, 0, 10, 5, box.light); + assert(buf.lines[0].includes('┌'), 'Should have top-left corner'); + assert(buf.lines[0].includes('┐'), 'Should have top-right corner'); + assert(buf.lines[4].includes('└'), 'Should have bottom-left corner'); +}); + +console.log(`\n${colors.cyan}List${colors.reset}`); + +test('List initializes with items', () => { + const list = new List({ + items: ['a', 'b', 'c'], + width: 20, + height: 5 + }); + assertEqual(list.items.length, 3); + assertEqual(list.index, 0); +}); + +test('List.selectNext advances selection', () => { + const list = new List({ items: ['a', 'b', 'c'] }); + list.selectNext(); + assertEqual(list.index, 1); +}); + +test('List.selectPrev moves selection back', () => { + const list = new List({ items: ['a', 'b', 'c'], index: 2 }); + list.selectPrev(); + assertEqual(list.index, 1); +}); + +test('List.selectNext clamps at end', () => { + const list = new List({ items: ['a', 'b', 'c'], index: 2 }); + list.selectNext(); + assertEqual(list.index, 2); +}); + +test('List.selectPrev clamps at start', () => { + const list = new List({ items: ['a', 'b', 'c'], index: 0 }); + list.selectPrev(); + assertEqual(list.index, 0); +}); + +test('List.getSelected returns current item', () => { + const list = new List({ items: ['a', 'b', 'c'], index: 1 }); + assertEqual(list.getSelected(), 'b'); +}); + +test('List.setItems updates and clamps index', () => { + const list = new List({ items: ['a', 'b', 'c', 'd'], index: 3 }); + list.setItems(['x', 'y']); + assertEqual(list.items.length, 2); + assertEqual(list.index, 1); +}); + +test('List.handleKey responds to vim keys', () => { + const list = new List({ items: ['a', 'b', 'c'] }); + list.handleKey('j'); + assertEqual(list.index, 1); + list.handleKey('k'); + assertEqual(list.index, 0); +}); + +test('List.handleKey responds to G/g', () => { + const list = new List({ items: ['a', 'b', 'c', 'd', 'e'] }); + list.handleKey('G'); + assertEqual(list.index, 4); + list.handleKey('g'); + assertEqual(list.index, 0); +}); + +console.log(`\n${colors.cyan}ProgressBar${colors.reset}`); + +test('ProgressBar initializes with value', () => { + const bar = new ProgressBar({ value: 50, max: 100 }); + assertEqual(bar.value, 50); + assertEqual(bar.max, 100); +}); + +test('ProgressBar.setValue clamps value', () => { + const bar = new ProgressBar({ max: 100 }); + bar.setValue(150); + assertEqual(bar.value, 100); + bar.setValue(-10); + assertEqual(bar.value, 0); +}); + +test('ProgressBar.render produces filled/empty chars', () => { + const bar = new ProgressBar({ value: 50, max: 100, width: 10, showPercent: false }); + const result = bar.render(); + const stripped = stripAnsi(result); + assert(stripped.includes('█'), 'Should have filled chars'); + assert(stripped.includes('░'), 'Should have empty chars'); +}); + +test('ProgressBar.render shows percentage', () => { + const bar = new ProgressBar({ value: 75, max: 100, width: 10, showPercent: true }); + const result = bar.render(); + assert(result.includes('75%'), 'Should show percentage'); +}); + +console.log(`\n${colors.cyan}Input${colors.reset}`); + +test('Input initializes with value', () => { + const input = new Input({ value: 'hello' }); + assertEqual(input.value, 'hello'); + assertEqual(input.cursorPos, 5); +}); + +test('Input.handleKey adds characters', () => { + const input = new Input(); + input.handleKey('a'); + input.handleKey('b'); + input.handleKey('c'); + assertEqual(input.value, 'abc'); +}); + +test('Input.handleKey handles backspace', () => { + const input = new Input({ value: 'hello' }); + input.handleKey(keys.BACKSPACE); + assertEqual(input.value, 'hell'); +}); + +test('Input.handleKey handles arrow keys', () => { + const input = new Input({ value: 'hello' }); + input.handleKey(keys.LEFT); + assertEqual(input.cursorPos, 4); + input.handleKey(keys.RIGHT); + assertEqual(input.cursorPos, 5); +}); + +test('Input.clear resets value and cursor', () => { + const input = new Input({ value: 'test' }); + input.clear(); + assertEqual(input.value, ''); + assertEqual(input.cursorPos, 0); +}); + +console.log(`\n${colors.cyan}Table${colors.reset}`); + +test('Table initializes with columns and rows', () => { + const table = new Table({ + columns: [ + { key: 'name', header: 'Name' }, + { key: 'value', header: 'Value' } + ], + rows: [{ name: 'foo', value: '123' }] + }); + assertEqual(table.columns.length, 2); + assertEqual(table.rows.length, 1); +}); + +console.log(`\n${colors.cyan}Box Drawing Styles${colors.reset}`); + +test('box.light has correct characters', () => { + assertEqual(box.light.tl, '┌'); + assertEqual(box.light.tr, '┐'); + assertEqual(box.light.h, '─'); + assertEqual(box.light.v, '│'); +}); + +test('box.double has correct characters', () => { + assertEqual(box.double.tl, '╔'); + assertEqual(box.double.tr, '╗'); + assertEqual(box.double.h, '═'); + assertEqual(box.double.v, '║'); +}); + +test('box.rounded has correct characters', () => { + assertEqual(box.rounded.tl, '╭'); + assertEqual(box.rounded.br, '╯'); +}); + +console.log(`\n${colors.cyan}Keys Constants${colors.reset}`); + +test('keys.UP is correct escape sequence', () => { + assertEqual(keys.UP, '\x1b[A'); +}); + +test('keys.ESCAPE is escape character', () => { + assertEqual(keys.ESCAPE, '\x1b'); +}); + +test('keys.ENTER is carriage return', () => { + assertEqual(keys.ENTER, '\r'); +}); + +console.log(`\n${colors.blue}═══════════════════════════════════════${colors.reset}`); +const total = passed + failed; +const rate = ((passed / total) * 100).toFixed(1); +const rateColor = failed === 0 ? colors.green : colors.yellow; + +console.log( + `${colors.bold}Results:${colors.reset} ${colors.green}${passed} passed${colors.reset}, ${failed > 0 ? colors.red : colors.dim}${failed} failed${colors.reset}` +); +console.log(`${colors.bold}Rate:${colors.reset} ${rateColor}${rate}%${colors.reset}`); +console.log(`${colors.blue}═══════════════════════════════════════${colors.reset}`); + +if (failed > 0) process.exit(1); diff --git a/examples/tui/tuey.js b/examples/tui/tuey.js new file mode 100644 index 0000000..293cae7 --- /dev/null +++ b/examples/tui/tuey.js @@ -0,0 +1,989 @@ +const ESC = '\x1b'; +const CSI = `${ESC}[`; + +export const codes = { + hideCursor: `${CSI}?25l`, + showCursor: `${CSI}?25h`, + altScreenOn: `${CSI}?1049h`, + altScreenOff: `${CSI}?1049l`, + syncStart: `${CSI}?2026h`, + syncEnd: `${CSI}?2026l`, + home: `${CSI}H`, + clear: `${CSI}2J`, + clearLine: `${CSI}2K`, + reset: `${CSI}0m`, + bold: `${CSI}1m`, + dim: `${CSI}2m`, + italic: `${CSI}3m`, + underline: `${CSI}4m`, + blink: `${CSI}5m`, + inverse: `${CSI}7m`, + hidden: `${CSI}8m`, + strikethrough: `${CSI}9m`, + fg: n => `${CSI}38;5;${n}m`, + bg: n => `${CSI}48;5;${n}m`, + rgb: (r, g, b) => `${CSI}38;2;${r};${g};${b}m`, + bgRgb: (r, g, b) => `${CSI}48;2;${r};${g};${b}m`, + moveTo: (x, y) => `${CSI}${y + 1};${x + 1}H`, + moveUp: n => `${CSI}${n}A`, + moveDown: n => `${CSI}${n}B`, + moveRight: n => `${CSI}${n}C`, + moveLeft: n => `${CSI}${n}D`, + saveCursor: `${ESC}7`, + restoreCursor: `${ESC}8`, + scrollUp: n => `${CSI}${n}S`, + scrollDown: n => `${CSI}${n}T` +}; + +export const colors = { + reset: codes.reset, + bold: codes.bold, + dim: codes.dim, + italic: codes.italic, + underline: codes.underline, + inverse: codes.inverse, + black: codes.fg(0), + red: codes.fg(196), + green: codes.fg(82), + yellow: codes.fg(226), + blue: codes.fg(39), + magenta: codes.fg(201), + cyan: codes.fg(51), + white: codes.fg(15), + gray: codes.fg(245), + brightRed: codes.fg(9), + brightGreen: codes.fg(10), + brightYellow: codes.fg(11), + brightBlue: codes.fg(12), + brightMagenta: codes.fg(13), + brightCyan: codes.fg(14), + bgBlack: codes.bg(0), + bgRed: codes.bg(196), + bgGreen: codes.bg(82), + bgYellow: codes.bg(226), + bgBlue: codes.bg(24), + bgMagenta: codes.bg(201), + bgCyan: codes.bg(51), + bgWhite: codes.bg(15), + bgGray: codes.bg(238) +}; + +export const box = { + light: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│', lT: '├', rT: '┤', tT: '┬', bT: '┴', cross: '┼' }, + heavy: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃', lT: '┣', rT: '┫', tT: '┳', bT: '┻', cross: '╋' }, + double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', lT: '╠', rT: '╣', tT: '╦', bT: '╩', cross: '╬' }, + rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', lT: '├', rT: '┤', tT: '┬', bT: '┴', cross: '┼' }, + ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', lT: '+', rT: '+', tT: '+', bT: '+', cross: '+' } +}; + +export const keys = { + UP: '\x1b[A', + DOWN: '\x1b[B', + RIGHT: '\x1b[C', + LEFT: '\x1b[D', + HOME: '\x1b[H', + END: '\x1b[F', + PAGE_UP: '\x1b[5~', + PAGE_DOWN: '\x1b[6~', + INSERT: '\x1b[2~', + DELETE: '\x1b[3~', + ENTER: '\r', + TAB: '\t', + ESCAPE: '\x1b', + BACKSPACE: '\x7f', + CTRL_C: '\x03', + CTRL_D: '\x04', + CTRL_Z: '\x1a', + F1: '\x1bOP', + F2: '\x1bOQ', + F3: '\x1bOR', + F4: '\x1bOS', + F5: '\x1b[15~', + F6: '\x1b[17~', + F7: '\x1b[18~', + F8: '\x1b[19~', + F9: '\x1b[20~', + F10: '\x1b[21~', + F11: '\x1b[23~', + F12: '\x1b[24~' +}; + +export function stripAnsi(str) { + return str.replace(/\x1b\[[0-9;]*m/g, ''); +} + +export function visibleLength(str) { + return stripAnsi(str).length; +} + +export function pad(str, len, char = ' ') { + const visible = visibleLength(str); + const diff = len - visible; + if (diff <= 0) return str; + if (char === undefined || char === null) { + console.error("pad() char undefined, str:", str, "len:", len); + console.error(new Error().stack); + process.exit(1); + } + return str + char.repeat(diff); +} + +export function padStart(str, len, char = ' ') { + const visible = visibleLength(str); + return char.repeat(Math.max(0, len - visible)) + str; +} + +export function padCenter(str, len, char = ' ') { + const visible = visibleLength(str); + const total = Math.max(0, len - visible); + const left = Math.floor(total / 2); + const right = total - left; + return char.repeat(left) + str + char.repeat(right); +} + +export function truncate(str, len, suffix = '…') { + const stripped = stripAnsi(str); + if (stripped.length <= len) return str; + + const suffixLen = visibleLength(suffix); + const targetLen = len - suffixLen; + if (targetLen <= 0) return suffix.slice(0, len); + + let visCount = 0; + let result = ''; + let i = 0; + + while (i < str.length && visCount < targetLen) { + if (str[i] === '\x1b') { + const match = str.slice(i).match(/^\x1b\[[0-9;]*m/); + if (match) { + result += match[0]; + i += match[0].length; + continue; + } + } + result += str[i]; + visCount++; + i++; + } + + return result + codes.reset + suffix; +} + +export function wrap(str, width) { + const words = str.split(' '); + const lines = []; + let line = ''; + let lineLen = 0; + + for (const word of words) { + const wordLen = visibleLength(word); + const spaceNeeded = line ? 1 : 0; + + if (lineLen + wordLen + spaceNeeded > width) { + if (line) lines.push(line); + line = word; + lineLen = wordLen; + } else { + line = line ? line + ' ' + word : word; + lineLen += wordLen + spaceNeeded; + } + } + if (line) lines.push(line); + return lines; +} + +export class Buffer { + constructor(width, height) { + this.width = width; + this.height = height; + this.lines = Array(height).fill('').map(() => ' '.repeat(width)); + this.styles = Array(height).fill('').map(() => ''); + } + + clear() { + for (let y = 0; y < this.height; y++) { + this.lines[y] = ' '.repeat(this.width); + this.styles[y] = ''; + } + } + + resize(width, height) { + const newLines = []; + const newStyles = []; + for (let y = 0; y < height; y++) { + if (y < this.height) { + newLines.push(pad(this.lines[y], width).slice(0, width)); + newStyles.push(this.styles[y]); + } else { + newLines.push(' '.repeat(width)); + newStyles.push(''); + } + } + this.width = width; + this.height = height; + this.lines = newLines; + this.styles = newStyles; + } + + write(x, y, text, style = '') { + if (y < 0 || y >= this.height) return; + const stripped = stripAnsi(text); + const line = this.lines[y]; + const before = line.slice(0, Math.max(0, x)); + const after = line.slice(x + stripped.length); + this.lines[y] = pad(before, x) + stripped + after; + if (style) this.styles[y] = style; + } + + writeStyled(x, y, text) { + if (y < 0 || y >= this.height) return; + const stripped = stripAnsi(text); + const line = this.lines[y]; + const lineStripped = stripAnsi(line); + + const before = lineStripped.slice(0, Math.max(0, x)); + const after = lineStripped.slice(x + stripped.length); + this.lines[y] = pad(before, x) + text + codes.reset + after; + } + + fill(x, y, width, height, char = ' ', style = '') { + for (let row = y; row < y + height && row < this.height; row++) { + if (row < 0) continue; + const fillStr = char.repeat(width); + this.write(x, row, fillStr, style); + } + } + + box(x, y, width, height, style = box.light, title = '', titleStyle = '') { + if (height < 2 || width < 2) return; + if (!style || !style.h) { + console.error("box() style undefined:", style); + console.error(new Error().stack); + process.exit(1); + } + + const top = style.tl + style.h.repeat(width - 2) + style.tr; + const bottom = style.bl + style.h.repeat(width - 2) + style.br; + + this.writeStyled(x, y, top); + this.writeStyled(x, y + height - 1, bottom); + + for (let row = y + 1; row < y + height - 1 && row < this.height; row++) { + if (row < 0) continue; + this.writeStyled(x, row, style.v); + this.writeStyled(x + width - 1, row, style.v); + } + + if (title) { + const titleStr = ` ${title} `; + const titleX = x + Math.floor((width - visibleLength(titleStr)) / 2); + this.writeStyled(titleX, y, titleStyle + titleStr + codes.reset); + } + } + + render() { + return this.lines.join('\n'); + } + + clone() { + const buf = new Buffer(this.width, this.height); + buf.lines = [...this.lines]; + buf.styles = [...this.styles]; + return buf; + } +} + +export class Screen { + constructor(options = {}) { + this.stdin = options.stdin || process.stdin; + this.stdout = options.stdout || process.stdout; + this.fullscreen = options.fullscreen !== false; + this.hideCursor = options.hideCursor !== false; + this.rawMode = options.rawMode !== false; + + this._width = this.stdout.columns || 80; + this._height = this.stdout.rows || 24; + this._buffer = new Buffer(this._width, this._height); + this._prevBuffer = null; + this._running = false; + this._inputBuf = ''; + this._keyHandlers = []; + this._resizeHandlers = []; + this._modalStack = []; + this._escapeTimer = null; + + this._onData = this._onData.bind(this); + this._onResize = this._onResize.bind(this); + this._cleanup = this._cleanup.bind(this); + } + + get width() { return this._width; } + get height() { return this._height; } + get buffer() { return this._buffer; } + + start() { + if (this._running) return; + this._running = true; + + if (this.rawMode && this.stdin.isTTY) { + this.stdin.setRawMode(true); + } + this.stdin.resume(); + this.stdin.on('data', this._onData); + this.stdout.on('resize', this._onResize); + process.on('SIGINT', this._cleanup); + process.on('SIGTERM', this._cleanup); + + let init = ''; + if (this.fullscreen) init += codes.altScreenOn; + if (this.hideCursor) init += codes.hideCursor; + init += codes.home + codes.clear; + this.stdout.write(init); + } + + stop() { + this._cleanup(); + } + + _cleanup() { + if (!this._running) return; + this._running = false; + + this.stdin.removeListener('data', this._onData); + this.stdout.removeListener('resize', this._onResize); + process.removeListener('SIGINT', this._cleanup); + process.removeListener('SIGTERM', this._cleanup); + + let cleanup = ''; + if (this.hideCursor) cleanup += codes.showCursor; + if (this.fullscreen) cleanup += codes.altScreenOff; + this.stdout.write(cleanup); + + if (this.rawMode && this.stdin.isTTY) { + this.stdin.setRawMode(false); + } + this.stdin.pause(); + } + + _onResize() { + this._width = this.stdout.columns || 80; + this._height = this.stdout.rows || 24; + this._buffer.resize(this._width, this._height); + this._prevBuffer = null; + for (const handler of this._resizeHandlers) { + handler(this._width, this._height); + } + } + + _onData(chunk) { + const str = chunk.toString(); + + if (this._escapeTimer) { + clearTimeout(this._escapeTimer); + this._escapeTimer = null; + } + + if (this._modalStack.length > 0 && str === '\x1b') { + this._emitKey('\x1b'); + return; + } + + for (const ch of str) { + if (this._inputBuf.length > 0) { + this._inputBuf += ch; + if (this._inputBuf.length >= 2) { + if (this._inputBuf.length >= 3 && /[A-Za-z~]/.test(ch)) { + this._emitKey(this._inputBuf); + this._inputBuf = ''; + } else if (this._inputBuf.length === 2 && /[A-Z]/.test(ch)) { + this._emitKey(this._inputBuf); + this._inputBuf = ''; + } else if (this._inputBuf.length > 6) { + this._emitKey(this._inputBuf); + this._inputBuf = ''; + } + } + } else if (ch === '\x1b') { + this._inputBuf = ch; + } else { + this._emitKey(ch); + } + } + + if (this._inputBuf === '\x1b') { + this._escapeTimer = setTimeout(() => { + if (this._inputBuf === '\x1b') { + this._emitKey('\x1b'); + this._inputBuf = ''; + } + this._escapeTimer = null; + }, 50); + } + } + + _emitKey(key) { + if (this._modalStack.length > 0) { + const modal = this._modalStack[this._modalStack.length - 1]; + if (modal.onKey) { + const result = modal.onKey(key, modal); + if (result === false) return; + } + } + + for (const handler of this._keyHandlers) { + handler(key); + } + } + + onKey(handler) { + this._keyHandlers.push(handler); + return () => { + const idx = this._keyHandlers.indexOf(handler); + if (idx >= 0) this._keyHandlers.splice(idx, 1); + }; + } + + onResize(handler) { + this._resizeHandlers.push(handler); + return () => { + const idx = this._resizeHandlers.indexOf(handler); + if (idx >= 0) this._resizeHandlers.splice(idx, 1); + }; + } + + clear() { + this._buffer.clear(); + } + + write(x, y, text, style = '') { + this._buffer.writeStyled(x, y, style + text); + } + + fill(x, y, width, height, char = ' ', style = '') { + for (let row = y; row < y + height && row < this._height; row++) { + if (row < 0) continue; + this.write(x, row, style + char.repeat(width)); + } + } + + box(x, y, width, height, style = box.light, title = '', titleStyle = '') { + this._buffer.box(x, y, width, height, style, title, titleStyle); + } + + pushModal(options) { + const savedBuffer = this._buffer.clone(); + const modal = { + id: options.id || Date.now().toString(), + x: options.x, + y: options.y, + width: options.width, + height: options.height, + savedBuffer, + onKey: options.onKey, + onRender: options.onRender + }; + this._modalStack.push(modal); + return modal; + } + + popModal(id) { + let idx = -1; + if (id) { + idx = this._modalStack.findIndex(m => m.id === id); + } else { + idx = this._modalStack.length - 1; + } + + if (idx >= 0) { + const modal = this._modalStack[idx]; + this._modalStack.splice(idx, 1); + this._buffer = modal.savedBuffer; + return true; + } + return false; + } + + hasModal(id) { + if (id) return this._modalStack.some(m => m.id === id); + return this._modalStack.length > 0; + } + + getModal(id) { + if (id) return this._modalStack.find(m => m.id === id); + return this._modalStack[this._modalStack.length - 1]; + } + + renderModal(modal, renderFn) { + const { x, y, width, height, savedBuffer } = modal; + + for (let row = 0; row < this._height; row++) { + this._buffer.lines[row] = savedBuffer.lines[row]; + } + + const modalBuffer = new Buffer(width, height); + renderFn(modalBuffer, width, height); + + for (let row = 0; row < height && y + row < this._height; row++) { + if (y + row < 0) continue; + const modalLine = modalBuffer.lines[row]; + const bgLine = this._buffer.lines[y + row]; + const bgStripped = stripAnsi(bgLine); + + const before = bgStripped.slice(0, Math.max(0, x)); + const after = bgStripped.slice(x + width); + + this._buffer.lines[y + row] = pad(before, x) + modalLine + after; + } + } + + render() { + for (const modal of this._modalStack) { + if (modal.onRender) { + this.renderModal(modal, modal.onRender); + } + } + + const output = this._buffer.render(); + this.stdout.write( + codes.syncStart + + codes.home + + output + + codes.syncEnd + ); + this._prevBuffer = this._buffer.clone(); + } + + exit(code = 0) { + this._cleanup(); + process.exit(code); + } +} + +export class List { + constructor(options = {}) { + this.items = options.items || []; + this.index = options.index || 0; + this.x = options.x || 0; + this.y = options.y || 0; + this.width = options.width || 40; + this.height = options.height || 10; + this.selectedStyle = options.selectedStyle || colors.bgBlue + colors.bold; + this.normalStyle = options.normalStyle || ''; + this.renderItem = options.renderItem || (item => String(item)); + this.scrollOffset = 0; + } + + setItems(items) { + this.items = items; + this.index = Math.min(this.index, Math.max(0, items.length - 1)); + this._updateScroll(); + } + + select(index) { + this.index = Math.max(0, Math.min(this.items.length - 1, index)); + this._updateScroll(); + } + + selectNext() { + this.select(this.index + 1); + } + + selectPrev() { + this.select(this.index - 1); + } + + pageDown(amount = 10) { + this.select(this.index + amount); + } + + pageUp(amount = 10) { + this.select(this.index - amount); + } + + selectFirst() { + this.select(0); + } + + selectLast() { + this.select(this.items.length - 1); + } + + getSelected() { + return this.items[this.index]; + } + + _updateScroll() { + const half = Math.floor(this.height / 2); + let start = this.index - half; + if (start < 0) start = 0; + if (start + this.height > this.items.length) { + start = Math.max(0, this.items.length - this.height); + } + this.scrollOffset = start; + } + + handleKey(key) { + switch (key) { + case keys.UP: + case 'k': + this.selectPrev(); + return true; + case keys.DOWN: + case 'j': + this.selectNext(); + return true; + case keys.PAGE_UP: + this.pageUp(); + return true; + case keys.PAGE_DOWN: + this.pageDown(); + return true; + case 'g': + this.selectFirst(); + return true; + case 'G': + this.selectLast(); + return true; + } + return false; + } + + render(screen) { + const end = Math.min(this.scrollOffset + this.height, this.items.length); + + for (let i = this.scrollOffset; i < end; i++) { + const row = this.y + (i - this.scrollOffset); + const item = this.items[i]; + const text = truncate(this.renderItem(item, i), this.width); + const isSelected = i === this.index; + const style = isSelected ? this.selectedStyle : this.normalStyle; + + screen.write(this.x, row, pad(style + text + codes.reset, this.width)); + } + + for (let i = end - this.scrollOffset; i < this.height; i++) { + screen.write(this.x, this.y + i, ' '.repeat(this.width)); + } + } +} + +export class ProgressBar { + constructor(options = {}) { + this.value = options.value || 0; + this.max = options.max || 100; + this.width = options.width || 20; + this.filled = options.filled || '█'; + this.empty = options.empty || '░'; + this.filledStyle = options.filledStyle || colors.green; + this.emptyStyle = options.emptyStyle || colors.dim; + this.showPercent = options.showPercent !== false; + } + + setValue(value) { + this.value = Math.max(0, Math.min(this.max, value)); + } + + render() { + const ratio = this.value / this.max; + const filledCount = Math.round(ratio * this.width); + const emptyCount = this.width - filledCount; + + let bar = this.filledStyle + this.filled.repeat(filledCount) + codes.reset; + bar += this.emptyStyle + this.empty.repeat(emptyCount) + codes.reset; + + if (this.showPercent) { + const percent = (ratio * 100).toFixed(0); + bar += ` ${percent}%`; + } + + return bar; + } +} + +export class Input { + constructor(options = {}) { + this.value = options.value || ''; + this.placeholder = options.placeholder || ''; + this.width = options.width || 20; + this.cursorPos = this.value.length; + this.style = options.style || ''; + this.cursorStyle = options.cursorStyle || colors.inverse; + } + + setValue(value) { + this.value = value; + this.cursorPos = value.length; + } + + clear() { + this.value = ''; + this.cursorPos = 0; + } + + handleKey(key) { + if (key === keys.BACKSPACE || key === '\b') { + if (this.cursorPos > 0) { + this.value = this.value.slice(0, this.cursorPos - 1) + this.value.slice(this.cursorPos); + this.cursorPos--; + } + return true; + } else if (key === keys.DELETE) { + if (this.cursorPos < this.value.length) { + this.value = this.value.slice(0, this.cursorPos) + this.value.slice(this.cursorPos + 1); + } + return true; + } else if (key === keys.LEFT) { + this.cursorPos = Math.max(0, this.cursorPos - 1); + return true; + } else if (key === keys.RIGHT) { + this.cursorPos = Math.min(this.value.length, this.cursorPos + 1); + return true; + } else if (key === keys.HOME) { + this.cursorPos = 0; + return true; + } else if (key === keys.END) { + this.cursorPos = this.value.length; + return true; + } else if (key.length === 1 && key >= ' ' && key <= '~') { + this.value = this.value.slice(0, this.cursorPos) + key + this.value.slice(this.cursorPos); + this.cursorPos++; + return true; + } + return false; + } + + render() { + let display = this.value || this.placeholder; + const isPlaceholder = !this.value && this.placeholder; + + if (isPlaceholder) { + display = colors.dim + display + codes.reset; + } + + if (this.value.length > 0) { + const before = this.value.slice(0, this.cursorPos); + const cursor = this.value[this.cursorPos] || ' '; + const after = this.value.slice(this.cursorPos + 1); + display = this.style + before + this.cursorStyle + cursor + codes.reset + this.style + after + codes.reset; + } else { + display = this.style + this.cursorStyle + ' ' + codes.reset; + } + + return truncate(display, this.width); + } +} + +export class Table { + constructor(options = {}) { + this.columns = options.columns || []; + this.rows = options.rows || []; + this.x = options.x || 0; + this.y = options.y || 0; + this.width = options.width; + this.headerStyle = options.headerStyle || colors.bold; + this.rowStyle = options.rowStyle || ''; + this.altRowStyle = options.altRowStyle || colors.dim; + this.borderStyle = options.borderStyle || box.light; + this.showBorder = options.showBorder !== false; + } + + _calcColumnWidths() { + const widths = this.columns.map(col => visibleLength(col.header || col.key)); + + for (const row of this.rows) { + for (let i = 0; i < this.columns.length; i++) { + const col = this.columns[i]; + const value = String(row[col.key] ?? ''); + widths[i] = Math.max(widths[i], visibleLength(value)); + } + } + + if (this.width) { + const totalWidth = widths.reduce((a, b) => a + b, 0); + const available = this.width - (this.showBorder ? this.columns.length + 1 : 0); + if (totalWidth > available) { + const scale = available / totalWidth; + for (let i = 0; i < widths.length; i++) { + widths[i] = Math.floor(widths[i] * scale); + } + } + } + + return widths; + } + + render(screen) { + const widths = this._calcColumnWidths(); + const bs = this.borderStyle; + let row = this.y; + + if (this.showBorder) { + let top = bs.tl; + for (let i = 0; i < widths.length; i++) { + top += bs.h.repeat(widths[i] + 2); + top += i < widths.length - 1 ? bs.tT : bs.tr; + } + screen.write(this.x, row++, top); + } + + let header = this.showBorder ? bs.v : ''; + for (let i = 0; i < this.columns.length; i++) { + const col = this.columns[i]; + const text = pad(col.header || col.key, widths[i]); + header += ' ' + this.headerStyle + text + codes.reset + ' '; + if (this.showBorder) header += bs.v; + } + screen.write(this.x, row++, header); + + if (this.showBorder) { + let sep = bs.lT; + for (let i = 0; i < widths.length; i++) { + sep += bs.h.repeat(widths[i] + 2); + sep += i < widths.length - 1 ? bs.cross : bs.rT; + } + screen.write(this.x, row++, sep); + } + + for (let r = 0; r < this.rows.length; r++) { + const data = this.rows[r]; + const style = r % 2 === 0 ? this.rowStyle : this.altRowStyle; + let line = this.showBorder ? bs.v : ''; + for (let i = 0; i < this.columns.length; i++) { + const col = this.columns[i]; + const value = String(data[col.key] ?? ''); + const text = pad(truncate(value, widths[i]), widths[i]); + line += ' ' + style + text + codes.reset + ' '; + if (this.showBorder) line += bs.v; + } + screen.write(this.x, row++, line); + } + + if (this.showBorder) { + let bottom = bs.bl; + for (let i = 0; i < widths.length; i++) { + bottom += bs.h.repeat(widths[i] + 2); + bottom += i < widths.length - 1 ? bs.bT : bs.br; + } + screen.write(this.x, row++, bottom); + } + + return row - this.y; + } +} + +export function modal(screen, options) { + const width = options.width || 40; + const height = options.height || 10; + const x = options.x ?? Math.floor((screen.width - width) / 2); + const y = options.y ?? Math.floor((screen.height - height) / 2); + const title = options.title || ''; + const titleStyle = options.titleStyle || colors.bold; + const borderStyle = options.borderStyle || box.rounded; + const bgStyle = options.bgStyle || ''; + + return screen.pushModal({ + id: options.id, + x, + y, + width, + height, + onKey: options.onKey, + onRender: (buf, w, h) => { + buf.fill(0, 0, w, h, ' '); + buf.box(0, 0, w, h, borderStyle, title, titleStyle); + + if (options.render) { + options.render(buf, w - 2, h - 2, 1, 1); + } + } + }); +} + +export function confirm(screen, options) { + return new Promise(resolve => { + const message = options.message || 'Are you sure?'; + const width = Math.max(visibleLength(message) + 4, 30); + const height = 7; + + modal(screen, { + id: 'confirm', + width, + height, + title: options.title || 'Confirm', + titleStyle: colors.bold + colors.yellow, + onKey: key => { + if (key === 'y' || key === 'Y' || key === keys.ENTER) { + screen.popModal('confirm'); + screen.render(); + resolve(true); + } else if (key === 'n' || key === 'N' || key === keys.ESCAPE || key === keys.CTRL_C) { + screen.popModal('confirm'); + screen.render(); + resolve(false); + } + return false; + }, + render: (buf, w, h, ox, oy) => { + buf.writeStyled(ox, oy + 1, padCenter(message, w)); + buf.writeStyled(ox, oy + 3, padCenter(`${colors.green}[Y]es${codes.reset} ${colors.red}[N]o${codes.reset}`, w + 20)); + } + }); + screen.render(); + }); +} + +export function alert(screen, options) { + return new Promise(resolve => { + const message = options.message || ''; + const lines = wrap(message, 50); + const width = Math.max(...lines.map(visibleLength), 20) + 4; + const height = lines.length + 5; + + modal(screen, { + id: 'alert', + width, + height, + title: options.title || 'Alert', + titleStyle: colors.bold + colors.cyan, + onKey: key => { + if (key === keys.ENTER || key === keys.ESCAPE || key === ' ') { + screen.popModal('alert'); + screen.render(); + resolve(); + } + return false; + }, + render: (buf, w, h, ox, oy) => { + for (let i = 0; i < lines.length; i++) { + buf.writeStyled(ox, oy + i + 1, padCenter(lines[i], w)); + } + buf.writeStyled(ox, oy + lines.length + 2, padCenter(`${colors.dim}[Enter] OK${codes.reset}`, w + 10)); + } + }); + screen.render(); + }); +} + +export default { + codes, + colors, + box, + keys, + stripAnsi, + visibleLength, + pad, + padStart, + padCenter, + truncate, + wrap, + Buffer, + Screen, + List, + ProgressBar, + Input, + Table, + modal, + confirm, + alert +}; diff --git a/src/ant.c b/src/ant.c index e5e82c1..a7f42ec 100644 --- a/src/ant.c +++ b/src/ant.c @@ -1056,9 +1056,9 @@ void js_run_event_loop(struct js *js) { if (work & (WORK_READLINE | WORK_STDIN)) { uv_run(uv_default_loop(), UV_RUN_NOWAIT); - } - - if (!(work & WORK_BLOCKING) && (work & WORK_TIMERS)) { + int64_t ms = has_pending_timers() ? get_next_timer_timeout() : 20; + if (ms > 20) ms = 20; if (ms > 0) usleep((useconds_t)(ms * 1000)); + } else if (!(work & WORK_BLOCKING) && (work & WORK_TIMERS)) { jsoff_t gc_thresh = js->brk / 2; if (gc_thresh < 4 * 1024 * 1024) gc_thresh = 4 * 1024 * 1024; if (js->gc_alloc_since > gc_thresh || js->needs_gc) { @@ -1066,11 +1066,10 @@ void js_run_event_loop(struct js *js) { js_gc_compact(js); js->gc_alloc_since = 0; } + int64_t ms = get_next_timer_timeout(); if (ms > 0) usleep(ms > 1000 ? 1000000 : (useconds_t)(ms * 1000)); - } else if ( - (work & (WORK_READLINE | WORK_STDIN)) && !(work & WORK_BLOCKING) - ) uv_run(uv_default_loop(), UV_RUN_ONCE); + } } js_poll_events(js); diff --git a/src/modules/fetch.c b/src/modules/fetch.c index daee53c..40c0d45 100644 --- a/src/modules/fetch.c +++ b/src/modules/fetch.c @@ -363,7 +363,7 @@ int has_pending_fetches(void) { void fetch_poll_events(void) { if (fetch_loop && fetch_loop == uv_default_loop() && (rt->flags & ANT_RUNTIME_EXT_EVENT_LOOP)) return; if (fetch_loop && uv_loop_alive(fetch_loop)) { - uv_run(fetch_loop, UV_RUN_ONCE); + uv_run(fetch_loop, fetch_loop == uv_default_loop() ? UV_RUN_NOWAIT : UV_RUN_ONCE); if (pending_requests && utarray_len(pending_requests) > 0) usleep(1000); } } diff --git a/src/modules/fs.c b/src/modules/fs.c index b1c6f4b..b0e4e0f 100644 --- a/src/modules/fs.c +++ b/src/modules/fs.c @@ -1306,7 +1306,7 @@ int has_pending_fs_ops(void) { void fs_poll_events(void) { if (fs_loop && fs_loop == uv_default_loop() && (rt->flags & ANT_RUNTIME_EXT_EVENT_LOOP)) return; if (fs_loop && uv_loop_alive(fs_loop)) { - uv_run(fs_loop, UV_RUN_ONCE); + uv_run(fs_loop, fs_loop == uv_default_loop() ? UV_RUN_NOWAIT : UV_RUN_ONCE); if (pending_requests && utarray_len(pending_requests) > 0) usleep(1000); } } diff --git a/tests/test_gc_coro.js b/tests/test_gc_coro.js new file mode 100644 index 0000000..d5ab194 --- /dev/null +++ b/tests/test_gc_coro.js @@ -0,0 +1,59 @@ +// Simulate TUI-like behavior: async handler with lots of string allocations + +function stripAnsi(str) { + return str.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function pad(str, len) { + const visible = stripAnsi(str).length; + const diff = len - visible; + if (diff <= 0) return str; + return str + ' '.repeat(diff); +} + +function render() { + // Simulate TUI render - matches what tui.js does + const width = 120; + const height = 40; + const lines = []; + + // Clear buffer + for (let y = 0; y < height; y++) { + lines.push(' '.repeat(width)); + } + + // Draw styled content (like the TUI does) + for (let y = 0; y < height; y++) { + let text = `\x1b[38;5;196mRow ${y}\x1b[0m: `; + text += `\x1b[38;5;82m${'█'.repeat(20)}\x1b[0m`; + text += `\x1b[2m${'░'.repeat(20)}\x1b[0m`; + text = pad(text, width); + lines[y] = text; + } + + return lines.join('\n'); +} + +async function handleEvent(n) { + // Simulate multiple renders per event (like scrolling does) + for (let i = 0; i < 10; i++) { + render(); + } +} + +let count = 0; +const interval = setInterval(() => { + count++; + handleEvent(count); + + const stats = Ant.stats(); + console.log(`tick ${count}: arena ${(stats.arenaUsed / 1024 / 1024).toFixed(1)}MB / ${(stats.arenaSize / 1024 / 1024).toFixed(1)}MB, rss ${(stats.rss / 1024 / 1024).toFixed(1)}MB`); + + if (count >= 500) { // Run for ~5 seconds + clearInterval(interval); + console.log('Done - forcing GC...'); + Ant.gc(); + const after = Ant.stats(); + console.log(`after GC: arena ${(after.arenaUsed / 1024 / 1024).toFixed(1)}MB`); + } +}, 10); // 500 * 10ms = 5 seconds