Avyならなんでもできる

Author: ayatakesi
GitHub Source: md

これはKarthik Chikmagalurさんによって記述された記事を日本語に翻訳した記事であり、記事の所有権と著作権はKarthik Chikmagalurさんに帰属します。

元の記事: Avy can do anything | Karthinks

Avyの使い方が間違っている

厳しすぎるだろうか? 言い直そう。あなたはAvyのほとんどを使っていない。まだ言い過ぎだろうか? よろしい、では扇動的ではないバージョンで言い直そう。 画面上を飛び回るEmacsパッケージであるAvyは、効率的に組み立て構成可能な使い方(デフォルトでは隠されている)に適しているパッケージなのだ。

説明はこれ位にしておこう。以下のデモでは複数のバッファーやウィンドウにたいして、手作業でカーソルを移動することなく、Avyのコマンド1つavy-goto-char-timerで様々な物事すべてを行う様子をお見せする:

テキストのコピー、行やリージョンのkill、テキストの移動、マーク、ヘルプバッファーの表示、定義の検索、Google検索、スペルチェックなど… まだまだある。

もう一度強調しておこう:Avyによって定義されるジャンプコマンドは数十個あるが、わたしが使うコマンドは1つだけだ。この記事ではあなたが自分用のバージョンを作れるようにこのコマンドを詳細まで掘り下げていくが、もっと重要なのはこれがなぜいかしたアイデアなのかという理由を説明することにある。

この記事はキーボードによって効果的にジャンプ移動するEmacsパッケージであるAvyに関するシリーズ2部作のうちのパート1となる。このパート1ではなんでもAvyで行うためにカスタマイズによるビルトイン機能の強化、あるいはそれに近いことに関して説明する。パート2では読者の個々のニーズに合わせてさらに複雑な機能を記述するために、より技術的(elispっぽい)な話題へと掘り下げていく予定だ。今回の記事に記載した短いスニペットに興味を感じたら、ここに1つのファイルとしてまとめてあるので参考にして欲しい。

フィルター選択アクション

主要目的がテキストをタイプすることではないEmacsとのやり取りのほとんどにおいて、同じパターンが繰り返されるのを目にする。あるタスクを実行するためにフィルター(filter:抽出)して、選択(select)して、それからアクション(act:実行)を順繰りに行うパターンのことだ。

フィルター:通常は何らかのテキストをタイプすることによって、大量に積まれたウィンドウ候補をより少ない候補へと絞り込む。以下に示す例のように候補は何でもよい。

選択:必要は候補いずれかを指定する(通常は視覚的な同意つき)。フィルターによって候補リストが1つの候補に絞り込まれた場合には自動的にそれを選択する。

アクション:引数としてその候補を渡してタスクを実行する。

  • ファイルをオープンしたい? ならコマンドを呼び出してから補完リストをフィルターするテキストをタイプして補完結果から選択してからfind-fileだ。

  • バッファーの切り替え? フィルターに何かをタイプ、バッファーを選択して切り替えるだけ。

  • コード内のシンボルの自動補完はどうだろう? ポップアップリストを絞り込むための文字を数文字タイプして、候補を選択したら確認に同意すればいい。

例のごとく、これは単純化されたモデルだ。たとえばHelm、Ivy、Diredの仲間たちは処理する複数の候補をユーザーが選択できるようにしている。このアイデアを検討する間は、この知識は脇に避けておくことにしよう。

この観察によっていくつかの興味深いアイデアが導かれる。たとえばフィルター選択アクションというプロセスが、1つの操作と考えられることが珍しくない。フィルタリング、選択、アクションのフェーズを(コードや脳内で)分離してみれば、多くの可能性を考慮する自由が得られるだろう。以下はミニバッファーにおけるやりとり:

minibuffer-interaction-paradigm.png

異なる補完スタイル(regexp、flex、あるいはorderlessによるマッチング)、異なる選択インターフェイス(Icomplete、Ivy、Helm、Vertico、Selectrumと日々増加中)、異なるアクションディスパッチャー(Embark、HelmやIvyのアクション)といった可能性が考えられる1シェルユーティリティ分野においてもfzfの登場以来、新たなユーティリティが次々と爆誕する様はさながらミニカンブリア爆発とでも形容したくなる勢いだ。Emacsにおけるミニバッファーとは、物事を選択する上でわたしたちが慣れ親しんだ場所なので、これらの例を考えるのは自然だろう。

この使用パターンはミニバッファーとの対話にかぎらず拡張できる。通常のバッファー操作において、フィルター選択アクションというパターンで物事を行うことは珍しいことではない。Emacsとのほとんどの(テキストのタイプに重点を置かない)対話において、この使い方を実際に目にできるだろう。ポイント(テキストカーソルのこと)が何らかのテキストにあるときにgoto-definitionコマンドを呼び出せば、フィルタリングのステップは飛ばしてthing-at-ptライブラリーの関数が選択を行い事前にセットされたアクションを行うのだ。マウスによる対話ならどうなるだろう? アイコンだらけのウィンドウをスクロールしてかいくぐり、お目当てのアイコンにマウスカーソルを置いてクリックといった具合である。この完全に冗長な例から、より興味深いケースにたいする気づきが得られる。

Isearchで考えてみよう:

isearch-interaction-paradigm.png

C-sの後に)何かタイプすると、バッファーのテキストが自動的にフィルターされてもっとも近くにあるマッチが選択される。その後に続くマッチを順に選択したり、最初や最後のマッチにジャンプすることも可能だ。ここではマッチした位置へのカーソル移動というプロセスがアクションに相当するが、これは検索文字列にたいするoccurquery-replaceの実行のような処理のうちの1つとみなすことができる。Isearchには多くはフィルターと選択、アクションを同時に行うコマンドが多く、そのためにユーザーは丸い穴に無理して四角い釘を打つような破目となる2

