├── .github └── workflows │ ├── macos-node.js.yml │ ├── ubuntu-node.js.yml │ └── windows-node.js.yml ├── .gitignore ├── .npmrc ├── README.md ├── bin └── cli.js ├── node-fzf-screenshot.gif ├── package.json ├── src └── main.js ├── test ├── animals.json ├── test.js └── youtube-search-results.json └── usage.txt /.github/workflows/macos-node.js.yml: -------------------------------------------------------------------------------- 1 | name: macos 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [20.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | 28 | - name: Workaround to create TTY 29 | run: | 30 | npm install 31 | npm run build --if-present 32 | script -q "npm test" 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/ubuntu-node.js.yml: -------------------------------------------------------------------------------- 1 | name: ubuntu 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [20.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | 28 | - name: Workaround to create TTY 29 | run: | 30 | npm install 31 | npm run build --if-present 32 | script -q -c "npm test" 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/windows-node.js.yml: -------------------------------------------------------------------------------- 1 | # not sure if it's possible to test this fully on windows that doesn't have 2 | # similar TTY support? 3 | ########################## 4 | # name: windows 5 | # 6 | # on: 7 | # push: 8 | # branches: [ "master" ] 9 | # pull_request: 10 | # branches: [ "master" ] 11 | # 12 | # jobs: 13 | # build: 14 | # 15 | # runs-on: windows-latest 16 | # 17 | # strategy: 18 | # matrix: 19 | # node-version: [20.x] 20 | # # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | # 22 | # steps: 23 | # - uses: actions/checkout@v4 24 | # 25 | # - name: Use Node.js ${{ matrix.node-version }} 26 | # uses: actions/setup-node@v1 27 | # with: 28 | # node-version: ${{ matrix.node-version }} 29 | # cache: 'npm' 30 | # 31 | # - name: Workaround to create TTY 32 | # shell: pwsh 33 | # run: | 34 | # npm.cmd install 35 | # npm.cmd run build --if-present 36 | # winpty npm.cmd test 37 | # 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # vim files 4 | *.sw? 5 | 6 | # mac files 7 | *.DS_Store 8 | 9 | # test files 10 | list.txt 11 | search.txt 12 | sample-readme.js 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/node-fzf.svg?maxAge=3600&style=flat-square)](https://www.npmjs.com/package/node-fzf) 2 | [![npm](https://img.shields.io/npm/dm/node-fzf.svg?maxAge=3600)](https://www.npmjs.com/package/node-fzf) 3 | [![npm](https://img.shields.io/npm/l/node-fzf.svg?maxAge=3600&style=flat-square)](https://github.com/talmobi/node-fzf/blob/master/LICENSE) 4 | ![mac](https://github.com/talmobi/node-fzf/actions/workflows/macos-node.js.yml/badge.svg?branch=master) 5 | ![ubuntu](https://github.com/talmobi/node-fzf/actions/workflows/ubuntu-node.js.yml/badge.svg?branch=master) 6 | ![windows](https://img.shields.io/badge/windows-unable%20to%20test%20automatically-yellow?style=flat) 7 | 8 | 9 | 10 | # node-fzf 11 | [fzf](https://github.com/junegunn/fzf) inspired fuzzy CLI list selection 🎀 12 | 13 | ![](https://i.imgur.com/SFUV5nW.gif) 14 | 15 | ## Easy to use 16 | 17 | #### CLI usage 18 | ```bash 19 | npm install -g node-fzf 20 | 21 | # by default (TTY mode) will glob list of current dir files 22 | nfzf 23 | 24 | # using pipes 25 | find . | nfzf | xargs cat | less 26 | mpv "`find ~/Dropbox/music | nfzf --exact --keep-right`" --no-audio-display 27 | alias merge="git branch | nfzf | xargs git merge" 28 | alias checkout="git branch | nfzf | xargs git checkout" 29 | ``` 30 | 31 | #### API usage 32 | 33 | ##### promises 34 | ```js 35 | const nfzf = require( 'node-fzf' ) 36 | 37 | // if you only care about r.query 38 | // nfzf.getInput( label ) 39 | 40 | const opts = { 41 | /* required */ 42 | list: [ 'whale', 'giraffe', 'monkey' ], 43 | 44 | /* (optional) */ 45 | // filtering mode (user can change modes by pressing ctrl-s) 46 | mode: 'fuzzy' || 'normal', 47 | 48 | /* (optional) */ 49 | // prefill user input 50 | query: '', 51 | 52 | /* (optional) */ 53 | // If there is only one match for the initial query (--query), do not 54 | // start interactive finder and automatically select the only match 55 | selectOne: false, 56 | 57 | /* (optional) */ 58 | // % of screen to use to display results (minimum/defaults to 6 rows) 59 | height: 0, // ex: 40 for 40%, 100 for 100% 60 | 61 | /* (optional) */ 62 | // text before each displayed line, list index supplied as arg 63 | prelinehook: function ( index ) { return '' }, 64 | 65 | /* (optional) */ 66 | // text after each displayed line, list index supplied as arg 67 | postlinehook: function ( index ) { return '' } 68 | } 69 | 70 | ;( async function () { 71 | // opens interactive selection CLI 72 | // note! this messes with stdout so if you are 73 | // writing to stdout at the same time it will look a bit messy.. 74 | const result = await nfzf( opts ) 75 | 76 | const { selected, query } = result 77 | 78 | if( !selected ) { 79 | console.log( 'No matches for:', query ) 80 | } else { 81 | console.log( selected.value ) // 'giraffe' 82 | console.log( selected.index ) // 1 83 | console.log( selected.value === opts.list[ selected.index ] ) // true 84 | } 85 | } )() 86 | 87 | // can also add more items later.. 88 | setInterval( function () { 89 | opts.list.push( 'foobar' ) 90 | 91 | // an .update method has been attached to the object/array 92 | // that you gave to nfzf( ... ) 93 | opts.update( list ) 94 | }, 1000 ) 95 | ``` 96 | 97 | ##### callbacks 98 | ```js 99 | const nfzf = require( 'node-fzf' ) 100 | 101 | // if you only care about r.query 102 | // nfzf.getInput( label, callback ) 103 | 104 | const list = [ 'whale', 'giraffe', 'monkey' ] 105 | 106 | // opens interactive selection CLI 107 | // note! this messes with stdout so if you are 108 | // writing to stdout at the same time it will look a bit messy.. 109 | const api = nfzf( list, function ( result ) { 110 | const { selected, query } = result 111 | if( !selected ) { 112 | console.log( 'No matches for:', query ) 113 | } else { 114 | console.log( selected.value ) // 'giraffe' 115 | console.log( selected.index ) // 1 116 | console.log( selected.value === list[ selected.index ] ) // true 117 | 118 | // the api is a reference to the same argument0 object 119 | // with an added .update method attached. 120 | console.log( list === api ) // true 121 | console.log( list.update === api.update ) // true 122 | } 123 | 124 | } ) 125 | 126 | // can also add more items later.. 127 | setInterval( function () { 128 | list.push( 'foobar' ) 129 | api.update( list ) 130 | }, 1000 ) 131 | ``` 132 | 133 | #### Keyboard 134 | ```bash 135 | switch between search modes (fuzzy, normal/exact) 136 | 137 | ,,down scroll down 138 | ,,up scroll up 139 | 140 | scroll down by page size 141 | scroll up by page size 142 | 143 | jump to start of input 144 | jump to end of input (and toggles --keep-right) 145 | 146 | ,, cancel 147 | 148 | , trigger callback/promise with current selection and exit 149 | 150 | delete last word from input 151 | 152 | jump back a word 153 | jump forward a word 154 | 155 | delete last input character 156 | ``` 157 | 158 | ## About 159 | [fzf](https://github.com/junegunn/fzf) inspired fuzzy CLI list selection thing for node. 160 | 161 | ## Why 162 | easy fuzzy list selection UI for NodeJS CLI programs. 163 | 164 | ## How 165 | Mostly [picocolors](https://github.com/alexeyraspopov/picocolors) for dealing with the terminal rendering 166 | ~~Mostly [cli-color](https://github.com/medikoo/cli-color) for dealing with the terminal rendering~~ 167 | and [ttys](https://github.com/TooTallNate/ttys) to hack the ttys to simultaneously 168 | read from non TTY stdin and read key inputs from TTY stdin -> So that we can get piped input while 169 | also at the same time receive and handle raw keyboard input. 170 | 171 | ## Used by 172 | [yt-play](https://github.com/talmobi/yt-play) 173 | 174 | [yt-search](https://github.com/talmobi/yt-search) 175 | 176 | ## Similar 177 | [fzf](https://github.com/junegunn/fzf) even though it doesn't work in NodeJS directly is all-in-all a better tool than this piece of crap :) Highly recommend~ 178 | 179 | [ipt](https://github.com/ruyadorno/ipt) - similar node based solution 180 | 181 | ## Test 182 | ```bash 183 | npm test 184 | ``` 185 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require( 'fs' ) 4 | const path = require( 'path' ) 5 | const glob = require( 'redstar' ) 6 | 7 | const nfzf = require( path.join( __dirname, '../src/main.js' ) ) 8 | 9 | const argv = require( 'minimist' )( process.argv.slice( 2 ) ) 10 | 11 | const pkgJSON = require( path.join( __dirname, '../package.json' ) ) 12 | 13 | if ( argv.version || argv.V || argv.v ) { 14 | console.log( 'nfzf version: ' + pkgJSON.version ) 15 | process.exit() 16 | } 17 | 18 | if ( argv.h || argv.help ) { 19 | const text = fs.readFileSync( path.join( __dirname, '../usage.txt' ), 'utf8' ) 20 | console.log( text ) 21 | process.exit() 22 | } 23 | 24 | const normalMode = ( argv.n || argv.normal || argv.norm || argv.e || argv.exact) 25 | 26 | return run() 27 | 28 | function run () 29 | { 30 | if ( process.stdin.isTTY && !argv._.length ) { 31 | return glob( '**', function ( err, files, dirs ) { 32 | if ( err ) throw err 33 | 34 | const opts = { 35 | mode: normalMode ? 'normal' : 'fuzzy', 36 | list: files, 37 | 38 | // Start finder with given prefilled query (similar to fzf) 39 | query: argv.query || argv.q, 40 | selectOne: argv['1'] || argv['select-1'] || argv['select-one'], 41 | 42 | height: argv.height, 43 | keepRight: !!argv['keep-right'], 44 | } 45 | 46 | nfzf( opts, function ( result ) { 47 | if ( result.selected ) { 48 | console.log( files[ result.selected.index ] ) 49 | } else if ( argv[ 'print-query' ] ) { 50 | console.log() 51 | console.log( result.query ) 52 | } 53 | process.exit() 54 | } ) 55 | } ) 56 | } else { 57 | // update list later with input piped from stdin 58 | const opts = { 59 | mode: normalMode ? 'normal' : 'fuzzy', 60 | list: [], // stdin will update it later 61 | 62 | // Start finder with given prefilled query (similar to fzf) 63 | query: argv.query || argv.q, 64 | selectOne: argv['1'] || argv['select-1'] || argv['select-one'], 65 | 66 | height: argv.height, 67 | keepRight: !!argv['keep-right'], 68 | } 69 | 70 | opts._selectOneActive = false 71 | const api = nfzf( opts, function ( result ) { 72 | if ( result.selected ) { 73 | console.log( result.selected.value ) 74 | } else if ( argv[ 'print-query' ] ) { 75 | console.log() 76 | console.log( result.query ) 77 | } 78 | process.exit() 79 | } ) 80 | 81 | let buffer = '' 82 | const list = [] 83 | process.stdin.setEncoding( 'utf8' ) 84 | 85 | process.stdin.on( 'data', function ( chunk ) { 86 | buffer += chunk 87 | 88 | // so you need this if you accidentally get stuck in 89 | // a `cat | nfzf` loop 90 | if ( 91 | chunk === '\x03' || // ctrl-c 92 | chunk === '\x1B' // esc 93 | ) { 94 | console.log( 'exit' ) 95 | return process.exit( 1 ) 96 | } 97 | 98 | const lines = buffer.split( '\n' ) 99 | buffer = lines.pop() || '' 100 | lines 101 | .filter( function ( t ) { return t.trim().length > 0 } ) 102 | .forEach( function ( line ) { 103 | list.push( line ) 104 | } ) 105 | 106 | throttleUpdateList( list ) 107 | } ) 108 | 109 | process.stdin.on( 'end', function () { 110 | const lines = buffer.split( '\n' ) 111 | lines 112 | .filter( function ( t ) { return t.trim().length > 0 } ) 113 | .forEach( function ( line ) { 114 | list.push( line ) 115 | } ) 116 | 117 | api._selectOneActive = true 118 | api.update( list ) 119 | } ) 120 | 121 | function throttleUpdateList ( list ) { 122 | const now = Date.now() 123 | throttleUpdateList.time = ( throttleUpdateList.time || now ) 124 | const delta = ( now - throttleUpdateList.time ) 125 | if ( delta < 100 ) {} else { 126 | api.update( list ) 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /node-fzf-screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talmobi/node-fzf/3e86445e52cc95b6357f16312fb4e4d6ec04b070/node-fzf-screenshot.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-fzf", 3 | "version": "0.14.0", 4 | "description": "fzf ( junegunn/fzf ) inspired cli utility for node", 5 | "main": "src/main.js", 6 | "bin": { 7 | "nfzf": "bin/cli.js" 8 | }, 9 | "files": [ 10 | "bin/cli.js", 11 | "src/main.js", 12 | "usage.txt" 13 | ], 14 | "scripts": { 15 | "test": "tape test/test.js" 16 | }, 17 | "keywords": [ 18 | "node-fzf", 19 | "fzf", 20 | "fuzzy", 21 | "list", 22 | "search", 23 | "cli" 24 | ], 25 | "author": "talmobi ", 26 | "license": "MIT", 27 | "private": false, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/talmobi/node-fzf" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/talmobi/node-fzf/issues", 34 | "email": "talmo.christian@gmail.com" 35 | }, 36 | "dependencies": { 37 | "keypress": "~0.2.1", 38 | "minimist": "~1.2.5", 39 | "picocolors": "~1.1.1", 40 | "redstar": "0.0.2", 41 | "restore-cursor": "~3.1.0", 42 | "string-width": "~2.1.1", 43 | "ttys": "0.0.3" 44 | }, 45 | "devDependencies": { 46 | "mock-stdin": "~1.0.0", 47 | "tape": "~4.13.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // Gracefully restore the CLI cursor on exit 2 | require( 'restore-cursor' )() 3 | 4 | // used to read keyboard input while at the same time 5 | // reading piped stdin input and printing to stdout 6 | const keypress = require( 'keypress' ) 7 | const ttys = require( 'ttys' ) 8 | 9 | const stdin = ttys.stdin 10 | const stdout = ttys.stdout 11 | 12 | // print/render to the terminal 13 | const colors = require( 'picocolors' ) 14 | 15 | // https://github.com/chalk/ansi-regex/blob/f338e1814144efb950276aac84135ff86b72dc8e/index.js#L1C16-L10C2 16 | const ansiRegex = function() { 17 | // Valid string terminator sequences are BEL, ESC\, and 0x9c 18 | const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)'; 19 | const pattern = [ 20 | `[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`, 21 | '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', 22 | ].join('|'); 23 | 24 | return new RegExp(pattern, 'g'); 25 | }() 26 | 27 | // get printed width of text 28 | // ex. 漢字 are 4 characters wide but still 29 | // only 2 characters in length 30 | const _stringWidth = require( 'string-width' ) 31 | function stringWidth ( str ) { 32 | return Math.max( str.replace(ansiRegex, '').length, _stringWidth( str ) ) 33 | } 34 | 35 | // available filtering modes ( fuzzy by default ) 36 | const modes = [ 'fuzzy', 'normal' ] 37 | 38 | module.exports = queryUser 39 | 40 | // helper to only get user input 41 | module.exports.getInput = getInput 42 | module.exports.cliColor = colors 43 | 44 | function xterm ( index ) { 45 | return function (text) { 46 | // https://github.com/jaywcjlove/colors-cli/blob/d3a3152ec2f087c46655e7d2a663ef637ed5fea5/lib/color.js#L121 47 | return colors.isColorSupported ? '\x1b[38;5;' + index + 'm' + text + '\x1b[0m' : text 48 | } 49 | } 50 | 51 | function bgXterm ( index ) { 52 | return function (text) { 53 | // https://github.com/jaywcjlove/colors-cli/blob/d3a3152ec2f087c46655e7d2a663ef637ed5fea5/lib/color.js#L126 54 | return colors.isColorSupported ? '\x1b[48;5;' + index + 'm' + text + '\x1b[0m' : text 55 | } 56 | } 57 | 58 | // https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/move.js#L8-L13 59 | function getMove (control) { 60 | return function (num) { 61 | return num ? '\x1b[' + num + control : '' 62 | } 63 | } 64 | 65 | const moveUp = getMove("A"); 66 | const moveDown = getMove("B") 67 | const moveRight = getMove("C") 68 | const moveLeft = getMove("D") 69 | 70 | function moveTo(x, y) { 71 | x++ 72 | y++ 73 | return '\x1b[' + y + ';' + x + 'H' 74 | } 75 | 76 | // https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/erase.js#L7C9-L7C16 77 | const eraseLine = '\x1b[2K' 78 | 79 | // https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/erase.js#L4 80 | const eraseScreen = '\x1b[2J' 81 | 82 | function getInput ( label, callback ) 83 | { 84 | const opts = { 85 | label: label, 86 | list: [], 87 | nolist: true // don't print list/matches 88 | } 89 | 90 | return queryUser( opts, callback ) 91 | } 92 | 93 | // https://github.com/medikoo/cli-color/blob/b9080d464c76930b3cbfb7f281999fcc26f39fb1/window-size.js#L6-L7 94 | function getWindowWidth() { 95 | return stdout.columns || process.stdout.columns || 0 96 | } 97 | 98 | function getWindowHeight() { 99 | return stdout.rows || process.stdout.rows || 0 100 | } 101 | 102 | function queryUser ( opts, callback ) 103 | { 104 | /* opts should reference same object at all times 105 | * as it will be returned as an api as well that the 106 | * user can use. 107 | * 108 | * a few functions will be added to opts that will 109 | * work as an API to the user to ex. update the list 110 | * at a later time. 111 | * 112 | * we do it like this instead of return a separate api 113 | * object in order to support promises when a callback 114 | * fn is omitted. 115 | */ 116 | const _opts = opts 117 | 118 | if ( Array.isArray( _opts ) ) { 119 | _opts.list = _opts 120 | _opts.mode = _opts.mode || 'fuzzy' 121 | } 122 | 123 | if ( typeof _opts !== 'object' ) { 124 | // in JavaScript arrays are also a typeof 'object' 125 | throw new TypeError( 'arg0 has to be an array or an object' ) 126 | } 127 | 128 | _opts.list = _opts.list || [] 129 | _opts.mode = _opts.mode || 'fuzzy' 130 | 131 | const promise = new Promise( function ( resolve, reject ) { 132 | let originalList = _opts.list || [] 133 | let _list = prepareList( originalList ) 134 | 135 | // user defined vertical scrolling 136 | let scrollOffset = 0 137 | 138 | _opts.update = function ( list ) { 139 | originalList = list 140 | _opts.list = originalList 141 | _list = prepareList( originalList ) 142 | render() 143 | } 144 | 145 | _opts.stop = function () { 146 | finish() 147 | } 148 | 149 | // prepare provided list for internal searching/sorting 150 | function prepareList ( newList ) { 151 | const list = newList.map( function ( value, index ) { 152 | return { 153 | originalValue: value, // text 154 | originalIndex: index 155 | } 156 | } ) 157 | return list 158 | } 159 | 160 | function finish ( result ) { 161 | if ( finish.done ) return 162 | finish.done = true 163 | 164 | clearTimeout( checkResize.timeout ) 165 | stdout.removeListener( 'resize', handleResize ) 166 | 167 | stdin.removeListener( 'keypress', handleKeypress ) 168 | 169 | stdin.setRawMode && stdin.setRawMode( false ) 170 | stdin.pause() 171 | 172 | if ( !result ) { 173 | // quit, exit, cancel, abort 174 | inputBuffer = undefined 175 | 176 | result = { 177 | selected: undefined, 178 | 179 | // common alternatives for the same thing 180 | query: inputBuffer, 181 | search: inputBuffer, 182 | input: inputBuffer 183 | } 184 | } 185 | 186 | if ( callback ) { 187 | callback( result ) 188 | } 189 | 190 | resolve( result ) 191 | } 192 | 193 | // make `process.stdin` begin emitting "keypress" events 194 | keypress( stdin ) 195 | 196 | // selected index relative to currently matched results 197 | // (filtered subset of _list) 198 | let selectedIndex = 0 199 | 200 | // input buffer 201 | let inputBuffer = _opts.query || '' 202 | 203 | // input cursor position ( only horizontal ) 204 | // relative to input buffer 205 | let cursorPosition = inputBuffer.length 206 | 207 | // number of items printed on screen, usually ~7 208 | let _printedMatches = 0 209 | 210 | let _matches = [] 211 | let _selectedItem 212 | 213 | const MIN_HEIGHT = 6 214 | 215 | function getMaxWidth () { 216 | const mx = stdout.columns - 7 217 | return Math.max( 0, mx ) 218 | } 219 | 220 | checkResize.prevCols = stdout.columns 221 | function checkResize () { 222 | const cols = getWindowWidth() 223 | if ( cols != checkResize.prevCols ) { 224 | stdout.columns = cols 225 | handleResize() 226 | } 227 | checkResize.prevCols = cols 228 | 229 | clearTimeout( checkResize.timeout ) 230 | checkResize.timeout = setTimeout( checkResize, 333 ) 231 | } 232 | clearTimeout( checkResize.timeout ) 233 | checkResize.timeout = setTimeout( checkResize, 333 ) 234 | 235 | stdout.on( 'resize', handleResize ) 236 | 237 | function handleResize () { 238 | clearTimeout( handleResize.timeout ) 239 | handleResize.timeout = setTimeout( function () { 240 | cleanDirtyScreen() 241 | render() 242 | }, 1 ) 243 | } 244 | 245 | const debug = false 246 | 247 | function handleKeypress ( chunk, key ) { 248 | debug && console.log( 'chunk: ' + chunk ) 249 | 250 | key = key || { name: '' } 251 | 252 | const name = String( key.name ) 253 | 254 | debug && console.log( 'got "keypress"', key ) 255 | 256 | if ( key && key.ctrl && name === 'c' ) { 257 | cleanDirtyScreen() 258 | return finish() 259 | } 260 | 261 | if ( key && key.ctrl && name === 'z' ) { 262 | cleanDirtyScreen() 263 | return finish() 264 | } 265 | 266 | if ( key && key.ctrl && name === 'l' ) { 267 | // return stdout.write( clc.reset ) 268 | } 269 | 270 | const view_height = _printedMatches || 10 271 | 272 | if ( key.ctrl ) { 273 | switch ( name ) { 274 | case 'h': // backspace 275 | // ignore 276 | break 277 | 278 | case 'b': // jump back 1 word 279 | { 280 | const slice = inputBuffer.slice( 0, cursorPosition ) 281 | const m = slice.match( /\S+\s*$/ ) // last word 282 | if ( m && m.index > 0 ) { 283 | // console.log( m.index ) 284 | cursorPosition = m.index 285 | } else { 286 | cursorPosition = 0 287 | } 288 | } 289 | return render() 290 | break 291 | 292 | case 'j': // down 293 | case 'n': // down 294 | selectedIndex += 1 295 | return render() 296 | break 297 | case 'k': // up 298 | case 'p': // up 299 | selectedIndex -= 1 300 | return render() 301 | break 302 | 303 | case 'l': // right 304 | // ignore 305 | break 306 | 307 | case 's': 308 | { 309 | // cleanDirtyScreen() 310 | let i = modes.indexOf( _opts.mode ) 311 | _opts.mode = modes[ ++i % modes.length ] 312 | } 313 | return render() 314 | break 315 | 316 | case 'f': // jump forward 1 word 317 | { 318 | const slice = inputBuffer.slice( cursorPosition ) 319 | const m = slice.match( /^\S+\s*/ ) // first word 320 | if ( m && m.index >= 0 && m[ 0 ] && m[ 0 ].length >= 0 ) { 321 | // console.log( m.index ) 322 | cursorPosition += ( m.index + m[ 0 ].length ) 323 | } else { 324 | cursorPosition = inputBuffer.length 325 | } 326 | } 327 | return render() 328 | break 329 | 330 | case 'd': // down 331 | // basically intended as page-down 332 | selectedIndex += view_height 333 | return render() 334 | break 335 | 336 | case 'u': // up 337 | // basically intended as page-up 338 | selectedIndex -= view_height 339 | return render() 340 | break 341 | 342 | case 'a': // beginning of line 343 | cursorPosition = 0 344 | return render() 345 | break 346 | 347 | case 'e': // end of line 348 | // TODO right-align names if already at end of line (useful for 349 | // list of filenames with long paths to see the end of the 350 | // filenames on the list) 351 | if ( cursorPosition === inputBuffer.length ) { 352 | _opts.keepRight = !_opts.keepRight 353 | } else { 354 | cursorPosition = inputBuffer.length 355 | } 356 | return render() 357 | break 358 | 359 | case 'w': // clear word 360 | { 361 | const a = inputBuffer.slice( 0, cursorPosition ) 362 | const b = inputBuffer.slice( cursorPosition ) 363 | const m = a.match( /\S+\s*$/ ) // last word 364 | if ( m && m.index > 0 ) { 365 | // console.log( m.index ) 366 | cursorPosition = m.index 367 | inputBuffer = a.slice( 0, cursorPosition ).concat( b ) 368 | } else { 369 | cursorPosition = 0 370 | inputBuffer = b 371 | } 372 | } 373 | return render() 374 | break 375 | 376 | case 'q': // quit 377 | cleanDirtyScreen() 378 | return finish() 379 | } 380 | } 381 | 382 | // usually ALT key 383 | if ( key.meta ) { 384 | switch ( name ) { 385 | case 'n': // left arrow key 386 | scrollOffset-- 387 | return render() 388 | 389 | case 'p': // right arrow key 390 | scrollOffset++ 391 | return render() 392 | } 393 | } 394 | 395 | if ( key.ctrl ) return 396 | if ( key.meta ) return 397 | 398 | switch ( name ) { 399 | case 'backspace': // ctrl-h 400 | { 401 | const a = inputBuffer.slice( 0, cursorPosition - 1 ) 402 | const b = inputBuffer.slice( cursorPosition ) 403 | inputBuffer = a.concat( b ) 404 | 405 | cursorPosition-- 406 | if ( cursorPosition < 0 ) { 407 | cursorPosition = 0 408 | } 409 | } 410 | return render() 411 | break 412 | 413 | case 'left': // left arrow key 414 | if ( _opts.nolist ) { 415 | cursorPosition-- 416 | if ( cursorPosition < 0 ) cursorPosition = 0 417 | return render() 418 | } else { 419 | scrollOffset-- 420 | return render() 421 | } 422 | break 423 | 424 | case 'right': // right arrow key 425 | if ( _opts.nolist ) { 426 | cursorPosition++ 427 | if ( cursorPosition > inputBuffer.length ) { 428 | cursorPosition = inputBuffer.length 429 | } 430 | return render() 431 | } else { 432 | scrollOffset++ 433 | return render() 434 | } 435 | break 436 | 437 | // text terminals treat ctrl-j as newline ( enter ) 438 | // ref: https://ss64.com/bash/syntax-keyboard.html 439 | case 'down': // ctrl-j 440 | case 'enter': 441 | selectedIndex += 1 442 | return render() 443 | 444 | case 'up': 445 | selectedIndex -= 1 446 | return render() 447 | 448 | case 'esc': 449 | case 'escape': 450 | cleanDirtyScreen() 451 | return finish() 452 | 453 | // hit return key ( aka enter key ) ( aka ctrl-m ) 454 | case 'return': // ctrl-m 455 | cleanDirtyScreen() 456 | 457 | function transformResult ( match ) { 458 | return { 459 | value: match.originalValue, 460 | index: match.originalIndex 461 | } 462 | } 463 | 464 | const result = { 465 | selected: _selectedItem && transformResult( _selectedItem ) || undefined, 466 | 467 | // common alternatives for the same thing 468 | query: inputBuffer, 469 | search: inputBuffer, 470 | input: inputBuffer 471 | } 472 | 473 | return finish( result ) 474 | } 475 | 476 | /* 477 | switch ( chunk ) { 478 | case '<': 479 | scrollOffset-- 480 | return render() 481 | break 482 | 483 | case '>': 484 | scrollOffset++ 485 | return render() 486 | break 487 | } 488 | */ 489 | 490 | if ( chunk && chunk.length === 1 ) { 491 | let c = '' 492 | if ( key.shift ) { 493 | c = chunk.toUpperCase() 494 | } else { 495 | c = chunk 496 | } 497 | 498 | if ( c ) { 499 | const a = inputBuffer.slice( 0, cursorPosition ) 500 | const b = inputBuffer.slice( cursorPosition ) 501 | inputBuffer = a.concat( c, b ) 502 | 503 | cursorPosition++ 504 | if ( cursorPosition > inputBuffer.length ) { 505 | cursorPosition = inputBuffer.length 506 | } 507 | } 508 | 509 | render() 510 | } 511 | } 512 | 513 | stdin.setEncoding( 'utf8' ) 514 | stdin.on( 'keypress', handleKeypress ) 515 | 516 | const clcBgGray = bgXterm( 236 ) 517 | const clcFgArrow = xterm( 198 ) 518 | const clcFgBufferArrow = xterm( 110 ) 519 | const clcFgGreen = xterm( 143 ) 520 | // const clcFgMatchGreen = xterm( 151 ) 521 | const clcFgModeStatus = xterm( 110 ) 522 | const clcFgMatchGreen = xterm( 107 ) 523 | 524 | // get matches based on the search mode 525 | function getMatches ( mode, filter, text ) 526 | { 527 | switch ( mode.trim().toLowerCase() ) { 528 | case 'normal': 529 | return textMatches( filter, text ) 530 | 531 | case 'fuzzy': 532 | default: 533 | // default to fuzzy matching 534 | return fuzzyMatches( filter, text ) 535 | } 536 | } 537 | 538 | // get matched list based on the search mode 539 | function getList ( mode, filter, list ) 540 | { 541 | // default to fuzzy matching 542 | switch ( mode.trim().toLowerCase() ) { 543 | case 'normal': 544 | return textList( filter, list ) 545 | 546 | case 'fuzzy': 547 | default: 548 | // default to fuzzy matching 549 | return fuzzyList( filter, list ) 550 | } 551 | } 552 | 553 | function fuzzyMatches ( fuzz, text ) 554 | { 555 | fuzz = fuzz.toLowerCase() 556 | text = text.toLowerCase() 557 | 558 | let tp = 0 // text position/pointer 559 | let matches = [] 560 | 561 | // nothing to match with 562 | if ( !fuzz ) return matches 563 | 564 | for ( let i = 0; i < fuzz.length; i++ ) { 565 | const f = fuzz[ i ] 566 | 567 | for ( ; tp < text.length; tp++ ) { 568 | const t = text[ tp ] 569 | if ( f === t ) { 570 | matches.push( tp ) 571 | tp++ 572 | break 573 | } 574 | } 575 | } 576 | 577 | return matches 578 | } 579 | 580 | function fuzzyList ( fuzz, list ) 581 | { 582 | const results = [] 583 | 584 | for ( let i = 0; i < list.length; i++ ) { 585 | const item = list[ i ] 586 | 587 | const originalIndex = item.originalIndex 588 | const originalValue = item.originalValue 589 | 590 | // get rid of unnecessary whitespace that only takes of 591 | // valuable scren space 592 | const normalizedItem = originalValue.split( /\s+/ ).join( ' ' ) 593 | 594 | /* matches is an array of indexes on the normalizedItem string 595 | * that have matched the fuzz 596 | */ 597 | const matches = fuzzyMatches( fuzz, normalizedItem ) 598 | 599 | if ( matches.length === fuzz.length ) { 600 | /* When the matches.length is exacly the same as fuzz.length 601 | * it means we have a fuzzy match -> all characters in 602 | * the fuzz string have been found on the normalizedItem string. 603 | * The matches array holds each string index position 604 | * of those matches on the normalizedItem string. 605 | * ex. fuzz = 'foo', normalizedItem = 'far out dog', matches = [0,4,9] 606 | */ 607 | 608 | let t = normalizedItem 609 | 610 | results.push( { 611 | originalIndex: originalIndex, 612 | originalValue: originalValue, 613 | matchedIndex: results.length, 614 | original: item, 615 | text: t // what shows up on terminal/screen 616 | } ) 617 | } 618 | } 619 | 620 | return results 621 | } 622 | 623 | function textMatches ( filter, text ) 624 | { 625 | filter = filter.toLowerCase() // ex. foo 626 | text = text.toLowerCase() // ex. dog food is geat 627 | 628 | let tp = 0 // text position/pointer 629 | let matches = [] 630 | 631 | // nothing to match with 632 | if ( !filter ) return matches 633 | 634 | // source pointer ( first index of matched text ) 635 | const sp = text.indexOf( filter ) 636 | if ( sp >= 0 ) { 637 | // end pointer ( last index of matched text ) 638 | const ep = sp + filter.length 639 | for ( let i = sp; i < ep; i++ ) { 640 | matches.push( i ) 641 | } 642 | } 643 | 644 | return matches 645 | } 646 | 647 | function textList ( filter, list ) 648 | { 649 | const results = [] 650 | 651 | for ( let i = 0; i < list.length; i++ ) { 652 | const item = list[ i ] 653 | 654 | const originalIndex = item.originalIndex 655 | const originalValue = item.originalValue 656 | 657 | // get rid of unnecessary whitespace that only takes of 658 | // valuable scren space 659 | const normalizedItem = originalValue.split( /\s+/ ).join( ' ' ) 660 | 661 | /* matches is an array of indexes on the normalizedItem string 662 | * that have matched the fuzz 663 | */ 664 | const matches = textMatches( filter, normalizedItem ) 665 | 666 | if ( matches.length === filter.length ) { 667 | /* When the matches.length is exacly the same as filter.length 668 | * it means we have a fuzzy match -> all characters in 669 | * the filter string have been found on the normalizedItem string. 670 | * The matches array holds each string index position 671 | * of those matches on the normalizedItem string. 672 | * ex. filter = 'foo', normalizedItem = 'dog food yum', matches = [4,5,6] 673 | */ 674 | 675 | let t = normalizedItem 676 | 677 | results.push( { 678 | originalIndex: originalIndex, 679 | originalValue: originalValue, 680 | matchedIndex: results.length, 681 | original: item, 682 | text: t // what shows up on terminal/screen 683 | } ) 684 | } 685 | } 686 | 687 | return results 688 | } 689 | 690 | function colorIndexesOnText ( indexes, text, clcColor ) 691 | { 692 | const paintBucket = [] // characters to colorize at the end 693 | 694 | for ( let i = 0; i < indexes.length; i++ ) { 695 | const index = indexes[ i ] 696 | paintBucket.push( { index: index, clc: clcColor || clcFgMatchGreen } ) 697 | } 698 | 699 | // copy match text colorize it based on the matches 700 | // this variable with the colorized ANSI text will be 701 | // returned at the end of the function 702 | let t = text 703 | 704 | // colorise in reverse because invisible ANSI color 705 | // characters increases string length 706 | paintBucket.sort( function ( a, b ) { 707 | return b.index - a.index 708 | } ) 709 | 710 | for ( let i = 0; i < paintBucket.length; i++ ) { 711 | const paint = paintBucket[ i ] 712 | const index = Number( paint.index ) 713 | 714 | // skip fuzzy chars that have shifted out of view 715 | if ( index < 0 ) continue 716 | if ( index > t.length ) continue 717 | 718 | const c = paint.clc( t[ index ] ) 719 | t = t.slice( 0, index ) + c + t.slice( index + 1 ) 720 | } 721 | 722 | // return the colorized match text 723 | return t 724 | } 725 | 726 | function trimOnIndexes ( indexes, text ) 727 | { 728 | let t = text 729 | indexes = ( 730 | indexes.map( function ( i ) { return Number( i ) } ) 731 | ) 732 | indexes.sort() // sort indexes 733 | 734 | // the last ( right-most ) index/character we want to be 735 | // visible on screen as centered as possible until there are 736 | // no more text to be shown to the right of it 737 | const lastIndex = indexes[ indexes.length - 1 ] 738 | 739 | const maxLen = getMaxWidth() - 2 // terminal width + padding 740 | 741 | /* we want to show the user the last characters that matches 742 | * as those are the most relevant 743 | * ( and ignore earlier matches if they go off-screen ) 744 | * 745 | * use the marginRight to shift the matched text left until 746 | * the last characters that match are visible on the screen 747 | */ 748 | const marginRight = Math.ceil( stdout.columns * 1 ) - 12 749 | 750 | // how wide the last index would be printed currently 751 | const lastMatchLength = stringWidth( t.slice( 0, lastIndex ) ) 752 | 753 | // how much to shift left to get last index to get into 754 | // marginRight range (almost center) 755 | let shiftLeft = ( marginRight - lastMatchLength ) 756 | 757 | // [1] but not too much if there is no additional text 758 | // const delta = ( stringWidth( t ) - lastMatchLength ) 759 | // if ( Math.abs( shiftLeft ) > delta ) shiftLeft = -Math.floor( delta * .5 ) 760 | 761 | let startIndex = 0 762 | let shiftAmount = 0 763 | 764 | if ( shiftLeft < 0 ) { 765 | // shift left so that the matched text is in view 766 | while ( shiftAmount > shiftLeft ) { 767 | startIndex++ 768 | shiftAmount = -stringWidth( t.slice( 0, startIndex ) ) 769 | if ( startIndex >= t.length ) { 770 | break // shouldn't happen because of [1] 771 | } 772 | } 773 | } 774 | 775 | startIndex = startIndex + scrollOffset 776 | if ( startIndex < 0 ) { 777 | startIndex = 0 778 | } 779 | 780 | if ( stringWidth( t ) < maxLen ) { 781 | // no need to offset as the whole thing fits anyway 782 | startIndex = 0 783 | } 784 | 785 | // find minimum amount to cut that fits screen 786 | // i.e., stop cutting off if the end has already been 787 | // printed to the terminal 788 | let delta = 0 789 | for ( let i = 0; i < scrollOffset; i++ ) { 790 | let s = t.slice( startIndex - i ) 791 | 792 | if ( stringWidth( s ) < maxLen ) { 793 | continue 794 | } else { 795 | delta = i - 1 796 | break 797 | } 798 | } 799 | 800 | startIndex = startIndex - delta 801 | t = t.slice( startIndex ) 802 | 803 | // console.log( 't.length: ' + t.length ) 804 | // console.log( 'shiftLeft: ' + shiftLeft ) 805 | // console.log( 'shiftamount: ' + shiftAmount ) 806 | // console.log( 'startindex: ' + startIndex ) 807 | 808 | // normalize excessive lengths to avoid too much while looping 809 | // if ( t.length > ( maxLen * 2 + 20 ) ) t = t.slice( 0, maxLen * 2 + 20 ) 810 | 811 | /* Cut off from the end of the (visual) line until 812 | * it fits on the terminal width screen. 813 | */ 814 | const tlen = t.length 815 | let endIndex = t.length 816 | while ( stringWidth( t ) > maxLen ) { 817 | t = t.slice( 0, --endIndex ) 818 | if ( t.length <= 0 ) break 819 | } 820 | 821 | if ( startIndex > 0 ) { 822 | t = '..' + t 823 | } 824 | 825 | if ( endIndex < tlen ) { 826 | t = t + '..' 827 | } 828 | 829 | return { 830 | text: t, 831 | startOffset: startIndex ? ( startIndex - '..'.length ) : startIndex 832 | } 833 | } 834 | 835 | function cleanDirtyScreen () 836 | { 837 | const width = getWindowWidth() 838 | const writtenHeight = Math.max( 839 | MIN_HEIGHT, 840 | 2 + _printedMatches 841 | ) 842 | 843 | stdout.write( moveLeft( width ) ) 844 | 845 | for ( let i = 0; i < writtenHeight; i++ ) { 846 | stdout.write( moveDown( 1 ) ) 847 | } 848 | 849 | for ( let i = 0; i < writtenHeight; i++ ) { 850 | stdout.write( eraseLine ) 851 | stdout.write( moveUp( 1 ) ) 852 | } 853 | 854 | stdout.write( eraseLine ) 855 | } 856 | 857 | function render () 858 | { 859 | // const height = getWindowHeight() 860 | // console.log( 'window height: ' + height ) 861 | // !debug && stdout.write( eraseScreen ) 862 | // stdout.write( moveTo( 0, height ) ) 863 | 864 | cleanDirtyScreen() 865 | 866 | // calculate matches 867 | _matches = [] // reset matches 868 | const words = inputBuffer.split( /\s+/ ).filter( function ( word ) { return word.length > 0 } ) 869 | for ( let i = 0; i < words.length; i++ ) { 870 | const word = words[ i ] 871 | let list = _list // fuzzy match against all items in list 872 | if ( i > 0 ) { 873 | // if we already have matches, fuzzy match against 874 | // those instead (combines the filters) 875 | list = _matches 876 | } 877 | const matches = getList( _opts.mode, word, list ) 878 | _matches = matches 879 | } 880 | 881 | // special case no input ( show all with no matches ) 882 | if ( words.length === 0 ) { 883 | const matches = getList( _opts.mode, '', _list ) 884 | _matches = matches 885 | } 886 | 887 | if ( selectedIndex >= _matches.length ) { 888 | // max out at end of filtered/matched results 889 | selectedIndex = _matches.length - 1 890 | } 891 | 892 | if ( selectedIndex < 0 ) { 893 | // min out at beginning of filtered/matched results 894 | selectedIndex = 0 895 | } 896 | 897 | const inputLabel = _opts.label || clcFgBufferArrow( '> ' ) 898 | const inputLabels = inputLabel.split( '\n' ) 899 | const lastInputLabel = inputLabels[ inputLabels.length - 1 ] 900 | const inputLabelHeight = inputLabels.length - 1 901 | 902 | if ( render.init ) { 903 | stdout.write( moveUp( inputLabelHeight ) ) 904 | } else { 905 | // get rid of dirt when being pushed above MIN_HEIGHT 906 | // from the bottom of the terminal 907 | cleanDirtyScreen() 908 | 909 | if (_opts._selectOneActive === undefined) _opts._selectOneActive = true 910 | } 911 | render.init = true 912 | 913 | // print input label 914 | stdout.write( inputLabel ) 915 | 916 | stdout.write( inputBuffer ) 917 | 918 | // do not print the list at all when `nolist` is set 919 | // this is used when we only care about the input query 920 | if ( !_opts.nolist ) { 921 | stdout.write( '\n' ) 922 | 923 | /* Here we color the matched items text for terminal 924 | * printing based on what characters were found/matched. 925 | * 926 | * Since each filter is separated by space we first 927 | * combine all matches from all filters(words). 928 | * 929 | * If we want to only color based on the most recent 930 | * filter (last word) then just use the matches from the 931 | * last word. 932 | */ 933 | for ( let i = 0; i < _matches.length; i++ ) { 934 | const match = _matches[ i ] 935 | 936 | const words = inputBuffer.split( /\s+/ ).filter( function ( word ) { return word.length > 0 } ) 937 | 938 | const indexMap = {} // as map to prevent duplicates indexes 939 | for ( let i = 0; i < words.length; i++ ) { 940 | // highlights last word only 941 | // if ( i !== ( words.length - 1 ) ) continue 942 | 943 | const word = words[ i ] 944 | const matches = getMatches( _opts.mode, word, match.text ) 945 | matches.forEach( function ( i ) { 946 | indexMap[ i ] = true 947 | } ) 948 | } 949 | 950 | if ( !_opts.keepRight ) { 951 | // trim and position text ( horizontally ) based on 952 | // last word/filter that matched ( most relevant ) 953 | const lastWord = words[ words.length - 1 ] || ' ' 954 | const lastIndexes = getMatches( _opts.mode, lastWord, match.text ) 955 | const { text, startOffset } = trimOnIndexes( lastIndexes, match.text ) 956 | match.text = text 957 | 958 | // skip colorization (no matches -> nothing to colorize) 959 | if ( words.length === 0 ) continue 960 | 961 | const indexes = ( 962 | Object.keys( indexMap ) 963 | .map( function ( i ) { return Number( i ) - startOffset } ) 964 | ) 965 | indexes.sort() // sort indexes 966 | 967 | // transform the text to a colorized version 968 | match.text = colorIndexesOnText( indexes, match.text /*, clcFgGreen */ ) 969 | } else { 970 | // trim and position text ( horizontally ) so that the end of 971 | // the matched text is visible on the screen on the right 972 | // screen edge 973 | const { text, startOffset } = trimOnIndexes( [ match.text.length - 1 ], match.text ) 974 | match.text = text 975 | 976 | // skip colorization (no matches -> nothing to colorize) 977 | if ( words.length === 0 ) continue 978 | 979 | const indexes = ( 980 | Object.keys( indexMap ) 981 | .map( function ( i ) { return Number( i ) - startOffset } ) 982 | ) 983 | indexes.sort() // sort indexes 984 | 985 | // transform the text to a colorized version 986 | match.text = colorIndexesOnText( indexes, match.text /*, clcFgGreen */ ) 987 | } 988 | } 989 | 990 | // status line/bar to show before the results 991 | let statusLine = '' 992 | 993 | // print matches length vs original list length 994 | const n = _matches.length 995 | statusLine += ( ' ' ) 996 | statusLine += ( clcFgGreen( n + '/' + _list.length ) ) 997 | 998 | // print mode 999 | statusLine += ( ' ' + clcFgModeStatus( _opts.mode + ' mode' ) ) 1000 | 1001 | // print mode ui legend 1002 | if ( _opts.mode === 'fuzzy' ) { 1003 | statusLine += ( colors.blackBright( ' ctrl-s' ) ) 1004 | } else { 1005 | statusLine += ( colors.yellowBright( ' ctrl-s' ) ) 1006 | } 1007 | 1008 | // print --keep-right ui legend 1009 | let keepRightColor = colors.blackBright 1010 | if (_opts.keepRight) { 1011 | keepRightColor = colors.yellowBright 1012 | } 1013 | statusLine += ( keepRightColor( ' ctrl-e' ) ) 1014 | 1015 | statusLine += ( ' ' + colors.magenta( `[${ scrollOffset > 0 ? '+' : '' }${ scrollOffset }]` ) ) 1016 | 1017 | // limit statusline to terminal width 1018 | let statusLineEndIndex = statusLine.length 1019 | const statusLineMaxLen = stdout.columns - 4 1020 | while ( stringWidth( statusLine ) > statusLineMaxLen ) { 1021 | statusLine = statusLine.slice( 0, --statusLineEndIndex ) 1022 | if ( statusLine.length <= 0 ) break 1023 | } 1024 | 1025 | if ( statusLine.length < statusLineEndIndex ) { 1026 | // add red space to prevent sliced colored text 1027 | // from bleeding forwards 1028 | statusLine = statusLine + colors.red( ' ' ) 1029 | } 1030 | 1031 | statusLine += ( '\n' ) 1032 | 1033 | // print the status line 1034 | stdout.write( statusLine ) 1035 | 1036 | // select first item in list by default ( empty fuzzy search matches first 1037 | // item.. ) 1038 | if ( !_selectedItem ) { 1039 | _selectedItem = _matches[ 0 ] 1040 | } 1041 | 1042 | if (_opts._selectOneActive && _matches.length === 1 && _opts.selectOne ) { 1043 | // console.log(' === attempting to select one === ') 1044 | _selectedItem = _matches[ 0 ] 1045 | cleanDirtyScreen() 1046 | process.nextTick(function () { 1047 | handleKeypress( '', { name: 'return' } ) 1048 | }) 1049 | } 1050 | _opts._selectOneActive = false 1051 | 1052 | // print the matches 1053 | _printedMatches = 0 1054 | 1055 | // padding to make room for command, query and status lines 1056 | const paddingTop = 3 1057 | const MAX_HEIGHT = ( stdout.rows - paddingTop ) 1058 | 1059 | // max lines to use for printing matched results 1060 | let maxPrintedLines = Math.min( _matches.length, MIN_HEIGHT ) 1061 | if ( _opts.height >= 0 ) { 1062 | const heightPercent = Math.min( _opts.height / 100, 1 ) 1063 | // console.log(heightPercent) 1064 | const heightNormalized = Math.floor( heightPercent * MAX_HEIGHT ) 1065 | // console.log(heightNormalized) 1066 | maxPrintedLines = Math.min( _matches.length, heightNormalized ) 1067 | maxPrintedLines = Math.max( maxPrintedLines, MIN_HEIGHT ) 1068 | } 1069 | 1070 | let paddingBottom = 2 // 1 extra padding at the bottom when scrolling down 1071 | if ( _matches.length <= MIN_HEIGHT ) { 1072 | // no extra padding at the bottom since there is no room for it 1073 | // - othewise first match is cut off and will not be visible 1074 | paddingBottom = 1 1075 | } 1076 | 1077 | // first matched result to print 1078 | const startIndex = Math.max( 0, selectedIndex - maxPrintedLines + paddingBottom ) 1079 | 1080 | // last matched result to print 1081 | const endIndex = Math.min( maxPrintedLines + startIndex, _matches.length ) 1082 | 1083 | // print matches 1084 | for ( let i = startIndex; i < endIndex; i++ ) { 1085 | _printedMatches++ 1086 | 1087 | const match = _matches[ i ] 1088 | 1089 | const item = match.text 1090 | 1091 | const itemSelected = ( 1092 | ( selectedIndex === i ) 1093 | ) 1094 | 1095 | if ( itemSelected ) { 1096 | _selectedItem = match 1097 | stdout.write( clcBgGray( clcFgArrow( '> ' ) ) ) 1098 | 1099 | if ( opts.prelinehook ) { 1100 | stdout.write( opts.prelinehook( match.originalIndex ) ) 1101 | } 1102 | 1103 | stdout.write( clcBgGray( item ) ) 1104 | 1105 | if ( opts.postlinehook ) { 1106 | stdout.write( opts.postlinehook( match.originalIndex ) ) 1107 | } 1108 | 1109 | stdout.write( '\n' ) 1110 | } else { 1111 | stdout.write( clcBgGray( ' ' ) ) 1112 | stdout.write( ' ' ) 1113 | 1114 | if ( opts.prelinehook ) { 1115 | stdout.write( opts.prelinehook( match.originalIndex ) ) 1116 | } 1117 | 1118 | stdout.write( item ) 1119 | 1120 | if ( opts.postlinehook ) { 1121 | stdout.write( opts.postlinehook( match.originalIndex ) ) 1122 | } 1123 | 1124 | stdout.write( '\n' ) 1125 | } 1126 | } 1127 | 1128 | // move back to cursor position after printing matches 1129 | stdout.write( moveUp( 2 + _printedMatches ) ) 1130 | } 1131 | 1132 | if ( _printedMatches < 1 ) { 1133 | // clear selected item when nothing matches 1134 | _selectedItem = undefined 1135 | } 1136 | 1137 | // if ( inputLabelHeight > 0 ) stdout.write( clc.move.up( inputLabelHeight ) ) 1138 | 1139 | // reset cursor left position 1140 | stdout.write( moveLeft( stdout.columns ) ) 1141 | 1142 | const cursorOffset = stringWidth( inputBuffer.slice( 0, cursorPosition ) ) 1143 | 1144 | const cursorLeftPadding = stringWidth( lastInputLabel ) 1145 | 1146 | // set cursor left position 1147 | stdout.write( moveRight( cursorLeftPadding + cursorOffset ) ) 1148 | } 1149 | 1150 | stdin.setRawMode && stdin.setRawMode( true ) 1151 | stdin.resume() 1152 | 1153 | render() 1154 | } ) 1155 | 1156 | if ( !callback ) { 1157 | return promise 1158 | } 1159 | 1160 | return _opts 1161 | } 1162 | 1163 | // quick debugging, only executes when run with `node main.js` 1164 | if ( require.main === module ) { 1165 | ;( async function () { 1166 | const r = await getInput( 'Name: ' ) 1167 | console.log( r.query ) 1168 | } )() 1169 | } 1170 | -------------------------------------------------------------------------------- /test/animals.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Apes", 3 | "Badgers", 4 | "Bats", 5 | "Bears", 6 | "Bees", 7 | "Buffalo", 8 | "Camels", 9 | "Cats", 10 | "Cobras", 11 | "Crocodiles", 12 | "Crows", 13 | "Dogs", 14 | "Donkeys", 15 | "Eagles", 16 | "Elephants", 17 | "Elk", 18 | "Falcons", 19 | "Ferrets", 20 | "Fish", 21 | "Flamingos", 22 | "Fox", 23 | "Frogs", 24 | "Geese", 25 | "Giraffes", 26 | "Gorillas", 27 | "Hippopotami", 28 | "Hyenas", 29 | "Jaguars", 30 | "Jellyfish", 31 | "Kangaroos", 32 | "Lemurs", 33 | "Leopards", 34 | "Lions", 35 | "Moles", 36 | "Monkeys", 37 | "Mules", 38 | "Otters", 39 | "Oxen", 40 | "Owls", 41 | "Parrots", 42 | "Pigs", 43 | "Porcupines", 44 | "Rabbits", 45 | "Rats", 46 | "Ravens", 47 | "Rhinoceroses", 48 | "Shark", 49 | "Skunk", 50 | "Snakes", 51 | "Squirrels", 52 | "Stingrays", 53 | "Swans", 54 | "Tigers", 55 | "Toads", 56 | "Turkeys", 57 | "Turtles", 58 | "Weasels", 59 | "Whales", 60 | "Wolves", 61 | "Zebras" 62 | ] 63 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * refer to this table for converting stdin keypresses as binary/hex 3 | * ref: https://www.eso.org/~ndelmott/ascii.html 4 | */ 5 | 6 | const test = require( 'tape' ) 7 | 8 | const stdin = require( 'mock-stdin' ).stdin() 9 | const nfzf = require( '../src/main.js' ) 10 | 11 | // fix stdin ( can't start with hex ) 12 | const _send = stdin.send 13 | stdin.send = function send ( text ) { 14 | // space + backspace ( net effect no changes to input string ) 15 | const stub = ' \x08' 16 | return _send.call( stdin, stub + text ) 17 | } 18 | 19 | log = function () {} 20 | // log = console.log 21 | 22 | // list of animals for testing 23 | const animals = require( './animals.json' ) 24 | 25 | const fs = require( 'fs' ) 26 | const path = require( 'path' ) 27 | 28 | // list of youtube search results for testing 29 | const ytr = require( './youtube-search-results.json' ) 30 | 31 | test( 'package.json main path correct', function ( t ) { 32 | t.plan( 1 ) 33 | 34 | try { 35 | const pkg = require( path.join( __dirname, '../package.json' ) ) 36 | const stat = fs.statSync( path.join( __dirname, '../', pkg.main ) ) 37 | t.ok( stat ) 38 | } catch ( err ) { 39 | t.fail( err ) 40 | } 41 | } ) 42 | 43 | test( 'package.json bin path correct', function ( t ) { 44 | t.plan( 1 ) 45 | 46 | try { 47 | const pkg = require( path.join( __dirname, '../package.json' ) ) 48 | const stat = fs.statSync( path.join( __dirname, '../', pkg.bin.nfzf ) ) 49 | t.ok( stat ) 50 | } catch ( err ) { 51 | t.fail( err ) 52 | } 53 | } ) 54 | 55 | test( 'select first result', async function ( t ) { 56 | t.plan( 2 ) 57 | 58 | // prepare mocked user input for nfzf 59 | process.nextTick( function () { 60 | stdin.send( '\r' ) 61 | } ) 62 | 63 | const r = await nfzf( require( '../test/animals.json' ) ) 64 | log( r ) 65 | 66 | t.equal( r.selected.value, 'Apes', 'Apes value' ) 67 | t.equal( r.selected.index, 0, 'Apes index' ) 68 | } ) 69 | 70 | test( 'select last result', async function ( t ) { 71 | t.plan( 2 ) 72 | 73 | // prepare mocked user input for nfzf 74 | process.nextTick( function () { 75 | // ctrl-j ( \x0A ) 76 | stdin.send( '\x0A'.repeat( 99 ) + '\r' ) 77 | } ) 78 | 79 | const r = await nfzf( require( '../test/animals.json' ) ) 80 | log( r ) 81 | 82 | t.equal( r.selected.value, 'Zebras', 'Zebras value' ) 83 | t.equal( r.selected.index, 59, 'Zebras index' ) 84 | } ) 85 | 86 | test( 'select first search result', async function ( t ) { 87 | t.plan( 2 ) 88 | 89 | // prepare mocked user input for nfzf 90 | process.nextTick( function () { 91 | stdin.send( 'j\r' ) 92 | } ) 93 | 94 | const r = await nfzf( require( '../test/animals.json' ) ) 95 | log( r ) 96 | 97 | t.equal( r.selected.value, 'Jaguars', 'Jaguars value' ) 98 | t.equal( r.selected.index, 27, 'Jaguars index' ) 99 | } ) 100 | 101 | test( 'select second search result', async function ( t ) { 102 | t.plan( 2 ) 103 | 104 | // prepare mocked user input for nfzf 105 | process.nextTick( function () { 106 | stdin.send( 'cro\r' ) 107 | } ) 108 | 109 | const r = await nfzf( require( '../test/animals.json' ) ) 110 | log( r ) 111 | 112 | t.equal( r.selected.value, 'Crocodiles', 'Crocodiles value' ) 113 | t.equal( r.selected.index, 9, 'Crocodiles index' ) 114 | } ) 115 | 116 | test( 'select fourth search result', async function ( t ) { 117 | t.plan( 2 ) 118 | 119 | // prepare mocked user input for nfzf 120 | process.nextTick( function () { 121 | // ctrl-j ( \x0A ) 122 | stdin.send( 'ba\x0A\x0A\x0A\x0A\r' ) 123 | } ) 124 | 125 | const r = await nfzf( require( '../test/animals.json' ) ) 126 | log( r ) 127 | 128 | t.equal( r.selected.value, 'Cobras', 'Cobras value' ) 129 | t.equal( r.selected.index, 8, 'Cobras index' ) 130 | } ) 131 | 132 | test( 'scroll down 4 (over limit), scroll up 1', async function ( t ) { 133 | t.plan( 2 ) 134 | 135 | // prepare mocked user input for nfzf 136 | process.nextTick( function () { 137 | // ctrl-j ( \x0A ) 138 | stdin.send( 'ho\x0A\x0A\x0A\x0A\r' ) 139 | } ) 140 | 141 | const r = await nfzf( require( '../test/animals.json' ) ) 142 | log( r ) 143 | 144 | t.equal( r.selected.value, 'Rhinoceroses', 'Rhinoceroses value' ) 145 | t.equal( r.selected.index, 45, 'Rhinoceroses index' ) 146 | } ) 147 | 148 | test( 'scroll down 4 (over limit), scroll up 8 (over limit)', async function ( t ) { 149 | t.plan( 2 ) 150 | 151 | // prepare mocked user input for nfzf 152 | process.nextTick( function () { 153 | // ctrl-j ( \x0A ), ctrl-k ( \x0B ) 154 | stdin.send( 'ho\x0A\x0A\x0A\x0A\x0B\x0B\x0B\x0B\x0B\x0B\x0B\x0B\r' ) 155 | } ) 156 | 157 | const r = await nfzf( require( '../test/animals.json' ) ) 158 | log( r ) 159 | 160 | t.equal( r.selected.value, 'Hippopotami', 'Hippopotami value' ) 161 | t.equal( r.selected.index, 25, 'Hippopotami index' ) 162 | } ) 163 | 164 | test( 'scroll down 4 (over limit), scroll up 8 (over limit)', async function ( t ) { 165 | t.plan( 2 ) 166 | 167 | // prepare mocked user input for nfzf 168 | process.nextTick( function () { 169 | // ctrl-j ( \x0A ), ctrl-k ( \x0B ) 170 | stdin.send( 'ho\x0A\x0A\x0A\x0A\x0B\x0B\x0B\x0B\x0B\x0B\x0B\x0B\r' ) 171 | } ) 172 | 173 | const r = await nfzf( require( '../test/animals.json' ) ) 174 | log( r ) 175 | 176 | t.equal( r.selected.value, 'Hippopotami', 'Hippopotami value' ) 177 | t.equal( r.selected.index, 25, 'Hippopotami index' ) 178 | } ) 179 | 180 | test( 'scroll down 16 (over page)', async function ( t ) { 181 | t.plan( 2 ) 182 | 183 | // prepare mocked user input for nfzf 184 | process.nextTick( function () { 185 | // ctrl-j ( \x0A ) 186 | stdin.send( '\x0A'.repeat( 16 ) + '\r' ) 187 | } ) 188 | 189 | const r = await nfzf( require( '../test/animals.json' ) ) 190 | log( r ) 191 | 192 | t.equal( r.selected.value, 'Falcons', 'Falcons value' ) 193 | t.equal( r.selected.index, 16, 'Falcons index' ) 194 | } ) 195 | 196 | test( 'one fuzz selection', async function ( t ) { 197 | t.plan( 2 ) 198 | 199 | // prepare mocked user input for nfzf 200 | process.nextTick( function () { 201 | // ctrl-j ( \x0A ) 202 | stdin.send( 'eas' + '\x0A\x0A' + '\r' ) 203 | } ) 204 | 205 | const r = await nfzf( require( '../test/animals.json' ) ) 206 | log( r ) 207 | 208 | t.equal( r.selected.value, 'Elephants', 'Elephants value' ) 209 | t.equal( r.selected.index, 14, 'Elephants index' ) 210 | } ) 211 | 212 | test( 'multiple fuzz selection', async function ( t ) { 213 | t.plan( 2 ) 214 | 215 | // prepare mocked user input for nfzf 216 | process.nextTick( function () { 217 | // ctrl-j ( \x0A ) 218 | stdin.send( 'e e r m' + '\r' ) 219 | } ) 220 | 221 | const r = await nfzf( require( '../test/animals.json' ) ) 222 | log( r ) 223 | 224 | t.equal( r.selected.value, 'Lemurs', 'Lemurs value' ) 225 | t.equal( r.selected.index, 30, 'Lemurs index' ) 226 | } ) 227 | 228 | test( 'select nothing in the list (undefined)', async function ( t ) { 229 | t.plan( 1 ) 230 | 231 | // prepare mocked user input for nfzf 232 | process.nextTick( function () { 233 | // ctrl-j ( \x0A ) 234 | stdin.send( 'music' + '\x0A\x0A\x0A' + '\r' ) 235 | } ) 236 | 237 | const r = await nfzf( require( '../test/animals.json' ) ) 238 | log( r ) 239 | 240 | t.equal( r.selected, undefined, 'undefined selected' ) 241 | } ) 242 | 243 | test( 'youtube search selection', async function ( t ) { 244 | t.plan( 2 ) 245 | 246 | // prepare mocked user input for nfzf 247 | process.nextTick( function () { 248 | // ctrl-j ( \x0A ) 249 | stdin.send( 'music' + '\x0A\x0A\x0A' + '\r' ) 250 | } ) 251 | 252 | const r = await nfzf( require( '../test/youtube-search-results.json' ) ) 253 | log( r ) 254 | 255 | t.equal( r.selected.value, ' 936460 | ~ N I G H T D R I V E ~ A Synthwave Music Video Mix [Chillwave - Retrowave] (45:31) | Euphoric Eugene', '~ N I G H T D R I V E ~' ) 256 | t.equal( r.selected.index, 6, '~ N I G H T D R I V E ~ index' ) 257 | } ) 258 | 259 | test( 'test original index correct', async function ( t ) { 260 | t.plan( 2 ) 261 | 262 | // prepare mocked user input for nfzf 263 | process.nextTick( function () { 264 | // ctrl-j ( \x0A ) 265 | stdin.send( 'r e tro syntwahve' + '\r' ) 266 | } ) 267 | 268 | const r = await nfzf( require( '../test/youtube-search-results.json' ) ) 269 | log( r ) 270 | 271 | t.equal( r.selected.value, ' 936460 | ~ N I G H T D R I V E ~ A Synthwave Music Video Mix [Chillwave - Retrowave] (45:31) | Euphoric Eugene', '~ N I G H T D R I V E ~' ) 272 | t.equal( r.selected.index, 6, '~ N I G H T D R I V E ~ index' ) 273 | } ) 274 | 275 | test( 'test normal mode', async function ( t ) { 276 | t.plan( 2 ) 277 | 278 | // prepare mocked user input for nfzf 279 | process.nextTick( function () { 280 | stdin.send( 'intend\r' ) 281 | } ) 282 | 283 | const opts = { 284 | mode: 'normal', 285 | list: require( '../test/youtube-search-results.json' ) 286 | } 287 | 288 | const r = await nfzf( opts ) 289 | log( r ) 290 | 291 | t.equal( r.selected.value, ' 506658 | 16-Bit Wave • Super Nintendo & Sega Genesis RetroWave Mix (38:13) | Axel Stone', 'Super Nintendo' ) 292 | t.equal( r.selected.index, 9, 'Super Nintendo index' ) 293 | } ) 294 | 295 | test( 'test normal mode multi fiter combination', async function ( t ) { 296 | t.plan( 2 ) 297 | 298 | // prepare mocked user input for nfzf 299 | process.nextTick( function () { 300 | // ctrl-j ( \x0A ) 301 | stdin.send( 'tro ni x chi\x0A\r' ) 302 | } ) 303 | 304 | const opts = { 305 | mode: 'normal', 306 | list: require( '../test/youtube-search-results.json' ) 307 | } 308 | 309 | const r = await nfzf( opts ) 310 | log( r ) 311 | 312 | t.equal( r.selected.value, ' 513166 | Interstellar (Chillwave - Retrowave - Electronic Mix) (51:36) | SoulSearchAndDestroy', 'Interstellar' ) 313 | t.equal( r.selected.index, 4, 'Interstellar index' ) 314 | } ) 315 | 316 | test( 'test ctrl-b, ctr-w ( jump back word ) ( delete word )', async function ( t ) { 317 | t.plan( 2 ) 318 | 319 | // prepare mocked user input for nfzf 320 | process.nextTick( function () { 321 | // ctrl-b ( \x02 ) ( back a word ) 322 | // ctrl-w ( \x17 ) ( delete a word ) 323 | stdin.send( 'hjklhjkl music retro\x02\x02\x17\r' ) 324 | } ) 325 | 326 | const opts = { 327 | mode: 'normal', 328 | list: require( '../test/youtube-search-results.json' ) 329 | } 330 | 331 | const r = await nfzf( opts ) 332 | log( r ) 333 | 334 | t.equal( r.selected.value, ' 253379 | Paradise Magic Music - \'Back To The 80\'s\' Best of Synthwave And Retro Electro Music (2:01:30) | Paradise Magic Music', 'Paradise Magic Music' ) 335 | t.equal( r.selected.index, 3, 'Paradise Magic Music index' ) 336 | } ) 337 | 338 | test( 'test 日本語, jump forward ( ctrl-f ), jump beginning ( ctrl-a )', async function ( t ) { 339 | t.plan( 2 ) 340 | 341 | // prepare mocked user input for nfzf 342 | process.nextTick( function () { 343 | // ctrl-a ( \x01 ) ( beginning ) 344 | // ctrl-b ( \x02 ) ( back a word ) 345 | // ctrl-f ( \x06 ) ( forward a word ) 346 | // ctrl-w ( \x17 ) ( delete a word ) 347 | stdin.send( '世界 hjklhjkl fan\x01\x06\x06\x17\r' ) 348 | } ) 349 | 350 | const opts = { 351 | mode: 'normal', 352 | list: require( '../test/youtube-search-results.json' ) 353 | } 354 | 355 | const r = await nfzf( opts ) 356 | log( r ) 357 | 358 | t.equal( r.selected.value, ' 2587609 | 【癒し効果】天国や異世界で流れる、魔法の音楽【作業用BGM】~ Fantasy Music ~ (43:58) | xxxJunaJunaxxx', 'Fantasy Music' ) 359 | t.equal( r.selected.index, 19, 'Fantasy Music index' ) 360 | } ) 361 | 362 | test( 'test backspace ( ctrl-h )', async function ( t ) { 363 | t.plan( 1 ) 364 | 365 | // prepare mocked user input for nfzf 366 | process.nextTick( function () { 367 | // ctrl-h ( \x08 ) ( backspace ) 368 | stdin.send( 'syntw\x08\r' ) 369 | } ) 370 | 371 | const opts = { 372 | mode: 'normal', 373 | list: require( '../test/youtube-search-results.json' ) 374 | } 375 | 376 | const r = await nfzf( opts ) 377 | log( r ) 378 | 379 | t.equal( r.selected.index, 1, 'Retro Grooves Mix index' ) 380 | } ) 381 | 382 | test( 'test jump to end ( ctrl-e )', async function ( t ) { 383 | t.plan( 1 ) 384 | 385 | // prepare mocked user input for nfzf 386 | process.nextTick( function () { 387 | // ctrl-a ( \x01 ) ( beginning ) 388 | // ctrl-b ( \x02 ) ( back a word ) 389 | // ctrl-e ( \x05 ) ( end ) 390 | // ctrl-f ( \x06 ) ( forward a word ) 391 | // ctrl-w ( \x17 ) ( delete a word ) 392 | stdin.send( 'iza iza iza xxxxx\x01\x05\x17\r' ) 393 | } ) 394 | 395 | const opts = { 396 | mode: 'normal', 397 | list: require( '../test/youtube-search-results.json' ) 398 | } 399 | 400 | const r = await nfzf( opts ) 401 | log( r ) 402 | 403 | t.equal( r.selected.index, 34, 'Izabelle' ) 404 | } ) 405 | 406 | test( 'test ctrl-s mode switching', async function ( t ) { 407 | t.plan( 2 ) 408 | 409 | // prepare mocked user input for nfzf 410 | process.nextTick( function () { 411 | // ctrl-a ( \x01 ) ( beginning ) 412 | // ctrl-b ( \x02 ) ( back a word ) 413 | // ctrl-e ( \x05 ) ( end ) 414 | // ctrl-f ( \x06 ) ( forward a word ) 415 | // ctrl-s ( \x13 ) ( switch modes ) 416 | // ctrl-w ( \x17 ) ( delete a word ) 417 | stdin.send( 'msl bo\r' ) 418 | } ) 419 | 420 | const opts = { 421 | mode: 'normal', 422 | list: require( '../test/youtube-search-results.json' ) 423 | } 424 | 425 | let r = await nfzf( opts ) 426 | log( r ) 427 | 428 | t.equal( r.selected, undefined, 'nothing found in normal mode' ) 429 | 430 | opts.mode = 'fuzzy' 431 | 432 | // prepare the same user input that will result 433 | // in a result in 'fuzzy' mode 434 | process.nextTick( function () { 435 | stdin.send( 'msl bo\r' ) 436 | } ) 437 | 438 | r = await nfzf( opts ) 439 | log( r ) 440 | 441 | t.equal( r.selected.index, 3, 'Paradise Music found in fuzzy mode' ) 442 | } ) 443 | 444 | test( 'test api update', async function ( t ) { 445 | t.plan( 6 ) 446 | 447 | // prepare mocked user input for nfzf 448 | process.nextTick( function () { 449 | stdin.send( '\r' ) 450 | } ) 451 | 452 | const opts = { 453 | mode: 'normal', 454 | list: require( '../test/youtube-search-results.json' ) 455 | } 456 | 457 | nfzf( opts, function ( r ) { 458 | log( r ) 459 | 460 | t.equal( r.selected.value, ' 161745 | Earmake - Sensual/ Sensual (Vapor) (9:34) | NewRetroWave', 'selected youtube-search-result' ) 461 | t.equal( opts.list[ 0 ], r.selected.value, 'opts.list still the same' ) 462 | 463 | const api = nfzf( opts, function ( r ) { 464 | log( r ) 465 | 466 | // a new result because the list was updated 467 | t.equal( r.selected.value, 'Apes', 'selected animals' ) 468 | t.equal( opts.list[ 0 ], 'Apes', 'opts.list was updated' ) 469 | 470 | t.equal( opts.update, api.update, 'opts.update === api.update' ) 471 | t.equal( opts, api, 'opts === api' ) 472 | } ) 473 | 474 | // update list to be of animals now instead 475 | api.update( require( '../test/animals.json' ) ) 476 | 477 | // prepare the same user input that will now select 478 | // a new result because the list was updated 479 | process.nextTick( function () { 480 | stdin.send( '\r' ) 481 | } ) 482 | } ) 483 | } ) 484 | 485 | test( 'test promise api update', async function ( t ) { 486 | t.plan( 4 ) 487 | 488 | // prepare mocked user input for nfzf 489 | process.nextTick( function () { 490 | stdin.send( '\r' ) 491 | } ) 492 | 493 | const opts = { 494 | list: require( '../test/youtube-search-results.json' ) 495 | } 496 | 497 | let r = await nfzf( opts ) 498 | log( r ) 499 | 500 | t.equal( r.selected.value, ' 161745 | Earmake - Sensual/ Sensual (Vapor) (9:34) | NewRetroWave', 'selected youtube-search-result' ) 501 | t.equal( opts.list[ 0 ], r.selected.value, 'opts.list still the same' ) 502 | 503 | setTimeout( function () { 504 | // update list to be of animals now instead 505 | opts.update( require( '../test/animals.json' ) ) 506 | 507 | // prepare the same user input that will now select 508 | // a new result because the list was updated 509 | process.nextTick( function () { 510 | stdin.send( '\r' ) 511 | } ) 512 | }, 100 ) 513 | 514 | r = await nfzf( opts ) 515 | log( r ) 516 | 517 | // a new result because the list was updated 518 | t.equal( r.selected.value, 'Apes', 'selected animals' ) 519 | t.equal( opts.list[ 0 ], 'Apes', 'opts.list was updated' ) 520 | } ) 521 | 522 | test( 'test api update as plain array', async function ( t ) { 523 | t.plan( 7 ) 524 | 525 | // prepare mocked user input for nfzf 526 | process.nextTick( function () { 527 | stdin.send( '\r' ) 528 | } ) 529 | 530 | // opts is now a plain JavaScript array, should work the same 531 | const opts = require( '../test/youtube-search-results.json' ) 532 | 533 | t.ok( Array.isArray( opts ), 'is plain array' ) 534 | 535 | nfzf( opts, function ( r ) { 536 | log( r ) 537 | 538 | t.equal( r.selected.value, ' 161745 | Earmake - Sensual/ Sensual (Vapor) (9:34) | NewRetroWave', 'selected youtube-search-result' ) 539 | t.equal( opts.list[ 0 ], r.selected.value, 'opts.list still the same' ) 540 | 541 | const api = nfzf( opts, function ( r ) { 542 | log( r ) 543 | 544 | // a new result because the list was updated 545 | t.equal( r.selected.value, 'Apes', 'selected animals' ) 546 | t.equal( opts.list[ 0 ], 'Apes', 'opts.list was updated' ) 547 | 548 | t.equal( opts.update, api.update, 'opts.update === api.update' ) 549 | t.equal( opts, api, 'opts === api' ) 550 | } ) 551 | 552 | // update list to be of animals now instead 553 | api.update( require( '../test/animals.json' ) ) 554 | 555 | // prepare the same user input that will now select 556 | // a new result because the list was updated 557 | process.nextTick( function () { 558 | stdin.send( '\r' ) 559 | } ) 560 | } ) 561 | } ) 562 | 563 | test( 'test promise api update as plain array', async function ( t ) { 564 | t.plan( 5 ) 565 | 566 | // prepare mocked user input for nfzf 567 | process.nextTick( function () { 568 | stdin.send( '\r' ) 569 | } ) 570 | 571 | // opts is now a plain JavaScript array, should work the same 572 | const opts = require( '../test/youtube-search-results.json' ) 573 | 574 | t.ok( Array.isArray( opts ), 'is plain array' ) 575 | 576 | let r = await nfzf( opts ) 577 | log( r ) 578 | 579 | t.equal( r.selected.value, ' 161745 | Earmake - Sensual/ Sensual (Vapor) (9:34) | NewRetroWave', 'selected youtube-search-result' ) 580 | t.equal( opts.list[ 0 ], r.selected.value, 'opts.list still the same' ) 581 | 582 | setTimeout( function () { 583 | // update list to be of animals now instead 584 | opts.update( require( '../test/animals.json' ) ) 585 | 586 | // prepare the same user input that will now select 587 | // a new result because the list was updated 588 | process.nextTick( function () { 589 | stdin.send( '\r' ) 590 | } ) 591 | }, 100 ) 592 | 593 | r = await nfzf( opts ) 594 | log( r ) 595 | 596 | // a new result because the list was updated 597 | t.equal( r.selected.value, 'Apes', 'selected animals' ) 598 | t.equal( opts.list[ 0 ], 'Apes', 'opts.list was updated' ) 599 | } ) 600 | 601 | test( 'test getInput callback', async function ( t ) { 602 | t.plan( 1 ) 603 | 604 | // prepare mocked user input for nfzf 605 | process.nextTick( function () { 606 | stdin.send( 'Mollie T. Muriel\r' ) 607 | } ) 608 | 609 | nfzf.getInput( 'Name: ', function ( r ) { 610 | log( r ) 611 | t.equal( r.query, 'Mollie T. Muriel' ) 612 | } ) 613 | } ) 614 | 615 | test( 'test opts.nolist, opts.label callback', async function ( t ) { 616 | t.plan( 1 ) 617 | 618 | // prepare mocked user input for nfzf 619 | process.nextTick( function () { 620 | stdin.send( 'Mollie T. Muriel\r' ) 621 | } ) 622 | 623 | const opts = { 624 | label: 'Name: ', 625 | nolist: true 626 | } 627 | 628 | nfzf( opts, function ( r ) { 629 | log( r ) 630 | t.equal( r.query, 'Mollie T. Muriel' ) 631 | } ) 632 | } ) 633 | 634 | test( 'test getInput promise', async function ( t ) { 635 | t.plan( 1 ) 636 | 637 | // prepare mocked user input for nfzf 638 | process.nextTick( function () { 639 | stdin.send( 'Mollie T. Muriel\r' ) 640 | } ) 641 | 642 | let r = await nfzf.getInput( 'Name: ' ) 643 | log( r ) 644 | 645 | t.equal( r.query, 'Mollie T. Muriel' ) 646 | } ) 647 | 648 | test( 'test opts.nolist, opts.label promise', async function ( t ) { 649 | t.plan( 1 ) 650 | 651 | // prepare mocked user input for nfzf 652 | process.nextTick( function () { 653 | stdin.send( 'Mollie T. Muriel\r' ) 654 | } ) 655 | 656 | const opts = { 657 | label: 'Name: ', 658 | nolist: true 659 | } 660 | 661 | let r = await nfzf( opts ) 662 | log( r ) 663 | 664 | t.equal( r.query, 'Mollie T. Muriel' ) 665 | } ) 666 | 667 | test( 'test prefilled query (-q, --query)', async function ( t ) { 668 | t.plan( 1 ) 669 | 670 | // prepare mocked user input for nfzf 671 | process.nextTick( function () { 672 | stdin.send( 'Mollie\r' ) 673 | } ) 674 | 675 | const opts = { 676 | label: 'Name: ', 677 | query: 'Apa the ', 678 | nolist: true 679 | } 680 | 681 | let r = await nfzf( opts ) 682 | log( r ) 683 | 684 | t.equal( r.query, 'Apa the Mollie' ) 685 | } ) 686 | 687 | test( 'test selectOne (-1, --select-1) with 1 result', async function ( t ) { 688 | const opts = { 689 | list: require( '../test/animals.json' ) , 690 | query: 'Gi', // should only match "Giraffe" 691 | mode: 'normal', 692 | selectOne: true, 693 | } 694 | const timeout = setTimeout(function () { 695 | t.fail('error: selectOne test timed out') 696 | process.exit(1) 697 | }, 100) 698 | const r = await nfzf( opts ) 699 | clearTimeout(timeout) 700 | log( r ) 701 | 702 | t.equal( r.selected.value, 'Giraffes', 'Giraffes found' ) 703 | t.equal( r.query, 'Gi', 'Gi was prefilled' ) 704 | t.end() 705 | } ) 706 | 707 | test( 'test selectOne (-1, --select-1) with 2 results', async function ( t ) { 708 | const opts = { 709 | list: require( '../test/animals.json' ) , 710 | query: 'do', // should match Dogs and Donkeys 711 | mode: 'normal', 712 | selectOne: true, 713 | } 714 | const timeout = setTimeout(function () { 715 | t.pass('did not select automatically because more than 1 result') 716 | 717 | process.nextTick( function () { 718 | // ctrl-j ( \x0A ) // select the second result (donkeys) 719 | stdin.send( '\x0A\r' ) 720 | } ) 721 | }, 100) 722 | const r = await nfzf( opts ) 723 | clearTimeout(timeout) 724 | log( r ) 725 | 726 | t.equal( r.selected.value, 'Donkeys', 'Donkeys found' ) 727 | t.equal( r.query, 'do', 'do was prefilled' ) 728 | t.end() 729 | } ) 730 | 731 | test( 'test selectOne (-1, --select-1) with no query', async function ( t ) { 732 | const opts = { 733 | list: require( '../test/animals.json' ) , 734 | mode: 'normal', 735 | selectOne: true, 736 | } 737 | const timeout = setTimeout(function () { 738 | t.pass('did not select automatically because more than 1 result') 739 | 740 | process.nextTick( function () { 741 | // ctrl-j ( \x0A ) // select the second result (donkeys) 742 | stdin.send( '\x0A\x0A\r' ) 743 | } ) 744 | }, 100) 745 | const r = await nfzf( opts ) 746 | clearTimeout(timeout) 747 | log( r ) 748 | 749 | t.equal( r.selected.value, 'Bats', 'Bats found' ) 750 | t.equal( r.query, '', 'no query found' ) 751 | t.end() 752 | } ) 753 | 754 | test( 'test selectOne (-1, --select-1) with no query but with a list of 1', async function ( t ) { 755 | const opts = { 756 | list: require( '../test/animals.json' ).slice(19, 20), // [ Flamingos ] 757 | mode: 'normal', 758 | selectOne: true, 759 | } 760 | const timeout = setTimeout(function () { 761 | t.fail('error: selectOne test timed out') 762 | process.exit(1) 763 | }, 100) 764 | const r = await nfzf( opts ) 765 | clearTimeout(timeout) 766 | log( r ) 767 | 768 | t.equal( r.selected.value, 'Flamingos', 'Flamingos found' ) 769 | t.equal( r.query, '', 'no query found' ) 770 | t.end() 771 | } ) 772 | 773 | test( 'test selectOne (-1, --select-1) -- should not auto select single match after initial load', async function ( t ) { 774 | t.plan(3) 775 | const opts = { 776 | list: require( '../test/animals.json' ), 777 | mode: 'normal', 778 | query: 'do', 779 | selectOne: true, 780 | } 781 | 782 | process.nextTick( function () { 783 | // add a 'g' to complete query for "Dog" which should change to match 1 784 | stdin.send( 'g' ) 785 | } ) 786 | const timeout = setTimeout(function () { 787 | t.pass('did not automatically select Dog after initial load') 788 | process.nextTick( function () { 789 | // ctrl-h ( \x08 ) ( backspace ) 790 | stdin.send( '\x08\x08\x08monk' ) 791 | 792 | process.nextTick( function () { 793 | stdin.send( 'eys\r' ) 794 | } ) 795 | } ) 796 | }, 100) 797 | const r = await nfzf( opts ) 798 | clearTimeout(timeout) 799 | log( r ) 800 | 801 | t.equal( r.selected.value, 'Monkeys', 'Monkeys found' ) 802 | t.equal( r.query, 'monkeys', 'full query OK' ) 803 | } ) 804 | 805 | // TODO test --keep-right somehow.. 806 | // TODO implement and test for --keep-left? 807 | // TODO add support for query or using '|' similar to fzf? 808 | -------------------------------------------------------------------------------- /test/youtube-search-results.json: -------------------------------------------------------------------------------- 1 | [ 2 | " 161745 | Earmake - Sensual/ Sensual (Vapor) (9:34) | NewRetroWave", 3 | " 793948 | Retro Grooves Mix Pt. 2 [Vaporwave/Nu Disco/Future Funk/Synthwave] (57:59) | Electronic Gems", 4 | " 18275822 | SPACE TRIP [ Chillwave - Synthwave - Retrowave Mix ] (37:27) | Asthenic", 5 | " 253379 | Paradise Magic Music - 'Back To The 80's' Best of Synthwave And Retro Electro Music (2:01:30) | Paradise Magic Music", 6 | " 513166 | Interstellar (Chillwave - Retrowave - Electronic Mix) (51:36) | SoulSearchAndDestroy", 7 | " 399838 | Aesthetic 80's Vaporwave/Chill Wave Music 2020! (1:11:25) | State", 8 | " 936460 | ~ N I G H T D R I V E ~ A Synthwave Music Video Mix [Chillwave - Retrowave] (45:31) | Euphoric Eugene", 9 | " 14452704 | Miami Nights 1984 - Accelerated (3:55) | NewRetroWave", 10 | " 0 | 🎧★★★ Synthwave - Retrowave - Retro Electro Livestream ★★★🎧 (0) | ThePrimeThanatos", 11 | " 506658 | 16-Bit Wave • Super Nintendo & Sega Genesis RetroWave Mix (38:13) | Axel Stone", 12 | " 46228 | NewRetroWave End of 2019 Mix - (The Future Beckons)| 1 Hour | Retrowave/ Dreamwave/ Outrun | (1:05:52) | NewRetroWave", 13 | " 424498 | Synthwave Workout Mix 💪 80's vibe Retrowave (49:49) | Gelbar", 14 | " 231069 | Simulations (Chillwave - Synthwave - Electro - Vaporwave Mix) (48:34) | SoulSearchAndDestroy", 15 | " 0 | AESTHETIC FM | Vaporwave Radio • Synthwave • Retro • Ambient • Live 24/7 🎧 (0) | Smile Machine Music", 16 | " 10174789 | SPACE TRIP II [ Chillwave - Synthwave - Retrowave Mix ] (45:56) | Asthenic", 17 | " 668024 | CYBER DREAM [Chillwave - Synthwave Mix] (2:00:12) | Smooth S o u n d s", 18 | " 431500 | The Best of NewRetroWave | October 2018 | A Retrowave Mixtape (1:02:16) | NewRetroWave", 19 | " 61726630 | HOME - Resonance (3:33) | Electronic Gems", 20 | " 10263411 | Zombie Hyperdrive - Red Eyes (4:27) | NewRetroWave", 21 | " 2587609 | 【癒し効果】天国や異世界で流れる、魔法の音楽【作業用BGM】~ Fantasy Music ~ (43:58) | xxxJunaJunaxxx", 22 | " 164807024 | SEKAI NO OWARI「RPG」 (4:56) | SEKAI NO OWARI", 23 | " 2078115 | 【癒し効果】幻想的な世界、ファンタジー系音楽【作業用BGM���~ Fantastic music ~ (45:17) | xxxJunaJunaxxx", 24 | " 427791 | 【幻想的BGM】ファンタジーの世界感溢れる音楽 ~ケルト風・ハープ・異世界イメージのBGM BGM - ミュージック - 音楽 ユーチューブ - ベス", 25 | "ト 音楽 - リラックス 音楽 (1:37:42) | Demetria Sterrett", 26 | " 26619299 | リラックス効果ですぐに眠くなる魔法の音楽【5分聞いているうち眠くなります】 (1:54:21) | Marcene Josue", 27 | " 776714 | 踊りたくなる 楽しい ケルト音楽集 【作業用BGM】Celtic Music (22:40) | BMG JP Production", 28 | " 822881 | 【癒し音楽】幻想的な世界に浸る、和風BGM ~ Traditional Japanese Music ~ (1:00:43) | BGM maker", 29 | " 3794237 | 【80秒で眠れます】地球上で最も眠れる睡眠音楽sleeping (4:56:42) | Relaxing CTR", 30 | " 2596553 | 世界で数人しか歌えないオペラ「魔笛」夜の女王のアリア (3:39) | はっちね", 31 | " 2955 | 【雪BGM】銀世界の京都にぴったりな音楽 (1:01:33) | HinataShin", 32 | " 2032411 | 睡眠音楽【ダイヤモンド賞】90秒以内に確実に眠れる睡眠専用音楽 sleeping (9:53:38) | Relaxing CTR", 33 | " 261364 | 幻想的な世界に浸る、癒し音楽【作業用・睡眠用BGM】 (1:05:46) | BGM maker", 34 | " 443013 | 【癒し効果】天空の島で流れる、伝説のファンタジー音楽【作業用BGM】~ Sky Island's Fantastic Music ~ (32:38) | xxxJunaJunaxxx", 35 | " 2911 | アメリカインディアン部族世界音楽スピリチュアルフルートリラックスドラムヒーリング瞑想儀式リラックス American World Music flute drum healing meditation (1:22:04) | RelaxingMusicTVJapan", 36 | " 203390 | 世界で最も美しい声!この女の子は泣き出しました (7:42) | Izabelle DeJavu69", 37 | " 204449 | 幻想的な世界に浸る、美しく切ないピアノ音楽【癒しBGM】 (3:06:54) | BGM maker", 38 | " 181461 | 【第三次世界大戦】トランプ『第七の封印を解く』真の目的とは? (4:14) | たっくーTVれいでぃお", 39 | " 125215 | 【鷲崎健】三角コーナー「世界の音楽音源」感激 リスナーの本気【神回】 (19:44) | 鷲崎健のしゃべり場チャンネル" 40 | ] 41 | -------------------------------------------------------------------------------- /usage.txt: -------------------------------------------------------------------------------- 1 | Usage: nfzf [options] 2 | 3 | Options: 4 | -n, --normal, --exact Use normal/exact text matching instead of fuzzy matching. 5 | -h, --help Display help (this text) 6 | -q, --query Start search with the given query prefilled. 7 | -1, --select-1 If there is only one match for the initial query 8 | (--query), do not start interactive finder and 9 | automatically select the only match 10 | --keep-right Fit to show end of result text (toggled with ctrl-e) 11 | --height % of screen space to use to for results (default 6 rows) 12 | 13 | Keyboard: 14 | switch between search modes (fuzzy, normal/exact) 15 | down,, scroll down 16 | up,, scroll up 17 | scroll down by page size 18 | scroll up by page size 19 | jump to start of input 20 | jump to end of input (and toggles --keep-right) 21 | ,, cancel 22 | , trigger callback/promise with current selection and exit 23 | delete last word from input 24 | jump back a word 25 | jump forward a word 26 | delete last input character 27 | 28 | Examples: 29 | find . | nfzf 30 | cat log.txt | nfzf -n 31 | mpv "`find ~/Dropbox/music/ | nfzf --exact --keep-right`" 32 | --------------------------------------------------------------------------------