先看下目标
主要用 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 比较有意思。可以看看效果然后想想怎么实现。