Isearchの使用に少しでも時間を費やした経験があればIsearchの単一のタスクを、独自に構成可能な3つのフェーズに分割することで生じるトレードオフが理解できるはずだ。2つ、あるいは3つすべてを単一操作にまとめることが、Isearchにおけるキーボード操作を邪悪なまでに高速化する。わたしがIsearchを使用する際には、頭が指を追いかける場面が珍しくない。他方モジュール化がもつ優位な点は、その表現力にある。3つのフェーズからなるプロセスは全体として遅くはなるもののフィルター選択アクションそれぞれのスロットに異なるパーツを繋げることによって、さらに多くのことを行うことが可能になる。まったく異なるタスクにたいして、ミニバッファーがさまざま方法を用いていかにして処理をこなすか思い浮かべてみればわかるだろう!

話をIsearchに戻す。どうやれば3つのステージに分割できるのか? 根性が持続できれば大したことはないだろう。すべてがelispである以上、それは難しい注文ではないのだ。たとえばProtesilaos Stavrouはビデオにおいて、候補のマーキングといった直観的な多くのアクションをIsearchに追加している。しかし Avy の存在によってIsearchの修整が不要だということが明らかになった。フィルターというキラー機能を有しつつも、王者同様3つのステージを分離しているのがAvyである。Avyによって何とも興味がそそられる可能性が生みだされた。

avy-interaction-paradigm.png

待った、Avyとは?

Avyabo-aboことOleh Krehelによって記述された。彼は実に多産な作者でありIvy、Counsel、Ace-Window、Hydra、Swiperを記述したのは彼であり、他にもおそらく読者も使ったことがあるEmacsのパッケージエコシステムの主要コンポーネントの多くも彼が記述した。この記事を読んでいる読者なら、すでにAvyを知っている(そして多分使ったことがある)かもしれない。したがってAvyのドキュメントから非常に短いバージョンを紹介しよう:

Avyとは文字ベースのデシジョンツリーを用いて可視なテキストにジャンプするためのGNU Emacs向けパッケージです。

Avyコマンドを呼び出して何かテキストをタイプしてみよう。フレーム(必要に応じてすべてのEmacsフレーム)上にあるテキストへのマッチすべてが選択のための候補となり、ヒント文字が上に重ねて表示される。ここ(訳注:このパラグラフの原文)で”an”と入力すると、これにマッチするフレーム上の文字列すべてがハイライト表示される:

avy-basic-demo.png

ヒントのいずれかをタイプすれば、カーソルがその位置にジャンプする。以下のビデオでは別のウィンドウからこのセンテンスにジャンプしている:

テキストにマッチする位置に(ウィンドウ間を横断して)ジャンプ、その後はpop-global-markC-x C-SPC)で元の位置にジャンプして戻る、というのがAvyの典型的な使い方だろう。後半のセクションではじAvyを使った前方あるいは後方へのジャンプについて詳解する。以下のデモではAvyで2回ジャンプしてから順番にジャンプして戻るプロセスを行っている:

少なくとも、これが公式の説明だ。詳細についてはREADMEを参照してほしいが、わたしが疑問に思うのは……

Avyのドキュメントにはもっとも肝心な部分が記載されていない

Avyは自動的にフィルタリングを行い、文字ベースのデシジョンツリーを通じて選択を行う。これを3つのパートからなるわたしたちの対話モデルにどのように適合させるかを示そう。

フィルターして、

あなたがAvyを呼び出すまでは、 スクリーン上にあるすべてのテキスト文字列 が選択のための候補である。どれを選ぶかはすべてあなたに委ねられており、選べる選択肢が多すぎるくらいだ!

プールされた大量の候補からフィルタリングを行うのは、ミニバッファーでテキストをタイプすることによってフィルタリングを行う方法と似ている。これによりプールのサイズは、入力にマッチするサイズへと絞り込まれる。Avyは数十個のフィルタリングスタイルを提供している。使用できるスタイルはポイントより上または下の候補だけを考慮するスタイル、単語の先頭、カレントのウィンドウ(またはフレーム)、空白、行頭、あるいはOrgファイルのヘッダーだけを考慮するスタイルといった具合にリストはまだまだ続いていく。

  • Avyのフィルタリングコマンド
単位: 文字 単位: 単語/シンボル 単位: 行/その他
avy-goto-char-timer avy-goto-word-0 avy-goto-line
avy-goto-char avy-goto-subword-0 avy-goto-end-of-line
avy-goto-char-2 avy-goto-symbol-1-above avy-goto-whitespace-end
avy-goto-char-in-line avy-goto-word-0-below avy-org-goto-heading-timer
avy-goto-char-2-below avy-goto-word-1-below avy-goto-whitespace-end-above
avy-goto-char-2-above avy-goto-symbol-1-below avy-goto-line-above
avy-goto-word-1-above avy-goto-whitespace-end-below  
  avy-goto-symbol-1 avy-goto-line-below
  avy-goto-word-or-subword-1  
  avy-goto-subword-1  
  avy-goto-word-1  
  avy-goto-word-0-above  

これは追い続けるのが厳しい気狂いじみたリスト だ。

(当然ではあるが)Avyのフィルタリングは選択メソッドからは独立しているが、フィルタリングには軽い目眩を覚える程の量のコレクションが存在する。これはユーザーはもっとも頻繁に使うコマンドをいくつか選んで、選んだコマンドだけを記憶に刻むという仮定がもたらしたアイデアではないかとわたしは考えている。

ここで問題が生じる:わたしたちはエディター自体にではなく、テキストエディターで解決しようとしている問題にたいして精神的な帯域幅を使用したいのだ。無意識に行えない意思決定にはコストがかかり、集中力も乱されてしまう。現段階では物事を見つけて処理を行うためには、IsearchとAvyの中間点の何かを使ってその場で決定する必要がある。Swiper、Helm-swoop、Consult-lineといった多機能な検索ツールを使っている場合には、選べるオプションは3つだ。大量のAvyコマンドをトップに据えると、それが精神的な行き詰まりへと導くための処方箋になりかねない。すべての面においてもっとも適応力があり、柔軟かつ汎用的なAvyコマンドavy-goto-char-timerを選択するだけにしておけば、これを回避できるだろう。

