🦬

Magit を支える transient.el の使い方

2022/06/28に公開

transient.el とは?

Magit で使われているメニューUIライブラリで単独で使うこともできる。通常のキーアサインでどこに何を定義したか一日で忘れてしまう自分には必須のライブラリである。

前提

事前に次が定義されているとする。

(defun c1() (interactive) (message "c1"))
(defun c2() (interactive) (message "c2"))
(defun c3() (interactive) (message "c3"))

いちばん簡単な例

(require 'transient)

(transient-define-prefix f1 ()
  [("a" "item1" (lambda () (interactive) (message "c1")))])

(global-set-key (kbd "C-c RET") 'f1)
a item1
  • M-x f1 または C-c RET でメニューが起動する
  • a で lambda ブロックを実行する

指定の関数を実行する

(transient-define-prefix f1 ()
  [("a" "item1" c1)])

lambda のかわりに関数を指定する。a item1 と表示され ac1 を実行する。

関数を複数指定する

(transient-define-prefix f1 ()
  [
   ("a" "item1" c1)
   ("b" "item2" c2)
   ])

複数書くとにならぶ。

a item1
b item2

実行してもメニューを閉じないようにする

(transient-define-prefix f1 ()
  [
   ("a" "item1" c1 :transient t)
   ])
  • :transient ta を押してもメニューが閉じなくなる
  • 何回か連続で実行するような関数に指定する
  • C-q C-g ESC ESC ESC のどれかで閉じる

関数の方にパラメータを書いてもいい

defun のかわりに transient-define-suffix を使うと関数側でパラメータを書ける。

(transient-define-suffix c1 ()
  :key "a"
  :description "item1"
  :transient t
  (interactive)
  (message "c1"))

これでメニューの方には関数を並べるだけでよくなる。

(transient-define-prefix f1 ()
  [(c1)])

設定は上書きできる。

(transient-define-prefix f1 ()
  [(c1 :key "b")])

普通の関数だったかのように書いてもいい。

(transient-define-prefix f1 ()
  [("b" "item2" c1)])

description を入れる

"コマンド" の部分のこと。なくてもいいけどあるとわかりやすい。

(transient-define-prefix f1 ()
  ["コマンド" ("a" "item1" (lambda () (interactive) (message "c1")))])
コマンド
 a item1

レイアウト

これを頭で理解するのは難しい。

「配列」を並べると下方向に増える

(transient-define-prefix f1 ()
  ["上" (a) (b)]
  ["下" (c) (d)])
上
 a
 b
下
 c
 d

「配列の配列」なら右方向に増える

(transient-define-prefix f1 ()
  [["左" (a) (b)]["右" (c) (d)]])
左    右
 a     c
 b     d

「配列の配列」を並べるとそれが下方向に増える

(transient-define-prefix f1 ()
  ["上" ["左上" (a) (b)] ["右上" (c) (d)]]
  ["下" ["左下" (a) (b)] ["右下" (c) (d)]])
左上    右上
  a       c
  b       d
左下    右下
  a       c
  b       d

description を動的に変更するには?

(transient-define-prefix f1 ()
  [:description (lambda () "上") (c1)])

引数・オプション

基本形

(transient-define-prefix f1 ()
  ["オプション" ("-x" "論理型" "--xxx")]
  ["コマンド" ("a" "item1" (lambda () (interactive) (prin1 (transient-args 'f1))))])
オプション
 -x 論理型 (--xxx)

コマンド
 a item1
  • すぐに a をタイプすると nil を表示する
  • -x をタイプすると --xxx が有効になる (色が少し変わる)
  • --xxx を有効にしてから a をタイプすると ("--xxx") を表示する
  • (transient-args 'f1) がオプションの配列を返している
    • 引数の f1 の部分は対象の関数名
    • オプションがないときは nil になる (空配列ではなく)
      • が、それが問題になることはとくにない
  • -x の部分はハイフンで始めなくてもいい
    • x とすれば x でトグルできる
    • しかしコマンド見間違うので - で始めた方がいい
  • "オプション""コマンド" は無くてもいいけどあるとわかりやすい
  • オプションとコマンドをどこに書くか決まりはない
    • 配列内要素の3つ目が関数かどうかで見分けているみたい
    • だからオプション類は下に配置してもいい

初期値を指定する

(transient-define-prefix f1 ()
  :value '("--xxx")
  ["オプション" ("-x" "論理型" "--xxx")]
  ["コマンド" ("a" "item1" (lambda () (interactive) (prin1 (transient-args 'f1))))])
  • :value で初期値を指定する
  • 上の例ではメニューを起動した時点で --xxx が有効になっている (色がついている)

オプションを永続化する

C-x を押すとこうなるので、


C-x を押したところ

続けて C-s するとファイルに保存する

~/.emacs.d/transient/values.el
((f1 "--xxx"))

よく使うかもしれない操作まとめ

操作 意味 備考
C-x C-s ファイルに保存 永続化
C-x s メモリに保存 Emacsを閉じたら元に戻る
C-x C-k 初期値に戻す

文字列型のオプション

--xxx--xxx= に変更する。

(transient-define-prefix f1 ()
  ["オプション" ("-x" "文字列型" "--xxx=")]
  ["コマンド" ("a" "item1" (lambda () (interactive) (prin1 (transient-args 'f1))))])
  • -x をタイプするとプロンプトが出る
  • foo と入力すると --xxx=foo と表示が変わる
  • その後で a をタイプすると ("--xxx=foo") を表示する
  • 論理型と同様に初期値を書ける。例: :value '("--xxx=foo")

オプションの値を取り出す

汎用の transient-arg-value で配列から特定の値だけをええ感じに取り出せる。

それぞれ論理型と文字列型の例:

(transient-arg-value "--xxx" '("--xxx"))      ; => t
(transient-arg-value "--xxx=" '("--xxx=foo")) ; => "foo"

となるので第二引数にはオプションの配列 (transient-args 'f1) を渡す。

(transient-args 'f1)                                ; => ("--xxx=foo")
(transient-arg-value "--xxx=" (transient-args 'f1)) ; => "foo"

文字列型オプションの = の有無はかなり重要

  • --xxx=foo ではなく --xxx foo としてしまうと値を取り出せない
  • 素直に --xxx=foo となるようにする
(transient-arg-value "--xxx " '("--xxx foo")) ; => nil

オプションから外部コマンドの組み立て

オプションの捌き方はさまざまだけど外部コマンドの仕様と一致している場合は円滑に渡せる。

そのまま `git` の引数とする例:
(require 's)
(shell-command (s-join " " (cons "git" (transient-args 'f1))))

(transient-args 'f1)("--version") だとすれば git --version を実行する。

表示条件

(transient-define-prefix f1 ()
  [
    :if (lambda () t)
    ("a" "item1" c1 :if (lambda () t))
   ])
  • 配列の先頭に書くと配列要素全体に適用する
  • 個別に指定してもよい
  • いろんな条件構文が用意されている
    • if if-not if-non-nil if-nil if-mode if-not-mode if-derived if-not-derived
  • if-derivedderived-mode-p で判定する
  • :if-mode (ruby-mode rust-mode) のように複数指定してもよい

日本語の問題

列で表示するとき項目名が日本語だとエラーになる場合がある。そんなときは :variable-pitch t で回避できる。

(transient-define-prefix f1 ()
  :variable-pitch t
  [
   [("a" "ダウンロード" c1)]
   [("b" "デスクトップ" c2)]
   ])

毎回指定するのは面倒なのでグローバルな設定とした方がよさそう。

(setq transient-align-variable-pitch t)

次の固定幅フォント使うオプションを有効にしても回避できる。

(setq transient-force-fixed-pitch t)

しかし文字に隙間が空いて古めかしい感じの見た目になってしまう。

実用編

ディレクトリ移動

(transient-define-prefix f1 ()
  "ディレクトリ移動"
  ["ディレクトリ移動"
   ("t" "デスクトップ" (lambda () (interactive) (dired "~/Desktop")))
   ("l" "ダウンロード" (lambda () (interactive) (dired "~/Downloads")))
   ("d" "書類"         (lambda () (interactive) (dired "~/Documents")))
   ("i" "画像"         (lambda () (interactive) (dired "~/Pictures")))
   ("x" "Dropbox"      (lambda () (interactive) (dired "~/Dropbox")))
   ("e" "Emacs"        (lambda () (interactive) (dired "~/.emacs.d")))
   ("z" "Zenn"         (lambda () (interactive) (dired "~/src/zenn-content/articles")))
   ("s" "src"          (lambda () (interactive) (dired "~/src")))
   ])

このようにすればディレクトリ間の距離の感覚はなくなっていく。

Railsアプリ内でディレクトリ移動

.git の親ディレクトリからの相対的な移動の例:

(transient-define-prefix f1 ()
  "Railsアプリ内の規定ディレクトリに移動する"
  ["ディレクトリ移動"
   ("l" "log"        (lambda () (interactive) (my-chdir-from-git-root "log")))
   ("m" "model"      (lambda () (interactive) (my-chdir-from-git-root "app/models")))
   ("c" "controller" (lambda () (interactive) (my-chdir-from-git-root "app/controllers")))
   ("t" "test"       (lambda () (interactive) (my-chdir-from-git-root "spec")))
   ])

(defun my-chdir-from-git-root (dir)
  "ディレクトリ移動(.gitの親から相対的に)"
  (interactive)
  (dired
   (concat
    (locate-dominating-file default-directory ".git")
    dir)))

入力がバッティングしたらどうする?

仮に最初につくった方と混ぜたとすると l が両方にあるので干渉してしまう。そういう場合は片方を2文字にする手もある。

(transient-define-prefix f1 ()
  ["ディレクトリ移動"
   ("l" "ダウンロード" (lambda () (interactive) (dired "~/Downloads")))
   ("rl" "log"         (lambda () (interactive) (my-chdir-from-git-root "log")))
   ])

ただし、慣習として Magit でこのような定義は見たことがない。徹底して1文字にこだわっている。2文字になるぐらいなら1文字目で別のメニューを発動させた方がいいかもしれない。

参照

https://github.com/magit/transient/wiki/Developer Quick Start Guide
https://magit.vc/manual/transient.html
https://github.com/magit/transient

Discussion