Stimulator

機械学習とか好きな技術話とかエンジニア的な話とかを書く

Python Prompt Toolkitで対話的な選択コマンドを作る

- はじめに -

この記事は、Xonsh Advent Calendar 2017 - Qiita 23日目の記事です。

遅れ気味ですが、Python Prompt Toolkit (以下、ptk)を利用して、対話型のセレクタ(上下矢印で回答を選択できるやつ)を作りたいなと思います。

名前が分からないんですが、selectコマンドみたいなやつです。

f:id:vaaaaaanquish:20171225155520g:plain:w400:h170


ptkが扱えれば、xonshにも安易に応用できるため、xonshを扱う上で覚えておきたいアイデアです。


追記:2018/11/23
ptk 2.xでは、本記事のコードが動作しないため、移行のための記事とrepositoryを公開しています。
vaaaaaanquish.hatenablog.com


 

- 選択コマンド -

Pythonで擬似的なselectコマンドを作るには、printとreadlineを組み合わせて作る方法や、ptk等のコンソール生成moduleを利用する方法があります。

python-prompt-toolkitリポジトリの中にも以下のように、選択できるメニューを生成するにはというissueが立っています、
How do I create a menu? · Issue #281 · prompt-toolkit/python-prompt-toolkit · GitHub

上記issueに対して、以下でsampleコードが示されています。
sample for custom control based on TokenListControl by markfink · Pull Request #427 · prompt-toolkit/python-prompt-toolkit · GitHub
sample for custom control based on TokenListControl by markfink · Pull Request #427 · prompt-toolkit/python-prompt-toolkit · GitHub


具体的には、ptkのfull-screen-layout APIを利用して、選択用の画面を作ってあげて、その中でkeyバインドで操作できるようにするというものです。
https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/examples/full-screen-layout.py

full_screen_appのdocsは以下
python-prompt-toolkit/full_screen_apps.rst at master · prompt-toolkit/python-prompt-toolkit · GitHub


 
以下に、上記を参考にしながら対話selectorによってコマンドを選択、コピー、実行するスクリプトを示します。
prompt_toolkitに加えて、クリップボードを利用するためにpyperclipモジュールを使っているので、必要に応じてpipすると良いです。

# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from prompt_toolkit.application import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.enums import DEFAULT_BUFFER
from prompt_toolkit.interface import CommandLineInterface # ptk 1.x
from prompt_toolkit.key_binding.manager import KeyBindingManager # ptk 1.x
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout.containers import Window
from prompt_toolkit.layout.controls import BufferControl
from prompt_toolkit.layout.margins import ScrollbarMargin
from prompt_toolkit.shortcuts import create_eventloop
from prompt_toolkit.filters import IsDone
from prompt_toolkit.layout.controls import TokenListControl
from prompt_toolkit.layout.containers import ConditionalContainer, ScrollOffsets, VSplit, HSplit
from prompt_toolkit.layout.screen import Char
from prompt_toolkit.layout.dimension import LayoutDimension as D
from prompt_toolkit.mouse_events import MouseEventTypes
from prompt_toolkit.token import Token
from prompt_toolkit.styles import style_from_dict

# セレクトアイテム
choices = ['ls', 'ifconfig', 'pwd', 'who']
# 質問文
string_query = ' Command Select '
# 操作説明
inst = ' (Use arrow keys)'

# 選択した際に実行する関数
def selected_item(text):
    # クリップボードにコピー
    # 必要 : pip install pyperclip
    import pyperclip
    pyperclip.copy(text)
    # Command実行
    import subprocess
    res = subprocess.call(text)
    print(res)


# 以下セレクタ実装

# マウス操作が入った時に落とすためのデコレータ
def if_mousedown(handler):
    def handle_if_mouse_down(cli, mouse_event):
        if mouse_event.event_type == MouseEventTypes.MOUSE_DOWN:
            return handler(cli, mouse_event)
        else:
            return NotImplemented
    return handle_if_mouse_down

