2022-08-15

Ivy/CounselからVertico/Consultへ移行~補完候補以外を選びづらい問題

通常バッファ内での補完をcompanyからcorfuに変えたので、ミニバッファ補完もivyからverticoへ変えてみた。

設定:

(vertico-mode)
(setq vertico-cycle t) ;;最初と最後の候補を行き来できるようにする
(setq completion-styles '(basic substring partial-completion flex)) ;;適当

;; 大文字小文字の区別をしない
(setq read-file-name-completion-ignore-case t
      read-buffer-completion-ignore-case t
      completion-ignore-case t)

;; 候補更新時に最初の候補を選択しない (2023-03-19追記: verticoにカスタマイズ変数が追加された! https://github.com/minad/vertico/commit/bedd146c3ffc236d746d088a94c3858eca0618d9 (Add vertico-preselect option (Fix #306) · minad/vertico@bedd146))
(setq vertico-preselect 'prompt)

;; (2023-04-18追記)
;; ただし、require-matchがt(やそれに類するもの)で入力が空ではなくマッ
;; チする候補がある場合は、その候補の先頭を選択する。
(defun my-vertico--recompute (orig-fun pt content &rest args)
  (let ((result (apply orig-fun pt content args)))
    (if (and (not (equal content "")) ;;入力が空の時は(require-matchであっても)defaultまたはnilを返すことになっている。
             (> (alist-get 'vertico--total result) 0)
             ;; completing-readの説明によれば
             ;; nil,confirm,confirm-after-completion以外はtのように
             ;; 振る舞うべき。
             (not (memq minibuffer--require-match
                        '(nil confirm confirm-after-completion))))
        (setf (alist-get 'vertico--index result) 0))
    result))
(advice-add #'vertico--recompute :around #'my-vertico--recompute)

;; 以下は過去のもの
;; ;; 候補更新時に最初の候補を選択しない (2022-12-01追記: vertico--allow-prompt-p関数が無くなって代わりに戻り値にvertico--allow-promptが増えたので修正)
;; (defun my-vertico--recompute (original-fun &rest args)
;;   ;; vertico--update-candidatesの最後の処理を置き換える。
;;   (let ((result (apply original-fun args)))
;;     (when result
;;       (let ((lock         (alist-get 'vertico--lock-candidate result))
;;             (allow-prompt (alist-get 'vertico--allow-prompt result))
;;             (index        (alist-get 'vertico--index result)))
;;         (when (and (not lock)
;;                    allow-prompt)
;;           ;; lockされておらず, require-matchじゃない場合は現在入力中の文字列を選択する。
;;           (setf (alist-get 'vertico--index result) -1))))
;;     result))
;; (advice-add #'vertico--recompute :around #'my-vertico--recompute)

;; ;; 候補更新時に最初の候補を選択しない (2022-10-24追記: 関数名や戻り値が変わったので修正)
;; (defun my-vertico--recompute (original-fun &rest args)
;;   ;; vertico--update-candidatesの最後の処理を置き換える。
;;   (let ((result (apply original-fun args)))
;;     (when result
;;       (let ((lock        (alist-get 'vertico--lock-candidate result))
;;             (def-missing (alist-get 'vertico--default-missing result))
;;             (index       (alist-get 'vertico--index result)))
;;         (when (and (not lock)
;;                    (let ((vertico--default-missing def-missing)) (vertico--allow-prompt-p)))
;;           ;; lockされておらず, require-matchじゃない場合は現在入力中の文字列を選択する。
;;           (setf (alist-get 'vertico--index result) -1))))
;;     result))
;; (advice-add #'vertico--recompute :around #'my-vertico--recompute)

;; ;; 候補更新時に最初の候補を選択しない (旧バージョン用)
;; (defun my-vertico--recompute-candidates (original-fun &rest args)
;;   ;; vertico--update-candidatesの最後の処理を置き換える
;;   (let ((result (apply original-fun args)))
;;     (when result
;;       (unless (nth 3 result) ;;3=index
;;         (setq vertico--lock-candidate nil)
;;         (setf (nth 3 result) ;;3=index
;;               (if (vertico--allow-prompt-selection-p)
;;                   ;; require-matchじゃない場合は現在入力中の文字列を選択する
;;                   -1
;;                 ;; require-matchの場合は最初の候補を選択する
;;                 0))))
;;     result))
;; (advice-add #'vertico--recompute-candidates :around #'my-vertico--recompute-candidates)

;; より簡単な方法としてRETをvertico-exit-inputにするという方法もある。
;; (define-key vertico-map [remap exit-minibuffer] #'vertico-exit-input)
;; しかし補完候補がハイライトされるので気持ち悪い。
;; プロンプトがハイライトされているべき。

移行して一番気になったのが、何かを入力するとそれにマッチする候補の中から最初の物を選択してしまうという挙動だ。これはhelmでもivyでもどうしてもなじめなかった。

(2023-03-19追記: 最初の物を選択しないようにできるカスタマイズ変数 vertico-preselect が追加されたので設定でこの挙動は抑制できるようになった)

よく起きる問題としては、find-fileで新規ファイルを作るときに間違って既存のファイルを開いてしまうというものだ。ファイル名を入力してRETを押したとき、その入力した文字列と部分的にマッチする候補が開いてしまうのだ。

helmにせよivyにせよverticoにせよそれに対する標準の解決策は一応あって、何か特殊なキーで確定させるというものだ。ivyではC-M-j、verticoではM-RETで現在の入力をそのまま確定できる。helmは覚えていないが、何かしらあったと思う。

私はこの挙動にとても強い違和感を覚える。

find-fileで出てくるミニバッファ入力は、「任意の」ファイル名を入力するためのものだ。決して限られた候補の中から選ぶというものでは無い。Emacsの標準的な動作でも、任意の文字列を入力して明示的にTABを押したときだけ補完されるというものだ。どんなOSのファイル選択ダイアログだって、ファイル名を入力してEnterを押したら別のファイル名が入力されるなんてバカなことは起きない。それをverticoでは新規のファイルを作る時だけM-RETを押せと言うのである。Emacsを使う人たちは学習能力が高いからどんな特殊な操作でもすぐに慣れてしまうのだろうけど私はそんなものには慣れたくない。そんなことだから初心者に逃げられるのでは無いか。

Emacs Lispにおいて補完入力を行う関数はcompleting-readだ。Verticoはここにも作用する。

(completing-read "商品: " '("ringo-ame" "ringo-cake" "ringo-juice"))

上のような式を評価したとして、ringo RETと入力したらringoと入力されるべきでringo-ame等が入力されるのは納得いかない。ringo-ameやらは例えば開発者が気を利かせて用意しただけの何か優先度の低い候補に過ぎないかもしれない。ringoよりringo-ameを優先すべき理由はない。

(completing-read "商品: " '("ringo-ame" "ringo-cake" "ringo-juice") nil t)

completing-readの第4引数(REQUIRE-MATCH)がtならば分かる。この場合、任意の文字列は入力できず、必ず候補の中から選ばなければならないからだ(ただし何も入力せずRETを押したときは第7引数のデフォルト値またはnilとなる)。

というわけで、この辺りを修正したのが上の設定だ。文字列を入力すると候補は絞り込まれるが選択はされない。C-n、C-pで明示的に選択したときだけそれが使われる。ただし、REQUIRE-MATCHがtのときだけは最初の候補を自動的に選択する。

このやり方の欠点は大半は候補の中から選べば済むときでも必ず明示的に選択操作をしなければならない点だ。例えばfind-fileは既存のファイルを選択することの方が多いはずだ。また、switch-to-bufferも既存のバッファを選択することの方が多い(任意のバッファ名を入力して新しく作れるということを覚えていない人もいるのではないか)。こういったときに、一つに絞り込んだのにいちいち選択操作をしなければならないのは面倒に感じることもあるかもしれない。

しかしそれはfind-fileやswitch-to-bufferといった用途毎に特有な事情であって、ミニバッファ補完付き入力全般に適用出来る問題ではないはずだ。用途毎にどちらが多いかは異なるのだから。後は好みで、find-fileやswitch-to-bufferに設定があればよい。個人的にはswitch-to-bufferで新しいバッファを作る機能は使っていないので、REQUIRE-MATCH=t、つまり既存のバッファのみ選択出来るようになっていて構わない。find-fileはいちいち選択するのに苦は感じない。どちらにせよ一つに絞り込んだらTABを押してRETすれば素のEmacsと同じなのだから分かりやすい。

ミニバッファ補完を活用する応用コマンドについては、ivyに依存するcounselではなくより幅広いミニバッファ補完に対応したconsultへ移行した。比較してみるとcounselの方が若干使いやすいと感じることもあるが、両者そう大きくは違わない。それほど使い込んでいるわけではないというのもあるけれど。Embarkと組み合わせると絞り込んだ候補に対して色々アクションを適用出来たりするようだ。拙作のel-winsearchにはconsult版を追加した。