pythonのCLIでリスト選択のUIにemacs/shell風のキーバインドを追加してみる

なんとなくCLIのツールを作ろうかと思って色々試してみていた。python-inquirerというものを発見したので使ってみていた。まだこれを常用するかは決めていない(元々inquirerはnode.js用のライブラリだった模様。それのpython版がこれ。yeomanなどに使われていたらしいのでちょっと古めのライブラリかも?ちなみにinquirerとは別にenquirerというものもありnpmはむずかしい)。

とりあえず触ってみてemacs風のキーバインドで操作したくなったのでコードを改変してみた。

インストール

pip install inquirer

で使えるようになる。

リスト選択UI

試しにサンプルの通りにリスト用のUIのAPIを作ってみる(examplesから拝借)

import inquirer


questions = [
    inquirer.List(
        "size",
        message="What size do you need?",
        choices=["Jumbo", "Large", "Standard", "Medium", "Small", "Micro"],
        carousel=True,
    )
]

answers = inquirer.prompt(questions)
print(answers)

こんな感じの表示。

list

上下のカーソルキーで選べる。

emacs風のキーバインドで動かしたい

ところで便利は便利なのだけど、リスト選択でemacs風のキーバインドが使いたくなる。具体的には以下の様な操作。

  • Ctrl f もしくは Ctrl n で次へ、Ctrl b もしくは Ctrl p で前へ
  • Ctrl a で先頭(行末)に Ctrl e で末尾(行末)に

これは以下のようにちょっと頑張ってあげるとできる。

import inquirer
import string
from inquirer import themes
from inquirer.render.console import ConsoleRender, List
from readchar import key


class ExtendedConsoleRender(ConsoleRender):
    def render_factory(self, question_type):
        if question_type == "list":
            return ExtendedList
        return super().render_factory(question_type)

# CTRL_MAP["B"]はkey.CTRL_Bでも良いのだけれどAからZまで全部は定義されていなかったので
CTRL_MAP = {c: chr(i) for i, c in enumerate(string.ascii_uppercase, 1)}


class ExtendedList(List):
    def process_input(self, pressed):
        # emacs style
        if pressed in (CTRL_MAP["B"], CTRL_MAP["P"]):
            pressed = key.UP
        elif pressed in (CTRL_MAP["F"], CTRL_MAP["N"]):
            pressed = key.DOWN
        elif pressed == CTRL_MAP["G"]:
            pressed = CTRL_MAP["C"]
        elif pressed == CTRL_MAP["A"]:
            self.current = 0
            return
        elif pressed == CTRL_MAP["G"]:
            self.current = len(self.question.choices) - 1
            return

        # vi style
        if pressed in ("k", "h"):
            pressed = key.UP
        elif pressed in ("j", "l"):
            pressed = key.DOWN
        elif pressed == "q":
            pressed = key.CTRL_C

        # effect (rendering)
        super().process_input(pressed)


questions = [
    inquirer.List(
        "size",
        message="What size do you need?",
        choices=["Jumbo", "Large", "Standard", "Medium", "Small", "Micro"],
        carousel=True,
    )
]

answers = inquirer.prompt(questions, render=ExtendedConsoleRender(theme=themes.GreenPassion()))
print(answers)

ちなみにモンキーパッチでも対応可。

むずかしいはなし

さっきのような感じで動くものはできたんだけれど、こういうCLI用のライブラリにどう対応するかと言うのがむずかしいなーと思ったりした。

  • 使うところのすべてで拡張部分コピペしたくない
  • 新たに小さくwrapperライブラリを用意したくない
  • ライブラリにPR出そうにもnode.js用のライブラリのport、独自の機能にはしたくない

という感じで悩ましい。