node-fzf
Version:
fzf ( junegunn/fzf ) inspired cli utility for node
1,170 lines (943 loc) • 34.2 kB
JavaScript
// Gracefully restore the CLI cursor on exit
require( 'restore-cursor' )()
// used to read keyboard input while at the same time
// reading piped stdin input and printing to stdout
const keypress = require( 'keypress' )
const ttys = require( 'ttys' )
const stdin = ttys.stdin
const stdout = ttys.stdout
// print/render to the terminal
const colors = require( 'picocolors' )
// https://github.com/chalk/ansi-regex/blob/f338e1814144efb950276aac84135ff86b72dc8e/index.js#L1C16-L10C2
const ansiRegex = function() {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)';
const pattern = [
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))',
].join('|');
return new RegExp(pattern, 'g');
}()
// get printed width of text
// ex. 漢字 are 4 characters wide but still
// only 2 characters in length
const _stringWidth = require( 'string-width' )
function stringWidth ( str ) {
return Math.max( str.replace(ansiRegex, '').length, _stringWidth( str ) )
}
// available filtering modes ( fuzzy by default )
const modes = [ 'fuzzy', 'normal' ]
module.exports = queryUser
// helper to only get user input
module.exports.getInput = getInput
module.exports.cliColor = colors
function xterm ( index ) {
return function (text) {
// https://github.com/jaywcjlove/colors-cli/blob/d3a3152ec2f087c46655e7d2a663ef637ed5fea5/lib/color.js#L121
return colors.isColorSupported ? '\x1b[38;5;' + index + 'm' + text + '\x1b[0m' : text
}
}
function bgXterm ( index ) {
return function (text) {
// https://github.com/jaywcjlove/colors-cli/blob/d3a3152ec2f087c46655e7d2a663ef637ed5fea5/lib/color.js#L126
return colors.isColorSupported ? '\x1b[48;5;' + index + 'm' + text + '\x1b[0m' : text
}
}
// https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/move.js#L8-L13
function getMove (control) {
return function (num) {
return num ? '\x1b[' + num + control : ''
}
}
const moveUp = getMove("A");
const moveDown = getMove("B")
const moveRight = getMove("C")
const moveLeft = getMove("D")
function moveTo(x, y) {
x++
y++
return '\x1b[' + y + ';' + x + 'H'
}
// https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/erase.js#L7C9-L7C16
const eraseLine = '\x1b[2K'
// https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/erase.js#L4
const eraseScreen = '\x1b[2J'
function getInput ( label, callback )
{
const opts = {
label: label,
list: [],
nolist: true // don't print list/matches
}
return queryUser( opts, callback )
}
// https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/window-size.js#L6-L7
function getWindowWidth() {
return stdout.columns || process.stdout.columns || 0
}
function getWindowHeight() {
return stdout.rows || process.stdout.rows || 0
}
function queryUser ( opts, callback )
{
/* opts should reference same object at all times
* as it will be returned as an api as well that the
* user can use.
*
* a few functions will be added to opts that will
* work as an API to the user to ex. update the list
* at a later time.
*
* we do it like this instead of return a separate api
* object in order to support promises when a callback
* fn is omitted.
*/
const _opts = opts
if ( Array.isArray( _opts ) ) {
_opts.list = _opts
_opts.mode = _opts.mode || 'fuzzy'
}
if ( typeof _opts !== 'object' ) {
// in JavaScript arrays are also a typeof 'object'
throw new TypeError( 'arg0 has to be an array or an object' )
}
_opts.list = _opts.list || []
_opts.mode = _opts.mode || 'fuzzy'
const promise = new Promise( function ( resolve, reject ) {
let originalList = _opts.list || []
let _list = prepareList( originalList )
// user defined vertical scrolling
let scrollOffset = 0
_opts.update = function ( list ) {
originalList = list
_opts.list = originalList
_list = prepareList( originalList )
render()
}
_opts.stop = function () {
finish()
}
// prepare provided list for internal searching/sorting
function prepareList ( newList ) {
const list = newList.map( function ( value, index ) {
return {
originalValue: value, // text
originalIndex: index
}
} )
return list
}
function finish ( result ) {
if ( finish.done ) return
finish.done = true
clearTimeout( checkResize.timeout )
stdout.removeListener( 'resize', handleResize )
stdin.removeListener( 'keypress', handleKeypress )
stdin.setRawMode && stdin.setRawMode( false )
stdin.pause()
if ( !result ) {
// quit, exit, cancel, abort
inputBuffer = undefined
result = {
selected: undefined,
// common alternatives for the same thing
query: inputBuffer,
search: inputBuffer,
input: inputBuffer
}
}
if ( callback ) {
callback( result )
}
resolve( result )
}
// make `process.stdin` begin emitting "keypress" events
keypress( stdin )
// selected index relative to currently matched results
// (filtered subset of _list)
let selectedIndex = 0
// input buffer
let inputBuffer = _opts.query || ''
// input cursor position ( only horizontal )
// relative to input buffer
let cursorPosition = inputBuffer.length
// number of items printed on screen, usually ~7
let _printedMatches = 0
let _matches = []
let _selectedItem
const MIN_HEIGHT = 6
function getMaxWidth () {
const mx = stdout.columns - 7
return Math.max( 0, mx )
}
checkResize.prevCols = stdout.columns
function checkResize () {
const cols = getWindowWidth()
if ( cols != checkResize.prevCols ) {
stdout.columns = cols
handleResize()
}
checkResize.prevCols = cols
clearTimeout( checkResize.timeout )
checkResize.timeout = setTimeout( checkResize, 333 )
}
clearTimeout( checkResize.timeout )
checkResize.timeout = setTimeout( checkResize, 333 )
stdout.on( 'resize', handleResize )
function handleResize () {
clearTimeout( handleResize.timeout )
handleResize.timeout = setTimeout( function () {
cleanDirtyScreen()
render()
}, 1 )
}
const debug = false
function handleKeypress ( chunk, key ) {
debug && console.log( 'chunk: ' + chunk )
key = key || { name: '' }
const name = String( key.name )
debug && console.log( 'got "keypress"', key )
if ( key && key.ctrl && name === 'c' ) {
cleanDirtyScreen()
return finish()
}
if ( key && key.ctrl && name === 'z' ) {
cleanDirtyScreen()
return finish()
}
if ( key && key.ctrl && name === 'l' ) {
// return stdout.write( clc.reset )
}
const view_height = _printedMatches || 10
if ( key.ctrl ) {
switch ( name ) {
case 'h': // backspace
// ignore
break
case 'b': // jump back 1 word
{
const slice = inputBuffer.slice( 0, cursorPosition )
const m = slice.match( /\S+\s*$/ ) // last word
if ( m && m.index > 0 ) {
// console.log( m.index )
cursorPosition = m.index
} else {
cursorPosition = 0
}
}
return render()
break
case 'j': // down
case 'n': // down
selectedIndex += 1
return render()
break
case 'k': // up
case 'p': // up
selectedIndex -= 1
return render()
break
case 'l': // right
// ignore
break
case 's':
{
// cleanDirtyScreen()
let i = modes.indexOf( _opts.mode )
_opts.mode = modes[ ++i % modes.length ]
}
return render()
break
case 'f': // jump forward 1 word
{
const slice = inputBuffer.slice( cursorPosition )
const m = slice.match( /^\S+\s*/ ) // first word
if ( m && m.index >= 0 && m[ 0 ] && m[ 0 ].length >= 0 ) {
// console.log( m.index )
cursorPosition += ( m.index + m[ 0 ].length )
} else {
cursorPosition = inputBuffer.length
}
}
return render()
break
case 'd': // down
// basically intended as page-down
selectedIndex += view_height
return render()
break
case 'u': // up
// basically intended as page-up
selectedIndex -= view_height
return render()
break
case 'a': // beginning of line
cursorPosition = 0
return render()
break
case 'e': // end of line
// TODO right-align names if already at end of line (useful for
// list of filenames with long paths to see the end of the
// filenames on the list)
if ( cursorPosition === inputBuffer.length ) {
_opts.keepRight = !_opts.keepRight
} else {
cursorPosition = inputBuffer.length
}
return render()
break
case 'w': // clear word
{
const a = inputBuffer.slice( 0, cursorPosition )
const b = inputBuffer.slice( cursorPosition )
const m = a.match( /\S+\s*$/ ) // last word
if ( m && m.index > 0 ) {
// console.log( m.index )
cursorPosition = m.index
inputBuffer = a.slice( 0, cursorPosition ).concat( b )
} else {
cursorPosition = 0
inputBuffer = b
}
}
return render()
break
case 'q': // quit
cleanDirtyScreen()
return finish()
}
}
// usually ALT key
if ( key.meta ) {
switch ( name ) {
case 'n': // left arrow key
scrollOffset--
return render()
case 'p': // right arrow key
scrollOffset++
return render()
}
}
if ( key.ctrl ) return
if ( key.meta ) return
switch ( name ) {
case 'backspace': // ctrl-h
{
const a = inputBuffer.slice( 0, cursorPosition - 1 )
const b = inputBuffer.slice( cursorPosition )
inputBuffer = a.concat( b )
cursorPosition--
if ( cursorPosition < 0 ) {
cursorPosition = 0
}
}
return render()
break
case 'left': // left arrow key
if ( _opts.nolist ) {
cursorPosition--
if ( cursorPosition < 0 ) cursorPosition = 0
return render()
} else {
scrollOffset--
return render()
}
break
case 'right': // right arrow key
if ( _opts.nolist ) {
cursorPosition++
if ( cursorPosition > inputBuffer.length ) {
cursorPosition = inputBuffer.length
}
return render()
} else {
scrollOffset++
return render()
}
break
// text terminals treat ctrl-j as newline ( enter )
// ref: https://ss64.com/bash/syntax-keyboard.html
case 'down': // ctrl-j
case 'enter':
selectedIndex += 1
return render()
case 'up':
selectedIndex -= 1
return render()
case 'esc':
case 'escape':
cleanDirtyScreen()
return finish()
// hit return key ( aka enter key ) ( aka ctrl-m )
case 'return': // ctrl-m
cleanDirtyScreen()
function transformResult ( match ) {
return {
value: match.originalValue,
index: match.originalIndex
}
}
const result = {
selected: _selectedItem && transformResult( _selectedItem ) || undefined,
// common alternatives for the same thing
query: inputBuffer,
search: inputBuffer,
input: inputBuffer
}
return finish( result )
}
/*
switch ( chunk ) {
case '<':
scrollOffset--
return render()
break
case '>':
scrollOffset++
return render()
break
}
*/
if ( chunk && chunk.length === 1 ) {
let c = ''
if ( key.shift ) {
c = chunk.toUpperCase()
} else {
c = chunk
}
if ( c ) {
const a = inputBuffer.slice( 0, cursorPosition )
const b = inputBuffer.slice( cursorPosition )
inputBuffer = a.concat( c, b )
cursorPosition++
if ( cursorPosition > inputBuffer.length ) {
cursorPosition = inputBuffer.length
}
}
render()
}
}
stdin.setEncoding( 'utf8' )
stdin.on( 'keypress', handleKeypress )
const clcBgGray = bgXterm( 236 )
const clcFgArrow = xterm( 198 )
const clcFgBufferArrow = xterm( 110 )
const clcFgGreen = xterm( 143 )
// const clcFgMatchGreen = xterm( 151 )
const clcFgModeStatus = xterm( 110 )
const clcFgMatchGreen = xterm( 107 )
// get matches based on the search mode
function getMatches ( mode, filter, text )
{
switch ( mode.trim().toLowerCase() ) {
case 'normal':
return textMatches( filter, text )
case 'fuzzy':
default:
// default to fuzzy matching
return fuzzyMatches( filter, text )
}
}
// get matched list based on the search mode
function getList ( mode, filter, list )
{
// default to fuzzy matching
switch ( mode.trim().toLowerCase() ) {
case 'normal':
return textList( filter, list )
case 'fuzzy':
default:
// default to fuzzy matching
return fuzzyList( filter, list )
}
}
function fuzzyMatches ( fuzz, text )
{
fuzz = fuzz.toLowerCase()
text = text.toLowerCase()
let tp = 0 // text position/pointer
let matches = []
// nothing to match with
if ( !fuzz ) return matches
for ( let i = 0; i < fuzz.length; i++ ) {
const f = fuzz[ i ]
for ( ; tp < text.length; tp++ ) {
const t = text[ tp ]
if ( f === t ) {
matches.push( tp )
tp++
break
}
}
}
return matches
}
function fuzzyList ( fuzz, list )
{
const results = []
for ( let i = 0; i < list.length; i++ ) {
const item = list[ i ]
const originalIndex = item.originalIndex
const originalValue = item.originalValue
// get rid of unnecessary whitespace that only takes of
// valuable scren space
const normalizedItem = originalValue.split( /\s+/ ).join( ' ' )
/* matches is an array of indexes on the normalizedItem string
* that have matched the fuzz
*/
const matches = fuzzyMatches( fuzz, normalizedItem )
if ( matches.length === fuzz.length ) {
/* When the matches.length is exacly the same as fuzz.length
* it means we have a fuzzy match -> all characters in
* the fuzz string have been found on the normalizedItem string.
* The matches array holds each string index position
* of those matches on the normalizedItem string.
* ex. fuzz = 'foo', normalizedItem = 'far out dog', matches = [0,4,9]
*/
let t = normalizedItem
results.push( {
originalIndex: originalIndex,
originalValue: originalValue,
matchedIndex: results.length,
original: item,
text: t // what shows up on terminal/screen
} )
}
}
return results
}
function textMatches ( filter, text )
{
filter = filter.toLowerCase() // ex. foo
text = text.toLowerCase() // ex. dog food is geat
let tp = 0 // text position/pointer
let matches = []
// nothing to match with
if ( !filter ) return matches
// source pointer ( first index of matched text )
const sp = text.indexOf( filter )
if ( sp >= 0 ) {
// end pointer ( last index of matched text )
const ep = sp + filter.length
for ( let i = sp; i < ep; i++ ) {
matches.push( i )
}
}
return matches
}
function textList ( filter, list )
{
const results = []
for ( let i = 0; i < list.length; i++ ) {
const item = list[ i ]
const originalIndex = item.originalIndex
const originalValue = item.originalValue
// get rid of unnecessary whitespace that only takes of
// valuable scren space
const normalizedItem = originalValue.split( /\s+/ ).join( ' ' )
/* matches is an array of indexes on the normalizedItem string
* that have matched the fuzz
*/
const matches = textMatches( filter, normalizedItem )
if ( matches.length === filter.length ) {
/* When the matches.length is exacly the same as filter.length
* it means we have a fuzzy match -> all characters in
* the filter string have been found on the normalizedItem string.
* The matches array holds each string index position
* of those matches on the normalizedItem string.
* ex. filter = 'foo', normalizedItem = 'dog food yum', matches = [4,5,6]
*/
let t = normalizedItem
results.push( {
originalIndex: originalIndex,
originalValue: originalValue,
matchedIndex: results.length,
original: item,
text: t // what shows up on terminal/screen
} )
}
}
return results
}
function colorIndexesOnText ( indexes, text, clcColor )
{
const paintBucket = [] // characters to colorize at the end
for ( let i = 0; i < indexes.length; i++ ) {
const index = indexes[ i ]
paintBucket.push( { index: index, clc: clcColor || clcFgMatchGreen } )
}
// copy match text colorize it based on the matches
// this variable with the colorized ANSI text will be
// returned at the end of the function
let t = text
// colorise in reverse because invisible ANSI color
// characters increases string length
paintBucket.sort( function ( a, b ) {
return b.index - a.index
} )
for ( let i = 0; i < paintBucket.length; i++ ) {
const paint = paintBucket[ i ]
const index = Number( paint.index )
// skip fuzzy chars that have shifted out of view
if ( index < 0 ) continue
if ( index > t.length ) continue
const c = paint.clc( t[ index ] )
t = t.slice( 0, index ) + c + t.slice( index + 1 )
}
// return the colorized match text
return t
}
function trimOnIndexes ( indexes, text )
{
let t = text
indexes = (
indexes.map( function ( i ) { return Number( i ) } )
)
indexes.sort() // sort indexes
// the last ( right-most ) index/character we want to be
// visible on screen as centered as possible until there are
// no more text to be shown to the right of it
const lastIndex = indexes[ indexes.length - 1 ]
const maxLen = getMaxWidth() - 2 // terminal width + padding
/* we want to show the user the last characters that matches
* as those are the most relevant
* ( and ignore earlier matches if they go off-screen )
*
* use the marginRight to shift the matched text left until
* the last characters that match are visible on the screen
*/
const marginRight = Math.ceil( stdout.columns * 1 ) - 12
// how wide the last index would be printed currently
const lastMatchLength = stringWidth( t.slice( 0, lastIndex ) )
// how much to shift left to get last index to get into
// marginRight range (almost center)
let shiftLeft = ( marginRight - lastMatchLength )
// [1] but not too much if there is no additional text
// const delta = ( stringWidth( t ) - lastMatchLength )
// if ( Math.abs( shiftLeft ) > delta ) shiftLeft = -Math.floor( delta * .5 )
let startIndex = 0
let shiftAmount = 0
if ( shiftLeft < 0 ) {
// shift left so that the matched text is in view
while ( shiftAmount > shiftLeft ) {
startIndex++
shiftAmount = -stringWidth( t.slice( 0, startIndex ) )
if ( startIndex >= t.length ) {
break // shouldn't happen because of [1]
}
}
}
startIndex = startIndex + scrollOffset
if ( startIndex < 0 ) {
startIndex = 0
}
if ( stringWidth( t ) < maxLen ) {
// no need to offset as the whole thing fits anyway
startIndex = 0
}
// find minimum amount to cut that fits screen
// i.e., stop cutting off if the end has already been
// printed to the terminal
let delta = 0
for ( let i = 0; i < scrollOffset; i++ ) {
let s = t.slice( startIndex - i )
if ( stringWidth( s ) < maxLen ) {
continue
} else {
delta = i - 1
break
}
}
startIndex = startIndex - delta
t = t.slice( startIndex )
// console.log( 't.length: ' + t.length )
// console.log( 'shiftLeft: ' + shiftLeft )
// console.log( 'shiftamount: ' + shiftAmount )
// console.log( 'startindex: ' + startIndex )
// normalize excessive lengths to avoid too much while looping
// if ( t.length > ( maxLen * 2 + 20 ) ) t = t.slice( 0, maxLen * 2 + 20 )
/* Cut off from the end of the (visual) line until
* it fits on the terminal width screen.
*/
const tlen = t.length
let endIndex = t.length
while ( stringWidth( t ) > maxLen ) {
t = t.slice( 0, --endIndex )
if ( t.length <= 0 ) break
}
if ( startIndex > 0 ) {
t = '..' + t
}
if ( endIndex < tlen ) {
t = t + '..'
}
return {
text: t,
startOffset: startIndex ? ( startIndex - '..'.length ) : startIndex
}
}
function cleanDirtyScreen ()
{
const width = getWindowWidth()
const writtenHeight = Math.max(
MIN_HEIGHT,
2 + _printedMatches
)
stdout.write( moveLeft( width ) )
for ( let i = 0; i < writtenHeight; i++ ) {
stdout.write( moveDown( 1 ) )
}
for ( let i = 0; i < writtenHeight; i++ ) {
stdout.write( eraseLine )
stdout.write( moveUp( 1 ) )
}
stdout.write( eraseLine )
}
function render ()
{
// const height = getWindowHeight()
// console.log( 'window height: ' + height )
// !debug && stdout.write( eraseScreen )
// stdout.write( moveTo( 0, height ) )
cleanDirtyScreen()
// calculate matches
_matches = [] // reset matches
const words = inputBuffer.split( /\s+/ ).filter( function ( word ) { return word.length > 0 } )
for ( let i = 0; i < words.length; i++ ) {
const word = words[ i ]
let list = _list // fuzzy match against all items in list
if ( i > 0 ) {
// if we already have matches, fuzzy match against
// those instead (combines the filters)
list = _matches
}
const matches = getList( _opts.mode, word, list )
_matches = matches
}
// special case no input ( show all with no matches )
if ( words.length === 0 ) {
const matches = getList( _opts.mode, '', _list )
_matches = matches
}
if ( selectedIndex >= _matches.length ) {
// max out at end of filtered/matched results
selectedIndex = _matches.length - 1
}
if ( selectedIndex < 0 ) {
// min out at beginning of filtered/matched results
selectedIndex = 0
}
const inputLabel = _opts.label || clcFgBufferArrow( '> ' )
const inputLabels = inputLabel.split( '\n' )
const lastInputLabel = inputLabels[ inputLabels.length - 1 ]
const inputLabelHeight = inputLabels.length - 1
if ( render.init ) {
stdout.write( moveUp( inputLabelHeight ) )
} else {
// get rid of dirt when being pushed above MIN_HEIGHT
// from the bottom of the terminal
cleanDirtyScreen()
if (_opts._selectOneActive === undefined) _opts._selectOneActive = true
}
render.init = true
// print input label
stdout.write( inputLabel )
stdout.write( inputBuffer )
// do not print the list at all when `nolist` is set
// this is used when we only care about the input query
if ( !_opts.nolist ) {
stdout.write( '\n' )
/* Here we color the matched items text for terminal
* printing based on what characters were found/matched.
*
* Since each filter is separated by space we first
* combine all matches from all filters(words).
*
* If we want to only color based on the most recent
* filter (last word) then just use the matches from the
* last word.
*/
for ( let i = 0; i < _matches.length; i++ ) {
const match = _matches[ i ]
const words = inputBuffer.split( /\s+/ ).filter( function ( word ) { return word.length > 0 } )
const indexMap = {} // as map to prevent duplicates indexes
for ( let i = 0; i < words.length; i++ ) {
// highlights last word only
// if ( i !== ( words.length - 1 ) ) continue
const word = words[ i ]
const matches = getMatches( _opts.mode, word, match.text )
matches.forEach( function ( i ) {
indexMap[ i ] = true
} )
}
if ( !_opts.keepRight ) {
// trim and position text ( horizontally ) based on
// last word/filter that matched ( most relevant )
const lastWord = words[ words.length - 1 ] || ' '
const lastIndexes = getMatches( _opts.mode, lastWord, match.text )
const { text, startOffset } = trimOnIndexes( lastIndexes, match.text )
match.text = text
// skip colorization (no matches -> nothing to colorize)
if ( words.length === 0 ) continue
const indexes = (
Object.keys( indexMap )
.map( function ( i ) { return Number( i ) - startOffset } )
)
indexes.sort() // sort indexes
// transform the text to a colorized version
match.text = colorIndexesOnText( indexes, match.text /*, clcFgGreen */ )
} else {
// trim and position text ( horizontally ) so that the end of
// the matched text is visible on the screen on the right
// screen edge
const { text, startOffset } = trimOnIndexes( [ match.text.length - 1 ], match.text )
match.text = text
// skip colorization (no matches -> nothing to colorize)
if ( words.length === 0 ) continue
const indexes = (
Object.keys( indexMap )
.map( function ( i ) { return Number( i ) - startOffset } )
)
indexes.sort() // sort indexes
// transform the text to a colorized version
match.text = colorIndexesOnText( indexes, match.text /*, clcFgGreen */ )
}
}
// status line/bar to show before the results
let statusLine = ''
// print matches length vs original list length
const n = _matches.length
statusLine += ( ' ' )
statusLine += ( clcFgGreen( n + '/' + _list.length ) )
// print mode
statusLine += ( ' ' + clcFgModeStatus( _opts.mode + ' mode' ) )
// print mode ui legend
if ( _opts.mode === 'fuzzy' ) {
statusLine += ( colors.blackBright( ' ctrl-s' ) )
} else {
statusLine += ( colors.yellowBright( ' ctrl-s' ) )
}
// print --keep-right ui legend
let keepRightColor = colors.blackBright
if (_opts.keepRight) {
keepRightColor = colors.yellowBright
}
statusLine += ( keepRightColor( ' ctrl-e' ) )
statusLine += ( ' ' + colors.magenta( `[${ scrollOffset > 0 ? '+' : '' }${ scrollOffset }]` ) )
// limit statusline to terminal width
let statusLineEndIndex = statusLine.length
const statusLineMaxLen = stdout.columns - 4
while ( stringWidth( statusLine ) > statusLineMaxLen ) {
statusLine = statusLine.slice( 0, --statusLineEndIndex )
if ( statusLine.length <= 0 ) break
}
if ( statusLine.length < statusLineEndIndex ) {
// add red space to prevent sliced colored text
// from bleeding forwards
statusLine = statusLine + colors.red( ' ' )
}
statusLine += ( '\n' )
// print the status line
stdout.write( statusLine )
// select first item in list by default ( empty fuzzy search matches first
// item.. )
if ( !_selectedItem ) {
_selectedItem = _matches[ 0 ]
}
if (_opts._selectOneActive && _matches.length === 1 && _opts.selectOne ) {
// console.log(' === attempting to select one === ')
_selectedItem = _matches[ 0 ]
cleanDirtyScreen()
process.nextTick(function () {
handleKeypress( '', { name: 'return' } )
})
}
_opts._selectOneActive = false
// print the matches
_printedMatches = 0
// padding to make room for command, query and status lines
const paddingTop = 3
const MAX_HEIGHT = ( stdout.rows - paddingTop )
// max lines to use for printing matched results
let maxPrintedLines = Math.min( _matches.length, MIN_HEIGHT )
if ( _opts.height >= 0 ) {
const heightPercent = Math.min( _opts.height / 100, 1 )
// console.log(heightPercent)
const heightNormalized = Math.floor( heightPercent * MAX_HEIGHT )
// console.log(heightNormalized)
maxPrintedLines = Math.min( _matches.length, heightNormalized )
maxPrintedLines = Math.max( maxPrintedLines, MIN_HEIGHT )
}
let paddingBottom = 2 // 1 extra padding at the bottom when scrolling down
if ( _matches.length <= MIN_HEIGHT ) {
// no extra padding at the bottom since there is no room for it
// - othewise first match is cut off and will not be visible
paddingBottom = 1
}
// first matched result to print
const startIndex = Math.max( 0, selectedIndex - maxPrintedLines + paddingBottom )
// last matched result to print
const endIndex = Math.min( maxPrintedLines + startIndex, _matches.length )
// print matches
for ( let i = startIndex; i < endIndex; i++ ) {
_printedMatches++
const match = _matches[ i ]
const item = match.text
const itemSelected = (
( selectedIndex === i )
)
if ( itemSelected ) {
_selectedItem = match
stdout.write( clcBgGray( clcFgArrow( '> ' ) ) )
if ( opts.prelinehook ) {
stdout.write( opts.prelinehook( match.originalIndex ) )
}
stdout.write( clcBgGray( item ) )
if ( opts.postlinehook ) {
stdout.write( opts.postlinehook( match.originalIndex ) )
}
stdout.write( '\n' )
} else {
stdout.write( clcBgGray( ' ' ) )
stdout.write( ' ' )
if ( opts.prelinehook ) {
stdout.write( opts.prelinehook( match.originalIndex ) )
}
stdout.write( item )
if ( opts.postlinehook ) {
stdout.write( opts.postlinehook( match.originalIndex ) )
}
stdout.write( '\n' )
}
}
// move back to cursor position after printing matches
stdout.write( moveUp( 2 + _printedMatches ) )
}
if ( _printedMatches < 1 ) {
// clear selected item when nothing matches
_selectedItem = undefined
}
// if ( inputLabelHeight > 0 ) stdout.write( clc.move.up( inputLabelHeight ) )
// reset cursor left position
stdout.write( moveLeft( stdout.columns ) )
const cursorOffset = stringWidth( inputBuffer.slice( 0, cursorPosition ) )
const cursorLeftPadding = stringWidth( lastInputLabel )
// set cursor left position
stdout.write( moveRight( cursorLeftPadding + cursorOffset ) )
}
stdin.setRawMode && stdin.setRawMode( true )
stdin.resume()
render()
} )
if ( !callback ) {
return promise
}
return _opts
}
// quick debugging, only executes when run with `node main.js`
if ( require.main === module ) {
;( async function () {
const r = await getInput( 'Name: ' )
console.log( r.query )
} )()
}