实现一个 inquirer 里面的 checkbox
发布于 6 年前 作者 yviscool 4263 次浏览 来自 分享

先看下目标 深度录屏_选择区域_20180701181546.gif

主要用 node 自带的 realine 以及第三方库 figures 来实现。

var param = {
    message: 'select toppings',
    choices: [
        {name: 'Pepperoni'},
        {name: 'Ham'},
        {name: 'Ground Meat'},
        {name: 'Bacon'},
        {name: '张三'},
        {name: '李四'},
        {name: '狗五'},
        {name: 'hahah', },
    ],
}

先来实现最简单的展示。

var figures = require('figures');
var readline = require('readline');

class Checkbox {
    constructor(quesiton) {
        this.rl = readline.createInterface({
            terminal: true,
            input: process.stdin,
            output: process.stdout,
        })

        this.opt = Object.assign({}, quesiton);
        this.opt.choices = this.opt.choices.map((choice) => ({name: choice.name}))
    }

    run() {
        this.render();
    }

    render() {
        var message = this.opt.message;
        var choiceStr = this.renderChoices()
        message += 
            '\n'      + 
            choiceStr +
            '\n'      + 
            '(Move up and down to reveal more choices)';

        this.realRender(message)
    }

    renderChoices() {

        var choices = this.opt.choices;
        var output = '';

        choices.forEach((choice, i) => {
            output +=
                ( figures.radioOff ) +
                '  '                 + 
                choice.name;
            output += '\n';
        });

        return output.replace(/\n$/, '');   // 替换最后一个换行
    }

    realRender(message) {
        this.rl.output.write(message);
    }
}

new Checkbox(param).run()

很简单把每个 choices 转化成一打对象。遍历输出即可。 圈圈圆圆圈圈 figures.radioOff 来表示。

箭头实现

箭头表示当前行,可以用 figures.pointer 符号来展示。

constructor(quesiton) {
    // add
    this.pointer = 0;
}

在构造函数加上一个 pointer 属性, 表示当前箭头所在的行。 因为我们现在移动不了,所以当前箭头只能在第一行。然后 renderChoice 遍历的时候加上即可。

renderChoices() {
    var choices = this.opt.choices;
    var output = '';
    
    // add
    var pointer = this.pointer;
    // add end
  
    choices.forEach((choice, i) => {
        // add
        var isSelected = i === pointer;
        output += isSelected ? figures.pointer : ' ';
        // add end
        output +=  
            ( figures.radioOff ) +
            '  '                 + 
            choice.name;
        output += '\n';
    });

    return output.replace(/\n$/, ''); 
}

选中效果

单选 spapce,全选 a , 反选 i

选中就是让 figures.radioOff -> figures.radioOn , 然后重新 render 一次。

怎么知道自己被选中了?

给 choice 加个 checked 属性。renderChoice 加上即可。

在这之前有个问题,我怎么知道你按下的是哪颗键?

process.stdin 有一个 keypress 事件,回调能获取到对应属性。

class Checkbox{
    constructor(){
        ...
        this.opt.choices = this.opt.choices.map((choice) => ({
            name: choice.name,
            checked: choice.checked, // add
        }))
        ...
    }
    run() {
        this.listen() // add
        this.render();
    }
    renderChoices() {
            var choices = this.opt.choices;
            var output = '';

            var pointer = this.pointer;
            choices.forEach((choice, i) => {
                var isSelected = i === pointer;
                output += isSelected ? figures.pointer : ' ';
                output +=
                    (
                        choice.checked ?   // add
                        figures.radioOn :   // add
                        figures.radioOff
                    ) +
                    '  ' +
                    choice.name;
                output += '\n';
            });

            return output.replace(/\n$/, '');
    }
    // add
    listen(){
        var self = this;
        process.stdin.on('keypress', (name, key) => {

            if (key.name === 'space') {
                var item = self.opt.choices[self.pointer];
                item && (self.opt.choices[self.pointer].checked = !item.checked);
                self.render();
            }

            if (key.name === 'a') {
                var shouldBeChecked = !!self.opt.choices.find(choice => {
                    return !choice.checked;
                });
                self.opt.choices.forEach(choice => {
                    choice.checked = shouldBeChecked;
                });

                self.render();
            }
            if (key.name === 'i') {
                self.opt.choices.forEach(choice => {
                    choice.checked = !choice.checked;
                });
                self.render();
            }
        })
    }
}

满心欢喜的运行后,你会发现,竟然会多出好几行一模一样的。 因为我们光标是在最下面,所以渲染的时候会从那一行开始。造成了这个情况。 解决办法: 清除,然后重新输出。 那么问题来了,怎么清除? inquire 使用 ansi 指令来清除。node 有对应的库 ansi-escapes readline 自身也提供了几个方法供我们使用,moveCursor clearLine clearScreenDown

目前为止的代码

class Checkbox {
    constructor(quesiton) {
        this.rl = readline.createInterface({
            terminal: true,
            input: process.stdin,
            output: process.stdout,
        })

        this.opt = Object.assign({}, quesiton);

        this.opt.choices = this.opt.choices.map((choice) => ({
            name: choice.name,
            checked: choice.checked,
        }))
      
        this.firstRender = true; // add
        this.pointer = 0;
    }

