Monthly Archives: 12月 2024

2024-12-12

which-key.elとメニューの定義

Emacs 30からwhich-key-modeが追加されます。which-key-mode自体は何年も前からある(github.com/justbur/emacs-which-key)ようですが、それがEmacsに組み込まれたということのようです。私はこれまで使ったことはありませんでしたが、手元のEmacs 30.0.92に入っていたので使ってみました。

そもそもwhich-key-modeは何かというと、次に押すべきキーを教えてくれる、というと抽象的で分かりづらいでしょうか。一言で言うと、自動的にキーメニューを表示してくれる(グローバルマイナー)モードです。例えばC-xと入力して少し待つ(次に打つべきキーを迷っている)とC-xで始まるキー割り当ての一覧が表示されます。もちろんC-xだけでなく複数のキーストロークが必要な場面では自動的にメニューが出ます。

which-key-modeを有効にしてC-xを押したところ
which-key-modeを有効にしてC-xを押したところ

キーメニューというとHydraTransientを思い出しますが、これらは基本的には明示的にメニューを定義した上で使うものだと思います。自分で使いやすいようにメニューを設計できますが、逆に言えばそのような手間をかけなければなりません。一方which-key-modeは現在のキーマップから自動的にメニューを作成してくれます。

which-key-modeは事前の定義が不要な分手軽で広範囲で使用できますが、表示されるのは無味乾燥なコマンド名の羅列……と言いたいところですが、そこにはちゃんと対策が用意されています。

次の関数を使うと特定のキー割り当てに対して表示される説明を好きなように変更(置き換え)できます。

後者二つはwhich-key-replacement-alist等のwhich-key専用の変数に置き換えルールを記録しますが、興味深いのは一つ目の特定のKEYMAPに対する説明の置き換えです。which-key-add-keymap-based-replacementsの実装を見れば分かりますが、その情報は指定されたKEYMAPそれ自体に保存されます。説明(replacement)に文字列が指定された場合を追ってみると、最終的には (define-key keymap (kbd key) (cons replacement 元の割り当て)) が実行されていることが分かります。これはいったいどういうことでしょうか?

元々Emacsのキーマップというのはメニューを記述する役割を兼ねています。そのためキーマップにはメニュー項目用の文字列を埋め込めるようになっています。キーマップの書式(Format of Keymaps (GNU Emacs Lisp Reference Manual))にある item-name というがそれです。つまりwhich-key-add-keymap-based-replacementsがやっていることは実質的にはメニューを構築しているようなものです。そしてwhich-key-modeはそのメニューの項目用の文字列をコマンド名の代わりに表示してくれるというわけです。

ということはつまり、わざわざwhich-key-add-keymap-based-replacementsを使わずともキーマップに項目文字列を最初から設定しておけばwhich-key-modeのキーメニューをよりわかりやすく出来るということです。

キーマップを作成するには例えば次のようなコードがよく使われてきました(<Emacs29)。

