├── .gitignore ├── LICENSE.txt ├── README.md ├── console-menu.js ├── package.json └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jason Ginchereau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # console-menu 2 | Displays a menu of items in the console and asynchronously waits for the user to select an item. Each item title is prefixed by a hotkey. An item may be selected by typing a hotkey or by using Down/Up arrows followed by Enter. 3 | ``` 4 | .--------------. 5 | | Example menu | 6 | +--------------+ 7 | | [a] Item A | 8 | | b) Item B | 9 | | c) Item C | 10 | | d) Item D | 11 | | e) Item E | 12 | '--\/----------' 13 | ``` 14 | The menu may be scrollable (hinted by `/\` and `\/` indicators). PageUp, PageDown, Home, and End keys are also supported. 15 | 16 | ## Usage 17 | The `menu` function takes two parameters: an `items` array and an `options` object. 18 | 19 | Each item must be an object with the following properties: 20 | * `separator` (boolean): If true, this is a separator item that inserts a blank line into the menu. (All other properties are ignored on separator items.) 21 | * `title` (string): Item title text. 22 | * `hotkey` (character): Unique item hotkey; must be a single letter, number, or other character. If omitted, the item is only selectable via arrow keys + Enter. 23 | * `selected` (boolean) True if this item should initially selected. If unspecified then the first item is initially selected. 24 | 25 | Items may have additional user-defined properties, which will be included in the returned result. 26 | 27 | The following options are supported: 28 | * `header` (string): Optional header text for the menu. 29 | * `border` (boolean): True to draw a border around the menu. False for a simpler-looking menu. 30 | * `pageSize` (integer): Max number of items to show at a time; additional items cause the menu to be scrollable. Omitting this value (or specifying 0) disables scrolling. 31 | * `helpMessage` (string): Message text to show under the menu. 32 | 33 | The return value is a `Promise` that resolves to the chosen item object, or to `null` if the menu was cancelled by pressing Esc or Ctrl-C. 34 | 35 | ## Example 36 | ```JavaScript 37 | var menu = require('console-menu'); 38 | menu([ 39 | { hotkey: '1', title: 'One' }, 40 | { hotkey: '2', title: 'Two', selected: true }, 41 | { hotkey: '3', title: 'Three' }, 42 | { separator: true }, 43 | { hotkey: '?', title: 'Help' }, 44 | ], { 45 | header: 'Example menu', 46 | border: true, 47 | }).then(item => { 48 | if (item) { 49 | console.log('You chose: ' + JSON.stringify(item)); 50 | } else { 51 | console.log('You cancelled the menu.'); 52 | } 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /console-menu.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const readline = require('readline'); 3 | const keypress = require('keypress'); 4 | 5 | const defaultHelpMessage = 6 | 'Type a hotkey or use Down/Up arrows then Enter to choose an item.'; 7 | 8 | /** 9 | * Displays a menu of items in the console and asynchronously waits for the user to select an item. 10 | * 11 | * @param {any} items Array of menu items, where each item is an object that includes a title 12 | * property and optional hotkey property. (Items may include additional user-defined properties.) 13 | * @param {any} options Dictionary of options for the menu: 14 | * - header {string}: Header text for the menu. 15 | * - border {boolean}: True to draw a border around the menu. 16 | * - pageSize {integer}: Max number of items to show at a time. Additional items cause the menu 17 | * to be scrollable. 18 | * - helpMessage {string}: Message text to show under the menu. 19 | * @returns A promise that resolves to the chosen item, or to null if the menu was cancelled. 20 | */ 21 | function menu(items, options) { 22 | if (!items || !Array.isArray(items) || items.length < 1) { 23 | throw new TypeError('A nonempty items array is required.'); 24 | } 25 | options = options || {}; 26 | 27 | var count = items.length; 28 | var selectedIndex = items.findIndex(item => item.selected); 29 | if (selectedIndex < 0) { 30 | selectedIndex = 0; 31 | while (selectedIndex < count && items[selectedIndex].separator) selectedIndex++; 32 | } 33 | 34 | var scrollOffset = 0; 35 | printMenu(items, options, selectedIndex, scrollOffset); 36 | 37 | return new Promise((resolve, reject) => { 38 | process.stdin.setRawMode(true); 39 | process.stdin.resume(); 40 | keypress(process.stdin); 41 | 42 | var handleMenuKeypress = (ch, key) => { 43 | var selection = null; 44 | if (isEnter(key)) { 45 | selection = items[selectedIndex]; 46 | } else if (ch) { 47 | selection = items.find(item => item.hotkey && item.hotkey === ch) || 48 | items.find(item => item.hotkey && 49 | item.hotkey.toLowerCase() === ch.toLowerCase()); 50 | } 51 | 52 | var newIndex = null; 53 | if (selection || isCancelCommand(key)) { 54 | process.stdin.removeListener('keypress', handleMenuKeypress); 55 | process.stdin.setRawMode(false); 56 | resetCursor(options, selectedIndex, scrollOffset); 57 | readline.clearScreenDown(process.stdout); 58 | process.stdin.pause(); 59 | resolve(selection); 60 | } else if (isUpCommand(key) && selectedIndex > 0) { 61 | newIndex = selectedIndex - 1; 62 | while (newIndex >= 0 && items[newIndex].separator) newIndex--; 63 | } else if (isDownCommand(key) && selectedIndex < count - 1) { 64 | newIndex = selectedIndex + 1; 65 | while (newIndex < count && items[newIndex].separator) newIndex++; 66 | } else if (isPageUpCommand(key) && selectedIndex > 0) { 67 | newIndex = (options.pageSize ? Math.max(0, selectedIndex - options.pageSize) : 0); 68 | while (newIndex < count && items[newIndex].separator) newIndex++; 69 | } else if (isPageDownCommand(key) && selectedIndex < count - 1) { 70 | newIndex = (options.pageSize 71 | ? Math.min(count - 1, selectedIndex + options.pageSize) : count - 1); 72 | while (newIndex >= 0 && items[newIndex].separator) newIndex--; 73 | } else if (isGoToFirstCommand(key) && selectedIndex > 0) { 74 | newIndex = 0; 75 | while (newIndex < count && items[newIndex].separator) newIndex++; 76 | } else if (isGoToLastCommand(key) && selectedIndex < count - 1) { 77 | newIndex = count - 1; 78 | while (newIndex >= 0 && items[newIndex].separator) newIndex--; 79 | } 80 | 81 | if (newIndex !== null && newIndex >= 0 && newIndex < count) { 82 | resetCursor(options, selectedIndex, scrollOffset); 83 | 84 | selectedIndex = newIndex; 85 | 86 | // Adjust the scroll offset when the selection moves off the page. 87 | if (selectedIndex < scrollOffset) { 88 | scrollOffset = (isPageUpCommand(key) 89 | ? Math.max(0, scrollOffset - options.pageSize) : selectedIndex); 90 | } else if (options.pageSize && selectedIndex >= scrollOffset + options.pageSize) { 91 | scrollOffset = (isPageDownCommand(key) 92 | ? Math.min(count - options.pageSize, scrollOffset + options.pageSize) 93 | : selectedIndex - options.pageSize + 1); 94 | } 95 | 96 | printMenu(items, options, selectedIndex, scrollOffset); 97 | } 98 | }; 99 | 100 | process.stdin.addListener('keypress', handleMenuKeypress); 101 | }); 102 | } 103 | 104 | function isEnter(key) { return key && (key.name === 'enter' || key.name === 'return'); } 105 | function isUpCommand(key) { return key && key.name === 'up'; } 106 | function isDownCommand(key) { return key && key.name === 'down'; } 107 | function isPageUpCommand(key) { return key && key.name === 'pageup'; } 108 | function isPageDownCommand(key) { return key && key.name === 'pagedown'; } 109 | function isGoToFirstCommand(key) { return key && key.name === 'home'; } 110 | function isGoToLastCommand(key) { return key && key.name === 'end'; } 111 | function isCancelCommand(key) { 112 | return key && ((key.ctrl && key.name == 'c') || key.name === 'escape'); 113 | } 114 | 115 | function resetCursor(options, selectedIndex, scrollOffset) { 116 | readline.moveCursor(process.stdout, -3, 117 | - (options.header ? 1 : 0) 118 | - (options.border ? (options.header ? 2 : 1) : 0) 119 | - selectedIndex + scrollOffset); 120 | } 121 | 122 | function printMenu(items, options, selectedIndex, scrollOffset) { 123 | var repeat = (s, n) => { 124 | return Array(n + 1).join(s); 125 | }; 126 | 127 | var width = 0; 128 | for (var i = 0; i < items.length; i++) { 129 | if (items[i].title && 4 + items[i].title.length > width) { 130 | width = 4 + items[i].title.length; 131 | } 132 | } 133 | 134 | var prefix = (options.border ? '|' : ''); 135 | var suffix = (options.border ? ' |' : ''); 136 | 137 | if (options.header && options.header.length > width) { 138 | width = options.header.length; 139 | } 140 | 141 | if (options.border) { 142 | if (!options.header && options.pageSize && scrollOffset > 0) { 143 | process.stdout.write('.--/\\' + repeat('-', width - 2) + '.' + os.EOL); 144 | } else { 145 | process.stdout.write('.' + repeat('-', width + 2) + '.' + os.EOL); 146 | } 147 | } 148 | 149 | if (options.header) { 150 | process.stdout.write(prefix + (options.border ? ' ' : '') + options.header + 151 | repeat(' ', width - options.header.length) + suffix + os.EOL); 152 | if (options.border) { 153 | if (options.pageSize && scrollOffset > 0) { 154 | process.stdout.write('+--/\\' + repeat('-', width - 2) + '+' + os.EOL); 155 | } else { 156 | process.stdout.write('+' + repeat('-', width + 2) + '+' + os.EOL); 157 | } 158 | } 159 | } 160 | 161 | var scrollEnd = options.pageSize 162 | ? Math.min(items.length, scrollOffset + options.pageSize) 163 | : items.length; 164 | for (var i = scrollOffset; i < scrollEnd; i++) { 165 | if (items[i].separator) { 166 | process.stdout.write(prefix + ' ' + repeat(' ', width) + suffix + os.EOL); 167 | } else { 168 | var hotkey = items[i].hotkey || '*'; 169 | var title = items[i].title || ''; 170 | var label = (i === selectedIndex 171 | ? '[' + hotkey + ']' : ' ' + hotkey + ')'); 172 | process.stdout.write(prefix + ' ' + label + ' ' + title + 173 | repeat(' ', width - title.length - 4) + suffix + os.EOL); 174 | } 175 | } 176 | 177 | if (options.border) { 178 | if (options.pageSize && scrollEnd < items.length) { 179 | process.stdout.write('\'--\\/' + repeat('-', width - 2) + '\'' + os.EOL); 180 | } else { 181 | process.stdout.write('\'' + repeat('-', width + 2) + '\'' + os.EOL); 182 | } 183 | } 184 | 185 | process.stdout.write(options.helpMessage || defaultHelpMessage); 186 | readline.moveCursor(process.stdout, 187 | -(options.helpMessage || defaultHelpMessage).length + prefix.length + 2, 188 | -(options.border ? 1 : 0) - (scrollEnd - scrollOffset) + selectedIndex - scrollOffset); 189 | } 190 | 191 | module.exports = menu; 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "console-menu", 3 | "version": "0.1.0", 4 | "description": "A scrollable menu for the Node.js console", 5 | "main": "console-menu.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/jasongin/console-menu.git" 12 | }, 13 | "keywords": [ 14 | "menu" 15 | ], 16 | "author": "", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/jasongin/console-menu/issues" 20 | }, 21 | "homepage": "https://github.com/jasongin/console-menu#readme", 22 | "dependencies": { 23 | "keypress": "^0.2.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | // A simple interactive test for the console-menu module. 2 | 3 | const menu = require('./console-menu'); 4 | 5 | menu([ 6 | { hotkey: '1', title: 'One' }, 7 | { hotkey: '2', title: 'Two', selected: true }, 8 | { hotkey: '3', title: 'Three' }, 9 | { hotkey: '4', title: 'Four' }, 10 | { separator: true }, 11 | { hotkey: '0', title: 'Do something else...', cascade: true }, 12 | { separator: true }, 13 | { hotkey: '?', title: 'Help' }, 14 | ], { 15 | header: 'Test menu', 16 | border: true, 17 | }).then(item => { 18 | if(item && item.cascade) { 19 | return menu(['a','b','c','d','e','f','g','h','i','j'].map(hotkey => { 20 | return { 21 | hotkey, 22 | title: 'Item ' + hotkey.toUpperCase(), 23 | }; 24 | }), { 25 | header: 'Another menu', 26 | border: true, 27 | pageSize: 5, 28 | }); 29 | } else { 30 | return item; 31 | } 32 | }).then(item => { 33 | if (item) { 34 | console.log('You chose: ' + JSON.stringify(item)); 35 | } else { 36 | console.log('You cancelled the menu.'); 37 | } 38 | }); 39 | --------------------------------------------------------------------------------