    run() {
        this.listen();
        this.render();
      
        this.firstRender = false;// add
    }

    render() {
        var message = this.opt.message;
        var choiceStr = this.renderChoices()
        message +=
            '\n' +
            choiceStr +
            '\n' +
            '(Move up and down to reveal more choices)';

        this.realRender(message)
    }
    renderChoices() {

        var choices = this.opt.choices;
        var output = '';

        var pointer = this.pointer;
        choices.forEach((choice, i) => {
            var isSelected = i === pointer;
            output += isSelected ? figures.pointer : ' ';
            output +=
                (
                    choice.checked 
                    ? figures.radioOn
                    : figures.radioOff
                )    +
                '  ' +
                choice.name;
            output += '\n';
        });

        return output.replace(/\n$/, '');
    }

    realRender(message) {
        var line = message.split('\n');
        // 获取最后一条提示,以便获取他的长度。好让光标移动到相对位置
        var lastLine = line[line.length - 1];  
        readline.moveCursor(process.stdout, - lastLine.length - 1, !this.firstRender ? -line.length + 1 : 0)
        readline.clearLine(process.stdout, 0); // 清除整行 
        readline.clearScreenDown(process.stdout); 
        this.rl.output.write(message);
    }

    listen() {
        var self = this;
        process.stdin.on('keypress', (name, key) => {
            if (key.name === 'space') {
                var item = self.opt.choices[self.pointer];
                item && (self.opt.choices[self.pointer].checked = !item.checked);
                self.render();
            }

            if (key.name === 'a') {
                var shouldBeChecked = !!self.opt.choices.find(choice => {
                    return !choice.checked;
                });
                self.opt.choices.forEach(choice => {
                    choice.checked = shouldBeChecked;
                });

                self.render();
            }
            if (key.name === 'i') {
                self.opt.choices.forEach(choice => {
                    choice.checked = !choice.checked;
                });
                self.render();
            }
        })
    }
}

箭头上下移动

前面我们讲过了, pointer 代表当前行,箭头移动就是让 pointer + 1 -1 置零。。

process.stdin.on('keypress', (name, key) => {
    // add
    var len = self.opt.choices.length;
    if (key.name === 'up' || key.name === 'k' || (key.name === 'p' && key.ctrl)) {
        self.pointer = self.pointer > 0 ? self.pointer - 1 : len - 1;
        self.render();
    }

    if (key.name === 'down' || key.name === 'j' || (key.name === 'n' && key.ctrl)) {
        self.pointer = self.pointer < len - 1 ? self.pointer + 1 : 0;
        self.render();
    }
})

分页

我们的低配版 checkbox 已经完成的差不多了, 能上下移动,能选中。恩 挺不错了。

如果你仔细观看会发现,inquirer 的 checkbox 移动的时候有个特点。

向上移动,数据肯定动。开始向下移动的时候数据不动,

只有向下移动到或者上下移动到列表中间位置时,箭头就固定了。

道理我都懂,怎么做到的?

这个分页比较难做,inquirer 把列表复制成三份,截取 pagesize 。

为什么复制成三份? 向上移动,和向下移动时要显示循环部分,用一个数组截取追加是十分麻烦的。

对三份 splice ,这样就很方便,只需要考虑截取的位置即可。

class Checkbox {
    constructor(quesiton) {
		...
        this.p = 0;  // add 
        this.lastIndex = 0; //add 
    }
    render() {
		...
        message +=
            '\n' +
            this.paginate(choiceStr) +         //add 
            '\n' +
            '(Move up and down to reveal more choices)';

        this.realRender(message)
    }
  	// add
    paginate(message, pageSize = 7) {
        var pointer = this.pointer;
        var middleOflist = Math.floor(pageSize / 2);
        var lines = message.split('\n');
        var infinite = [...lines, ...lines, ...lines];
		if (lines.length <= pageSize) {
      		return message;
	    }
        if (
            (
                this.lastIndex < pointer && !this.up ||
                this.lastIndex > pointer && !this.up
            ) &&
            this.p < middleOflist
        ) {
            this.p += 1;
        }
        this.lastIndex = pointer;
        var index = lines.length + pointer - this.p;
        return infinite.splice(index, pageSize).join('\n')
    }
    listen() {
		...
        process.stdin.on('keypress', (name, key) => {	
          	...
            if (key.name === 'up' || key.name === 'k' || (key.name === 'p' && key.ctrl)) {
                self.pointer = self.pointer > 0 ? self.pointer - 1 : len - 1;
                self.up = true; // add
                self.render();
                self.up = false;  // add 
            }
			...
        })
    }
}

这个位置截取比较复杂,是不在 inquirer checkbox 里面的,也就是说我们扩展 checkbox 的时候不用考虑这个实现。

扩展

中配版的 checkbox 完成了。但还有很多可以完善的地方。

firstRender 的时候添加提示, 给 message 上色. 默认 default 选中。光标隐藏。

分割线类型的 choice, disabled 的 choice ,这样当前箭头就不能在这一行,而且移动的时候就必须跳过(用偏移量和真实choices实现)。

还有应答部分,就是回车的时候把选中取出来(监听 pipe 事件)。进行 filter,validate.

inquirer 最底下有一个插件 checkbox-plus 比较有意思。可以看看效果然后想想怎么实现。

回到顶部