TIL

Today I Learned. 知ったこと、学んだことを書いていく

prompt-toolkitで検索ツールバーの実装と、インクリメンタル検索の実装した - Python

いろいろやって、できたから、まとめてみる

ソースはGistにあげた

https://gist.github.com/tamago324/806aac08455412d06b48b7022f5b660f

2018-11-16 00:21:49 編集:カレントバッファで検索結果の移動をするように変更

単純な検索機能を実装する

まずは、単純な検索を実装する

  • /で検索の文字入力開始
  • ?で検索の文字入力開始(逆順に検索)
  • Enterで検索実行
  • c-cで検索入力中止
  • 検索文字が0文字になったら、検索入力中止
  • nで次の検索位置
  • Nで逆順に次の検索位置

今は、ReadOnlyの想定で作ったから、nとかNとか入力できないのは気にしない

from prompt_toolkit.application import Application
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.filters import Condition, is_searching
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.bindings import search
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import BufferControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.widgets.toolbars import SearchToolbar


text = "\n".join([str(i) for i in range(100)])

# vi_mode=Trueとすると`/`と`?`で表示されるようになる
search_toolbar = SearchToolbar(vi_mode=True)
control = BufferControl(
    Buffer(document=Document(text), read_only=True),
    search_buffer_control=search_toolbar.control,
)


# ウィンドウ
body = HSplit([Window(control), search_toolbar])

# キーバインディング
kb = KeyBindings()


@kb.add("q")
def _(event):
    event.app.exit()


# 検索ツールバーの文字が空になったかどうか
@Condition
def search_buffer_is_empty():
    " Returns True when the search buffer is empty. "
    return get_app().current_buffer.text == ""


kb.add("/")(search.start_forward_incremental_search)
kb.add("?")(search.start_reverse_incremental_search)
kb.add("enter", filter=is_searching)(search.accept_search)
kb.add("c-c")(search.abort_search)
kb.add("backspace", filter=search_buffer_is_empty)(search.abort_search)


@kb.add("n", filter=~is_searching)
def _(event):
    search_state = get_app().current_search_state
    current_buffer = get_app().current_buffer

    cursor_position = current_buffer.get_search_position(
        search_state, include_current_position=False
    )
    current_buffer.cursor_position = cursor_position

@kb.add("N", filter=~is_searching)
def _(event):
    search_state = get_app().current_search_state
    current_buffer = get_app().current_buffer

    cursor_position = current_buffer.get_search_position(
        ~search_state, include_current_position=False
    )
    current_buffer.cursor_position = cursor_position


app = Application(layout=Layout(body), key_bindings=kb, full_screen=True)

app.run()

ptkのソースのprompt_toolkit.key_binging.key_bingings.vi.load_vi_search_bindingsとか、サンプルとかがすごい参考になった

https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/key_binding/bindings/vi.py#L1815-L1853

https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/examples/full-screen/text-editor.py#L250-L255

実装手順

SearchToolBarインスタンスつくって、BufferControlsearch_buffer_controlに作ったインスタンスcontrolを渡す。(検索ツールバーの紐付け?)

search_toolbar = SearchToolbar(vi_mode=True)
control = BufferControl(
    Buffer(document=Document(text), read_only=True),
    search_buffer_control=search_toolbar.control,
)

/で前方検索の開始

kb.add("/")(search.start_forward_incremental_search)

enterで検索実行

kb.add("enter", filter=is_searching)(search.accept_search)

nNで次の検索結果に移動

@kb.add("n", filter=~is_searching)
def _(event):
    search_state = get_app().current_search_state
    current_buffer = get_app().current_buffer

    cursor_position = current_buffer.get_search_position(
        search_state, include_current_position=False
    )
    current_buffer.cursor_position = cursor_position

@kb.add("N", filter=~is_searching)
def _(event):
    search_state = get_app().current_search_state
    current_buffer = get_app().current_buffer

    cursor_position = current_buffer.get_search_position(
        ~search_state, include_current_position=False
    )
    current_buffer.cursor_position = cursor_position