(global-set-key (kbd "M-j") 'avy-goto-char-timer)

さらに以下では、常にIsearchを使用して必要に応じてAvyに切り替えることにより、この決定さえ不要だというわたしの考えを述べる。

明確にしておこう。それに忙殺されているように見えるもののAvyを使えば回避できる、慢性的かつ頻繁なコンテキスト切り替えによるコストが存在する。そのコストに見合ったコストであることが決定コストには要求されるものとする。ニーズにたいしてAvyの柔軟なフィルタリングオプションの調整を要するケースは存在する。Avyベースの候補(lintエラーからバッファー選択に至るすべて)にたいしてフィルタリングを提供する数々のパッケージの存在がその証拠だ。これについてはパート2で詳しく検討しよう。

とは言ったものの興味があるのは、まだ探求が済んでいないAvyの別の側面だ。以下の主張ではそれについても目を向けていこう。

選択して、

Avyが提供する選択メソッドには、すべてスクリーン上の位置にマッピングされた文字のタイプが含まれている。これはIsearchとは大きく異なる。isearch-repeat-forwardC-s、数プレフィックス引数が指定されているかもしれない)を呼び出すIsearch、補完バッファー(completions buffer)のナビゲーションにC-nC-pを用いるミニバッファーとはまったく異なるのだ。Avyの選択メソッドは使用する文字シーケンスが最小となるようにデザインされており、わたしたちには40のキーに計算量オーダーO(1)でアクセス可能な10本の指が備わっているので、より高速であることが一般的だ。ジャンプしようとしている位置を直接見ていることが多いという事実は、ちんぷんかんぷんなスクリーン全体の解析が不要なことを意味している。この記事にとっては不幸ではあるが、スクリーンショットやデモを見るよりも、Avyを使ってみるほうが遥かに直観的であることをも意味している。

この洗練されたデザインによって、選択フェーズに手を加える理由はほとんどなくなった。選択フェーズは必要十分にモジュール化されているので、他のフィルターやアクションのステージに融通できる。選択に用いる文字集合や文字位置を変更したければ、Avyのスタイルをカスタマイズできる。候補選択に簡単な単語を用いる例を示す。

avy-words-style-demo.png

選択操作については第2部でも注目していく予定。

アクション

という訳で、この記事の焦点をアクションに定めよう。Avyがゴールとして掲げているのは何かにジャンプすることである。これは(文脈的には)より高速化されたIsearchのように聞こえる。しかしジャンプすることは、Avyのもつ可能性のうちの1つに過ぎない。Avyは”ディスパッチリスト(dispatch list)”を提供する。これは候補にたいして実行可能なアクションのコレクションであり、すべてのアクションはジャンプアクションと同等に扱われる。これらのコマンドは、Avy使用中なら何時でも?で表示できる:

avy-dispatch-demo.png

これはスクリーン上の任意の場所で実行したいアクションが何であれ、すべてのアクションがAvy独自のフィルタリングおよび選択のメソッドを自由に使用できることを意味する。わたしたちの対話モデルの終わり方は以下のようなブロックとなるだろう。

avy-interaction-paradigm-detailed.png

さらにAvyには、スクリーン上の何処かからテキストをコピーするのように、他のアクションを実行するいくつかのコマンドも定義されている:

Kill Copy Move
avy-kill-ring-save-whole-line avy-copy-line avy-move-line
avy-kill-ring-save-region avy-copy-region avy-move-region
avy-kill-region   avy-transpose-lines-in-region
avy-kill-whole-line   avy-org-refile-as-child

このアプローチは拡張性において問題がある。 これらはそれぞれフィルター選択アクションという完全なプロセスを定義するコマンドなので、多様性や対応範囲で何らかの必要が生じたとしても、拡張の余地やキーボードの空きはすぐに枯渇してしまうだろう。動的な面においても不十分だ。一旦コマンドを開始してから気が変わっても、コマンド間をつなぐパイプラインが固定されているので変更できないのだ。

Vimの編集モデルが愛されるのには理由がある。M個のアクション(動詞)とN個のカーソル移動を知ることによって、M×N個の合成操作を得られるミニ言語であることがその理由だ。(M+N)の知識から(M×N)の結果が得られるという比率関係により、Vimで動詞や移動の習得に努力を費やせば、その見返りとして得られる報酬が二次関数的に増加するからだ。この理由によりAvyのVimバージョンに相当するEasymotion3には、合成機能の一部が組み込まれている。この機能を部分的にAvyに導入する方法を見つけられれば、動詞と移動の組み合わせよりも多くのことを行える筈なのだ(こっちはEmacsなのだから)。これを達成するためにAvyのソースコードに分け入る必要はない。すでに必要なフックはすべて揃っている。

Avyのアクション

Avyにおいて任意のアクションを実行する際の基本的な使い方は以下のとおり:

  1. Avyのコマンドを呼び出す。どのコマンドでもよいが、わたしはavy-goto-char-timerの一択。

  2. フィルター:スクリーン全体からいくつかの位置に候補を絞り込むためのテキストを何かタイプする。

  3. 選択:実行したいアクションを指定する。?でディスパッチのヘルプを表示できる。正しくセットアップしてあれば不要だが、詳細が知りたければ『Avyにたいする心構え』を参照のこと。

  4. アクション:アクションを実行したい候補を1つ選ぶ。

以下はわたしがAvyで頻繁に行うことの一部。アクティブなウィンドウにかぎらず、フレーム内のすべてのテキスト行えることに注目!

まずはAvyを用いて一般的な編集アクションを行う際の悩みの種を解消する。VimとEasymotionを使っていれば、以下のアクションのうち最初のいくつかは無料で入手できる:

以下のデモに関する注意
  • わかりやすくするために、いくつのアクションの間はスクリーン彩度を下げて、行が“点滅”するようにAvyをセットアップしてある。これらはデフォルトでは有効になっていない。追いやすくするために、遅延を追加して操作の低速化も行った。実際の使用では瞬時に実行される。
  • Avyで候補にアクションをディスパッチするために使用するキーは、avy-dispatch-alistで指定する。
  • 使用するキーがAvyがスクリーン上の選択ヒントにAvyが用いるキーと重複しないことも保証する必要があるだろう。これを行うためにはavy-keysのカスタマイズも検討してほしい。

