UNPKG

13.9 kBJavaScriptView Raw
1/*
2 Terminal Kit
3
4 Copyright (c) 2009 - 2020 Cédric Ronvel
5
6 The MIT License (MIT)
7
8 Permission is hereby granted, free of charge, to any person obtaining a copy
9 of this software and associated documentation files (the "Software"), to deal
10 in the Software without restriction, including without limitation the rights
11 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 copies of the Software, and to permit persons to whom the Software is
13 furnished to do so, subject to the following conditions:
14
15 The above copyright notice and this permission notice shall be included in all
16 copies or substantial portions of the Software.
17
18 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 SOFTWARE.
25*/
26
27"use strict" ;
28
29
30
31const termkit = require( './termkit' ) ;
32const stringWidth = termkit.stringWidth ;
33const NextGenEvents = require( 'nextgen-events' ) ;
34const Promise = require( 'seventh' ) ;
35
36
37
38const defaultKeyBindings = {
39 ENTER: 'submit' ,
40 KP_ENTER: 'submit' ,
41 LEFT: 'previous' ,
42 RIGHT: 'next' ,
43 UP: 'previousPage' ,
44 DOWN: 'nextPage' ,
45 TAB: 'cycleNext' ,
46 SHIFT_TAB: 'cyclePrevious' ,
47 HOME: 'first' ,
48 END: 'last' ,
49 ESCAPE: 'escape'
50} ;
51
52
53
54/*
55 singleLineMenu( menuItems , [options] , callback )
56 * menuItems `array` of menu item text
57 * options `object` of options, where:
58 * y `number` the line where the menu will be displayed, default to the next line
59 * separator `string` (default: ' ') the string separating each menu item
60 * nextPageHint `string` (default: ' » ') string indicator for a next page
61 * previousPageHint `string` (default: ' « ') string indicator for a previous page
62 * style `function` the style of unselected items, default to `term`
63 * selectedStyle `function` the style of the selected item, default to `term.dim.blue.bgGreen`
64 * selectedIndex `number` selected index at initialization (default: 0)
65 * align `string` one of 'left' (default), 'right' or 'center', align the menu accordingly
66 * fillIn `boolean` if true (default: false), the menu will fill in the whole line with white chars
67 * keyBindings `Object` overide default key bindings
68 * cancelable `boolean` if ESCAPE is pressed, it exits, calling the callback with undefined values
69 * exitOnUnexpectedKey `boolean` if an unexpected key is pressed, it exits, calling the callback with undefined values
70 * callback( error , response ), where:
71 * error
72 * response `Object` where:
73 * selectedIndex `number` the user-selected menu item index
74 * selectedText `string` the user-selected menu item text
75 * x `number` the x coordinate of the selected menu item (the first character)
76 * y `number` the y coordinate of the selected menu item (same coordinate for all items since it's a single line menu)
77 * unexpectedKey `string` when 'exitOnUnexpectedKey' option is set, this contains the key that produced the exit
78 * canceled `bool` when 'cancelable' option is set, this is set to true
79*/
80module.exports = function singleLineMenu( menuItems_ , options , callback ) {
81 if ( arguments.length < 1 ) { throw new Error( '[terminal] singleLineMenu() needs at least an array of menuItems' ) ; }
82 if ( ! Array.isArray( menuItems_ ) || ! menuItems_.length ) { throw new TypeError( '[terminal] singleLineMenu(): argument #0 should be a non-empty array' ) ; }
83
84 if ( typeof options === 'function' ) { callback = options ; options = {} ; }
85 else if ( ! options || typeof options !== 'object' ) { options = {} ; }
86
87 if ( options.separator === undefined ) { options.separator = ' ' ; }
88 if ( options.nextPageHint === undefined ) { options.nextPageHint = ' » ' ; }
89 if ( options.previousPageHint === undefined ) { options.previousPageHint = ' « ' ; }
90 if ( ! options.style ) { options.style = this ; }
91 if ( ! options.selectedStyle ) { options.selectedStyle = this.dim.blue.bgGreen ; }
92
93 if ( ! options.y ) { this( '\n' ) ; }
94 else { this.moveTo( 1 , options.y ) ; }
95
96 var keyBindings = options.keyBindings || defaultKeyBindings ;
97
98 if ( ! this.grabbing ) { this.grabInput() ; }
99
100 var menuItems = menuItems_.map( e => typeof e === 'string' ? e : '' + e ) ;
101
102 var selectedIndexInPage = options.selectedIndex = options.selectedIndex || 0 ;
103 var start = {} , selectedPage = 0 , finished = false , menuPages = [] , alreadyCleanedUp = false ;
104
105 // Width
106 var nextPageHintWidth = stringWidth( options.nextPageHint ) ,
107 previousPageHintWidth = stringWidth( options.previousPageHint ) ,
108 separatorWidth = stringWidth( options.separator ) ;
109
110 var computePages = () => {
111 var i , itemWidth , displayText , p = 0 , endX = 1 , nextEndX , firstItem = true ,
112 lastItem , lineWidth , offset ,
113 xMax = this.width - nextPageHintWidth ;
114
115 menuPages = [ [] ] ;
116
117 for ( i = 0 ; i < menuItems.length ; i ++ ) {
118 if ( p >= menuPages.length ) { menuPages.push( [] ) ; }
119
120 itemWidth = stringWidth( menuItems[ i ] ) ;
121 nextEndX = endX + itemWidth + separatorWidth ;
122
123 if ( nextEndX > xMax ) {
124 if ( firstItem ) {
125 itemWidth = xMax - endX ;
126 displayText = termkit.truncateString( menuItems[ i ] , itemWidth - 1 ) + '…' ;
127
128 if ( i === options.selectedIndex ) {
129 selectedPage = p ;
130 selectedIndexInPage = menuPages[ p ].length ;
131 }
132
133 menuPages[ p ].push( {
134 index: i ,
135 text: menuItems[ i ] ,
136 displayText: displayText ,
137 displayTextWidth: itemWidth ,
138 x: endX
139 } ) ;
140 }
141 else {
142 i -- ;
143 }
144
145 p ++ ;
146 endX = 1 + previousPageHintWidth ;
147 firstItem = true ;
148
149 continue ;
150 }
151
152 if ( i === options.selectedIndex ) {
153 selectedPage = p ;
154 selectedIndexInPage = menuPages[ p ].length ;
155 }
156
157 menuPages[ p ].push( {
158 index: i ,
159 text: menuItems[ i ] ,
160 displayText: menuItems[ i ] ,
161 displayTextWidth: itemWidth ,
162 x: endX
163 } ) ;
164
165 endX = nextEndX ;
166 firstItem = false ;
167 }
168
169 for ( p = 0 ; p < menuPages.length ; p ++ ) {
170 lastItem = menuPages[ p ][ menuPages[ p ].length - 1 ] ;
171 lineWidth = lastItem.x + lastItem.displayTextWidth - 1 ;
172 if ( p < menuPages.length - 1 ) { lineWidth += nextPageHintWidth ; }
173
174 menuPages[ p ].x = 1 ;
175
176 if ( lineWidth < this.width ) {
177 if ( options.align === 'right' ) { offset = this.width - lineWidth ; }
178 else if ( options.align === 'center' ) { offset = Math.floor( ( this.width - lineWidth ) / 2 ) ; }
179 else { offset = 0 ; }
180
181 menuPages[ p ].x += offset ;
182
183 if ( offset ) {
184 menuPages[ p ].forEach( item => item.x += offset ) ;
185 }
186 }
187 }
188 } ;
189
190 var cleanup = ( error , data ) => {
191 if ( alreadyCleanedUp ) { return ; }
192 alreadyCleanedUp = true ;
193
194 finished = true ;
195 this.removeListener( 'key' , onKey ) ;
196 this.removeListener( 'mouse' , onMouse ) ;
197
198 if ( error ) {
199 if ( callback ) { callback( error ) ; }
200 else { controller.promise.reject( error ) ; }
201 return ;
202 }
203
204 var page = menuPages[ selectedPage ] ;
205
206 var value = data !== undefined ? data : {
207 selectedIndex: page[ selectedIndexInPage ].index ,
208 selectedText: page[ selectedIndexInPage ].text ,
209 x: page[ selectedIndexInPage ].x ,
210 y: start.y
211 } ;
212
213 if ( callback ) { callback( undefined , value ) ; }
214 else { controller.promise.resolve( value ) ; }
215 } ;
216
217 // Compute the coordinate of the end of a string, given a start coordinate
218 var redraw = () => {
219 var i , cursorX ,
220 page = menuPages[ selectedPage ] ,
221 endX = page.x ;
222
223 this.moveTo.eraseLineAfter( 1 , start.y ) ;
224
225 if ( options.fillIn && endX > 1 ) { options.style.noFormat( ' '.repeat( endX - 1 ) ) ; }
226 else { this.column( endX ) ; }
227
228 if ( selectedPage ) {
229 options.style.forceStyleOnReset.noFormat( options.previousPageHint ) ;
230 endX += previousPageHintWidth ;
231 }
232
233 for ( i = 0 ; i < page.length ; i ++ ) {
234 if ( i ) {
235 options.style.forceStyleOnReset.noFormat( options.separator ) ;
236 endX += separatorWidth ;
237 }
238
239 if ( i === selectedIndexInPage ) {
240 options.selectedStyle.forceStyleOnReset.noFormat( page[ i ].displayText ) ;
241 cursorX = endX ;
242 }
243 else {
244 options.style.forceStyleOnReset.noFormat( page[ i ].displayText ) ;
245 }
246
247 endX += page[ i ].displayTextWidth ;
248 }
249
250 if ( selectedPage < menuPages.length - 1 ) {
251 options.style.forceStyleOnReset.noFormat( options.nextPageHint ) ;
252 endX += nextPageHintWidth ;
253 }
254
255 if ( options.fillIn && endX < this.width ) { options.style.noFormat( ' '.repeat( this.width - endX ) ) ; }
256
257 this.column( cursorX ) ;
258 } ;
259
260 var emitHighlight = () => {
261 var item = menuPages[ selectedPage ][ selectedIndexInPage ] ;
262
263 controller.emit( 'highlight' , {
264 highlightedIndex: item.index ,
265 highlightedText: item.text ,
266 x: item.x ,
267 y: start.y
268 } ) ;
269 } ;
270
271
272 var onKey = ( key , trash , data ) => {
273 if ( finished ) { return ; }
274
275 var changed = false ,
276 page = menuPages[ selectedPage ] ;
277
278 switch( keyBindings[ key ] ) {
279 case 'submit' :
280 cleanup() ;
281 break ;
282
283 case 'previous' :
284 if ( selectedIndexInPage > 0 ) {
285 selectedIndexInPage -- ;
286 changed = true ;
287 }
288 else if ( selectedPage > 0 ) {
289 selectedPage -- ;
290 selectedIndexInPage = menuPages[ selectedPage ].length - 1 ;
291 changed = true ;
292 }
293 break ;
294
295 case 'next' :
296 if ( selectedIndexInPage < page.length - 1 ) {
297 selectedIndexInPage ++ ;
298 changed = true ;
299 }
300 else if ( selectedPage < menuPages.length - 1 ) {
301 selectedPage ++ ;
302 selectedIndexInPage = 0 ;
303 changed = true ;
304 }
305 break ;
306
307 case 'cycleNext' :
308 if ( selectedPage === menuPages.length - 1 && selectedIndexInPage === page.length - 1 ) {
309 selectedPage = 0 ;
310 selectedIndexInPage = 0 ;
311 changed = true ;
312 }
313 else if ( selectedIndexInPage < page.length - 1 ) {
314 selectedIndexInPage ++ ;
315 changed = true ;
316 }
317 else if ( selectedPage < menuPages.length - 1 ) {
318 selectedPage ++ ;
319 selectedIndexInPage = 0 ;
320 changed = true ;
321 }
322 break ;
323
324 case 'cyclePrevious' :
325 if ( selectedPage === 0 && selectedIndexInPage === 0 ) {
326 selectedPage = menuPages.length - 1 ;
327 selectedIndexInPage = menuPages[ selectedPage ].length - 1 ;
328 changed = true ;
329 }
330 else if ( selectedIndexInPage > 0 ) {
331 selectedIndexInPage -- ;
332 changed = true ;
333 }
334 else if ( selectedPage > 0 ) {
335 selectedPage -- ;
336 selectedIndexInPage = menuPages[ selectedPage ].length - 1 ;
337 changed = true ;
338 }
339 break ;
340
341 case 'first' :
342 if ( selectedPage !== 0 || selectedIndexInPage !== 0 ) {
343 selectedPage = 0 ;
344 selectedIndexInPage = 0 ;
345 changed = true ;
346 }
347 break ;
348
349 case 'last' :
350 if ( selectedPage !== menuPages.length - 1 || selectedIndexInPage !== menuPages[ selectedPage ].length - 1 ) {
351 selectedPage = menuPages.length - 1 ;
352 selectedIndexInPage = menuPages[ selectedPage ].length - 1 ;
353 changed = true ;
354 }
355 break ;
356
357 case 'previousPage' :
358 if ( selectedPage > 0 ) {
359 selectedPage -- ;
360 selectedIndexInPage = 0 ;
361 changed = true ;
362 }
363 break ;
364
365 case 'nextPage' :
366 if ( selectedPage < menuPages.length - 1 ) {
367 selectedPage ++ ;
368 selectedIndexInPage = 0 ;
369 changed = true ;
370 }
371 break ;
372
373 case 'escape' :
374 if ( options.cancelable ) {
375 cleanup( undefined , { canceled: true } ) ;
376 }
377 if ( options.exitOnUnexpectedKey ) {
378 cleanup( undefined , { unexpectedKey: key , unexpectedKeyData: data } ) ;
379 }
380 break ;
381
382 default :
383 if ( options.exitOnUnexpectedKey ) {
384 cleanup( undefined , { unexpectedKey: key , unexpectedKeyData: data } ) ;
385 }
386 break ;
387
388 }
389
390 if ( changed ) {
391 redraw() ;
392 emitHighlight() ;
393 }
394 } ;
395
396
397 var onMouse = ( name , data ) => {
398 if ( finished ) { return ; }
399
400 // If out of bounds, exit now!
401 if ( data.y !== start.y ) { return ; }
402
403 var i , item , nextButtonX ,
404 inBounds = false ,
405 page = menuPages[ selectedPage ] ;
406
407 // First check previous/next page button click
408 if ( name === 'MOUSE_LEFT_BUTTON_PRESSED' ) {
409 if ( selectedPage > 0 && data.x >= 1 && data.x < 1 + previousPageHintWidth ) {
410 selectedPage -- ;
411 selectedIndexInPage = 0 ;
412 redraw() ;
413 emitHighlight() ;
414 return ;
415 }
416
417 nextButtonX = page[ page.length - 1 ].x + page[ page.length - 1 ].displayTextWidth ;
418
419 if ( selectedPage < menuPages.length - 1 && data.x >= nextButtonX && data.x < nextButtonX + nextPageHintWidth ) {
420 selectedPage ++ ;
421 selectedIndexInPage = 0 ;
422 redraw() ;
423 emitHighlight() ;
424 return ;
425 }
426 }
427
428 for ( i = 0 ; i < page.length ; i ++ ) {
429 item = page[ i ] ;
430
431 if ( data.x >= item.x && data.x < item.x + item.displayTextWidth ) {
432 inBounds = true ;
433
434 if ( selectedIndexInPage !== i ) {
435 selectedIndexInPage = i ;
436 redraw() ;
437 emitHighlight() ;
438 }
439
440 break ;
441 }
442 }
443
444 if ( inBounds && name === 'MOUSE_LEFT_BUTTON_PRESSED' ) {
445 cleanup() ;
446 }
447 } ;
448
449 var controller = Object.create( NextGenEvents.prototype ) ;
450
451 controller.promise = new Promise() ;
452
453 this.getCursorLocation( ( error , x , y ) => {
454 if ( error ) {
455 // Some bad terminals (windows...) doesn't support cursor location request, we should fallback to a decent behavior.
456 // So we just move to the last line and create a new line.
457 //cleanup( error ) ; return ;
458 this.row.eraseLineAfter( this.height )( '\n' ) ;
459 x = 1 ;
460 y = this.height ;
461 }
462
463 start.x = x ;
464 start.y = y ;
465 computePages() ;
466 redraw() ;
467
468 // Emit the first auto-selected item
469 emitHighlight() ;
470
471 this.on( 'key' , onKey ) ;
472 if ( this.mouseGrabbing ) { this.on( 'mouse' , onMouse ) ; }
473 } ) ;
474
475 return controller ;
476} ;
477