2018-11-16 00:21:49 編集

current_buffer = get_app().current_bufferにすることで、カレントバッファで検索結果の移動ができるようになる

これで一通りの検索ができるようになった!

実装するときにつまづいたこと

次の検索結果への移動の実装方法がわからなかった

これは、サンプルのtext_editorを見たら書いてあったから、参考にした

https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/examples/full-screen/text-editor.py#L250-L255

def do_find_next():
    search_state = get_app().current_search_state

    cursor_position = text_field.buffer.get_search_position(
        search_state, include_current_position=False)
    text_field.buffer.cursor_position = cursor_position

search_stateを反転させれば、逆に検索もできることをどこかで知って、それも使った。

filters.is_searchingがなんなのかよくわからなかった

いろいろ調べた結果、検索中で、検索対象のコントロールがカレントコントロールの場合、Trueになるってことがわかった

prompt_toolkit.filters.app.is_searchingに以下のように書いてあった

@Condition
def is_searching():
    " When we are searching. "
    app = get_app()
    return app.layout.is_searching

prompt_toolkit.layout.layout.Layout.is_searchingを見てみる

    @property
    def is_searching(self):
        " True if we are searching right now. "
        return self.current_control in self.search_links

prompt_toolkit.layout.layout.Layout.search_linksとは

class Layout(object):
    def __init__(self, container, focused_element=None):
        ...
        # Map search BufferControl back to the original BufferControl.
        # This is used to keep track of when exactly we are searching, and for
        # applying the search.
        # When a link exists in this dictionary, that means the search is
        # currently active.
        self.search_links = {}  # search_buffer_control -> original buffer control.

Map Search BufferControlを元のBufferControlに戻します。 これは、正確にいつ検索しているかを追跡し、検索を適用するために使用されます。 この辞書にリンクが存在する場合、検索が現在アクティブであることを意味します。

検索している時には、get_app().layout.search_linksに検索対象のBufferControlが格納されるから、is_searchingではinで調べる

search_linksを調べてみた

こんな感じのdictになっている

  • Key: SearchBufferControlで、検索文字入力用のSearchBufferControl
  • Value: BufferControlで、検索対象のBufferControl

検索文字列入力中は、current_controlSearchBufferControlになる。search_links[current_control]とすると、それに紐づく検索対象のBufferControlが返される

そのため、is_searchingは、検索文字列を入力しているかどうかということ。(検索ツールバーが表示されているかどうか)

nNが検索文字列入力中に打てなくなってしまった

@kb.add('n')みたいに書いちゃうと、検索するときに、nが打てなくなってしまった。。。そのため、以下のようにする

@kb.add('n', filter=~is_searching)
@kb.add('N', filter=~is_searching)

こうすることで、検索文字を入力していないときのみ、nNのKeyBindingが有効になる

インクリメンタル検索を実装する

検索文字入力中にもハイライト(インクリメンタル検索, incremental search)したかったから、やってみた

from prompt_toolkit.application import Application
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.filters import Condition, is_searching
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.bindings import search
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import BufferControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.styles import Style
from prompt_toolkit.widgets.toolbars import SearchToolbar
from prompt_toolkit.layout.processors import (
    ConditionalProcessor,
    DisplayMultipleCursors,
    HighlightIncrementalSearchProcessor,
    HighlightSearchProcessor,
    HighlightSelectionProcessor,
)

text = "\n".join([str(i) for i in range(100)])

all_input_processors = [
    # 検索モードではないときだけハイライト
    ConditionalProcessor(HighlightSearchProcessor(), ~is_searching),
    HighlightIncrementalSearchProcessor(),
    HighlightSelectionProcessor(),
    DisplayMultipleCursors(),
]

