MIRROR: javascript for ๐Ÿœ's, a tiny runtime with big ambitions
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add TUI demo with task management and system monitoring

+2242 -8
+293
examples/tui/docker.js
··· 1 + import { Screen, List, Input, colors, box, keys, codes, confirm, alert, pad, padCenter, truncate } from './tuey.js'; 2 + import { $ } from 'ant:shell'; 3 + 4 + const screen = new Screen({ fullscreen: true, hideCursor: true }); 5 + 6 + let containers = []; 7 + 8 + const state = { 9 + searchMode: false, 10 + searchQuery: '', 11 + lastError: '' 12 + }; 13 + 14 + const searchInput = new Input({ width: 30, placeholder: 'Search containers...' }); 15 + 16 + const containerList = new List({ 17 + items: [], 18 + x: 2, 19 + y: 6, 20 + width: 60, 21 + height: 10, 22 + selectedStyle: colors.bgBlue + colors.bold + colors.white, 23 + renderItem: container => formatContainer(container) 24 + }); 25 + 26 + function runDocker(command) { 27 + const result = $(command); 28 + if (result.exitCode !== 0) { 29 + const output = result.text().trim(); 30 + state.lastError = output || `Command failed: ${command}`; 31 + return null; 32 + } 33 + return result.text(); 34 + } 35 + 36 + function isRunning(container) { 37 + return container.status && container.status.startsWith('Up'); 38 + } 39 + 40 + function formatStatus(container) { 41 + if (!container.status) { 42 + return colors.dim + 'UNKNOWN' + codes.reset; 43 + } 44 + if (isRunning(container)) { 45 + return colors.green + 'UP' + codes.reset; 46 + } 47 + if (container.status.startsWith('Exited')) { 48 + return colors.red + 'EXIT' + codes.reset; 49 + } 50 + return colors.yellow + truncate(container.status.split(' ')[0].toUpperCase(), 6) + codes.reset; 51 + } 52 + 53 + function formatContainer(container) { 54 + const width = containerList.width; 55 + const nameWidth = 20; 56 + const imageWidth = 20; 57 + const statusWidth = 8; 58 + const portsWidth = Math.max(10, width - nameWidth - imageWidth - statusWidth - 6); 59 + 60 + const name = pad(truncate(container.name || '', nameWidth), nameWidth); 61 + const image = pad(truncate(container.image || '', imageWidth), imageWidth); 62 + const ports = pad(truncate(container.ports || '', portsWidth), portsWidth); 63 + 64 + return `${name} ${image} ${formatStatus(container)} ${ports}`; 65 + } 66 + 67 + function parseContainers(output) { 68 + const lines = output.trim() ? output.trim().split('\n') : []; 69 + return lines.map(line => { 70 + const [id, name, image, status, ports] = line.split('\t'); 71 + return { 72 + id: id || '', 73 + name: name || '', 74 + image: image || '', 75 + status: status || '', 76 + ports: ports || '' 77 + }; 78 + }); 79 + } 80 + 81 + function applyFilter() { 82 + let filtered = containers; 83 + if (state.searchQuery) { 84 + const q = state.searchQuery.toLowerCase(); 85 + filtered = containers.filter(container => { 86 + return container.name.toLowerCase().includes(q) || container.image.toLowerCase().includes(q) || container.id.toLowerCase().includes(q); 87 + }); 88 + } 89 + containerList.setItems(filtered); 90 + } 91 + 92 + function refreshContainers() { 93 + state.lastError = ''; 94 + const output = runDocker("docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"); 95 + if (output === null) { 96 + containers = []; 97 + containerList.setItems([]); 98 + return; 99 + } 100 + containers = parseContainers(output); 101 + applyFilter(); 102 + } 103 + 104 + function drawHeader() { 105 + const title = ' Docker TUI - Containers '; 106 + const w = screen.width; 107 + screen.write(0, 0, colors.bgBlue + colors.bold + colors.white + padCenter(title, w) + codes.reset); 108 + 109 + const statusLine = state.lastError 110 + ? colors.bgRed + colors.white + pad(` Error: ${truncate(state.lastError, w - 8)} `, w) + codes.reset 111 + : colors.bgGray + colors.white + pad(' Connected to docker CLI ', w) + codes.reset; 112 + screen.write(0, 1, statusLine); 113 + } 114 + 115 + function drawFooter() { 116 + const w = screen.width; 117 + const help = ' Up/Down:Navigate Enter:Start/Stop s:Start t:Stop R:Restart r:Refresh /:Search q:Quit '; 118 + screen.write(0, screen.height - 1, colors.bgGray + colors.white + pad(help, w) + codes.reset); 119 + } 120 + 121 + function drawList() { 122 + const listWidth = screen.width > 95 ? 62 : Math.max(40, screen.width - 4); 123 + containerList.width = listWidth; 124 + containerList.height = Math.max(5, screen.height - 8); 125 + containerList.x = 2; 126 + containerList.y = state.searchMode ? 7 : 6; 127 + 128 + const heading = pad('Name', 20) + ' ' + pad('Image', 20) + ' ' + pad('State', 8) + ' ' + pad('Ports', listWidth - 20 - 20 - 8 - 3); 129 + screen.write(2, 5, colors.bold + heading + codes.reset); 130 + 131 + if (state.searchMode) { 132 + screen.write(2, 3, colors.cyan + 'Search: ' + codes.reset + searchInput.render()); 133 + } else { 134 + screen.write(2, 3, colors.bold + `Containers - ${containerList.items.length} total` + codes.reset); 135 + } 136 + 137 + containerList.render(screen); 138 + 139 + if (screen.width > 95) { 140 + drawDetailsPanel(listWidth + 4); 141 + } 142 + } 143 + 144 + function drawDetailsPanel(x) { 145 + const selected = containerList.getSelected(); 146 + const width = screen.width - x - 2; 147 + if (!selected || width < 20) return; 148 + 149 + const panelHeight = 10; 150 + screen.box(x, 5, width, panelHeight, box.rounded, 'Details', colors.bold + colors.cyan); 151 + screen.write(x + 2, 7, `${colors.bold}Name:${codes.reset} ${truncate(selected.name, width - 12)}`); 152 + screen.write(x + 2, 8, `${colors.bold}ID:${codes.reset} ${truncate(selected.id, width - 10)}`); 153 + screen.write(x + 2, 9, `${colors.bold}Image:${codes.reset} ${truncate(selected.image, width - 12)}`); 154 + screen.write(x + 2, 10, `${colors.bold}Status:${codes.reset} ${truncate(selected.status, width - 13)}`); 155 + screen.write(x + 2, 11, `${colors.bold}Ports:${codes.reset} ${truncate(selected.ports || '-', width - 12)}`); 156 + } 157 + 158 + function render() { 159 + screen.clear(); 160 + drawHeader(); 161 + drawList(); 162 + drawFooter(); 163 + screen.render(); 164 + } 165 + 166 + function runAction(action, container) { 167 + const verb = action.charAt(0).toUpperCase() + action.slice(1); 168 + confirm(screen, { 169 + title: `${verb} Container`, 170 + message: `Run: docker ${action} ${container.name}?` 171 + }).then(confirmed => { 172 + if (!confirmed) { 173 + render(); 174 + return; 175 + } 176 + 177 + const output = runDocker(`docker ${action} ${container.id}`); 178 + if (output === null) { 179 + alert(screen, { 180 + title: 'Docker Error', 181 + message: state.lastError || 'Docker command failed.' 182 + }).then(() => { 183 + refreshContainers(); 184 + render(); 185 + }); 186 + return; 187 + } 188 + 189 + refreshContainers(); 190 + render(); 191 + }); 192 + } 193 + 194 + function toggleStartStop(container) { 195 + if (isRunning(container)) { 196 + runAction('stop', container); 197 + } else { 198 + runAction('start', container); 199 + } 200 + } 201 + 202 + function handleKey(key) { 203 + if (screen.hasModal()) return; 204 + 205 + if (state.searchMode) { 206 + if (key === keys.ESCAPE) { 207 + state.searchMode = false; 208 + state.searchQuery = ''; 209 + searchInput.clear(); 210 + applyFilter(); 211 + } else if (key === keys.ENTER) { 212 + state.searchMode = false; 213 + state.searchQuery = searchInput.value; 214 + applyFilter(); 215 + } else { 216 + searchInput.handleKey(key); 217 + state.searchQuery = searchInput.value; 218 + applyFilter(); 219 + } 220 + render(); 221 + return; 222 + } 223 + 224 + switch (key) { 225 + case 'q': 226 + case keys.CTRL_C: 227 + confirm(screen, { 228 + title: 'Quit', 229 + message: 'Exit Docker TUI?' 230 + }).then(confirmed => { 231 + if (confirmed) { 232 + screen.exit(0); 233 + } 234 + render(); 235 + }); 236 + return; 237 + case '/': 238 + state.searchMode = true; 239 + searchInput.clear(); 240 + render(); 241 + return; 242 + case 'r': 243 + refreshContainers(); 244 + render(); 245 + return; 246 + case keys.ENTER: { 247 + const selected = containerList.getSelected(); 248 + if (selected) { 249 + toggleStartStop(selected); 250 + } 251 + return; 252 + } 253 + case 's': { 254 + const selected = containerList.getSelected(); 255 + if (selected) { 256 + runAction('start', selected); 257 + } 258 + return; 259 + } 260 + case 't': { 261 + const selected = containerList.getSelected(); 262 + if (selected) { 263 + runAction('stop', selected); 264 + } 265 + return; 266 + } 267 + case 'R': { 268 + const selected = containerList.getSelected(); 269 + if (selected) { 270 + runAction('restart', selected); 271 + } 272 + return; 273 + } 274 + } 275 + 276 + if (containerList.handleKey(key)) { 277 + render(); 278 + } 279 + } 280 + 281 + screen.onKey(handleKey); 282 + screen.onResize(() => render()); 283 + 284 + screen.start(); 285 + refreshContainers(); 286 + render(); 287 + 288 + setInterval(() => { 289 + if (!screen.hasModal()) { 290 + refreshContainers(); 291 + render(); 292 + } 293 + }, 4000);
+526
examples/tui/index.js
··· 1 + import { Screen, List, ProgressBar, Input, Table, colors, box, keys, codes, modal, confirm, pad, padCenter, truncate } from './tuey.js'; 2 + 3 + const screen = new Screen({ fullscreen: true, hideCursor: true }); 4 + 5 + const tasks = [ 6 + { id: 1, name: 'Build TUI library', status: 'done', priority: 'high' }, 7 + { id: 2, name: 'Add modal support', status: 'done', priority: 'high' }, 8 + { id: 3, name: 'Implement themes', status: 'in_progress', priority: 'medium' }, 9 + { id: 4, name: 'Write documentation', status: 'todo', priority: 'low' }, 10 + { id: 5, name: 'Add animation support', status: 'todo', priority: 'low' }, 11 + { id: 6, name: 'Create widget system', status: 'in_progress', priority: 'medium' }, 12 + { id: 7, name: 'Performance optimization', status: 'todo', priority: 'high' }, 13 + { id: 8, name: 'Add mouse support', status: 'todo', priority: 'low' }, 14 + { id: 9, name: 'Create color picker', status: 'todo', priority: 'medium' }, 15 + { id: 10, name: 'Build file browser', status: 'in_progress', priority: 'high' } 16 + ]; 17 + 18 + const logs = [ 19 + { time: '10:23:45', level: 'INFO', message: 'Application started' }, 20 + { time: '10:23:46', level: 'DEBUG', message: 'Loading configuration...' }, 21 + { time: '10:23:47', level: 'INFO', message: 'Config loaded successfully' }, 22 + { time: '10:23:48', level: 'WARN', message: 'Cache directory not found, creating...' }, 23 + { time: '10:23:49', level: 'INFO', message: 'Cache initialized' }, 24 + { time: '10:24:01', level: 'DEBUG', message: 'Connecting to database...' }, 25 + { time: '10:24:02', level: 'INFO', message: 'Database connection established' }, 26 + { time: '10:24:05', level: 'ERROR', message: 'Failed to load plugin: missing-plugin' }, 27 + { time: '10:24:06', level: 'WARN', message: 'Running with reduced functionality' }, 28 + { time: '10:24:10', level: 'INFO', message: 'Ready to accept connections' } 29 + ]; 30 + 31 + let state = { 32 + view: 'dashboard', 33 + taskFilter: 'all', 34 + searchMode: false, 35 + searchQuery: '', 36 + cpuUsage: 45, 37 + memUsage: 62, 38 + diskUsage: 78, 39 + networkIn: 0, 40 + networkOut: 0 41 + }; 42 + 43 + const taskList = new List({ 44 + items: tasks, 45 + x: 2, 46 + y: 5, 47 + width: 50, 48 + height: 12, 49 + selectedStyle: colors.bgBlue + colors.bold + colors.white, 50 + renderItem: task => { 51 + const statusIcon = 52 + task.status === 'done' 53 + ? `${colors.green}โœ“${codes.reset}` 54 + : task.status === 'in_progress' 55 + ? `${colors.yellow}โ—${codes.reset}` 56 + : `${colors.gray}โ—‹${codes.reset}`; 57 + const priorityColor = task.priority === 'high' ? colors.red : task.priority === 'medium' ? colors.yellow : colors.gray; 58 + return ` ${statusIcon} ${task.name} ${priorityColor}[${task.priority}]${codes.reset}`; 59 + } 60 + }); 61 + 62 + const logList = new List({ 63 + items: logs, 64 + x: 2, 65 + y: 5, 66 + width: screen.width - 4, 67 + height: 15, 68 + selectedStyle: colors.bgGray + colors.white, 69 + renderItem: log => { 70 + const levelColor = log.level === 'ERROR' ? colors.red : log.level === 'WARN' ? colors.yellow : log.level === 'DEBUG' ? colors.cyan : colors.green; 71 + return `${colors.dim}${log.time}${codes.reset} ${levelColor}${pad(log.level, 5)}${codes.reset} ${log.message}`; 72 + } 73 + }); 74 + 75 + const cpuBar = new ProgressBar({ width: 25, filledStyle: colors.green, showPercent: true }); 76 + const memBar = new ProgressBar({ width: 25, filledStyle: colors.blue, showPercent: true }); 77 + const diskBar = new ProgressBar({ width: 25, filledStyle: colors.yellow, showPercent: true }); 78 + 79 + const searchInput = new Input({ width: 30, placeholder: 'Search tasks...' }); 80 + 81 + function filterTasks() { 82 + let filtered = tasks; 83 + if (state.taskFilter !== 'all') { 84 + filtered = filtered.filter(t => t.status === state.taskFilter); 85 + } 86 + if (state.searchQuery) { 87 + const q = state.searchQuery.toLowerCase(); 88 + filtered = filtered.filter(t => t.name.toLowerCase().includes(q)); 89 + } 90 + taskList.setItems(filtered); 91 + } 92 + 93 + function drawHeader() { 94 + const title = ' TUI Demo - Press ? for help '; 95 + const w = screen.width; 96 + 97 + screen.write(0, 0, colors.bgBlue + colors.bold + colors.white + padCenter(title, w) + codes.reset); 98 + 99 + const tabs = [ 100 + { key: '1', name: 'Dashboard', view: 'dashboard' }, 101 + { key: '2', name: 'Tasks', view: 'tasks' }, 102 + { key: '3', name: 'Logs', view: 'logs' }, 103 + { key: '4', name: 'Settings', view: 'settings' } 104 + ]; 105 + 106 + let tabLine = ' '; 107 + for (const tab of tabs) { 108 + const isActive = state.view === tab.view; 109 + const style = isActive ? colors.bgWhite + colors.black + colors.bold : colors.dim; 110 + tabLine += `${style} ${tab.key}:${tab.name} ${codes.reset} `; 111 + } 112 + screen.write(0, 1, tabLine); 113 + } 114 + 115 + function drawFooter() { 116 + const w = screen.width; 117 + const h = screen.height; 118 + 119 + const help = 120 + state.view === 'tasks' ? ' โ†‘โ†“:Navigate Enter:Toggle a:All t:Todo p:Progress d:Done /:Search q:Quit ' : ' 1-4:Switch tabs ?:Help q:Quit '; 121 + 122 + screen.write(0, h - 1, colors.bgGray + colors.white + pad(help, w) + codes.reset); 123 + } 124 + 125 + function drawDashboard() { 126 + const w = screen.width; 127 + 128 + screen.write(2, 3, colors.bold + colors.cyan + 'โ”Œโ”€ System Status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”' + codes.reset); 129 + 130 + cpuBar.setValue(state.cpuUsage); 131 + memBar.setValue(state.memUsage); 132 + diskBar.setValue(state.diskUsage); 133 + 134 + screen.write(2, 4, colors.cyan + 'โ”‚' + codes.reset); 135 + screen.write(4, 4, `CPU: ${cpuBar.render()}`); 136 + screen.write(45, 4, colors.cyan + 'โ”‚' + codes.reset); 137 + 138 + screen.write(2, 5, colors.cyan + 'โ”‚' + codes.reset); 139 + screen.write(4, 5, `Memory: ${memBar.render()}`); 140 + screen.write(45, 5, colors.cyan + 'โ”‚' + codes.reset); 141 + 142 + screen.write(2, 6, colors.cyan + 'โ”‚' + codes.reset); 143 + screen.write(4, 6, `Disk: ${diskBar.render()}`); 144 + screen.write(45, 6, colors.cyan + 'โ”‚' + codes.reset); 145 + 146 + screen.write(2, 7, colors.cyan + 'โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜' + codes.reset); 147 + 148 + screen.write(2, 9, colors.bold + colors.yellow + 'โ”Œโ”€ Task Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”' + codes.reset); 149 + 150 + const done = tasks.filter(t => t.status === 'done').length; 151 + const inProgress = tasks.filter(t => t.status === 'in_progress').length; 152 + const todo = tasks.filter(t => t.status === 'todo').length; 153 + 154 + screen.write(2, 10, colors.yellow + 'โ”‚' + codes.reset); 155 + screen.write( 156 + 4, 157 + 10, 158 + `${colors.green}โœ“ Completed:${codes.reset} ${done} ${colors.yellow}โ— In Progress:${codes.reset} ${inProgress} ${colors.gray}โ—‹ Todo:${codes.reset} ${todo}` 159 + ); 160 + screen.write(45, 10, colors.yellow + 'โ”‚' + codes.reset); 161 + 162 + const progress = new ProgressBar({ 163 + value: done, 164 + max: tasks.length, 165 + width: 35, 166 + filledStyle: colors.green, 167 + showPercent: true 168 + }); 169 + 170 + screen.write(2, 11, colors.yellow + 'โ”‚' + codes.reset); 171 + screen.write(4, 11, `Progress: ${progress.render()}`); 172 + screen.write(45, 11, colors.yellow + 'โ”‚' + codes.reset); 173 + 174 + screen.write(2, 12, colors.yellow + 'โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜' + codes.reset); 175 + 176 + screen.write(2, 14, colors.bold + colors.magenta + 'โ”Œโ”€ Recent Activity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”' + codes.reset); 177 + 178 + for (let i = 0; i < Math.min(5, logs.length); i++) { 179 + const log = logs[logs.length - 1 - i]; 180 + const levelColor = log.level === 'ERROR' ? colors.red : log.level === 'WARN' ? colors.yellow : colors.green; 181 + screen.write(2, 15 + i, colors.magenta + 'โ”‚' + codes.reset); 182 + screen.write(4, 15 + i, `${colors.dim}${log.time}${codes.reset} ${levelColor}${log.level}${codes.reset} ${truncate(log.message, 30)}`); 183 + screen.write(45, 15 + i, colors.magenta + 'โ”‚' + codes.reset); 184 + } 185 + 186 + screen.write(2, 20, colors.magenta + 'โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜' + codes.reset); 187 + 188 + if (w > 55) { 189 + screen.box(50, 3, 30, 10, box.rounded, 'Quick Stats', colors.bold + colors.green); 190 + screen.write(52, 5, `${colors.bold}Uptime:${codes.reset} 2h 34m`); 191 + screen.write(52, 6, `${colors.bold}Network In:${codes.reset} ${state.networkIn} KB/s`); 192 + screen.write(52, 7, `${colors.bold}Network Out:${codes.reset} ${state.networkOut} KB/s`); 193 + screen.write(52, 8, `${colors.bold}Active Users:${codes.reset} 42`); 194 + screen.write(52, 9, `${colors.bold}Requests/s:${codes.reset} 1,234`); 195 + screen.write(52, 10, `${colors.bold}Errors:${codes.reset} ${colors.red}3${codes.reset}`); 196 + } 197 + } 198 + 199 + function drawTasks() { 200 + const filterLabel = 201 + state.taskFilter === 'all' ? 'All' : state.taskFilter === 'todo' ? 'Todo' : state.taskFilter === 'in_progress' ? 'In Progress' : 'Done'; 202 + 203 + screen.write(2, 3, colors.bold + `Tasks [${filterLabel}] - ${taskList.items.length} items` + codes.reset); 204 + 205 + if (state.searchMode) { 206 + screen.write(2, 4, colors.cyan + 'Search: ' + codes.reset + searchInput.render()); 207 + } 208 + 209 + taskList.y = state.searchMode ? 6 : 5; 210 + taskList.height = state.searchMode ? screen.height - 9 : screen.height - 8; 211 + taskList.width = Math.min(60, screen.width - 4); 212 + taskList.render(screen); 213 + 214 + const selected = taskList.getSelected(); 215 + if (selected && screen.width > 65) { 216 + const detailX = taskList.width + 5; 217 + screen.box(detailX, 5, 35, 12, box.rounded, 'Details', colors.bold + colors.cyan); 218 + 219 + screen.write(detailX + 2, 7, `${colors.bold}ID:${codes.reset} ${selected.id}`); 220 + screen.write(detailX + 2, 8, `${colors.bold}Name:${codes.reset} ${truncate(selected.name, 25)}`); 221 + screen.write(detailX + 2, 9, `${colors.bold}Status:${codes.reset} ${selected.status}`); 222 + screen.write(detailX + 2, 10, `${colors.bold}Priority:${codes.reset} ${selected.priority}`); 223 + 224 + screen.write(detailX + 2, 12, colors.dim + 'Enter to toggle status' + codes.reset); 225 + screen.write(detailX + 2, 13, colors.dim + 'D to delete task' + codes.reset); 226 + screen.write(detailX + 2, 14, colors.dim + 'N to add new task' + codes.reset); 227 + } 228 + } 229 + 230 + function drawLogs() { 231 + screen.write(2, 3, colors.bold + `System Logs - ${logs.length} entries` + codes.reset); 232 + 233 + logList.width = screen.width - 4; 234 + logList.height = screen.height - 8; 235 + logList.render(screen); 236 + } 237 + 238 + function drawSettings() { 239 + screen.write(2, 3, colors.bold + colors.cyan + 'Settings' + codes.reset); 240 + 241 + const table = new Table({ 242 + x: 2, 243 + y: 5, 244 + width: 60, 245 + columns: [ 246 + { key: 'setting', header: 'Setting' }, 247 + { key: 'value', header: 'Value' }, 248 + { key: 'description', header: 'Description' } 249 + ], 250 + rows: [ 251 + { setting: 'Theme', value: 'Dark', description: 'UI color scheme' }, 252 + { setting: 'Refresh Rate', value: '1000ms', description: 'Dashboard update interval' }, 253 + { setting: 'Log Level', value: 'INFO', description: 'Minimum log level to display' }, 254 + { setting: 'Notifications', value: 'Enabled', description: 'Show system notifications' }, 255 + { setting: 'Auto-save', value: 'On', description: 'Automatically save changes' } 256 + ], 257 + borderStyle: box.rounded, 258 + headerStyle: colors.bold + colors.cyan 259 + }); 260 + 261 + table.render(screen); 262 + 263 + screen.write(2, 15, colors.dim + 'Press Enter on a setting to modify it' + codes.reset); 264 + } 265 + 266 + function render() { 267 + screen.clear(); 268 + drawHeader(); 269 + 270 + switch (state.view) { 271 + case 'dashboard': 272 + drawDashboard(); 273 + break; 274 + case 'tasks': 275 + drawTasks(); 276 + break; 277 + case 'logs': 278 + drawLogs(); 279 + break; 280 + case 'settings': 281 + drawSettings(); 282 + break; 283 + } 284 + 285 + drawFooter(); 286 + screen.render(); 287 + } 288 + 289 + function showHelp() { 290 + modal(screen, { 291 + id: 'help', 292 + width: 50, 293 + height: 18, 294 + title: 'Keyboard Shortcuts', 295 + titleStyle: colors.bold + colors.cyan, 296 + borderStyle: box.double, 297 + onKey: key => { 298 + if (key === keys.ESCAPE || key === keys.ENTER || key === '?') { 299 + screen.popModal('help'); 300 + render(); 301 + } 302 + return false; 303 + }, 304 + render: (buf, w, _h, ox, oy) => { 305 + const shortcuts = [ 306 + ['1-4', 'Switch between tabs'], 307 + ['โ†‘/โ†“ or j/k', 'Navigate lists'], 308 + ['Enter', 'Select/Toggle item'], 309 + ['/', 'Search (in Tasks)'], 310 + ['Escape', 'Cancel/Close'], 311 + ['a', 'Show all tasks'], 312 + ['t', 'Filter: Todo only'], 313 + ['p', 'Filter: In Progress'], 314 + ['d', 'Filter: Done'], 315 + ['m', 'Show memory stats'], 316 + ['?', 'Show this help'], 317 + ['q', 'Quit application'] 318 + ]; 319 + 320 + for (let i = 0; i < shortcuts.length; i++) { 321 + const [key, desc] = shortcuts[i]; 322 + buf.writeStyled(ox, oy + i + 1, ` ${colors.cyan}${pad(key, 12)}${codes.reset} ${desc}`); 323 + } 324 + 325 + buf.writeStyled(ox, oy + shortcuts.length + 2, colors.dim + padCenter('Press Escape to close', w) + codes.reset); 326 + } 327 + }); 328 + screen.render(); 329 + } 330 + 331 + function showMemoryModal() { 332 + const fmt = bytes => { 333 + if (bytes < 1024) return bytes + ' B'; 334 + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; 335 + return (bytes / 1024 / 1024).toFixed(2) + ' MB'; 336 + }; 337 + 338 + modal(screen, { 339 + id: 'memory', 340 + width: 40, 341 + height: 14, 342 + title: 'Memory Usage', 343 + titleStyle: colors.bold + colors.yellow, 344 + borderStyle: box.rounded, 345 + onKey: key => { 346 + if (key === 'm' || key === keys.ESCAPE) { 347 + screen.popModal('memory'); 348 + render(); 349 + } else if (key === 'g') { 350 + Ant.gc(); 351 + screen.popModal('memory'); 352 + showMemoryModal(); 353 + } 354 + return false; 355 + }, 356 + render: (buf, _w, _h, ox, oy) => { 357 + const mem = Ant.stats(); 358 + buf.writeStyled(ox, oy + 1, `${colors.cyan}Arena${codes.reset}`); 359 + buf.writeStyled(ox, oy + 2, ` Used: ${colors.bold}${fmt(mem.arenaUsed)}${codes.reset}`); 360 + buf.writeStyled(ox, oy + 3, ` Size: ${colors.bold}${fmt(mem.arenaSize)}${codes.reset}`); 361 + buf.writeStyled(ox, oy + 5, `${colors.cyan}Process${codes.reset}`); 362 + buf.writeStyled(ox, oy + 6, ` RSS: ${colors.bold}${fmt(mem.rss)}${codes.reset}`); 363 + buf.writeStyled(ox, oy + 8, `${colors.cyan}C Stack${codes.reset}`); 364 + buf.writeStyled(ox, oy + 9, ` Max: ${colors.bold}${fmt(mem.cstack)}${codes.reset}`); 365 + buf.writeStyled(ox, oy + 11, colors.dim + ' g: Run GC m/Esc: Close' + codes.reset); 366 + } 367 + }); 368 + screen.render(); 369 + } 370 + 371 + function handleKey(key) { 372 + if (screen.hasModal()) return; 373 + 374 + if (state.searchMode) { 375 + if (key === keys.ESCAPE) { 376 + state.searchMode = false; 377 + state.searchQuery = ''; 378 + searchInput.clear(); 379 + filterTasks(); 380 + } else if (key === keys.ENTER) { 381 + state.searchMode = false; 382 + state.searchQuery = searchInput.value; 383 + filterTasks(); 384 + } else { 385 + searchInput.handleKey(key); 386 + state.searchQuery = searchInput.value; 387 + filterTasks(); 388 + } 389 + render(); 390 + return; 391 + } 392 + 393 + switch (key) { 394 + case 'q': 395 + case keys.CTRL_C: 396 + confirm(screen, { 397 + title: 'Quit', 398 + message: 'Are you sure you want to quit?' 399 + }).then(confirmed => { 400 + if (confirmed) screen.exit(0); 401 + render(); 402 + }); 403 + return; 404 + 405 + case '1': 406 + state.view = 'dashboard'; 407 + render(); 408 + break; 409 + case '2': 410 + state.view = 'tasks'; 411 + render(); 412 + break; 413 + case '3': 414 + state.view = 'logs'; 415 + render(); 416 + break; 417 + case '4': 418 + state.view = 'settings'; 419 + render(); 420 + break; 421 + 422 + case '?': 423 + showHelp(); 424 + break; 425 + case 'm': 426 + showMemoryModal(); 427 + break; 428 + 429 + case keys.UP: 430 + case 'k': 431 + if (state.view === 'tasks') taskList.selectPrev(); 432 + else if (state.view === 'logs') logList.selectPrev(); 433 + render(); 434 + break; 435 + 436 + case keys.DOWN: 437 + case 'j': 438 + if (state.view === 'tasks') taskList.selectNext(); 439 + else if (state.view === 'logs') logList.selectNext(); 440 + render(); 441 + break; 442 + 443 + case keys.PAGE_UP: 444 + if (state.view === 'tasks') taskList.pageUp(); 445 + else if (state.view === 'logs') logList.pageUp(); 446 + render(); 447 + break; 448 + 449 + case keys.PAGE_DOWN: 450 + if (state.view === 'tasks') taskList.pageDown(); 451 + else if (state.view === 'logs') logList.pageDown(); 452 + render(); 453 + break; 454 + 455 + case keys.ENTER: 456 + if (state.view === 'tasks') { 457 + const task = taskList.getSelected(); 458 + if (task) { 459 + task.status = task.status === 'done' ? 'todo' : task.status === 'todo' ? 'in_progress' : 'done'; 460 + filterTasks(); 461 + } 462 + } 463 + render(); 464 + break; 465 + 466 + case '/': 467 + if (state.view === 'tasks') { 468 + state.searchMode = true; 469 + searchInput.clear(); 470 + } 471 + render(); 472 + break; 473 + 474 + case 'a': 475 + if (state.view === 'tasks') { 476 + state.taskFilter = 'all'; 477 + filterTasks(); 478 + render(); 479 + } 480 + break; 481 + 482 + case 't': 483 + if (state.view === 'tasks') { 484 + state.taskFilter = 'todo'; 485 + filterTasks(); 486 + render(); 487 + } 488 + break; 489 + 490 + case 'p': 491 + if (state.view === 'tasks') { 492 + state.taskFilter = 'in_progress'; 493 + filterTasks(); 494 + render(); 495 + } 496 + break; 497 + 498 + case 'd': 499 + if (state.view === 'tasks') { 500 + state.taskFilter = 'done'; 501 + filterTasks(); 502 + render(); 503 + } 504 + break; 505 + } 506 + } 507 + 508 + screen.onKey(handleKey); 509 + 510 + screen.onResize(() => { 511 + render(); 512 + }); 513 + 514 + setInterval(() => { 515 + state.cpuUsage = Math.max(5, Math.min(95, state.cpuUsage + (Math.random() - 0.5) * 10)); 516 + state.memUsage = Math.max(20, Math.min(90, state.memUsage + (Math.random() - 0.5) * 5)); 517 + state.networkIn = Math.floor(Math.random() * 500); 518 + state.networkOut = Math.floor(Math.random() * 200); 519 + 520 + if (state.view === 'dashboard' && !screen.hasModal()) { 521 + render(); 522 + } 523 + }, 1000); 524 + 525 + screen.start(); 526 + render();
+368
examples/tui/test.js
··· 1 + import { 2 + codes, 3 + colors, 4 + box, 5 + keys, 6 + stripAnsi, 7 + visibleLength, 8 + pad, 9 + padStart, 10 + padCenter, 11 + truncate, 12 + wrap, 13 + Buffer, 14 + List, 15 + ProgressBar, 16 + Input, 17 + Table 18 + } from './tuey.js'; 19 + 20 + let passed = 0; 21 + let failed = 0; 22 + 23 + function test(name, fn) { 24 + try { 25 + fn(); 26 + console.log(`${colors.green}โœ“${colors.reset} ${name}`); 27 + passed++; 28 + } catch (e) { 29 + console.log(`${colors.red}โœ—${colors.reset} ${name}`); 30 + console.log(` ${colors.dim}${e.message}${colors.reset}`); 31 + failed++; 32 + } 33 + } 34 + 35 + function assert(condition, message) { 36 + if (!condition) throw new Error(message || 'Assertion failed'); 37 + } 38 + 39 + function assertEqual(actual, expected, message) { 40 + if (actual !== expected) { 41 + throw new Error(message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); 42 + } 43 + } 44 + 45 + console.log(`${colors.bold}${colors.blue}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${colors.reset}`); 46 + console.log(`${colors.bold} TUI Library Tests${colors.reset}`); 47 + console.log(`${colors.bold}${colors.blue}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${colors.reset}\n`); 48 + 49 + console.log(`${colors.cyan}ANSI Code Generation${colors.reset}`); 50 + 51 + test('codes.fg generates correct 256-color code', () => { 52 + assertEqual(codes.fg(196), '\x1b[38;5;196m'); 53 + }); 54 + 55 + test('codes.bg generates correct 256-color code', () => { 56 + assertEqual(codes.bg(24), '\x1b[48;5;24m'); 57 + }); 58 + 59 + test('codes.rgb generates correct 24-bit color code', () => { 60 + assertEqual(codes.rgb(255, 128, 0), '\x1b[38;2;255;128;0m'); 61 + }); 62 + 63 + test('codes.moveTo generates correct cursor position', () => { 64 + assertEqual(codes.moveTo(10, 5), '\x1b[6;11H'); 65 + }); 66 + 67 + console.log(`\n${colors.cyan}String Utilities${colors.reset}`); 68 + 69 + test('stripAnsi removes ANSI codes', () => { 70 + const input = `${colors.red}hello${colors.reset} ${colors.bold}world${colors.reset}`; 71 + assertEqual(stripAnsi(input), 'hello world'); 72 + }); 73 + 74 + test('visibleLength calculates correct length with ANSI', () => { 75 + const input = `${colors.green}test${colors.reset}`; 76 + assertEqual(visibleLength(input), 4); 77 + }); 78 + 79 + test('pad right-pads to target length', () => { 80 + assertEqual(pad('hello', 10), 'hello '); 81 + }); 82 + 83 + test('pad handles strings longer than target', () => { 84 + assertEqual(pad('hello world', 5), 'hello world'); 85 + }); 86 + 87 + test('pad works with ANSI codes', () => { 88 + const input = `${colors.red}hi${colors.reset}`; 89 + const result = pad(input, 5); 90 + assertEqual(visibleLength(result), 5); 91 + assert(result.includes('\x1b[38;5;196m'), 'Should contain red color code'); 92 + }); 93 + 94 + test('padStart left-pads to target length', () => { 95 + assertEqual(padStart('123', 6), ' 123'); 96 + }); 97 + 98 + test('padCenter centers text', () => { 99 + assertEqual(padCenter('hi', 6), ' hi '); 100 + }); 101 + 102 + test('truncate shortens long strings', () => { 103 + const result = truncate('hello world', 8, '.'); 104 + assertEqual(stripAnsi(result), 'hello w.'); 105 + }); 106 + 107 + test('truncate preserves short strings', () => { 108 + assertEqual(truncate('hi', 10), 'hi'); 109 + }); 110 + 111 + test('truncate handles ANSI codes correctly', () => { 112 + const input = `${colors.red}hello world${colors.reset}`; 113 + const result = truncate(input, 8); 114 + assertEqual(visibleLength(result), 8); 115 + }); 116 + 117 + test('wrap splits text at word boundaries', () => { 118 + const result = wrap('hello world foo bar', 12); 119 + assertEqual(result.length, 2); 120 + assertEqual(result[0], 'hello world'); 121 + }); 122 + 123 + console.log(`\n${colors.cyan}Buffer${colors.reset}`); 124 + 125 + test('Buffer initializes with correct dimensions', () => { 126 + const buf = new Buffer(20, 10); 127 + assertEqual(buf.width, 20); 128 + assertEqual(buf.height, 10); 129 + assertEqual(buf.lines.length, 10); 130 + }); 131 + 132 + test('Buffer.write writes text at position', () => { 133 + const buf = new Buffer(20, 5); 134 + buf.write(5, 2, 'hello'); 135 + assert(buf.lines[2].includes('hello'), 'Buffer should contain "hello"'); 136 + }); 137 + 138 + test('Buffer.clear resets all lines', () => { 139 + const buf = new Buffer(10, 5); 140 + buf.write(0, 0, 'test'); 141 + buf.clear(); 142 + assertEqual(buf.lines[0], ' '.repeat(10)); 143 + }); 144 + 145 + test('Buffer.resize changes dimensions', () => { 146 + const buf = new Buffer(10, 5); 147 + buf.write(0, 0, 'hello'); 148 + buf.resize(20, 10); 149 + assertEqual(buf.width, 20); 150 + assertEqual(buf.height, 10); 151 + assert(buf.lines[0].includes('hello'), 'Content should be preserved'); 152 + }); 153 + 154 + test('Buffer.clone creates independent copy', () => { 155 + const buf = new Buffer(10, 5); 156 + buf.write(0, 0, 'original'); 157 + const clone = buf.clone(); 158 + buf.write(0, 0, 'modified'); 159 + assert(clone.lines[0].includes('original'), 'Clone should be independent'); 160 + }); 161 + 162 + test('Buffer.fill fills rectangular region', () => { 163 + const buf = new Buffer(10, 5); 164 + buf.fill(2, 1, 3, 2, 'X'); 165 + assert(buf.lines[1].includes('XXX'), 'Should contain filled region'); 166 + assert(buf.lines[2].includes('XXX'), 'Should contain filled region'); 167 + }); 168 + 169 + test('Buffer.box draws box characters', () => { 170 + const buf = new Buffer(20, 10); 171 + buf.box(0, 0, 10, 5, box.light); 172 + assert(buf.lines[0].includes('โ”Œ'), 'Should have top-left corner'); 173 + assert(buf.lines[0].includes('โ”'), 'Should have top-right corner'); 174 + assert(buf.lines[4].includes('โ””'), 'Should have bottom-left corner'); 175 + }); 176 + 177 + console.log(`\n${colors.cyan}List${colors.reset}`); 178 + 179 + test('List initializes with items', () => { 180 + const list = new List({ 181 + items: ['a', 'b', 'c'], 182 + width: 20, 183 + height: 5 184 + }); 185 + assertEqual(list.items.length, 3); 186 + assertEqual(list.index, 0); 187 + }); 188 + 189 + test('List.selectNext advances selection', () => { 190 + const list = new List({ items: ['a', 'b', 'c'] }); 191 + list.selectNext(); 192 + assertEqual(list.index, 1); 193 + }); 194 + 195 + test('List.selectPrev moves selection back', () => { 196 + const list = new List({ items: ['a', 'b', 'c'], index: 2 }); 197 + list.selectPrev(); 198 + assertEqual(list.index, 1); 199 + }); 200 + 201 + test('List.selectNext clamps at end', () => { 202 + const list = new List({ items: ['a', 'b', 'c'], index: 2 }); 203 + list.selectNext(); 204 + assertEqual(list.index, 2); 205 + }); 206 + 207 + test('List.selectPrev clamps at start', () => { 208 + const list = new List({ items: ['a', 'b', 'c'], index: 0 }); 209 + list.selectPrev(); 210 + assertEqual(list.index, 0); 211 + }); 212 + 213 + test('List.getSelected returns current item', () => { 214 + const list = new List({ items: ['a', 'b', 'c'], index: 1 }); 215 + assertEqual(list.getSelected(), 'b'); 216 + }); 217 + 218 + test('List.setItems updates and clamps index', () => { 219 + const list = new List({ items: ['a', 'b', 'c', 'd'], index: 3 }); 220 + list.setItems(['x', 'y']); 221 + assertEqual(list.items.length, 2); 222 + assertEqual(list.index, 1); 223 + }); 224 + 225 + test('List.handleKey responds to vim keys', () => { 226 + const list = new List({ items: ['a', 'b', 'c'] }); 227 + list.handleKey('j'); 228 + assertEqual(list.index, 1); 229 + list.handleKey('k'); 230 + assertEqual(list.index, 0); 231 + }); 232 + 233 + test('List.handleKey responds to G/g', () => { 234 + const list = new List({ items: ['a', 'b', 'c', 'd', 'e'] }); 235 + list.handleKey('G'); 236 + assertEqual(list.index, 4); 237 + list.handleKey('g'); 238 + assertEqual(list.index, 0); 239 + }); 240 + 241 + console.log(`\n${colors.cyan}ProgressBar${colors.reset}`); 242 + 243 + test('ProgressBar initializes with value', () => { 244 + const bar = new ProgressBar({ value: 50, max: 100 }); 245 + assertEqual(bar.value, 50); 246 + assertEqual(bar.max, 100); 247 + }); 248 + 249 + test('ProgressBar.setValue clamps value', () => { 250 + const bar = new ProgressBar({ max: 100 }); 251 + bar.setValue(150); 252 + assertEqual(bar.value, 100); 253 + bar.setValue(-10); 254 + assertEqual(bar.value, 0); 255 + }); 256 + 257 + test('ProgressBar.render produces filled/empty chars', () => { 258 + const bar = new ProgressBar({ value: 50, max: 100, width: 10, showPercent: false }); 259 + const result = bar.render(); 260 + const stripped = stripAnsi(result); 261 + assert(stripped.includes('โ–ˆ'), 'Should have filled chars'); 262 + assert(stripped.includes('โ–‘'), 'Should have empty chars'); 263 + }); 264 + 265 + test('ProgressBar.render shows percentage', () => { 266 + const bar = new ProgressBar({ value: 75, max: 100, width: 10, showPercent: true }); 267 + const result = bar.render(); 268 + assert(result.includes('75%'), 'Should show percentage'); 269 + }); 270 + 271 + console.log(`\n${colors.cyan}Input${colors.reset}`); 272 + 273 + test('Input initializes with value', () => { 274 + const input = new Input({ value: 'hello' }); 275 + assertEqual(input.value, 'hello'); 276 + assertEqual(input.cursorPos, 5); 277 + }); 278 + 279 + test('Input.handleKey adds characters', () => { 280 + const input = new Input(); 281 + input.handleKey('a'); 282 + input.handleKey('b'); 283 + input.handleKey('c'); 284 + assertEqual(input.value, 'abc'); 285 + }); 286 + 287 + test('Input.handleKey handles backspace', () => { 288 + const input = new Input({ value: 'hello' }); 289 + input.handleKey(keys.BACKSPACE); 290 + assertEqual(input.value, 'hell'); 291 + }); 292 + 293 + test('Input.handleKey handles arrow keys', () => { 294 + const input = new Input({ value: 'hello' }); 295 + input.handleKey(keys.LEFT); 296 + assertEqual(input.cursorPos, 4); 297 + input.handleKey(keys.RIGHT); 298 + assertEqual(input.cursorPos, 5); 299 + }); 300 + 301 + test('Input.clear resets value and cursor', () => { 302 + const input = new Input({ value: 'test' }); 303 + input.clear(); 304 + assertEqual(input.value, ''); 305 + assertEqual(input.cursorPos, 0); 306 + }); 307 + 308 + console.log(`\n${colors.cyan}Table${colors.reset}`); 309 + 310 + test('Table initializes with columns and rows', () => { 311 + const table = new Table({ 312 + columns: [ 313 + { key: 'name', header: 'Name' }, 314 + { key: 'value', header: 'Value' } 315 + ], 316 + rows: [{ name: 'foo', value: '123' }] 317 + }); 318 + assertEqual(table.columns.length, 2); 319 + assertEqual(table.rows.length, 1); 320 + }); 321 + 322 + console.log(`\n${colors.cyan}Box Drawing Styles${colors.reset}`); 323 + 324 + test('box.light has correct characters', () => { 325 + assertEqual(box.light.tl, 'โ”Œ'); 326 + assertEqual(box.light.tr, 'โ”'); 327 + assertEqual(box.light.h, 'โ”€'); 328 + assertEqual(box.light.v, 'โ”‚'); 329 + }); 330 + 331 + test('box.double has correct characters', () => { 332 + assertEqual(box.double.tl, 'โ•”'); 333 + assertEqual(box.double.tr, 'โ•—'); 334 + assertEqual(box.double.h, 'โ•'); 335 + assertEqual(box.double.v, 'โ•‘'); 336 + }); 337 + 338 + test('box.rounded has correct characters', () => { 339 + assertEqual(box.rounded.tl, 'โ•ญ'); 340 + assertEqual(box.rounded.br, 'โ•ฏ'); 341 + }); 342 + 343 + console.log(`\n${colors.cyan}Keys Constants${colors.reset}`); 344 + 345 + test('keys.UP is correct escape sequence', () => { 346 + assertEqual(keys.UP, '\x1b[A'); 347 + }); 348 + 349 + test('keys.ESCAPE is escape character', () => { 350 + assertEqual(keys.ESCAPE, '\x1b'); 351 + }); 352 + 353 + test('keys.ENTER is carriage return', () => { 354 + assertEqual(keys.ENTER, '\r'); 355 + }); 356 + 357 + console.log(`\n${colors.blue}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${colors.reset}`); 358 + const total = passed + failed; 359 + const rate = ((passed / total) * 100).toFixed(1); 360 + const rateColor = failed === 0 ? colors.green : colors.yellow; 361 + 362 + console.log( 363 + `${colors.bold}Results:${colors.reset} ${colors.green}${passed} passed${colors.reset}, ${failed > 0 ? colors.red : colors.dim}${failed} failed${colors.reset}` 364 + ); 365 + console.log(`${colors.bold}Rate:${colors.reset} ${rateColor}${rate}%${colors.reset}`); 366 + console.log(`${colors.blue}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${colors.reset}`); 367 + 368 + if (failed > 0) process.exit(1);
+989
examples/tui/tuey.js
··· 1 + const ESC = '\x1b'; 2 + const CSI = `${ESC}[`; 3 + 4 + export const codes = { 5 + hideCursor: `${CSI}?25l`, 6 + showCursor: `${CSI}?25h`, 7 + altScreenOn: `${CSI}?1049h`, 8 + altScreenOff: `${CSI}?1049l`, 9 + syncStart: `${CSI}?2026h`, 10 + syncEnd: `${CSI}?2026l`, 11 + home: `${CSI}H`, 12 + clear: `${CSI}2J`, 13 + clearLine: `${CSI}2K`, 14 + reset: `${CSI}0m`, 15 + bold: `${CSI}1m`, 16 + dim: `${CSI}2m`, 17 + italic: `${CSI}3m`, 18 + underline: `${CSI}4m`, 19 + blink: `${CSI}5m`, 20 + inverse: `${CSI}7m`, 21 + hidden: `${CSI}8m`, 22 + strikethrough: `${CSI}9m`, 23 + fg: n => `${CSI}38;5;${n}m`, 24 + bg: n => `${CSI}48;5;${n}m`, 25 + rgb: (r, g, b) => `${CSI}38;2;${r};${g};${b}m`, 26 + bgRgb: (r, g, b) => `${CSI}48;2;${r};${g};${b}m`, 27 + moveTo: (x, y) => `${CSI}${y + 1};${x + 1}H`, 28 + moveUp: n => `${CSI}${n}A`, 29 + moveDown: n => `${CSI}${n}B`, 30 + moveRight: n => `${CSI}${n}C`, 31 + moveLeft: n => `${CSI}${n}D`, 32 + saveCursor: `${ESC}7`, 33 + restoreCursor: `${ESC}8`, 34 + scrollUp: n => `${CSI}${n}S`, 35 + scrollDown: n => `${CSI}${n}T` 36 + }; 37 + 38 + export const colors = { 39 + reset: codes.reset, 40 + bold: codes.bold, 41 + dim: codes.dim, 42 + italic: codes.italic, 43 + underline: codes.underline, 44 + inverse: codes.inverse, 45 + black: codes.fg(0), 46 + red: codes.fg(196), 47 + green: codes.fg(82), 48 + yellow: codes.fg(226), 49 + blue: codes.fg(39), 50 + magenta: codes.fg(201), 51 + cyan: codes.fg(51), 52 + white: codes.fg(15), 53 + gray: codes.fg(245), 54 + brightRed: codes.fg(9), 55 + brightGreen: codes.fg(10), 56 + brightYellow: codes.fg(11), 57 + brightBlue: codes.fg(12), 58 + brightMagenta: codes.fg(13), 59 + brightCyan: codes.fg(14), 60 + bgBlack: codes.bg(0), 61 + bgRed: codes.bg(196), 62 + bgGreen: codes.bg(82), 63 + bgYellow: codes.bg(226), 64 + bgBlue: codes.bg(24), 65 + bgMagenta: codes.bg(201), 66 + bgCyan: codes.bg(51), 67 + bgWhite: codes.bg(15), 68 + bgGray: codes.bg(238) 69 + }; 70 + 71 + export const box = { 72 + light: { tl: 'โ”Œ', tr: 'โ”', bl: 'โ””', br: 'โ”˜', h: 'โ”€', v: 'โ”‚', lT: 'โ”œ', rT: 'โ”ค', tT: 'โ”ฌ', bT: 'โ”ด', cross: 'โ”ผ' }, 73 + heavy: { tl: 'โ”', tr: 'โ”“', bl: 'โ”—', br: 'โ”›', h: 'โ”', v: 'โ”ƒ', lT: 'โ”ฃ', rT: 'โ”ซ', tT: 'โ”ณ', bT: 'โ”ป', cross: 'โ•‹' }, 74 + double: { tl: 'โ•”', tr: 'โ•—', bl: 'โ•š', br: 'โ•', h: 'โ•', v: 'โ•‘', lT: 'โ• ', rT: 'โ•ฃ', tT: 'โ•ฆ', bT: 'โ•ฉ', cross: 'โ•ฌ' }, 75 + rounded: { tl: 'โ•ญ', tr: 'โ•ฎ', bl: 'โ•ฐ', br: 'โ•ฏ', h: 'โ”€', v: 'โ”‚', lT: 'โ”œ', rT: 'โ”ค', tT: 'โ”ฌ', bT: 'โ”ด', cross: 'โ”ผ' }, 76 + ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', lT: '+', rT: '+', tT: '+', bT: '+', cross: '+' } 77 + }; 78 + 79 + export const keys = { 80 + UP: '\x1b[A', 81 + DOWN: '\x1b[B', 82 + RIGHT: '\x1b[C', 83 + LEFT: '\x1b[D', 84 + HOME: '\x1b[H', 85 + END: '\x1b[F', 86 + PAGE_UP: '\x1b[5~', 87 + PAGE_DOWN: '\x1b[6~', 88 + INSERT: '\x1b[2~', 89 + DELETE: '\x1b[3~', 90 + ENTER: '\r', 91 + TAB: '\t', 92 + ESCAPE: '\x1b', 93 + BACKSPACE: '\x7f', 94 + CTRL_C: '\x03', 95 + CTRL_D: '\x04', 96 + CTRL_Z: '\x1a', 97 + F1: '\x1bOP', 98 + F2: '\x1bOQ', 99 + F3: '\x1bOR', 100 + F4: '\x1bOS', 101 + F5: '\x1b[15~', 102 + F6: '\x1b[17~', 103 + F7: '\x1b[18~', 104 + F8: '\x1b[19~', 105 + F9: '\x1b[20~', 106 + F10: '\x1b[21~', 107 + F11: '\x1b[23~', 108 + F12: '\x1b[24~' 109 + }; 110 + 111 + export function stripAnsi(str) { 112 + return str.replace(/\x1b\[[0-9;]*m/g, ''); 113 + } 114 + 115 + export function visibleLength(str) { 116 + return stripAnsi(str).length; 117 + } 118 + 119 + export function pad(str, len, char = ' ') { 120 + const visible = visibleLength(str); 121 + const diff = len - visible; 122 + if (diff <= 0) return str; 123 + if (char === undefined || char === null) { 124 + console.error("pad() char undefined, str:", str, "len:", len); 125 + console.error(new Error().stack); 126 + process.exit(1); 127 + } 128 + return str + char.repeat(diff); 129 + } 130 + 131 + export function padStart(str, len, char = ' ') { 132 + const visible = visibleLength(str); 133 + return char.repeat(Math.max(0, len - visible)) + str; 134 + } 135 + 136 + export function padCenter(str, len, char = ' ') { 137 + const visible = visibleLength(str); 138 + const total = Math.max(0, len - visible); 139 + const left = Math.floor(total / 2); 140 + const right = total - left; 141 + return char.repeat(left) + str + char.repeat(right); 142 + } 143 + 144 + export function truncate(str, len, suffix = 'โ€ฆ') { 145 + const stripped = stripAnsi(str); 146 + if (stripped.length <= len) return str; 147 + 148 + const suffixLen = visibleLength(suffix); 149 + const targetLen = len - suffixLen; 150 + if (targetLen <= 0) return suffix.slice(0, len); 151 + 152 + let visCount = 0; 153 + let result = ''; 154 + let i = 0; 155 + 156 + while (i < str.length && visCount < targetLen) { 157 + if (str[i] === '\x1b') { 158 + const match = str.slice(i).match(/^\x1b\[[0-9;]*m/); 159 + if (match) { 160 + result += match[0]; 161 + i += match[0].length; 162 + continue; 163 + } 164 + } 165 + result += str[i]; 166 + visCount++; 167 + i++; 168 + } 169 + 170 + return result + codes.reset + suffix; 171 + } 172 + 173 + export function wrap(str, width) { 174 + const words = str.split(' '); 175 + const lines = []; 176 + let line = ''; 177 + let lineLen = 0; 178 + 179 + for (const word of words) { 180 + const wordLen = visibleLength(word); 181 + const spaceNeeded = line ? 1 : 0; 182 + 183 + if (lineLen + wordLen + spaceNeeded > width) { 184 + if (line) lines.push(line); 185 + line = word; 186 + lineLen = wordLen; 187 + } else { 188 + line = line ? line + ' ' + word : word; 189 + lineLen += wordLen + spaceNeeded; 190 + } 191 + } 192 + if (line) lines.push(line); 193 + return lines; 194 + } 195 + 196 + export class Buffer { 197 + constructor(width, height) { 198 + this.width = width; 199 + this.height = height; 200 + this.lines = Array(height).fill('').map(() => ' '.repeat(width)); 201 + this.styles = Array(height).fill('').map(() => ''); 202 + } 203 + 204 + clear() { 205 + for (let y = 0; y < this.height; y++) { 206 + this.lines[y] = ' '.repeat(this.width); 207 + this.styles[y] = ''; 208 + } 209 + } 210 + 211 + resize(width, height) { 212 + const newLines = []; 213 + const newStyles = []; 214 + for (let y = 0; y < height; y++) { 215 + if (y < this.height) { 216 + newLines.push(pad(this.lines[y], width).slice(0, width)); 217 + newStyles.push(this.styles[y]); 218 + } else { 219 + newLines.push(' '.repeat(width)); 220 + newStyles.push(''); 221 + } 222 + } 223 + this.width = width; 224 + this.height = height; 225 + this.lines = newLines; 226 + this.styles = newStyles; 227 + } 228 + 229 + write(x, y, text, style = '') { 230 + if (y < 0 || y >= this.height) return; 231 + const stripped = stripAnsi(text); 232 + const line = this.lines[y]; 233 + const before = line.slice(0, Math.max(0, x)); 234 + const after = line.slice(x + stripped.length); 235 + this.lines[y] = pad(before, x) + stripped + after; 236 + if (style) this.styles[y] = style; 237 + } 238 + 239 + writeStyled(x, y, text) { 240 + if (y < 0 || y >= this.height) return; 241 + const stripped = stripAnsi(text); 242 + const line = this.lines[y]; 243 + const lineStripped = stripAnsi(line); 244 + 245 + const before = lineStripped.slice(0, Math.max(0, x)); 246 + const after = lineStripped.slice(x + stripped.length); 247 + this.lines[y] = pad(before, x) + text + codes.reset + after; 248 + } 249 + 250 + fill(x, y, width, height, char = ' ', style = '') { 251 + for (let row = y; row < y + height && row < this.height; row++) { 252 + if (row < 0) continue; 253 + const fillStr = char.repeat(width); 254 + this.write(x, row, fillStr, style); 255 + } 256 + } 257 + 258 + box(x, y, width, height, style = box.light, title = '', titleStyle = '') { 259 + if (height < 2 || width < 2) return; 260 + if (!style || !style.h) { 261 + console.error("box() style undefined:", style); 262 + console.error(new Error().stack); 263 + process.exit(1); 264 + } 265 + 266 + const top = style.tl + style.h.repeat(width - 2) + style.tr; 267 + const bottom = style.bl + style.h.repeat(width - 2) + style.br; 268 + 269 + this.writeStyled(x, y, top); 270 + this.writeStyled(x, y + height - 1, bottom); 271 + 272 + for (let row = y + 1; row < y + height - 1 && row < this.height; row++) { 273 + if (row < 0) continue; 274 + this.writeStyled(x, row, style.v); 275 + this.writeStyled(x + width - 1, row, style.v); 276 + } 277 + 278 + if (title) { 279 + const titleStr = ` ${title} `; 280 + const titleX = x + Math.floor((width - visibleLength(titleStr)) / 2); 281 + this.writeStyled(titleX, y, titleStyle + titleStr + codes.reset); 282 + } 283 + } 284 + 285 + render() { 286 + return this.lines.join('\n'); 287 + } 288 + 289 + clone() { 290 + const buf = new Buffer(this.width, this.height); 291 + buf.lines = [...this.lines]; 292 + buf.styles = [...this.styles]; 293 + return buf; 294 + } 295 + } 296 + 297 + export class Screen { 298 + constructor(options = {}) { 299 + this.stdin = options.stdin || process.stdin; 300 + this.stdout = options.stdout || process.stdout; 301 + this.fullscreen = options.fullscreen !== false; 302 + this.hideCursor = options.hideCursor !== false; 303 + this.rawMode = options.rawMode !== false; 304 + 305 + this._width = this.stdout.columns || 80; 306 + this._height = this.stdout.rows || 24; 307 + this._buffer = new Buffer(this._width, this._height); 308 + this._prevBuffer = null; 309 + this._running = false; 310 + this._inputBuf = ''; 311 + this._keyHandlers = []; 312 + this._resizeHandlers = []; 313 + this._modalStack = []; 314 + this._escapeTimer = null; 315 + 316 + this._onData = this._onData.bind(this); 317 + this._onResize = this._onResize.bind(this); 318 + this._cleanup = this._cleanup.bind(this); 319 + } 320 + 321 + get width() { return this._width; } 322 + get height() { return this._height; } 323 + get buffer() { return this._buffer; } 324 + 325 + start() { 326 + if (this._running) return; 327 + this._running = true; 328 + 329 + if (this.rawMode && this.stdin.isTTY) { 330 + this.stdin.setRawMode(true); 331 + } 332 + this.stdin.resume(); 333 + this.stdin.on('data', this._onData); 334 + this.stdout.on('resize', this._onResize); 335 + process.on('SIGINT', this._cleanup); 336 + process.on('SIGTERM', this._cleanup); 337 + 338 + let init = ''; 339 + if (this.fullscreen) init += codes.altScreenOn; 340 + if (this.hideCursor) init += codes.hideCursor; 341 + init += codes.home + codes.clear; 342 + this.stdout.write(init); 343 + } 344 + 345 + stop() { 346 + this._cleanup(); 347 + } 348 + 349 + _cleanup() { 350 + if (!this._running) return; 351 + this._running = false; 352 + 353 + this.stdin.removeListener('data', this._onData); 354 + this.stdout.removeListener('resize', this._onResize); 355 + process.removeListener('SIGINT', this._cleanup); 356 + process.removeListener('SIGTERM', this._cleanup); 357 + 358 + let cleanup = ''; 359 + if (this.hideCursor) cleanup += codes.showCursor; 360 + if (this.fullscreen) cleanup += codes.altScreenOff; 361 + this.stdout.write(cleanup); 362 + 363 + if (this.rawMode && this.stdin.isTTY) { 364 + this.stdin.setRawMode(false); 365 + } 366 + this.stdin.pause(); 367 + } 368 + 369 + _onResize() { 370 + this._width = this.stdout.columns || 80; 371 + this._height = this.stdout.rows || 24; 372 + this._buffer.resize(this._width, this._height); 373 + this._prevBuffer = null; 374 + for (const handler of this._resizeHandlers) { 375 + handler(this._width, this._height); 376 + } 377 + } 378 + 379 + _onData(chunk) { 380 + const str = chunk.toString(); 381 + 382 + if (this._escapeTimer) { 383 + clearTimeout(this._escapeTimer); 384 + this._escapeTimer = null; 385 + } 386 + 387 + if (this._modalStack.length > 0 && str === '\x1b') { 388 + this._emitKey('\x1b'); 389 + return; 390 + } 391 + 392 + for (const ch of str) { 393 + if (this._inputBuf.length > 0) { 394 + this._inputBuf += ch; 395 + if (this._inputBuf.length >= 2) { 396 + if (this._inputBuf.length >= 3 && /[A-Za-z~]/.test(ch)) { 397 + this._emitKey(this._inputBuf); 398 + this._inputBuf = ''; 399 + } else if (this._inputBuf.length === 2 && /[A-Z]/.test(ch)) { 400 + this._emitKey(this._inputBuf); 401 + this._inputBuf = ''; 402 + } else if (this._inputBuf.length > 6) { 403 + this._emitKey(this._inputBuf); 404 + this._inputBuf = ''; 405 + } 406 + } 407 + } else if (ch === '\x1b') { 408 + this._inputBuf = ch; 409 + } else { 410 + this._emitKey(ch); 411 + } 412 + } 413 + 414 + if (this._inputBuf === '\x1b') { 415 + this._escapeTimer = setTimeout(() => { 416 + if (this._inputBuf === '\x1b') { 417 + this._emitKey('\x1b'); 418 + this._inputBuf = ''; 419 + } 420 + this._escapeTimer = null; 421 + }, 50); 422 + } 423 + } 424 + 425 + _emitKey(key) { 426 + if (this._modalStack.length > 0) { 427 + const modal = this._modalStack[this._modalStack.length - 1]; 428 + if (modal.onKey) { 429 + const result = modal.onKey(key, modal); 430 + if (result === false) return; 431 + } 432 + } 433 + 434 + for (const handler of this._keyHandlers) { 435 + handler(key); 436 + } 437 + } 438 + 439 + onKey(handler) { 440 + this._keyHandlers.push(handler); 441 + return () => { 442 + const idx = this._keyHandlers.indexOf(handler); 443 + if (idx >= 0) this._keyHandlers.splice(idx, 1); 444 + }; 445 + } 446 + 447 + onResize(handler) { 448 + this._resizeHandlers.push(handler); 449 + return () => { 450 + const idx = this._resizeHandlers.indexOf(handler); 451 + if (idx >= 0) this._resizeHandlers.splice(idx, 1); 452 + }; 453 + } 454 + 455 + clear() { 456 + this._buffer.clear(); 457 + } 458 + 459 + write(x, y, text, style = '') { 460 + this._buffer.writeStyled(x, y, style + text); 461 + } 462 + 463 + fill(x, y, width, height, char = ' ', style = '') { 464 + for (let row = y; row < y + height && row < this._height; row++) { 465 + if (row < 0) continue; 466 + this.write(x, row, style + char.repeat(width)); 467 + } 468 + } 469 + 470 + box(x, y, width, height, style = box.light, title = '', titleStyle = '') { 471 + this._buffer.box(x, y, width, height, style, title, titleStyle); 472 + } 473 + 474 + pushModal(options) { 475 + const savedBuffer = this._buffer.clone(); 476 + const modal = { 477 + id: options.id || Date.now().toString(), 478 + x: options.x, 479 + y: options.y, 480 + width: options.width, 481 + height: options.height, 482 + savedBuffer, 483 + onKey: options.onKey, 484 + onRender: options.onRender 485 + }; 486 + this._modalStack.push(modal); 487 + return modal; 488 + } 489 + 490 + popModal(id) { 491 + let idx = -1; 492 + if (id) { 493 + idx = this._modalStack.findIndex(m => m.id === id); 494 + } else { 495 + idx = this._modalStack.length - 1; 496 + } 497 + 498 + if (idx >= 0) { 499 + const modal = this._modalStack[idx]; 500 + this._modalStack.splice(idx, 1); 501 + this._buffer = modal.savedBuffer; 502 + return true; 503 + } 504 + return false; 505 + } 506 + 507 + hasModal(id) { 508 + if (id) return this._modalStack.some(m => m.id === id); 509 + return this._modalStack.length > 0; 510 + } 511 + 512 + getModal(id) { 513 + if (id) return this._modalStack.find(m => m.id === id); 514 + return this._modalStack[this._modalStack.length - 1]; 515 + } 516 + 517 + renderModal(modal, renderFn) { 518 + const { x, y, width, height, savedBuffer } = modal; 519 + 520 + for (let row = 0; row < this._height; row++) { 521 + this._buffer.lines[row] = savedBuffer.lines[row]; 522 + } 523 + 524 + const modalBuffer = new Buffer(width, height); 525 + renderFn(modalBuffer, width, height); 526 + 527 + for (let row = 0; row < height && y + row < this._height; row++) { 528 + if (y + row < 0) continue; 529 + const modalLine = modalBuffer.lines[row]; 530 + const bgLine = this._buffer.lines[y + row]; 531 + const bgStripped = stripAnsi(bgLine); 532 + 533 + const before = bgStripped.slice(0, Math.max(0, x)); 534 + const after = bgStripped.slice(x + width); 535 + 536 + this._buffer.lines[y + row] = pad(before, x) + modalLine + after; 537 + } 538 + } 539 + 540 + render() { 541 + for (const modal of this._modalStack) { 542 + if (modal.onRender) { 543 + this.renderModal(modal, modal.onRender); 544 + } 545 + } 546 + 547 + const output = this._buffer.render(); 548 + this.stdout.write( 549 + codes.syncStart + 550 + codes.home + 551 + output + 552 + codes.syncEnd 553 + ); 554 + this._prevBuffer = this._buffer.clone(); 555 + } 556 + 557 + exit(code = 0) { 558 + this._cleanup(); 559 + process.exit(code); 560 + } 561 + } 562 + 563 + export class List { 564 + constructor(options = {}) { 565 + this.items = options.items || []; 566 + this.index = options.index || 0; 567 + this.x = options.x || 0; 568 + this.y = options.y || 0; 569 + this.width = options.width || 40; 570 + this.height = options.height || 10; 571 + this.selectedStyle = options.selectedStyle || colors.bgBlue + colors.bold; 572 + this.normalStyle = options.normalStyle || ''; 573 + this.renderItem = options.renderItem || (item => String(item)); 574 + this.scrollOffset = 0; 575 + } 576 + 577 + setItems(items) { 578 + this.items = items; 579 + this.index = Math.min(this.index, Math.max(0, items.length - 1)); 580 + this._updateScroll(); 581 + } 582 + 583 + select(index) { 584 + this.index = Math.max(0, Math.min(this.items.length - 1, index)); 585 + this._updateScroll(); 586 + } 587 + 588 + selectNext() { 589 + this.select(this.index + 1); 590 + } 591 + 592 + selectPrev() { 593 + this.select(this.index - 1); 594 + } 595 + 596 + pageDown(amount = 10) { 597 + this.select(this.index + amount); 598 + } 599 + 600 + pageUp(amount = 10) { 601 + this.select(this.index - amount); 602 + } 603 + 604 + selectFirst() { 605 + this.select(0); 606 + } 607 + 608 + selectLast() { 609 + this.select(this.items.length - 1); 610 + } 611 + 612 + getSelected() { 613 + return this.items[this.index]; 614 + } 615 + 616 + _updateScroll() { 617 + const half = Math.floor(this.height / 2); 618 + let start = this.index - half; 619 + if (start < 0) start = 0; 620 + if (start + this.height > this.items.length) { 621 + start = Math.max(0, this.items.length - this.height); 622 + } 623 + this.scrollOffset = start; 624 + } 625 + 626 + handleKey(key) { 627 + switch (key) { 628 + case keys.UP: 629 + case 'k': 630 + this.selectPrev(); 631 + return true; 632 + case keys.DOWN: 633 + case 'j': 634 + this.selectNext(); 635 + return true; 636 + case keys.PAGE_UP: 637 + this.pageUp(); 638 + return true; 639 + case keys.PAGE_DOWN: 640 + this.pageDown(); 641 + return true; 642 + case 'g': 643 + this.selectFirst(); 644 + return true; 645 + case 'G': 646 + this.selectLast(); 647 + return true; 648 + } 649 + return false; 650 + } 651 + 652 + render(screen) { 653 + const end = Math.min(this.scrollOffset + this.height, this.items.length); 654 + 655 + for (let i = this.scrollOffset; i < end; i++) { 656 + const row = this.y + (i - this.scrollOffset); 657 + const item = this.items[i]; 658 + const text = truncate(this.renderItem(item, i), this.width); 659 + const isSelected = i === this.index; 660 + const style = isSelected ? this.selectedStyle : this.normalStyle; 661 + 662 + screen.write(this.x, row, pad(style + text + codes.reset, this.width)); 663 + } 664 + 665 + for (let i = end - this.scrollOffset; i < this.height; i++) { 666 + screen.write(this.x, this.y + i, ' '.repeat(this.width)); 667 + } 668 + } 669 + } 670 + 671 + export class ProgressBar { 672 + constructor(options = {}) { 673 + this.value = options.value || 0; 674 + this.max = options.max || 100; 675 + this.width = options.width || 20; 676 + this.filled = options.filled || 'โ–ˆ'; 677 + this.empty = options.empty || 'โ–‘'; 678 + this.filledStyle = options.filledStyle || colors.green; 679 + this.emptyStyle = options.emptyStyle || colors.dim; 680 + this.showPercent = options.showPercent !== false; 681 + } 682 + 683 + setValue(value) { 684 + this.value = Math.max(0, Math.min(this.max, value)); 685 + } 686 + 687 + render() { 688 + const ratio = this.value / this.max; 689 + const filledCount = Math.round(ratio * this.width); 690 + const emptyCount = this.width - filledCount; 691 + 692 + let bar = this.filledStyle + this.filled.repeat(filledCount) + codes.reset; 693 + bar += this.emptyStyle + this.empty.repeat(emptyCount) + codes.reset; 694 + 695 + if (this.showPercent) { 696 + const percent = (ratio * 100).toFixed(0); 697 + bar += ` ${percent}%`; 698 + } 699 + 700 + return bar; 701 + } 702 + } 703 + 704 + export class Input { 705 + constructor(options = {}) { 706 + this.value = options.value || ''; 707 + this.placeholder = options.placeholder || ''; 708 + this.width = options.width || 20; 709 + this.cursorPos = this.value.length; 710 + this.style = options.style || ''; 711 + this.cursorStyle = options.cursorStyle || colors.inverse; 712 + } 713 + 714 + setValue(value) { 715 + this.value = value; 716 + this.cursorPos = value.length; 717 + } 718 + 719 + clear() { 720 + this.value = ''; 721 + this.cursorPos = 0; 722 + } 723 + 724 + handleKey(key) { 725 + if (key === keys.BACKSPACE || key === '\b') { 726 + if (this.cursorPos > 0) { 727 + this.value = this.value.slice(0, this.cursorPos - 1) + this.value.slice(this.cursorPos); 728 + this.cursorPos--; 729 + } 730 + return true; 731 + } else if (key === keys.DELETE) { 732 + if (this.cursorPos < this.value.length) { 733 + this.value = this.value.slice(0, this.cursorPos) + this.value.slice(this.cursorPos + 1); 734 + } 735 + return true; 736 + } else if (key === keys.LEFT) { 737 + this.cursorPos = Math.max(0, this.cursorPos - 1); 738 + return true; 739 + } else if (key === keys.RIGHT) { 740 + this.cursorPos = Math.min(this.value.length, this.cursorPos + 1); 741 + return true; 742 + } else if (key === keys.HOME) { 743 + this.cursorPos = 0; 744 + return true; 745 + } else if (key === keys.END) { 746 + this.cursorPos = this.value.length; 747 + return true; 748 + } else if (key.length === 1 && key >= ' ' && key <= '~') { 749 + this.value = this.value.slice(0, this.cursorPos) + key + this.value.slice(this.cursorPos); 750 + this.cursorPos++; 751 + return true; 752 + } 753 + return false; 754 + } 755 + 756 + render() { 757 + let display = this.value || this.placeholder; 758 + const isPlaceholder = !this.value && this.placeholder; 759 + 760 + if (isPlaceholder) { 761 + display = colors.dim + display + codes.reset; 762 + } 763 + 764 + if (this.value.length > 0) { 765 + const before = this.value.slice(0, this.cursorPos); 766 + const cursor = this.value[this.cursorPos] || ' '; 767 + const after = this.value.slice(this.cursorPos + 1); 768 + display = this.style + before + this.cursorStyle + cursor + codes.reset + this.style + after + codes.reset; 769 + } else { 770 + display = this.style + this.cursorStyle + ' ' + codes.reset; 771 + } 772 + 773 + return truncate(display, this.width); 774 + } 775 + } 776 + 777 + export class Table { 778 + constructor(options = {}) { 779 + this.columns = options.columns || []; 780 + this.rows = options.rows || []; 781 + this.x = options.x || 0; 782 + this.y = options.y || 0; 783 + this.width = options.width; 784 + this.headerStyle = options.headerStyle || colors.bold; 785 + this.rowStyle = options.rowStyle || ''; 786 + this.altRowStyle = options.altRowStyle || colors.dim; 787 + this.borderStyle = options.borderStyle || box.light; 788 + this.showBorder = options.showBorder !== false; 789 + } 790 + 791 + _calcColumnWidths() { 792 + const widths = this.columns.map(col => visibleLength(col.header || col.key)); 793 + 794 + for (const row of this.rows) { 795 + for (let i = 0; i < this.columns.length; i++) { 796 + const col = this.columns[i]; 797 + const value = String(row[col.key] ?? ''); 798 + widths[i] = Math.max(widths[i], visibleLength(value)); 799 + } 800 + } 801 + 802 + if (this.width) { 803 + const totalWidth = widths.reduce((a, b) => a + b, 0); 804 + const available = this.width - (this.showBorder ? this.columns.length + 1 : 0); 805 + if (totalWidth > available) { 806 + const scale = available / totalWidth; 807 + for (let i = 0; i < widths.length; i++) { 808 + widths[i] = Math.floor(widths[i] * scale); 809 + } 810 + } 811 + } 812 + 813 + return widths; 814 + } 815 + 816 + render(screen) { 817 + const widths = this._calcColumnWidths(); 818 + const bs = this.borderStyle; 819 + let row = this.y; 820 + 821 + if (this.showBorder) { 822 + let top = bs.tl; 823 + for (let i = 0; i < widths.length; i++) { 824 + top += bs.h.repeat(widths[i] + 2); 825 + top += i < widths.length - 1 ? bs.tT : bs.tr; 826 + } 827 + screen.write(this.x, row++, top); 828 + } 829 + 830 + let header = this.showBorder ? bs.v : ''; 831 + for (let i = 0; i < this.columns.length; i++) { 832 + const col = this.columns[i]; 833 + const text = pad(col.header || col.key, widths[i]); 834 + header += ' ' + this.headerStyle + text + codes.reset + ' '; 835 + if (this.showBorder) header += bs.v; 836 + } 837 + screen.write(this.x, row++, header); 838 + 839 + if (this.showBorder) { 840 + let sep = bs.lT; 841 + for (let i = 0; i < widths.length; i++) { 842 + sep += bs.h.repeat(widths[i] + 2); 843 + sep += i < widths.length - 1 ? bs.cross : bs.rT; 844 + } 845 + screen.write(this.x, row++, sep); 846 + } 847 + 848 + for (let r = 0; r < this.rows.length; r++) { 849 + const data = this.rows[r]; 850 + const style = r % 2 === 0 ? this.rowStyle : this.altRowStyle; 851 + let line = this.showBorder ? bs.v : ''; 852 + for (let i = 0; i < this.columns.length; i++) { 853 + const col = this.columns[i]; 854 + const value = String(data[col.key] ?? ''); 855 + const text = pad(truncate(value, widths[i]), widths[i]); 856 + line += ' ' + style + text + codes.reset + ' '; 857 + if (this.showBorder) line += bs.v; 858 + } 859 + screen.write(this.x, row++, line); 860 + } 861 + 862 + if (this.showBorder) { 863 + let bottom = bs.bl; 864 + for (let i = 0; i < widths.length; i++) { 865 + bottom += bs.h.repeat(widths[i] + 2); 866 + bottom += i < widths.length - 1 ? bs.bT : bs.br; 867 + } 868 + screen.write(this.x, row++, bottom); 869 + } 870 + 871 + return row - this.y; 872 + } 873 + } 874 + 875 + export function modal(screen, options) { 876 + const width = options.width || 40; 877 + const height = options.height || 10; 878 + const x = options.x ?? Math.floor((screen.width - width) / 2); 879 + const y = options.y ?? Math.floor((screen.height - height) / 2); 880 + const title = options.title || ''; 881 + const titleStyle = options.titleStyle || colors.bold; 882 + const borderStyle = options.borderStyle || box.rounded; 883 + const bgStyle = options.bgStyle || ''; 884 + 885 + return screen.pushModal({ 886 + id: options.id, 887 + x, 888 + y, 889 + width, 890 + height, 891 + onKey: options.onKey, 892 + onRender: (buf, w, h) => { 893 + buf.fill(0, 0, w, h, ' '); 894 + buf.box(0, 0, w, h, borderStyle, title, titleStyle); 895 + 896 + if (options.render) { 897 + options.render(buf, w - 2, h - 2, 1, 1); 898 + } 899 + } 900 + }); 901 + } 902 + 903 + export function confirm(screen, options) { 904 + return new Promise(resolve => { 905 + const message = options.message || 'Are you sure?'; 906 + const width = Math.max(visibleLength(message) + 4, 30); 907 + const height = 7; 908 + 909 + modal(screen, { 910 + id: 'confirm', 911 + width, 912 + height, 913 + title: options.title || 'Confirm', 914 + titleStyle: colors.bold + colors.yellow, 915 + onKey: key => { 916 + if (key === 'y' || key === 'Y' || key === keys.ENTER) { 917 + screen.popModal('confirm'); 918 + screen.render(); 919 + resolve(true); 920 + } else if (key === 'n' || key === 'N' || key === keys.ESCAPE || key === keys.CTRL_C) { 921 + screen.popModal('confirm'); 922 + screen.render(); 923 + resolve(false); 924 + } 925 + return false; 926 + }, 927 + render: (buf, w, h, ox, oy) => { 928 + buf.writeStyled(ox, oy + 1, padCenter(message, w)); 929 + buf.writeStyled(ox, oy + 3, padCenter(`${colors.green}[Y]es${codes.reset} ${colors.red}[N]o${codes.reset}`, w + 20)); 930 + } 931 + }); 932 + screen.render(); 933 + }); 934 + } 935 + 936 + export function alert(screen, options) { 937 + return new Promise(resolve => { 938 + const message = options.message || ''; 939 + const lines = wrap(message, 50); 940 + const width = Math.max(...lines.map(visibleLength), 20) + 4; 941 + const height = lines.length + 5; 942 + 943 + modal(screen, { 944 + id: 'alert', 945 + width, 946 + height, 947 + title: options.title || 'Alert', 948 + titleStyle: colors.bold + colors.cyan, 949 + onKey: key => { 950 + if (key === keys.ENTER || key === keys.ESCAPE || key === ' ') { 951 + screen.popModal('alert'); 952 + screen.render(); 953 + resolve(); 954 + } 955 + return false; 956 + }, 957 + render: (buf, w, h, ox, oy) => { 958 + for (let i = 0; i < lines.length; i++) { 959 + buf.writeStyled(ox, oy + i + 1, padCenter(lines[i], w)); 960 + } 961 + buf.writeStyled(ox, oy + lines.length + 2, padCenter(`${colors.dim}[Enter] OK${codes.reset}`, w + 10)); 962 + } 963 + }); 964 + screen.render(); 965 + }); 966 + } 967 + 968 + export default { 969 + codes, 970 + colors, 971 + box, 972 + keys, 973 + stripAnsi, 974 + visibleLength, 975 + pad, 976 + padStart, 977 + padCenter, 978 + truncate, 979 + wrap, 980 + Buffer, 981 + Screen, 982 + List, 983 + ProgressBar, 984 + Input, 985 + Table, 986 + modal, 987 + confirm, 988 + alert 989 + };
+5 -6
src/ant.c
··· 1056 1056 1057 1057 if (work & (WORK_READLINE | WORK_STDIN)) { 1058 1058 uv_run(uv_default_loop(), UV_RUN_NOWAIT); 1059 - } 1060 - 1061 - if (!(work & WORK_BLOCKING) && (work & WORK_TIMERS)) { 1059 + int64_t ms = has_pending_timers() ? get_next_timer_timeout() : 20; 1060 + if (ms > 20) ms = 20; if (ms > 0) usleep((useconds_t)(ms * 1000)); 1061 + } else if (!(work & WORK_BLOCKING) && (work & WORK_TIMERS)) { 1062 1062 jsoff_t gc_thresh = js->brk / 2; 1063 1063 if (gc_thresh < 4 * 1024 * 1024) gc_thresh = 4 * 1024 * 1024; 1064 1064 if (js->gc_alloc_since > gc_thresh || js->needs_gc) { ··· 1066 1066 js_gc_compact(js); 1067 1067 js->gc_alloc_since = 0; 1068 1068 } 1069 + 1069 1070 int64_t ms = get_next_timer_timeout(); 1070 1071 if (ms > 0) usleep(ms > 1000 ? 1000000 : (useconds_t)(ms * 1000)); 1071 - } else if ( 1072 - (work & (WORK_READLINE | WORK_STDIN)) && !(work & WORK_BLOCKING) 1073 - ) uv_run(uv_default_loop(), UV_RUN_ONCE); 1072 + } 1074 1073 } 1075 1074 1076 1075 js_poll_events(js);
+1 -1
src/modules/fetch.c
··· 363 363 void fetch_poll_events(void) { 364 364 if (fetch_loop && fetch_loop == uv_default_loop() && (rt->flags & ANT_RUNTIME_EXT_EVENT_LOOP)) return; 365 365 if (fetch_loop && uv_loop_alive(fetch_loop)) { 366 - uv_run(fetch_loop, UV_RUN_ONCE); 366 + uv_run(fetch_loop, fetch_loop == uv_default_loop() ? UV_RUN_NOWAIT : UV_RUN_ONCE); 367 367 if (pending_requests && utarray_len(pending_requests) > 0) usleep(1000); 368 368 } 369 369 }
+1 -1
src/modules/fs.c
··· 1306 1306 void fs_poll_events(void) { 1307 1307 if (fs_loop && fs_loop == uv_default_loop() && (rt->flags & ANT_RUNTIME_EXT_EVENT_LOOP)) return; 1308 1308 if (fs_loop && uv_loop_alive(fs_loop)) { 1309 - uv_run(fs_loop, UV_RUN_ONCE); 1309 + uv_run(fs_loop, fs_loop == uv_default_loop() ? UV_RUN_NOWAIT : UV_RUN_ONCE); 1310 1310 if (pending_requests && utarray_len(pending_requests) > 0) usleep(1000); 1311 1311 } 1312 1312 }
+59
tests/test_gc_coro.js
··· 1 + // Simulate TUI-like behavior: async handler with lots of string allocations 2 + 3 + function stripAnsi(str) { 4 + return str.replace(/\x1b\[[0-9;]*m/g, ''); 5 + } 6 + 7 + function pad(str, len) { 8 + const visible = stripAnsi(str).length; 9 + const diff = len - visible; 10 + if (diff <= 0) return str; 11 + return str + ' '.repeat(diff); 12 + } 13 + 14 + function render() { 15 + // Simulate TUI render - matches what tui.js does 16 + const width = 120; 17 + const height = 40; 18 + const lines = []; 19 + 20 + // Clear buffer 21 + for (let y = 0; y < height; y++) { 22 + lines.push(' '.repeat(width)); 23 + } 24 + 25 + // Draw styled content (like the TUI does) 26 + for (let y = 0; y < height; y++) { 27 + let text = `\x1b[38;5;196mRow ${y}\x1b[0m: `; 28 + text += `\x1b[38;5;82m${'โ–ˆ'.repeat(20)}\x1b[0m`; 29 + text += `\x1b[2m${'โ–‘'.repeat(20)}\x1b[0m`; 30 + text = pad(text, width); 31 + lines[y] = text; 32 + } 33 + 34 + return lines.join('\n'); 35 + } 36 + 37 + async function handleEvent(n) { 38 + // Simulate multiple renders per event (like scrolling does) 39 + for (let i = 0; i < 10; i++) { 40 + render(); 41 + } 42 + } 43 + 44 + let count = 0; 45 + const interval = setInterval(() => { 46 + count++; 47 + handleEvent(count); 48 + 49 + const stats = Ant.stats(); 50 + 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`); 51 + 52 + if (count >= 500) { // Run for ~5 seconds 53 + clearInterval(interval); 54 + console.log('Done - forcing GC...'); 55 + Ant.gc(); 56 + const after = Ant.stats(); 57 + console.log(`after GC: arena ${(after.arenaUsed / 1024 / 1024).toFixed(1)}MB`); 58 + } 59 + }, 10); // 500 * 10ms = 5 seconds