【blessed.js】tabキーで要素間を移動する方法について

## やりたいこと

[blessed.js](https://github.com/chjj/blessed)でWebブラウザのように<kbd>tab</kbd>キーで各要素間を移動したい。

## formを使用する場合

formを作成する際に、`keys`オプションに`true`を指定するだけでよい。

```javascript
const blessed = require('neo-blessed');

const screen = blessed.screen({
  autopadding: true,
  smartCSR: true,
  fullUnicode: true,
});

const form = blessed.form({
  parent: screen,
  keys: true // trueを指定しておくと、tabキーの押下を監視して、要素間を移動してくれる。
});

const name = blessed.textbox({
  parent: form,
  position: { width: 16, height: 3 },
  inputOnFocus: true,
  border: 'line',
  name: 'name'
});

const submit = blessed.button({
  parent: form,
  position: { top: 3, height: 1, width: 8 },
  name: 'submit',
  content: '{center}Submit{/}',
  tags: true,
  style: {
    bg: 'gray',
    focus: {
      bg: 'blue'
    }
  }
});

screen.key(['q'], () => process.exit(0));
screen.render();
```

## formを使用しない場合

`screen.focusNext`メソッドを呼ぶことで各要素間を移動できるので、<kbd>tab</kbd>キーの押下時に呼びます。

ただし、textboxやtextareaのような入力要素があるとうまく動かないため、一工夫する必要があります。

```javascript
const blessed = require('neo-blessed');

const screen = blessed.screen({
  autopadding: true,
  smartCSR: true,
  fullUnicode: true,
  debug: true
});

// (1)
const rooms = blessed.list({
  parent: screen,
  items: ['room-1', 'room-2', 'room-3'],
  border: 'line',
  keyable: true,
  position: { height: 8, width: '20%' },
  vi: true,
  style: {
    focus: { border: { fg: 'yellow' } },
    selected: { bg: 'blue' }
  }
});

const messages = blessed.log({
  parent: screen,
  border: 'line',
  vi: true,
  keyable: true,
  position: { left: '20%', width: '80%', height: '80%' },
  style: {
    focus: { border: { fg: 'yellow' } },
  }
});

const messageInput = blessed.textarea({
  parent: screen,
  position: { left: '20%', top: '80%', height: '15%' },
  inputOnFocus: true,
  keyable: true,
  keys: true,
  vi: true,
  border: 'line',
  name: 'name',
  style: {
    focus: { border: { fg: 'yellow' } }
  }
});

const submit = blessed.button({
  parent: screen,
  position: { left: '20%', top: '95%', height: 1, width: 8 },
  name: 'submit',
  keyable: true,
  content: '{center}Submit{/}',
  tags: true,
  style: {
    bg: 'gray',
    focus: {
      bg: 'blue'
    }
  }
});

// (2)
screen.focusNext();

// (3)
screen.on('element keypress', (el, ch, key) => {
  if (key.full === 'tab') {
    focusNext(screen);
  }
});

function focusNext(screen) {
  const isInputElement = typeof screen.focused.cancel === 'function';
  if (isInputElement) {
    const nextIndex = screen.children.indexOf(screen.focused) + 1;
    const nextElement = screen.children[nextIndex];
    screen.focused.setValue(removeLastChar(screen.focused.getValue()));
    screen.focused.cancel();  
    screen.focusPush(nextElement);
  } else {
    screen.focusNext();
  }
}

function removeLastChar(s) {
  return s.substring(0, s.length - 1);
}

screen.key(['q'], () => process.exit(0));
screen.render();
```

### 解説

##### (1) フォーカスを当てたい各要素に対して、`keyable: true`を指定する。

```javascript
const rooms = blessed.list({
  parent: screen,
  items: ['room-1', 'room-2', 'room-3'],
  border: 'line',
  keyable: true,
  position: { height: 8, width: '20%' },
  vi: true,
  style: {
    focus: { border: { fg: 'yellow' } },
    selected: { bg: 'blue' }
  }
});

const messages = blessed.log({
  parent: screen,
  border: 'line',
  vi: true,
  keyable: true,
  position: { left: '20%', width: '80%', height: '80%' },
  style: {
    focus: { border: { fg: 'yellow' } },
  }
});
...
```

##### (2) 先頭要素にフォーカスを当てる。

```javascript
// (2)
screen.focusNext();
```

##### (3) <kbd>tab</kbd>キーの押下を監視して、次の要素へフォーカスを移す。

`screeon.on('element keypress', callback)`でスクリーン配下の要素のキー入力を監視できます。

入力されたキーは、`callback`の第３引数から調べることができます。

```javascript
screen.on('element keypress', (el, ch, key) => {
  if (key.full === 'tab') {
    focusNext(screen);
  }
});
```

以下の関数で、次の要素へフォーカスを移す処理を行っています。

textareaやtextbox等の入力要素と非入力要素で処理を分岐させる必要があります。

入力要素の判定は、`cancel`メソッドの有無で行っています。

```javascript
function focusNext(screen) {
  const isInputElement = typeof screen.focused.cancel === 'function';
  if (isInputElement) {
    const nextIndex = screen.children.indexOf(screen.focused) + 1;
    const nextElement = screen.children[nextIndex];
    screen.focused.setValue(removeLastChar(screen.focused.getValue())); // tab文字が入力されてしまうため、削除しています。
    screen.focused.cancel(); // 次の要素へ映るため、入力をキャンセルする。
    screen.focusPush(nextElement); // screen.focusPushを呼ぶことで、指定した要素がフォーカスされます。
  } else {
    screen.focusNext(); // 非入力要素に対しては、単純にscreen.focusNextを呼ぶだけでよい。
  }
}

function removeLastChar(s) {
  return s.substring(0, s.length - 1);
}
```

## 参考

https://github.com/embark-framework/neo-blessed