# vi_mode=Trueとすると`/`と`?`で表示される様になる
search_toolbar = SearchToolbar(vi_mode=True)
control = BufferControl(
    Buffer(document=Document(text), read_only=True),
    search_buffer_control=search_toolbar.control,
    preview_search=True,
    include_default_input_processors=False,
    input_processors=all_input_processors,
)


# ウィンドウ
body = HSplit([Window(control), search_toolbar])

# キーバインディング
kb = KeyBindings()


@kb.add("q")
def _(event):
    event.app.exit()


@Condition
def search_buffer_is_empty():
    " Returns True when the search buffer is empty. "
    return get_app().current_buffer.text == ""


kb.add("/")(search.start_forward_incremental_search)
kb.add("?")(search.start_reverse_incremental_search)
kb.add("enter", filter=is_searching)(search.accept_search)
kb.add("c-c")(search.abort_search)
kb.add("backspace", filter=search_buffer_is_empty)(search.abort_search)


@kb.add("n", filter=~is_searching)
def _(event):
    search_state = get_app().current_search_state
    current_buffer = get_app().current_buffer

    cursor_position = current_buffer.get_search_position(
        search_state, include_current_position=False
    )
    current_buffer.cursor_position = cursor_position

@kb.add("N", filter=~is_searching)
def _(event):
    search_state = get_app().current_search_state
    current_buffer = get_app().current_buffer

    cursor_position = current_buffer.get_search_position(
        ~search_state, include_current_position=False
    )
    current_buffer.cursor_position = cursor_position


style = Style([("incsearch", "fg:ansibrightyellow reverse")])

app = Application(layout=Layout(body), key_bindings=kb, full_screen=True, style=style)

app.run()

シンプルな検索との違いは

  • Processorのリストを作っている
  • BufferControlのコンストラクタへの引数を追加
  • IncSearch用のスタイルを用意

Processorのリストを作る

all_input_processors = [
    # 検索モードではないときだけハイライト
    ConditionalProcessor(HighlightSearchProcessor(), ~is_searching),
    HighlightIncrementalSearchProcessor(),
    HighlightSelectionProcessor(),
    DisplayMultipleCursors(),
]

何も指定しなくても、デフォルトでこの4つは生成されるが、HighlightSearchProcessorが、デフォルトのままだと検索文字列入力中にもハイライトされてしまうため、(1回目の検索結果が2回目のIncSearchのハイライトの邪魔になる)ConditionalProcessorで検索中はハイライトしないようにした

BufferControlのコンストラクタへの引数を追加

以下の3つを追加した

  • preview_search=True
    • IncSearchの有効化
  • include_default_input_processors=False
    • input_proecssorsで渡したPrcessorのみを有効にしたいため、False
  • input_processors=all_input_processors
    • 適用するProcessorのリストを渡す

IncSearch用のスタイルを用意

style = Style([("incsearch", "fg:ansibrightyellow reverse")])

IncSearch中には、styleincsearchというclassがつくため、それ用のスタイルを定義して、Applicationに渡しておく。

ドキュメントに書いてあった

https://python-prompt-toolkit.readthedocs.io/en/stable/pages/reference.html#prompt_toolkit.layout.processors.HighlightIncrementalSearchProcessor

増分検索を強調表示するために使用される検索条件を強調表示します。 スタイルクラス 'incsearch'がコンテンツに適用されます。

重要:これは、BufferControlにpreview_search = Trueフラグを設定する必要があります。 そうしないと、検索中にカーソル位置が検索一致に設定されず、何も起こりません。

実装してみて

ドキュメントとソースをたくさん読んでて、少しずつわかっていくのがなんか楽しかった。あと、動いたときの嬉しさ半端ない

ディレクトリパスをエイリアスで登録して簡単に移動できるgotoコマンドを使ってみる

ディレクトリのパスをエイリアスとして登録して簡単に移動できるgotoっていうコマンドを見つけたから使い方のメモ

github.com

インストール

$ mkdir -p ~/.bash
$ wget https://raw.githubusercontent.com/iridakos/goto/master/goto.sh -O ~/.bash/goto.sh

