UNPKG

11.7 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
31var Promise = require( 'seventh' ) ;
32
33
34
35var defaultKeyBindings = {
36 ENTER: 'submit' ,
37 KP_ENTER: 'submit' ,
38 //LEFT: 'previous' ,
39 //RIGHT: 'next' ,
40 //UP: 'previousRow' ,
41 //DOWN: 'nextRow' ,
42 UP: 'previous' ,
43 DOWN: 'next' ,
44 LEFT: 'previousColumn' ,
45 RIGHT: 'nextColumn' ,
46 TAB: 'cycleNext' ,
47 SHIFT_TAB: 'cyclePrevious' ,
48 HOME: 'first' ,
49 END: 'last'
50} ;
51
52
53
54/*
55 gridMenu( 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 * x `number` the column where the menu will be displayed (default: 1)
60 * width `number` the maximum width of the grid menu (default: terminal's width)
61 * style `function` the style of unselected items, default to `term`
62 * selectedStyle `function` the style of the selected item, default to `term.inverse`
63 * leftPadding `string` the text to put before a menu item, default to ' '
64 * selectedLeftPadding `string` the text to put before a selected menu item, default to ' '
65 * rightPadding `string` the text to put after a menu item, default to ' '
66 * selectedRightPadding `string` the text to put after a selected menu item, default to ' '
67 * itemMaxWidth `number` the max width for an item, default to the 1/3 of the terminal width
68 or of the specified width option
69 * keyBindings `Object` overide default key bindings
70 * exitOnUnexpectedKey `boolean` if an unexpected key is pressed, it exits, calling the callback with undefined values
71 * callback( error , response ), where:
72 * error
73 * response `Object` where:
74 * selectedIndex `number` the user-selected menu item index
75 * selectedText `string` the user-selected menu item text
76 * x `number` the x coordinate of the selected menu item (the first character)
77 * y `number` the y coordinate of the selected menu item
78 * unexpectedKey `string` when 'exitOnUnexpectedKey' option is set, this contains the key that produced the exit
79*/
80module.exports = function gridMenu( menuItems_ , options , callback ) {
81 if ( arguments.length < 1 ) { throw new Error( '[terminal] gridMenu() needs at least an array of menuItems argument' ) ; }
82
83 if ( ! Array.isArray( menuItems_ ) || ! menuItems_.length ) { throw new TypeError( '[terminal] gridMenu(): argument #0 should be a non-empty array' ) ; }
84
85 if ( typeof options === 'function' ) { callback = options ; options = {} ; }
86 else if ( ! options || typeof options !== 'object' ) { options = {} ; }
87
88 if ( ! options.style ) { options.style = this ; }
89 if ( ! options.selectedStyle ) { options.selectedStyle = this.inverse ; }
90
91 if ( options.leftPadding === undefined ) { options.leftPadding = ' ' ; }
92 if ( options.selectedLeftPadding === undefined ) { options.selectedLeftPadding = ' ' ; }
93 if ( options.rightPadding === undefined ) { options.rightPadding = ' ' ; }
94 if ( options.selectedRightPadding === undefined ) { options.selectedRightPadding = ' ' ; }
95
96 if ( ! options.x ) { options.x = 1 ; }
97
98 if ( ! options.y ) { this( '\n' ) ; }
99 else { this.moveTo( options.x , options.y ) ; }
100
101 if ( ! options.width ) { options.width = this.width - options.x + 1 ; }
102
103 // itemMaxWidth default to 1/3 of the terminal width
104 if ( ! options.itemMaxWidth ) { options.itemMaxWidth = Math.floor( ( options.width - 1 ) / 3 ) ; }
105
106 var keyBindings = options.keyBindings || defaultKeyBindings ;
107
108 if ( ! this.grabbing ) { this.grabInput() ; }
109
110
111 var start = {} , selectedIndex = 0 , finished = false , alreadyCleanedUp = false ,
112 itemInnerWidth = 0 , itemOuterWidth = 0 ,
113 menuItems , columns , rows , padLength ;
114
115 padLength = Math.max( options.leftPadding.length , options.selectedLeftPadding.length ) +
116 Math.max( options.rightPadding.length , options.selectedRightPadding.length ) ;
117
118 menuItems_ = menuItems_.map( element => {
119 if ( typeof element !== 'string' ) { element = '' + element ; }
120 itemInnerWidth = Math.max( itemInnerWidth , element.length ) ;
121 return element ;
122 } ) ;
123
124 itemInnerWidth = Math.min( itemInnerWidth , options.itemMaxWidth - padLength ) ;
125 itemOuterWidth = itemInnerWidth + padLength ;
126 columns = Math.floor( options.width / itemOuterWidth ) ;
127 rows = Math.ceil( menuItems_.length / columns ) ;
128
129 menuItems = menuItems_.map( ( element , index ) => ( {
130 // row first
131 //offsetX: ( index % columns ) * itemOuterWidth ,
132 //offsetY: Math.floor( index / columns ) ,
133
134 // column first
135 offsetY: index % rows ,
136 offsetX: options.x - 1 + Math.floor( index / rows ) * itemOuterWidth ,
137
138 index: index ,
139 text: element ,
140 displayText: element.length > itemInnerWidth ?
141 element.slice( 0 , itemInnerWidth - 1 ) + '…' :
142 element + ' '.repeat( itemInnerWidth - element.length )
143 } ) ) ;
144
145
146 //console.log( menuItems ) ; process.exit() ;
147
148 var cleanup = ( error , data ) => {
149 if ( alreadyCleanedUp ) { return ; }
150 alreadyCleanedUp = true ;
151
152 finished = true ;
153 this.removeListener( 'key' , onKey ) ;
154 this.removeListener( 'mouse' , onMouse ) ;
155 this.moveTo( 1 , start.y + rows ) ;
156
157 if ( error ) {
158 if ( callback ) { callback( error ) ; }
159 else { controller.promise.reject( error ) ; }
160 return ;
161 }
162
163 var value = data !== undefined ? data : {
164 selectedIndex: selectedIndex ,
165 selectedText: menuItems[ selectedIndex ].text ,
166 x: 1 + menuItems[ selectedIndex ].offsetX ,
167 y: start.y + menuItems[ selectedIndex ].offsetY
168 } ;
169
170 if ( callback ) { callback( undefined , value ) ; }
171 else { controller.promise.resolve( value ) ; }
172 } ;
173
174 // Compute the coordinate of the end of a string, given a start coordinate
175 var redraw = () => {
176 for ( var i = 0 ; i < menuItems.length ; i ++ ) { redrawItem( i ) ; }
177 redrawCursor() ;
178 } ;
179
180 var redrawItem = ( index ) => {
181 var item = menuItems[ index ] ;
182
183 this.moveTo( 1 + item.offsetX , start.y + item.offsetY ) ;
184
185 if ( index === selectedIndex ) {
186 options.selectedStyle.noFormat( options.selectedLeftPadding ) ;
187 options.selectedStyle.noFormat( item.displayText ) ;
188 options.selectedStyle.noFormat( options.selectedRightPadding ) ;
189 }
190 else {
191 options.style.noFormat( options.leftPadding ) ;
192 options.style.noFormat( item.displayText ) ;
193 options.style.noFormat( options.rightPadding ) ;
194 }
195 } ;
196
197 var redrawCursor = () => {
198 this.moveTo( 1 + menuItems[ selectedIndex ].offsetX , start.y + menuItems[ selectedIndex ].offsetY ) ;
199 } ;
200
201
202 var onKey = ( key , trash , data ) => {
203 if ( finished ) { return ; }
204
205 var oldSelectedIndex = selectedIndex ;
206
207 switch( keyBindings[ key ] ) {
208 case 'submit' :
209 cleanup() ;
210 break ;
211
212 case 'previous' :
213 if ( selectedIndex > 0 ) {
214 selectedIndex -- ;
215 redrawItem( selectedIndex ) ;
216 redrawItem( selectedIndex + 1 ) ;
217 redrawCursor() ;
218 //redraw() ;
219 }
220 break ;
221
222 case 'next' :
223 if ( selectedIndex < menuItems.length - 1 ) {
224 selectedIndex ++ ;
225 redrawItem( selectedIndex - 1 ) ;
226 redrawItem( selectedIndex ) ;
227 redrawCursor() ;
228 //redraw() ;
229 }
230 break ;
231 /*
232 case 'previousRow' :
233 if ( selectedIndex >= columns )
234 {
235 selectedIndex -= columns ;
236 redrawItem( oldSelectedIndex ) ;
237 redrawItem( selectedIndex ) ;
238 redrawCursor() ;
239 //redraw() ;
240 }
241 break ;
242
243 case 'nextRow' :
244 if ( selectedIndex < menuItems.length - columns )
245 {
246 selectedIndex += columns ;
247 redrawItem( oldSelectedIndex ) ;
248 redrawItem( selectedIndex ) ;
249 redrawCursor() ;
250 //redraw() ;
251 }
252 break ;
253 */
254 case 'previousColumn' :
255 if ( selectedIndex >= rows ) {
256 selectedIndex -= rows ;
257 redrawItem( oldSelectedIndex ) ;
258 redrawItem( selectedIndex ) ;
259 redrawCursor() ;
260 //redraw() ;
261 }
262 break ;
263
264 case 'nextColumn' :
265 if ( selectedIndex < menuItems.length - rows ) {
266 selectedIndex += rows ;
267 redrawItem( oldSelectedIndex ) ;
268 redrawItem( selectedIndex ) ;
269 redrawCursor() ;
270 //redraw() ;
271 }
272 break ;
273
274 case 'cyclePrevious' :
275 selectedIndex -- ;
276
277 if ( selectedIndex < 0 ) { selectedIndex = menuItems.length - 1 ; }
278
279 redrawItem( oldSelectedIndex ) ;
280 redrawItem( selectedIndex ) ;
281 redrawCursor() ;
282 //redraw() ;
283 break ;
284
285 case 'cycleNext' :
286 selectedIndex ++ ;
287
288 if ( selectedIndex >= menuItems.length ) { selectedIndex = 0 ; }
289
290 redrawItem( oldSelectedIndex ) ;
291 redrawItem( selectedIndex ) ;
292 redrawCursor() ;
293 //redraw() ;
294 break ;
295
296 case 'first' :
297 if ( selectedIndex !== 0 ) {
298 selectedIndex = 0 ;
299 redrawItem( oldSelectedIndex ) ;
300 redrawItem( selectedIndex ) ;
301 redrawCursor() ;
302 //redraw() ;
303 }
304 break ;
305
306 case 'last' :
307 if ( selectedIndex !== menuItems.length - 1 ) {
308 selectedIndex = menuItems.length - 1 ;
309 redrawItem( oldSelectedIndex ) ;
310 redrawItem( selectedIndex ) ;
311 redrawCursor() ;
312 //redraw() ;
313 }
314 break ;
315
316 default :
317 if ( options.exitOnUnexpectedKey ) {
318 cleanup( undefined , { unexpectedKey: key , unexpectedKeyData: data } ) ;
319 }
320 break ;
321 }
322 } ;
323
324
325 var onMouse = ( name , data ) => {
326
327 if ( finished ) { return ; }
328
329 // If out of bounds, exit now!
330 if ( data.y < start.y || data.y >= start.y + rows ) { return ; }
331
332 var i , inBounds = false ,
333 oldSelectedIndex = selectedIndex ;
334
335 for ( i = 0 ; i < menuItems.length ; i ++ ) {
336 if (
337 data.y === start.y + menuItems[ i ].offsetY &&
338 data.x >= 1 + menuItems[ i ].offsetX &&
339 data.x < 1 + menuItems[ i ].offsetX + itemOuterWidth
340 ) {
341 inBounds = true ;
342
343 if ( selectedIndex !== i ) {
344 selectedIndex = i ;
345 redrawItem( oldSelectedIndex ) ;
346 redrawItem( selectedIndex ) ;
347 redrawCursor() ;
348 }
349
350 break ;
351 }
352 }
353
354 if ( inBounds && name === 'MOUSE_LEFT_BUTTON_PRESSED' ) {
355 cleanup() ;
356 }
357 } ;
358
359
360 this.getCursorLocation( ( error , x , y ) => {
361 if ( error ) {
362 // Some bad terminals (windows...) doesn't support cursor location request, we should fallback to a decent behavior.
363 // So we just move to the last line and create a new line.
364 //cleanup( error ) ; return ;
365 this.row.eraseLineAfter( this.height )( '\n' ) ;
366 x = 1 ;
367 y = this.height ;
368 }
369
370 start.x = x ;
371 start.y = y ;
372
373 var extra = start.y + rows - this.height ;
374
375 if ( extra > 0 ) {
376 // create extra lines
377 this( '\n'.repeat( extra ) ) ;
378 start.y -= extra ;
379 }
380
381 redraw() ;
382
383 this.on( 'key' , onKey ) ;
384 if ( this.mouseGrabbing ) { this.on( 'mouse' , onMouse ) ; }
385 } ) ;
386
387 // For compatibility
388 var controller = {} ;
389
390 controller.promise = new Promise() ;
391
392 return controller ;
393} ;
394