単語、sexp、行の候補のkill

単語とsexpのkillについてはビルトイン。行のkillはわたしが追加した。このデモでは手っ取り早くtypo修整とコメント削除を行い、その後に別ウィンドウのコードを削除している:

ビデオ実況
  1. avy-goto-char-timerを呼び出す。
  2. "is"をタイプする。"is"にマッチするすべての候補にヒントが表示される。
  3. kavy-action-killを呼び出す。
  4. 重複した"is"のいずれかを選択して削除する。
  5. 余分な"and"にたいして1-4のステップを繰り返す。
  6. avy-goto-char-timerを呼び出す。
  7. keyへのマッチに絞り込むために"key"とタイプする。
  8. Kavy-action-kill-whole-lineを呼び出す。
  9. コメント行を選択してその行を削除する。
  10. 今度は別ウィンドウにある"("を選択して1-4のステップを繰り返して、関数の定義をkillする。
  11. 今度は別ウィンドウにある"add"を選択して7-9のステップを繰り返して"(advice-add …)"がある行をkillする。
(defun avy-action-kill-whole-line (pt)
  (save-excursion
    (goto-char pt)
    (kill-whole-line))
  (select-window
   (cdr
    (ring-ref avy-ring 0)))
  t)

(setf (alist-get ?k avy-dispatch-alist) 'avy-action-kill-stay
      (alist-get ?K avy-dispatch-alist) 'avy-action-kill-whole-line)

単語、sexp、行の候補のyank

killリングまたはバッファーのポイント位置にコピーする。このデモではmanページからファイルにテキストをコピーしている:

ビデオ実況
  1. avy-goto-char-timerを呼び出す。
  2. "["をタイプしてフレーム内の"["へのマッチすべてをフィルタリングする。
  3. yにバインドされているavy-action-yankを呼び出す。
  4. "[big-cache]"にたいするマッチを選択する。これは別ウィンドウからこのバッファーにコピーされたテキスト。
  5. avy-goto-char-timerを呼び出す。
  6. "de"をタイプして"demuxer"を含んだマッチを選択する。
  7. Yにバインドされているavy-action-yank-whole-lineを呼び出す。
  8. マッチからいずれか1つ選択する。その行がバッファーにコピーされる。
  9. デフォルトでM-SPCにバインドされているjust-one-spaceでインデントを修整する。
(defun avy-action-copy-whole-line (pt)
  (save-excursion
    (goto-char pt)
    (cl-destructuring-bind (start . end)
        (bounds-of-thing-at-point 'line)
      (copy-region-as-kill start end)))
  (select-window
   (cdr
    (ring-ref avy-ring 0)))
  t)

(defun avy-action-yank-whole-line (pt)
  (avy-action-copy-whole-line pt)
  (save-excursion (yank))
  t)

(setf (alist-get ?y avy-dispatch-alist) 'avy-action-yank
      (alist-get ?w avy-dispatch-alist) 'avy-action-copy
      (alist-get ?W avy-dispatch-alist) 'avy-action-copy-whole-line
      (alist-get ?Y avy-dispatch-alist) 'avy-action-yank-whole-line)

フレームのどこかから行やリージョンをコピーするために、実際にはavy-copy-lineおよびavy-copy-regionというコマンドをAvyが個別に定義している。これらのコマンドの方が関数呼び出しにアクションステージが織り込み済みなので僅かに速いので、そちらを使うほうがよいかもしれない。しかし多すぎるトップレベルコマンドを記憶することによる精神的な燃え尽き症候群をわたしたちは避けたいので、avy-goto-char-timerを呼び出して(フィルター選択)、その後にお目当ての候補を選択してディスパッチを行うという、よりシンプルな2つのステージで作業を行っている。

単語、sexp、行の候補の移動

これはAvyでは”teleport”、わたしは”transpose”と呼んでいるが、いずれにせよtにバインドされることになる。このデモではバッファーのあちこちをあまり移動せずに、バッファーのあちこちにテキストを移動する。

ビデオ実況
  1. 場所を空けるためにスペースを何個かタイプする。
  2. avy-goto-char-timerを呼び出す。
  3. "("で始まる候補にフィルタリングする。
  4. tを押下してavy-action-teleportを実行する。
  5. "(parametric forcing)"という候補を選択する。
  6. avy-goto-char-timerでそのウィンドウ内にある"DOWNLOADED"という候補にジャンプする。ポイントがある位置に移動(またはtranspoae:転地)するはず。
  7. avy-goto-char-timerを呼び出す。
  8. "the"にマッチする候補にフィルタリングする。
  9. Tを押下してavy-action-teleport-lineを実行する。
  10. 候補の行(イメージの直下にある)を選択する。ポイントがある位置に移動(またはtranspoae:転地)するはず。
(defun avy-action-teleport-whole-line (pt)
    (avy-action-kill-whole-line pt)
    (save-excursion (yank)) t)

(setf (alist-get ?t avy-dispatch-alist) 'avy-action-teleport
      (alist-get ?T avy-dispatch-alist) 'avy-action-teleport-whole-line)

候補の位置までzap

これはビルトインのコマンドで、デフォルトではzにバインドされている。

ビデオ実況
  1. avy-goto-char-timerを呼び出す。
  2. "in"とタイプする。"In Emacs "を含めて、"in"へのすべてのマッチにヒントが表示される。
  3. avy-action-zapを実行するためにzを押下する。
  4. 候補(この場合だと"In Emacs"にたいするヒントの文字を選択する。ポイントと候補の間にあったテキストがkillされるだろう。

単語やsexpの候補をマーク

これもビルトインのコマンドであり、デフォルトではmにバインドされている。Avyを使って候補にジャンプするのは、mark-sexpを呼び出すジャンプと変わらないが、Avyの方がより使いやすくなっている:

ビデオ実況
  1. avy-goto-char-timerを呼び出す。
  2. フィルタリングするためのテキスト(ここでは"(")をタイプする。
  3. avy-action-markを実行するためにmを押下する。
  4. 候補から単語かsexp(ここでは("~/.local/share"))を選択する。
  5. 他の候補(data_directory...RotatingFileHandlerをマークするために、1-4のステップを2回繰り返す。

ポイントから候補までのリージョンをマーク

Avyジャンプする前にマークをセットするので、C-x C-xを使用してリージョンをアクティブにすることも可能だが、このコマンドを使うほうがトラブルを回避できるだろう。

ビデオ実況
  1. avy-goto-char-timerを呼び出す。
  2. フィルタリングに使用するテキスト(ここでは"')")をタイプする。
  3. avy-action-mark-to-charを実行するために、SPCを押下する。
  4. 候補のヒント文字を選択する。これによりポイントからヒント文字までのリージョンをマークして、ポイントを移動する。
  5. avy-goto-char-timerを呼び出す。
  6. フィルタリングに使用するテキスト(ここでは単に一連のスペース)をタイプする。
  7. avy-action-mark-to-charを実行するために、SPCを押下する。
  8. 行の開始となる候補(ここでは一連のスペース)を選択する。これによりポイントからその行までがマークされる。
(defun avy-action-mark-to-char (pt)
  (activate-mark)
  (goto-char pt))

(setf (alist-get ?t avy-dispatch-alist) 'avy-action-mark-to-char
      (alist-get ?T avy-dispatch-alist) 'avy-action-teleport-whole-line)

Avyにより自動的に実行される、コンテキストに応じたアクションをいくつか示そう:

候補の単語にispell

これはビルトインのコマンドであり、デフォルトではiにバインドされている。

ビデオ実況
  1. avy-goto-char-timer(Avyの他のジャンプコマンドでもよい)を呼び出す。
  2. "can"とタイプすると"candidate"(スペル間違い)がハイライトされる。
  3. avy-action-ispell用のディスパッチキー(デフォルトではiにセットされている)を押下する。
  4. マッチのいずれか(ここではスペル間違いの"candidate"のマッチ)を選択する。
  5. これにより選択にたいしてispell-wordが実行される。
  6. 正しいスペルを選ぶ。
  7. もう一度avy-goto-char-timerを呼び出す。
  8. "te"をタイプすると"teh"にたいするマッチが(他より強調されて)ハイライトされる。
  9. avy-action-ispell用のディスパッチキーを押下する 。
  10. 候補(ここでは"teh"のマッチ)を選択する。
  11. これによりもう一度ispell-wordが実行されて、"teh"が修整される。

このプロセスを自動化するために、一番上にある修整を自動的に採用するバージョンで、avy-action-ispell(ビルトイン)を置き換えることもできる。

(defun avy-action-flyspell (pt)
  (save-excursion
    (goto-char pt)
    (when (require 'flyspell nil t)
      (flyspell-auto-correct-word)))
  (select-window
   (cdr (ring-ref avy-ring 0)))
  t)

;; セミコロンにバインド (flyspellが使用するのはC-;)
(setf (alist-get ?\; avy-dispatch-alist) 'avy-action-flyspell)

単語の定義

わたしはEmacsのdictionaryパッケージを使っているので、単語の登録は怠けがちだ:

ビデオ実況
  1. avy-goto-char-timer(Avyの他のジャンプコマンドでもよい)を呼び出す。
  2. "non"とタイプすると"nonpareil"にたいするマッチが(他より強調されて)がハイライトされる。
  3. avy-action-define用のディスパッチキー(ここでは=にセットされている)を押下する。
  4. マッチのいずれか(ここでは"nonpareil"にたいするマッチのいずれか)を選択する。
  5. これにより"nonpareil"の定義とともにバッファーが生成される。
  6. このdefinitionバッファーをスクロールするために、scroll-other-windowC-M-v)を呼び出す。
  7. もう一度avy-goto-char-timerを呼び出す。
  8. "fi"をタイプすると"finch"にたいするマッチが(他より強調されて)ハイライトされる。これは定義をもつ別のバッファーでのマッチであることに注意。このバッファーへの切り替えは不要。
  9. avy-action-define用のディスパッチキーを押下する 。
  10. 候補(ここでは"finch"のマッチ)を選択する。
  11. これによりもう一度"finch"の辞書定義とともにバッファーが生成される。
;パッケージマネージャーを変えたりお気に入りの辞書パッケージに変えてもよい
(package-install 'dictionary)

(defun dictionary-search-dwim (&optional arg)
  "ポイント位置の単語の定義を検索する。
リージョンがアクティブなら、そのリージョンのコンテンツを検索する。
プレフィックス引数とともに呼び出した場合には、検索する単語の入力を求める。"
  (interactive "P")
  (if arg
      (dictionary-search nil)
    (if (use-region-p)
        (dictionary-search (buffer-substring-no-properties
                            (region-beginning)
                            (region-end)))
      (if (thing-at-point 'word)
          (dictionary-lookup-definition)
        (dictionary-search-dwim '(4))))))

(defun avy-action-define (pt)
  (save-excursion
    (goto-char pt)
    (dictionary-search-dwim))
  (select-window
   (cdr (ring-ref avy-ring 0)))
  t)

(setf (alist-get ?= avy-dispatch-alist) 'dictionary-search-dwim)

シンボルのドキュメントを調べる

ビデオ実況
  1. avy-goto-char-timerを呼び出す。
  2. フィルタリングするためのテキスト(ここでは"case")をタイプする。
  3. avy-action-helpfulを実行するためにHを押下する。
  4. 候補フレーズ(ここでは"pcase-lambda")を選択する。これによりシンボルのドキュメントのバッファーがオープンする。
  5. helpバッファーをスクロールするために、C-M-vscroll-other-windowを呼び出す。
  6. avy-goto-char-timerを呼び出す。
  7. フィルタリングするためのテキスト(ここでは"ma")をタイプする。
  8. avy-action-helpfulを実行するためにHを押下する。
  9. 候補フレーズ(ここでは"macroexp-parse-body")を選択する。これは他のウィンドウ(helpバッファー)でのマッチであることに注意。これによりシンボルのドキュメントが表示される。
  10. 他のシンボル(ここではmemq)のドキュメントを調べるために、ステップ5-9を繰り返す。
;パッケージマネージャーの置き換えや別のヘルプライブラリーの選択も可
(package-install 'helpful)

(defun avy-action-helpful (pt)
  (save-excursion
    (goto-char pt)
    (helpful-at-point))
  (select-window
   (cdr (ring-ref avy-ring 0)))
  t)

(setf (alist-get ?H avy-dispatch-alist) 'avy-action-helpful)

単語やsexpにたいするGoogle検索4

Googleを検索できるEmacs機能が必要になるだろう。いくつか存在するが、わたしが使っているTuxiという名前のCLIプログラムはとても便利だ:

ビデオ実況
  1. avy-goto-char-timer(Avyの他のジャンプコマンドでもよい)を呼び出す。
  2. "ema"とタイプすると"Emacs"にたいするマッチが(他より強調されて)がハイライトされる。
  3. avy-action-tuxi用のディスパッチキー(ここではC-=にセット)を押下する。
  4. 候補(ここでは"Emacs"にたいするマッチのいずれか)を選択する。
  5. これによりGoogleのEmacsの説明とともにバッファーが生成される。
  6. もう一度avy-goto-char-timerを呼び出す。
  7. "vi"とタイプすると"Vi"にたいするマッチが(他より強調されて)がハイライトされる。これはGoogleの説明が表示されている別のバッファーでのマッチであることに注意。このバッファーへの切り替えは不要。
  8. avy-action-tuxi用のディスパッチキーを押下する。
  9. 候補(ここでは"Vi"にたいするマッチ)を選択する。
  10. これによりGoogleのViの説明とともにバッファーが生成される。
  11. "POSIX"という文字列を選択して、ステップ6-10を繰り返す。

さて、信じ難いほど難解なコンテキスト向けのアクションをこなす関数を、avy-dispatch-alistに追加し続けることも可能ではあるが、ひとまず一歩下がってみよう。わたしたちが望んでいるのは意味的にクラス分けされているバッファーテキストにたいして、簡単に呼び出すことが可能なアクションのリストだった。これに似た何かを以前どこかで目にしたことはないだろうか?

Avy + Embark: どこでもAnyアクション

AvyとEmbarkはレゴブロックのように互いに接続させることができる。例をいくつか示そう:

出現位置のハイライト

このデモでは細部まで記述されたLaTeXドキュメントの一部のキーワードをハイライトして、その後に手作業でカーソルを移動するのではなく、AvyとEmbarkを用いて引用キーから参考文献にアクセスしている:

ビデオ実況
  1. avy-goto-char-timer(Avyの他のジャンプコマンドでもよい)を呼び出す。
  2. "Floquet"を含むマッチにフィルタリングするために、"flo"とタイプする。
  3. oavy-action-embarkを実行する。
  4. "Floquet"にたいするマッチからいずれかを選択する。これによりそのマッチにEmbarkが実行される。
  5. Hembark-toggle-highlightアクションを選択する。
  6. "Parametric"をハイライトするために、1-5を繰り返す。
  7. もう一度avy-goto-char-timerを呼び出す。
  8. 引用キーの中から1つのキーにマッチさせるために、"na"とタイプする。
  9. oavy-action-embarkを実行する。
  10. マッチした引用キーを選択する。これによりそのマッチにEmbarkが実行される。
  11. Bibファイル(訳注:文献データベースファイル)をvisitするために、bibtex-actionを選択する(bibtex-actionsパッケージによってeにバインドされている)。

Emacsのヘルプシステムで複雑なもつれを切り抜ける

このデモではパッケージで何かを探す際にAvyとEmbarkを使ってhelp、apropos、customizationのバッファーを結びつける。ここでも手動でのカーソル移動は行わない。

ビデオ実況
  1. avy-goto-char-timerを実行する。
  2. フィルタリングするためにテキスト(ここでは"root")をタイプする。
  3. oavy-action-embarkを実行する。
  4. マッチの中から"project-root"を選択する。これによりそのマッチにたいしてEmbarkが実行される。
  5. hを押下するとマッチにたいしてEmbarkがdescribe-symbolを実行する。これによりそのマッチの関数にたいするヘルプバッファーがオープンする(これより前の箇所でAvyにヘルプコマンドをバインドしてあるので、そちらを使うこともできるだろう)。
  6. ヘルプバッファーをスクロールするためにC-M-vscroll-other-window)を押下する。
  7. もう一度avy-goto-char-timerを呼び出す。
  8. フィルタリングするためにテキスト(ここでは"proj")をタイプする。
  9. oavy-action-embarkを実行する。
  10. マッチの中から"project-x"を選択する。これによりそのマッチにたいしてEmbarkが実行される。
  11. embark-cycleを呼び出して、ターゲットを("poject-x"という名前の)ファイルから("poject-x"という名前の)ライブラリーに変更する。
  12. hを押下すると"project-x"ライブラリーにたいしてEmbarkがfinder-commentaryを実行する。これによりコメントが表示されているバッファーがオープンする。
  13. 前のステップを繰り返して、もう一度"project-x"にたいしてEmbarkを実行する。
  14. 今度はシンボルproject-x-local-identfierにたいして前のステップを繰り返す。
  15. uでEmbarkのcustomize-variableアクションを選択する。これにより編集project-x-local-identfierにたいするcustomizationバッファーがオープンする。

責任の分担

ここではわたしたち自身がもつ大量の冗長性と指に教え込んだ記憶を節約する。Avyには独自のフィルタリング手段を提供してもらい、Embarkには自分のベストを尽くすこと、すなわちアクションを実行してもらうのだ! 候補選択における中間的な作業はAvyとEmbarkで共有して行ってもらおう。Avyが候補の一般的な位置を指定して、その位置でアクションを行う際の意味的単位の解決はEmbarkが行う。Avyによってフィルター選択アクションというプロセスが使いやすいようにchunk化(訳注:あるものをより小さな断片に分割したり、より大きな断片にまとめすること)されているので、2つを統合するために必要なelispは簡単なもので事足りるはずだ5

(defun avy-action-embark (pt)
  (unwind-protect
      (save-excursion
        (goto-char pt)
        (embark-act))
    (select-window
     (cdr (ring-ref avy-ring 0))))
  t)

(setf (alist-get ?. avy-dispatch-alist) 'avy-action-embark)

アクションを行う単位としてEmbarkが採用した候補が気に入らなければ、他のターゲットに巡回するためにembark-cycleを呼び出すことができる。

これまではすべて、ポイントを動かすことさえ行っていない6

Avy + Isearch: 探してジャンプ

IsearchとAvyにはお互い違う強みがある。Avyは任意のウィンドウ上にある可視の要素すべて、Isearchはバッファー内の任意のマッチ候補に素早くジャンプできる。ある程度の距離範囲をジャンプしたければAvy、短距離または非常に長い距離を移動したければIsearchが適しているだろう。すでに視界にターゲットを収めている場合にはAvyが役に立つし、何かを探している場合にはIsearchが便利だ。しかしいずれか一方を選ぶ必要はない。Avy候補プールIsearchの候補に制限すれば、2つを簡単に組み合わせることができるからだ。こうすればIsearchを開始してAvyで完了できる:

(define-key isearch-mode-map (kbd "M-j") 'avy-isearch)

繰り返す。2つのコマンドのいずれかを呼び出すかを毎回意識的に判断するのは悪いアイデアだ。常にIsearch呼び出して、必要に応じてAvyを呼び出すというのなら悪くないが。

ビデオ実況
  1. C-sでIsearchを開始する。ビデオではM-risearch-regexpに切り替えている。
  2. 検索するフレーズをタイプする。ビデオでは"-region"で終わるregexpをタイプしている。
  3. Isearchのマッチ間のナビゲートはC-s、スクリーンの再センタリングは必要に応じてC-lで行っている。
  4. avy-goto-char-timerを呼び出す。候補プールはIsearchのマッチによって限界が定められる。
  5. 以前と同様、Isearchのいずれかのマッチを選んでジャンプする。

少なくとも、これが通常のピッチだろう。

ただしわたしたちにとって説明の中の”ジャンプ”は”アクション”に置き換えられる。Isearchの可視な候補であればどれでにたいしても、上述したいずれかのアクションを適用できる。Isearchのマッチとマッチの間のテキストのkill? ある単語を含む前の行をカレント位置にコピー? 試してみよう。要するにIsearchからフィルター以外の2つのアクションを間接的に切り離してフィルタリング選択を行い、Avyでアクションを行えばよいのだ。

Avyが賢すぎるとき

今度のパターンには失敗モードがある。マッチが1つしかなければ、その位置にAvyがジャンプするだけでアクションは何も提案しないのだ。あらら…

1つだけのマッチにたいして選択用のヒント/文字を表示するようAvyに強いることも可能だが、デフォルトのDWIMを選択する挙動のほうが通常は望ましいだろう。その場合に考え得るはオプションが2つある:

  • たとえば1文字をタイプするといったような保守的な方法によって候補をフィルタリングする。avy-goto-charavy-goto-char-2を使えば、ほとんど常に2つ以上のマッチを得られるので、この問題は回避できる。timerベースのAvyコマンドのいずれかを使えば、フィルターにどのくらいのテキストを用いるかをその場で変更できる。

  • ジャンプしたら昔ながらの方法でアクションを実行して、その後はAvyがセットしたマークpopして元の位置にジャンプして戻る。これはデフォルトのset-mark-command (C-u C-SPC)で行うことができる7。これはポイントをジャンプさせるようなほとんどのコマンド(Isearchも含む)で行うことが可能だ。Vimユーザーならジャンプリスト(jumplist)へのアクセスはC-o、変更リスト(changelist)はgでアクセスできる。

以下のデモはテキストを編集するためにAvyで2回ジャンプ、それから連鎖的にジャンプを辿って開始位置に戻っている:

ビデオ実況
  1. avy-goto-char-timerを呼び出して候補にジャンプする(またはアクシデントによりそこで終わる
  2. 編集する(またはしない)。
  3. プレフィックス引数とともにset-mark-commandを呼び出して戻る(C-u C-SPC)。これらのジャンプを連鎖して行うことができる。

ところでポイントとは何ぞや?

これは知識をひけらかしたい人向けのセクションだ。

この記述では”ポイント”と”カーソル”を互いに置き換え可能な用語として用いてきた。これら2つ用語の定義の違いについては理解している。

Avyを用いることで得られる利点として、カーソルを移動せずにアクションを実行するテキストのフィルタリングと選択を行えることが証拠の1つとして挙げられる。しかしこれまで示してきたコードスニペットのsave-excursionブロックによりポイントを移動しないのではなく、単にほとんど不可視な状態でポイントを移動していたことが明らかになった。ポイントがあるのはEmacsのgap-bufferと呼ばれるデータ構造体の中であり、Emacsのコマンドはすべてそのポイントを中心に動作することを指向しているし、通常はその方が効率的なのだ。

正にその通り。other-windowを呼び出してからテキストをIsearch、アクションを実行して戻るよりも、別のウィンドウにあるテキストにたいして任意のAvyアクションを実行するほうがより高速だ。しかしわたしにとってポイント(har)は、elispを記述に役に立つ抽象化なのだ(訳注:harはポイントを実際に実装するgapではなくハードウェアポイント、つまりカーソル位置と等価な意味でのポイントを強調するためだと思われる)。フレーム全体のコンテンツについて、フィルター選択アクションという強力なパラダイムで考えさせてくれることがAvyの真価だ。意識的にウィンドウやフレームを移動することで生じる精神的なコンテキストスイッチなしで操作を行うことができるという事実は、ボーナスのようなものだろう。

Avyにたいする心構え

Avyでスクリーン上を飛び回るのは、マウスを使うのとある点似ている。その見地からマウスのほうが速いので好ましいと主張するのは簡単だ。この組み合わせにアクションのディスパッチを追加すれば、この不公平な比較は消失する。他のウィンドウからの行のyankやスクリーン向こう端のシンボルにたいするgoto-definitionの実行では、マウスによる選択/右クリック稼業よりAvyのほうが遥かに高速だ。そもそもこれこそが頻繁なコンテキストスイッチによる破壊的効果の勘定を抜きにしても、キーボード(もし可能ならマウスでも)で全操作を行うことを好む理由なのだ。

嗅覚テスト

あなたがすでにAvyのジャンプを使っていたとしても、Avyのクションの使用はスクリーン上のテキストとのやりとりにおける新たな手段だろう。Avyアクションの使用や新たなアクションを発見する心構えとして、わたしはEmacsの日常的な使い方の“匂い”を探すよう努めている:

  • テキストにカーソルを合わせるためにウィンドウを何回か切り替えている
  • Isearchで正しいマッチにたどり着くために4個以上のマッチをジャンプしている
  • 何かを調べるコマンドを実行するためにポイントを長距離移動している
  • 毎回C-SPCによる手作業でマークをアクティブにしている
  • 単語1つの削除のために場所をジャンプしている

キーマップに埋もれて

“Avyにたいする心構え”には別の意味がある。シンプルなテキスト編集の上に新たな抽象化レイヤーを積み重ねるということは、新たにキーマップを学ぶ必要があることをも意味しているのだ。Emacsのキーマップに関してはすでに多すぎるというのに!

これは真実だが自分にとって道理に適ったキーを選択することで、労力は大幅に軽減されるだろう。これまでのコードスニペットでは、わたしのEmacsのキーバインディング(ほぼデフォルト)を真似たので、新たに何かを覚える必要はないはずだ:

アクション Avyキーバインド Emacsキーバインド Emacs標準?
Kill k, K (行) C-k Yes
Copy w, W (行) M-w Yes
Yank y, Y (行) C-y Yes
Transpose t, T (行) C-t, M-t など Yes
Zap z M-z Yes
Flyspell ; C-; Yes
Mark m m in special buffers Yes
Activate region SPC C-SPC Yes
Dictionary = C-h = No
Google search C-= C-h C-= No
Embark o C-o No

ニーモニックに留めず通常の編集で使うのと同じキーバインディングを再利用するだけだ。若干長目のキーシーケンスという犠牲を払うことによって、指に覚えさせた記憶を最大限に再利用できる。Embarkユーザーであれば上記のキーすら必要なくなる。必要なのはembark-actの呼び出しだけだ。

未解決問題

一般的な編集においてポイントをどこか(別のウィンドウかもしれない)へ移動する際に、依然として手作業を要するアクションが2つ存在する:

  • スクリーンの境界を超えた別ウィンドウのコンテンツにたいする検索やジャンプ。これにはシンプルな解決策がある:
別ウィンドウをIsearchする

(defun isearch-forward-other-window (prefix)
    "Function to isearch-forward in other-window."
    (interactive "P")
    (unless (one-window-p)
      (save-excursion
        (let ((next (if prefix -1 1)))
          (other-window next)
          (isearch-forward)
          (other-window (- next))))))

(defun isearch-backward-other-window (prefix)
  "Function to isearch-backward in other-window."
  (interactive "P")
  (unless (one-window-p)
    (save-excursion
      (let ((next (if prefix 1 -1)))
        (other-window next)
        (isearch-backward)
        (other-window (- next))))))

(define-key global-map (kbd "C-M-s") 'isearch-forward-other-window)
(define-key global-map (kbd "C-M-r") 'isearch-backward-other-window)

C-M-vで他のウィンドウのスクロールしながら、そのウィンドウに切り替えずにC-M-sでIsearchできる8。冒険してみたければ、上記関数の(other-window next)(ace-window)に置き換えてみよう。

基本的には他のウィンドウのバッファーにある任意のテキストにたいして、以前と同じようにIsearchからAvyを呼び出すことができる。

  • リージョンのテキストのコピー。これにはavy-copy-regionというAvyベースの解決策がある。とは言え、必要なのはAvyコマンドを1回呼び出すだけ、というのが冒頭の約束だった。現時点ではAvyだけで短いジャンプを繰り返すか、あるいはIsearchAvyを使うという退屈な方法でこれを行うことになるだろう。もっとelispっぽい解決策については、この記述のシリーズ第2弾を待ってほしい。

この記事はEmbarkの使用方法に関する記事のアイデアと関連しているので、主にアクションについて記述されている。しかしAvyはモジュールの組み合わせにより構成されているので、フィルター選択によって機能するさまざまなアプリケーションの広い範囲に適性がある。シリーズ第2弾ではAvyのAPI、および独自コマンドを作成する方法についても掘り下げていくつもりだ。

脚注

  1. 技術的には候補の並べ替えも含まれる。この内訳ではフィルターとしてひとまとめにしてある。 

  2. Isearchはフィルター/選択/アクションを効果的に1つのコマンドに組み合わせている。とは言えこれは他にも多くのアクションをサポートする、非常に巧妙にデザインされたEmacs用ライブラリーなのだ。Swiperや類似コマンドによってIsearchを完全に置き換えてしまった人たちはチャンスを逃していると思う。 

  3. あるいはEasymotionのEmacs版がAvyなのかもしれない。 

  4. なぜsexpをググりたいのだろうか? もっとも一般的なのは、sexpがFink-Nottleのようにハイフン区切りフレーズの場合だろう。 

  5. この関数を改善してくれたOmar Antolinに感謝する。 

  6. Avyのすべてのアクション関数の内部でsave-excursionを使用しているので、これは技術的には誤りだ。ここでの実際の成果はEmbarkに言及する前に、このコード断片にほぼ4000語にのぼる単語を詰め込んだことだろう。 

  7. 違うウィンドウに戻ってしまう場合には、かわりにpop-global-mark (C-x C-SPC)でグローバルマークをpopできる。今ではdogearsのような外部パッケージでこれを行うことができる。 

  8. C-M-sはデフォルトではisearch-forward-regexpにバインドされている。isearch-forward-regexpにはC-s M-rのようにIsearchマップでM-rとタイプすればアクセスできる。