.bashrcに以下を追記する

source ~/.bash/goto.sh

読み込んで

$ source ~/.bashrc

使ってみる

$ goto

usage: goto [<option>] <alias> [<directory>]

default usage:
  goto <alias> - changes to the directory registered for the given alias

  OPTIONS:
    -r, --register: registers an alias
      goto -r|--register <alias> <directory>
    -u, --unregister: unregisters an alias
      goto -u|--unregister <alias>
    -p, --push: pushes the current directory onto the stack, then performs goto
      goto -p|--push <alias>
    -o, --pop: pops the top directory from the stack, then changes to that directory
      goto -o|--pop
    -l, --list: lists aliases
      goto -l|--list
    -x, --expand: expands an alias
      goto -x|--expand <alias>
    -c, --cleanup: cleans up non existent directory aliases
      goto -c|--cleanup
    -h, --help: prints this help
      goto -h|--help
    -v, --version: displays the version of the goto script
      goto -v|--version

使い方

移動

goto <alias>

また、Tabで補完できる

エイリアス登録(-r)

-r --register

goto -r <alias> <directory>

例)~/src/pythonpythonとしてエイリアス登録

$ goto -r python ~/src/python

エイリアス登録を解除(-u)

-u --unregisters

goto -u <alias>

例)エイリアスpythonの登録を解除

$ goto -u python

カレントディレクトリをスタックにpushし、goto(-p)

-p --push

goto -p <alias>

カレントディレクトリに戻ってきたいときとか使えそう

例)カレントディレクトリをスタックに追加してから、pythonに移動

$ pwd
/Users/tamago324/dotfiles/

$ goto -p python

スタックからpopし、移動(-o)

-o --pop

goto -o

-pでpushされたスタックからpopし、移動

例)/Users/tamago324/dotfiles/goto -p xxxとしていた場合

$ goto -o

$ pwd
/Users/tamago324/dotfiles/

スタックに追加していたため、戻れる

エイリアスの一覧(-l)

-l --list

goto -l

エイリアスの完全パスを取得(-x)

-x --expand

goto -x <alias>

存在しないパスのエイリアスを削除(-c)

-c --cleanup

goto -c

なくなったディレクトリパスのエイリアスを削除してくれる

参考文献

指定フォルダ以下のファイルをコピーする - Python

指定のフォルダの下にあるファイルをコピー

再帰的に見ていく

再帰的にファイルを見るのにpathlib.Path.glob()を使い、ファイルをコピーするのにshutil.copy()を使う

例)C:\sample以下のファイルで、ファイル名に"a"が含まれているファイルをカレントディレクトリにコピーする

>>> from pathlib import Path
>>> p = Path(r"C:\sample")
WindowsPath('C:/sample')
>>> for f in p.glob("**/*a*):
...     shutil.copy(f.absolute(), "./")

参考文献

nonlocal ネストした関数から変数にアクセスする - Python

ネストした関数で、上で定義された変数を使うにはnonlocalキーワードを使う

def main():
    a = 1

    def child():
        nonlocal a
        a += 1
        print(a)

    print(a)
    child()
    print(a)

main()

出力結果

1
2
2

ネストした関数から、アクセスできた!!

これは、メモ化のための実装方法らしい。クロージャというやつ
メモ化ってどっかで聞いたことある。
たしか、同じ引数だったら、前回の結果をそのまま返すやつだっけ

クロージャについてはこんどちゃんとやる。こんど。うん。

参考文献

【Windows】フォルダをエクスプローラで開く - Python

subprocessモジュールのcallを使う

import subprocess
subprocess.call(f'explorer "{開くフォルダのパス}"')

参考文献

os.path.expanduser関数 - Python

カレントユーザのホームディレクトリのパスを取得することができる

>>> os.path.expanduser('~/src/python/')
'/Users/tamago324/src/python/'

使えるときあるのかな?

参考文献