(let ((km (make-sparse-keymap)))
  (define-key km (kbd "C-c h c") 'hello-cat)
  (define-key km (kbd "C-c h d") 'hello-dog)
  (define-key km (kbd "C-c h f") 'hello-flog)
  km)

これを次のようにするだけでwhich-key-modeのメニューをよりわかりやすくすることが出来ます。

(let ((km (make-sparse-keymap)))
  (define-key km (kbd "C-c h") (cons "Hello Animals" (make-sparse-keymap)))
  (define-key km (kbd "C-c h c") '("Cat" . hello-cat))
  (define-key km (kbd "C-c h d") '("Dog" . hello-dog))
  (define-key km (kbd "C-c h f") '("Flog" . hello-flog))
  km)

(注: 最近(>=Emacs29)ではdefine-keyはレガシー扱いとなりkeymap-setやdefine-keymapが追加されていますが、いずれにせよ項目文字列を指定する方法は用意されています)

マイナーモードならこんな感じでしょうか。

(define-minor-mode hello-animals-mode
  "Hello Animals"
  :keymap
  `((,(kbd "C-c h") . ("Hello Animals" . ,(make-sparse-keymap)))
    (,(kbd "C-c h c") . ("Cat" . hello-cat))
    (,(kbd "C-c h d") . ("Dog" . hello-dog))
    (,(kbd "C-c h f") . ("Flog" . hello-flog))))

実際に有効にして C-c h を押してみたところ、次のように表示されました。

マイナーモードのキーマップに項目名を入れて使ってみた所
マイナーモードのキーマップに項目名を入れて使ってみた所

複雑なキーマップを分かりやすくしたい、でもHydraやTransientを使うのは面倒という場合はこのような工夫をしてみてはどうでしょうか。

余談:

ちなみにキーマップをキーボードで操作できるメニューにしたいのであれば tmm-prompt を使うという方法もあります。

(defun hello-cat () (interactive) (message "Nya"))
(defun hello-dog () (interactive) (message "Wan"))
(defun hello-flog () (interactive) (message "Geko"))

(let ((km (make-sparse-keymap)))
  (define-key km (kbd "C-c") (cons "C-c" (make-sparse-keymap)))
  (define-key km (kbd "C-c h") (cons "Hello Animals" (make-sparse-keymap)))
  (define-key km (kbd "C-c h c") '("Cat" . hello-cat))
  (define-key km (kbd "C-c h d") '("Dog" . hello-dog))
  (define-key km (kbd "C-c h f") '("Flog" . hello-flog))
  (tmm-prompt km)) ;; (x-popup-menu t km))だとマウスで操作するメニューになる

HydraTransientは良くも悪くも独特の世界観を作ってしまっているところが欠点ではありますよね。で、作り込んでみてもキー操作を覚えてしまえば見なくなるわけですし。それに一つのメニューの中に沢山のコマンドを表示してしまうと探すのが大変でむしろM-xでミニバッファから補完した方が探しやすいということにもなりかねません。Magitでたまにしか使わないコマンドのキーがメニューから見つけられないことが私は良くあります。いや、MagitはそもそもGitのコマンド体系自体が(以下略

2024-12-11 ,

Diredでファイルの右側に好きな情報を追加する

いやぁ、やっぱりSVGっていいですよね。先日から作っているel-xmpですが、レーティングの表示が単なる数字なのがつまらないのでSVGで描いてみました。ひょっとしたらUnicodeやアイコンフォントで作れるのかなとも思いましたが、よく分かりませんし出来たとしてもフォントへの依存は避けられないと思ったのでSVGでいいや。それで色々調整してこんな感じで生成できました。

2024-12-11-svg-rating-text.png

何がいいって、こういう風にエディタの中に直接結果が挿入されるところですよね。私は80年代のBASICからプログラムを始めた人間ですが、あれもこんな風にコードを書いて実行したらその画面の中の好きな位置に結果が出力されるような環境でした(末尾では無く好きな位置にというのが結構重要だと思っています)。グラフィックスは別プレーンに描いてから無限ループで止めておかなければならなかったりもしましたが、時代が進むとテキストの後ろに合成表示されるようになったりもしました。私はEmacs Lispをいじっているとよくあの頃の感覚を思い出します。

それで気を良くして実際にdiredの中にレーティングを表示させてみたところ……

2024-12-11-dired-with-rating-1.png

あれ、数字のままだ。何でだ???

……ああ、私はファイルの詳細情報を右側に表示するためにdired-details-rを使用していたのでした。こいつはオーバーレイ(またはファイル数が多いときはテキストプロパティ)を使用して詳細情報をファイル名の右側に表示します。そしてオーバーレイ(またはテキストプロパティ)のdisplayプロパティを使用してファイル名前後の文字を詳細情報込みの文字列に置き換えることで無理矢理詳細情報を好きな位置に表示させているのでした。

で、SVG画像もまたdisplayプロパティを必要とします。もちろん画像を含めて全てのテキストプロパティをコピーして右側に持ってきているのですが、displayプロパティの中のdisplayプロパティはEmacsの仕様では無視されるため、SVG画像としてでは無く元の数字文字列として表示されてしまうわけです。

なのでいったんdired-details-rを切れば表示されます。

2024-12-11-dired-with-rating-2.png

この通り。

うーん、どうしよう。オーバーレイプロパティのbefore-stringやafter-stringを使えば回避できなくもないけれど、ファイル数が多いときにはテキストプロパティを使いたいので却下。

と、ここで思い出すのは前々回書いたDiredに好きなファイル情報を追加する話。

そこではファイル名の左側に情報を挿入しましたが、ファイルの右側にも情報を挿入できるのでしょうか。

そこでも触れましたが、Diredバッファ内のファイル名が書かれている範囲には dired-filename というテキストプロパティが設定されています。そのためファイル名の前後に何か余計なテキストが挿入されてもファイル名を見失うことはありません。なのでその点だけ見れば大丈夫なはずです。……本当にそうでしょうか?

ファイル名の右側に情報を追加してみる

前と同じサンプルを使って試してみましょう。

ファイルのスコアを書いた.file-score.csvファイル(前と同じ):

muscat.html,230
melon.html,140
grape.html,185
cherry.html,210
strawberry.html,153

これを読む関数(前と同じ):

(defun my-dired-sort-read-file-score (dir)
  (ignore-errors
    (with-temp-buffer
      (insert-file-contents (file-name-concat dir ".file-score.csv"))
      (goto-char (point-min))
      (cl-loop until (eobp)
               for (file score) = (split-string (buffer-substring (point) (line-end-position)) ",")
               collect (cons (expand-file-name file dir)
                             (string-to-number score))
               do (forward-line)))))

一つのディレクトリの中のファイルリストの範囲を特定する関数(前と同じ):

(defun my-dired-next-dir-files-region ()
  ;; ファイル名が無いところをスキップ
  (while (and (null (dired-move-to-filename))
              (= (forward-line) 0)))
  (unless (eobp)
    (let ((beg (line-beginning-position)))
      ;; ファイル名があるところをスキップ
      (while (and (dired-move-to-filename)
                  (= (forward-line) 0)))
      (cons beg (line-beginning-position)))))

で、肝心の追加するところは関数名は同じままで少し変更してみます。

(defun my-dired-add-file-score-to-region (beg end)
  ;; (↓when-let/if-let廃止するとか言ってるの超うざくね? アホか)
  (when-let* ((file-score-alist
               (my-dired-sort-read-file-score (dired-current-directory))))
    (goto-char beg)
    (while (< (point) end)
      ;; 各行について (★この辺から変更)
      (when-let* ((file (dired-get-filename nil t)) ;; ファイル名の取得
                  (score (alist-get file file-score-alist nil nil #'string=))) ;; スコアの取得
        ;; 両者取得できたら
        ;; 行末へ(つまりファイル名の右へ)
        (end-of-line)
        ;; スコアを挿入
        (insert (format "  %4d" score)))
      ;; 次の行へ
      (forward-line))))

最後のコマンドの部分(前と同じ):

(defun my-dired-add-file-score ()
  (interactive nil dired-mode)
  (widen)
  (goto-char (point-min))
  (let ((inhibit-read-only t))
    (while-let ((files-region (my-dired-next-dir-files-region)))
      (let ((beg (car files-region))
            (end (copy-marker (cdr files-region))))
        (my-dired-add-file-score-to-region beg end)
        (goto-char end)
        (set-marker end nil)))))

実際に試してみる

サンプルファイルがあるディレクトリで M-x my-dired-add-file-score したところ、次のような見た目が得られました。

c:/home/user/tmp/my-dired:
drwxrwxrwx  1 user user 4096 24-12-09 21:44 .
drwxrwxrwx  1 user user 4096 24-12-11 17:38 ..
-rw-rw-rw-  1 user user   87 24-12-09 18:45 .file-score.csv
-rw-rw-rw-  1 user user    8 24-12-09 18:46 cherry.html   210
-rw-rw-rw-  1 user user    7 24-12-09 18:46 grape.html   185
-rw-rw-rw-  1 user user    7 24-12-09 18:46 melon.html   140
-rw-rw-rw-  1 user user    8 24-12-09 18:46 muscat.html   230
-rw-rw-rw-  1 user user   14 24-12-09 18:47 strawberry.html   153

数字の位置がずれているのはご愛敬。揃える処理を入れていないので。問題はDiredとしてちゃんと動作するのかどうか。

少し試したくらいだと問題ないように見えます。wでコピーできるファイル名は正しいものですし、ファイルを開いたりも出来ます。

問題点と原因

しかししばらくいじっているとおかしな所も見つけました。

  • ~ (dired-flag-backup-files) や # (dired-flag-auto-save-files) によるマークができない(バックアップファイルや自動保存ファイルが検出されない)
  • 色付け(fontify、font-lock)がおかしい
    • バックアップファイルなどに色が付かない
    • マークやフラグが付いた行はファイル名だけでなくその右側のスコア部分まで色が付く

調べてみるとやはり行の末尾がファイル名の末尾であることを前提にしたコードが存在していることが分かりました。まぁ、そういうのが嫌なのでdired-details-rはオーバーレイやテキストプロパティを使っていた訳なので、案の定と言ったところです。

マークができない原因は、マークする関数が各ファイル行においてend-of-lineで行末へ飛んでからpreceding-charでその前の文字を取得・チェックしている(~や#であるかどうかをチェックしている)からでした。付近のコメントも読んでみた限り、どうも高速化のためにあえてそのようにしているフシがあります。

色付けについては、font-lockの色付けルール(dired-font-lock-keywords)が全般的に行の末尾がファイル名の末尾であることを前提にして書かれてしまっていることが原因です。例えば一番分かりやすいのが正規表現 $ をファイル名の末尾として使ってしまっている所。 $ の前には拡張子とマッチする正規表現が先行します。なので、ファイル名の後に何か情報を追加すると(その追加した情報の末尾がたまたまマッチしない限り)マッチしなくなってしまいます。

dired-details-rの改善

これらの調査を踏まえた上で、右側に詳細情報を表示したときにSVG画像が反映されない問題は結局はdired-details-rの問題なので、そちらを修正することにしました。

まずは詳細情報の表示を(オーバーレイやテキストプロパティでは無く)テキストの挿入によって行うオプションを追加。これまでにも表示方式を指定するカスタマイズ変数はあったので、そこにテキスト挿入によって表示を行う指定値(text)を追加。そしてオーバーレイやテキストを更新する所では、設定値によって代わりにテキスト挿入や削除を行うように変更。

そして今回見つけたいくつかの弊害に対処。マークの問題は関数を丸丸置き換えるしか無さそう。色付けは問題があるfont-lock-keywordsルールを置き換える関数を作成し、dired-mode-hookで実行。どちらもかなり無理矢理。もちろんまだ見つけていない問題がどこかにあるかもしれませんが、それは見つけたらその都度直しましょう。

というわけで、これによって次のように詳細情報をファイル名の右に表示しつつSVGで描かれたレーティングの星マークも表示できました。

2024-12-11-dired-with-rating-3.png

el-xmpの進捗

el-xmpの方はとりあえず、すぐにやりたいことは一通りやったので一段落といった所でしょうか。まだまだやれることはいくらでもありますがキリがないので。

そうそう、ISO base media file formatというのを解析してメタデータを抽出できるようにしたんですよ(規格書が日本円で3~4万円くらいしててドン引き)。QuickTimeから始まってMPEG4(mp4、m4a)とかJPEG2000なんかもだいたい同じ形式みたいです。基本的な構造はボックスと呼ばれるサイズとタイプのヘッダーから始まるデータブロックの羅列です。ボックスの中にボックスが入れ子になる事が良くあるのでツリーのような構造とも言えますが、それほど自己記述性は無くボックスタイプ毎に定義される内部形式(syntax)が分からなければ中に何が入っているかは分かりません(当然入れ子になっているボックスがあるのかも分かりません)。ボックスタイプは典型的な4文字コードに加えてUUIDでも表現できるようになっていて誰でも他人とぶつからないボックスタイプを追加できます。それでメタデータなのですが、案の定あちこちに散らばっている感じですね。そのファイルの素性毎にどこにあるのかまちまちです。もちろんあらゆる方式に対応することは出来ませんが、とりあえず手元にあるm4aファイルくらいは読めるようにしておきました。それと写真をPhotoshopでJPEG2000で保存し直したものなんかもXMPとEXIFが埋め込まれるので読めるようにしておきました。何なんですかねこれ? 自由にボックスタイプを追加できるからって好き勝手しすぎ。これはそう……まるで闇鍋じゃないか!

2024-12-06 ,

Dired内にファイルのメタデータを表示する

というわけで、el-xmpを使えばDiredの中にファイルのメタデータを表示できるようになりました。

2024-12-06-xmp-dired-filter-and-add-columns.png

もちろんソートやフィルタ、マークも可能です。

ようやくWindowsのエクスプローラでも出来るようなことがEmacsでもできるようになりました。良かった良かった。

とは言えサポートしているファイル形式がまだ限られているのでもう少し増やしたいところですね。

とりあえずID3に対応したので、MP3のアーティスト名やアルバム名なんかも表示できるようになりました。