# セレクトアイテムを受け取って選択させるための制御Class
# controlsのTokenListControlを継承する
class InquirerControl(TokenListControl):
    selected_option_index = 0
    answered = False
    choices = []

    def __init__(self, choices, **kwargs):
        self.choices = choices
        super(InquirerControl, self).__init__(self._get_choice_tokens, **kwargs)

    @property
    def choice_count(self):
        return len(self.choices)

    def _get_choice_tokens(self, cli):
        tokens = []
        T = Token

        def append(index, label):
            selected = (index == self.selected_option_index)

            @if_mousedown
            def select_item(cli, mouse_event):
                # bind option with this index to mouse event
                self.selected_option_index = index
                self.answered = True
                cli.set_return_value(None)

            token = T.Selected if selected else T
            tokens.append((T.Selected if selected else T, ' > ' if selected else '   '))
            if selected:
                tokens.append((Token.SetCursorPosition, ''))
            tokens.append((T.Selected if selected else T, '%-24s' % label, select_item))
            tokens.append((T, '\n'))

        for i, choice in enumerate(self.choices):
            append(i, choice)
        tokens.pop()  # Remove last newline.
        return tokens

    def get_selection(self):
        return self.choices[self.selected_option_index]

# インスタンス生成
ic = InquirerControl(choices)

# prompt情報の取得
def get_prompt_tokens(cli):
    tokens = []
    T = Token
    tokens.append((Token.QuestionMark, '?'))
    tokens.append((Token.Question, string_query))
    if ic.answered:
        # 選択した値を取得
        tokens.append((Token.Answer, ' ' + ic.get_selection()))
        # 任意関数実行
        selected_item(ic.get_selection())
    else:
        tokens.append((Token.Instruction, inst))
    return tokens


# 疑似レイアウトをトークンリストから設定
layout = HSplit([
    Window(height=D.exact(1),
           content=TokenListControl(get_prompt_tokens, align_center=False)),
    ConditionalContainer(
        Window(
            ic,
            width=D.exact(43),
            height=D(min=3),
            scroll_offsets=ScrollOffsets(top=1, bottom=1)
        ),
        filter=~IsDone())])

# keyバインディングの設定
manager = KeyBindingManager.for_prompt()
@manager.registry.add_binding(Keys.ControlQ, eager=True)
@manager.registry.add_binding(Keys.ControlC, eager=True)
def _(event):
    event.cli.set_return_value(None)
@manager.registry.add_binding(Keys.Down, eager=True)
def move_cursor_down(event):
    ic.selected_option_index = (
        (ic.selected_option_index + 1) % ic.choice_count)
@manager.registry.add_binding(Keys.Up, eager=True)
def move_cursor_up(event):
    ic.selected_option_index = (
        (ic.selected_option_index - 1) % ic.choice_count)
@manager.registry.add_binding(Keys.Enter, eager=True)
def set_answer(event):
    ic.answered = True
    event.cli.set_return_value(None)

# Color Font Style
inquirer_style = style_from_dict({
    Token.QuestionMark: '#5F819D',
    Token.Selected: '#FF9D00',
    Token.Instruction: '',
    Token.Answer: '#FF9D00 bold',
    Token.Question: 'bold',
})

# layoutを選択モデルにしたAppを設定
app = Application(
    layout=layout,
    key_bindings_registry=manager.registry,
    mouse_support=True,
    style=inquirer_style
)

# eventloopで実行、終了時close
eventloop = create_eventloop()
try:
    cli = CommandLineInterface(application=app, eventloop=eventloop)
    cli.run(reset_current_buffer=False)
finally:
    eventloop.close()

 
実行してみます。
f:id:vaaaaaanquish:20171225155917p:plain

概ね良さそうです。
様々な関数と組み合わせられるので、疑似pecoみたいな感じの物も実装できそうです。


 

- おわりに -

Python Prompt Toolkitを利用したセレクタの実装でした。

もちろん、ptkを利用しているxonsh上でも動くので、xonshで自作スニペットセレクタを作ったり、ssh先を選んだりする際に表示させるようなものが作れそうだなと思っています。

次の記事では、これを利用したxonsh関数を作って書きたいと思います。

 
Xonsh Advent Calendar 2017 - Qiita


追記:
この記事を利用してxonshでselectコマンドを作る記事を書きました
vaaaaaanquish.hatenablog.com