To draw Popups and more inside a Node.js program I've built this example (a TCP server)
So, in this program if I press "s" I can choose the sending speed of the TCP message:
And using the Up and Down arrows...
Press enter to confirm:
And then with "h" I can set the Max value (a numeric threshold):
Only numbers are allowed.
To make this it's neccessary to:
- use readline library to enable keyPress event:
import readline from 'readline'; readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); // With this I only get the key value
- create an updateConsole function to draw frames:
const updateConsole = async() => { console.clear() console.log(chalk.yellow(`TCP server simulator app! Welcome...`)) console.log(chalk.green(`TCP Server listening on ${HOST}:${PORT}`)); console.log(chalk.green(`Connected clients: `) + chalk.white(`${connectedClients}\n`)); console.log(chalk.magenta(`TCP Messages sent: `) + chalk.white(`${tcpCounter}`) + `\n`); // Print if simulator is running or not if (!valueEmitter) { console.log(chalk.red(`Simulator is not running! `) + chalk.white(`press 'space' to start`)) } else { console.log(chalk.green(`Simulator is running! `) + chalk.white(`press 'space' to stop`)) } // Print mode: console.log(chalk.cyan(`Mode:`) + chalk.white(` ${mode}`)); // Print message frequency: console.log(chalk.cyan(`Message period:`) + chalk.white(` ${period} ms`)); // Print Min and Max console.log(chalk.cyan(`Min:`) + chalk.white(` ${min}`)); console.log(chalk.cyan(`Max:`) + chalk.white(` ${max}`)); // Print current values: console.log(chalk.cyan(`Values:`) + chalk.white(` ${values.map(v => v.toFixed(4)).join(' ')}`)); // Spacer console.log(`\n`); if (lastErr.length > 0) { console.error(lastErr) console.log('\n') } console.log(chalk.bgBlack(`Commands:`)); console.log(` ${chalk.bold('space')} - ${chalk.italic('Start/stop simulator')}`); console.log(` ${chalk.bold('m')} - ${chalk.italic('Select simulation mode')}`); console.log(` ${chalk.bold('s')} - ${chalk.italic('Select message period')}`); console.log(` ${chalk.bold('h')} - ${chalk.italic('Set max value')}`); console.log(` ${chalk.bold('l')} - ${chalk.italic('Set min value')}`); console.log(` ${chalk.bold('q')} - ${chalk.italic('Quit')}`); }
- and next add other layers to show over the main screen (Like windows)
/** * @description Draws a window with an options selector (Select) * * @param {*} title - Title of the window * @param {*} options - Options of the window * @param {*} selected - Selected option */ const addOptionPopupLayer = (title, options, selected) => { const offset = 2 const Terminal = process.stdout; const maxOptionsLength = options.reduce((max, option) => Math.max(max, option.toString().length), 0) const windowWidth = maxOptionsLength > title.length ? maxOptionsLength + (2 * offset) : title.length + (2 * offset) let header = "┌" for (let i = 0; i < windowWidth; i++) { header += "─" } header += "┐\n" header += `│${" ".repeat((windowWidth - title.length) / 2)}${title}${" ".repeat((windowWidth - title.length) / 2)}│\n` header += "├" + "─".repeat(windowWidth) + "┤\n" //││ let footer = "└" for (let i = 0; i < windowWidth; i++) { footer += "─" } footer += "┘\n" let content = "" options.forEach((option, index) => { content += `│${option === selected ? "<" : " "} ${option}${option === selected ? " >" : " "}${" ".repeat(windowWidth - option.toString().length - 4)}│\n` }) const windowDesign = `${header}${content}${footer}` windowDesign.split('\n').forEach((line, index) => { Terminal.cursorTo(Math.round((Terminal.columns / 2) - (windowWidth / 2)), 4 + index) Terminal.write(line) }) } /** * @description Draws a window with a text input (Input) * * @param {*} title - Title of the window * @param {*} value - Current value of the textbox */ const addInputPopupLayer = (title, value) => { const offset = 2 const Terminal = process.stdout; const windowWidth = title.length > value.toString().length ? title.length + (2 * offset) : value.toString().length + (2 * offset) let header = "┌" for (let i = 0; i < windowWidth; i++) { header += "─" } header += "┐\n" header += `│${" ".repeat(windowWidth % 2 ? Math.round((windowWidth - title.length) / 2) : Math.round((windowWidth - title.length) / 2) - 1)}${title}${" ".repeat(Math.round((windowWidth - title.length) / 2))}│\n` header += "├" + "─".repeat(windowWidth) + "┤\n" //││ let footer = "└" for (let i = 0; i < windowWidth; i++) { footer += "─" } footer += "┘\n" let content = "" // Draw an input field content += `│${"> "}${value}${" ".repeat(windowWidth - value.toString().length - 2)}│\n` const windowDesign = `${header}${content}${footer}` windowDesign.split('\n').forEach((line, index) => { Terminal.cursorTo(Math.round((Terminal.columns / 2) - (windowWidth / 2)), 4 + index) Terminal.write(line) }) Terminal.cursorTo(Math.round((Terminal.columns / 2) - (windowWidth / 2)) + 2 + value.toString().length, 4 + 3) }
- all this design templates can be managed by only one main function:
const drawGui = () => { updateConsole() switch (window) { case "SET_SPEED": addOptionPopupLayer("Set message period", periodList, selectedPeriod) break case "SET_MODE": addOptionPopupLayer("Set simulation mode", modeList, selectedMode) break case "SET_MAX": addInputPopupLayer("Set max value", typedMaxValue) break case "SET_MIN": addInputPopupLayer("Set min value", typedMinValue) break default: break } }
- and finally we have to manage the user input keys and make a different management based of which window is showed:
// Add a command input listener to change mode process.stdin.on('keypress', (str, key) => { if (key.ctrl && key.name === 'c') { clearInterval(valueEmitter) server.close() process.exit() } // Controls of the main window switch (window) { case "HOME": { switch (key.name) { case 'm': window = "SET_MODE" selectedMode = mode break case 'space': { if (!valueEmitter) { if (connectedClients > 0) { valueEmitter = setInterval(frame, period) if (lastErr.includes("No clients connected!")) { lastErr = "" } } else { lastErr = chalk.redBright("Error: ") + chalk.white(`No clients connected!`); } } else { clearInterval(valueEmitter) valueEmitter = null } } break case 's': window = "SET_SPEED" selectedPeriod = period break case 'h': window = "SET_MAX" typedMaxValue = max break case 'l': window = "SET_MIN" typedMinValue = min break case 'q': clearInterval(valueEmitter) server.close() process.exit() default: break } } break // Controls of the SET_SPEED window case "SET_SPEED": { switch (key.name) { case 'down': selectedPeriod = periodList[periodList.indexOf(selectedPeriod) + 1] break case 'up': selectedPeriod = periodList[periodList.indexOf(selectedPeriod) - 1] break case 'return': { period = selectedPeriod if (valueEmitter) { clearInterval(valueEmitter) valueEmitter = setInterval(frame, period) } window = "HOME" } break case 'escape': window = "HOME" break case 'q': clearInterval(valueEmitter) server.close() process.exit() default: break } } break // Controls of the SET_MODE window case "SET_MODE": { switch (key.name) { case 'down': selectedMode = modeList[modeList.indexOf(selectedMode) + 1] break case 'up': selectedMode = modeList[modeList.indexOf(selectedMode) - 1] break case 'return': { mode = selectedMode window = "HOME" } break case 'escape': window = "HOME" break case 'q': clearInterval(valueEmitter) server.close() process.exit() default: break } } break // Controls of the SET_MAX window case "SET_MAX": { // In this case I want to allow only numbers to be typed and so I use the key.name to check if it is a number // It means that the typed key is a number or numpad number if (!Number.isNaN(Number(key.name))) { if (typedMaxValue.toString().length < 20) { let tmp = typedMaxValue.toString() tmp += key.name typedMaxValue = Number(tmp) } // To change the sign I check for the keys "+" and "-" } else if (key.sequence === '-') { typedMaxValue = typedMaxValue * -1 } else if (key.sequence === '+') { typedMaxValue = Math.abs(typedMaxValue) } else { switch (key.name) { // Otherwise I check for the keys "return", "escape" and "backspace" case 'backspace': // If backspace is pressed I remove the last character from the typed value if (typedMaxValue.toString().length > 0) { typedMaxValue = Number(typedMaxValue.toString().slice(0, typedMaxValue.toString().length - 1)) } break case 'return': // In case of "enter" I return the typed value to the main window { max = typedMaxValue window = "HOME" } break case 'escape': window = "HOME" break case 'q': clearInterval(valueEmitter) server.close() process.exit() default: break } } } break // Controls of the SET_MIN window case "SET_MIN": { if (!Number.isNaN(Number(key.name))) { if (typedMinValue.toString().length < 20) { let tmp = typedMinValue.toString() tmp += key.name typedMinValue = Number(tmp) } } else if (key.sequence === '-') { typedMinValue = typedMinValue * -1 } else if (key.sequence === '+') { typedMinValue = Math.abs(typedMinValue) } else { switch (key.name) { case 'backspace': if (typedMinValue.toString().length > 0) { typedMinValue = Number(typedMinValue.toString().slice(0, typedMinValue.toString().length - 1)) } break case 'return': { min = typedMinValue window = "HOME" } break case 'escape': window = "HOME" break case 'q': clearInterval(valueEmitter) server.close() process.exit() default: break } } } break default: break } drawGui() })
You maybe want to add other gui components... Let's do it!