2023-08-17

image-diredにサムネイル画像関連フックを追加する

もっと……もっとフックを……、フックが欲しいのじゃ!

私はdired-details-rを使ってファイルの詳細情報をファイル名の右側に表示していますが、image-diredでファイル名の前にサムネイルを追加すると表示がずれることがあります。

少し前の改良でファイル名の前にあるアイコンやらサムネイルやらの幅を考慮することは出来たのですが、それはdiredがディレクトリを読み込んだ直後の話。その後でサムネイルの状態に何か変化があると、やはりずれてしまいます。

典型的なのは、サムネイルの表示・非表示を切り替える操作をした場合。 image-dired-dired-toggle-marked-thumbs コマンド(C-t C-tで選択した画像のサムネイルをトグル)や私の改造だと my-image-dired-dired-show-all-thumbs コマンド(C-t C-aで全画像のサムネイルを表示)あたりの操作です。表示すれば詳細が右にずれますし、整えてから消せば左にずれます。

もう一つはサムネイル画像を生成し終わったとき。新しくサムネイルを生成するときは、ひとまず無効な画像(30ピクセル四方の正方形です)を表示させておいて、生成が終わったら更新するようにimage-diredは出来ています。問題はこれが非同期であるという点です(もちろん長時間待たされないという意味では良い点です)。サムネイルを生成するための外部プロセス(ImageMagickやらGraphicsMagickやら)が終了したらclear-image-cacheを実行することで画像を表示するオーバーレイが更新されます。その時に詳細情報が右にずれます。非同期なのでそのタイミングは予測できません。

というわけで次図のような画面が出来上がるわけです。

詳細情報が右にずれたdiredバッファ
図1: 詳細情報が右にずれたdiredバッファ

もちろんgを押せばすぐに揃うのですが、面倒くさいせいかなぜか押さないことも多いです。するとずれた部分が時々目の中にチラチラ入ってきて何だか気分が悪いです。

それに何だかみっともないじゃないですか。こうやってずれたスクリーンショットを気が付かずに出してしまうこともあります(実際に最近の記事の中にあります!)。こんなに好き勝手に改造して自己満足に浸っているくせにズレてるのかよ! みたいな。

というわけで直すことにしたのでした。

これを直すには、diredバッファへサムネイルのオーバーレイを挿入した後とサムネイル画像の生成が終わった後に、見た目を更新する処理を挟む必要があります。しかしそのようなタイミングで呼び出してくれる便利なフックはもちろん存在しません。フック……もっとフックを! と思うことはEmacsではいつものことです。というわけで、そのフック(それらのタイミングで任意の関数を呼び出す仕組み)をimage-diredに追加してみます。

まずはサムネイルを表示・消去した直後に呼び出すフック。diredバッファにサムネイルを表示するオーバーレイを挿入するのはimage-dired-dired.el内のimage-dired-dired-toggle-marked-thumbsコマンドです(Emacs 29.1時点)。その中身は次の通り。

;; Emacs 29.1付属のimage-dired-dired.elより
;;;###autoload
(defun image-dired-dired-toggle-marked-thumbs (&optional arg)
  "Toggle thumbnails in front of marked file names in the Dired buffer.
If no file is marked, toggle display of thumbnail on the current file's line.
ARG, if non-nil (interactively, the prefix argument), specifies the files
whose thumbnail display to toggle instead of the marked files: if ARG is an
integer, use the next ARG (or previous -ARG, if ARG<0) files; any other
value of ARG means toggle thumbnail display of the current line's file."
  (interactive "P" dired-mode)
  (setq image-dired--generate-thumbs-start  (current-time))
  (dired-map-over-marks
   (let ((image-pos  (dired-move-to-filename))
         (image-file (dired-get-filename nil t))
         thumb-file
         overlay)
     (when (and image-file
                (string-match-p (image-dired--file-name-regexp) image-file))
       (setq thumb-file (create-image
                         (image-dired--get-create-thumbnail-file image-file)))
       ;; If image is not already added, then add it.
       (let ((thumb-ov (cl-loop for ov in (overlays-in (point) (1+ (point)))
                                if (overlay-get ov 'thumb-file) return ov)))
         (if thumb-ov
             (delete-overlay thumb-ov)
           (put-image thumb-file image-pos)
           (setq overlay
                 (cl-loop for ov in (overlays-in (point) (1+ (point)))
                          if (overlay-get ov 'put-image) return ov))
           (overlay-put overlay 'image-file image-file)
           (overlay-put overlay 'thumb-file thumb-file))
         ;; ★ここに追加したい
         )))
   ;; Show or hide thumbnail on ARG next files.
   arg)
  (add-hook 'dired-after-readin-hook
            'image-dired-dired-after-readin-hook nil t))

この関数の (if thumb-ov (overlay-put overlay 'thumb-file thumb-file)) の後くらいに処理を挟みたいわけです。この関数はトグル動作なので、表示するときと消去するときの両方で通るところに挟むのが良いでしょう。しかしそこに処理を挟むのは困難です。

それ以前に、私はこの前の改造でこの関数をバラしてサムネイルの前後の余白を調整したところでした。

なので、その改造後の関数に手を入れてフック機能を追加してしまいましょう。以下★印の所三箇所を追加しました。

(defun my-image-dired-dired-toggle-marked-thumbs (&optional arg)
  ;; Derived from `image-dired-dired-toggle-marked-thumbs'
  "Toggle thumbnails in front of file names in the Dired buffer.
If no marked file could be found, insert or hide thumbnails on the
current line.  ARG, if non-nil, specifies the files to use instead
of the marked files.  If ARG is an integer, use the next ARG (or
previous -ARG, if ARG<0) files."
  (interactive "P")
  (dired-map-over-marks
   (my-image-dired-dired-set-thumb-visibility 'toggle) ;;ファイル毎の処理を分離
   arg             ; Show or hide image on ARG next files.
   'show-progress) ; Update dired display after each image is updated.
  (add-hook 'dired-after-readin-hook
            'image-dired-dired-after-readin-hook nil t))

;; ★2023-08-17追加
(defvar my-image-dired-dired-change-thumb-hook nil
  "diredバッファ内のサムネイルの表示状態が変化したら呼び出されるフッ
クです。

呼ばれるときの引数は(THUMBNAIL-OVERLAY ORIGINAL-IMAGE-FILENAME
IMAGE-POSITION)です。現在のバッファは変化したdiredバッファです。")

(defun my-image-dired-dired-set-thumb-visibility (visibility)
  ;; Derived from `image-dired-dired-toggle-marked-thumbs'
  (let ((image-pos  (dired-move-to-filename))
        (image-file (dired-get-filename nil t)))
    (when (and image-file
               (string-match-p (image-file-name-regexp) image-file))
      (let* ((thumb-file
              ;; Emacs 28まで
              ;;(image-dired-get-thumbnail-image image-file)
              ;; Emacs 29から
              (create-image
               (image-dired--get-create-thumbnail-file image-file)))
             (thumb-ov (cl-loop for ov in (overlays-in (point) (1+ (point)))
                                if (overlay-get ov 'thumb-file) return ov)))
        ;; 他から使うためにトグル以外もできるようにした
        (if thumb-ov
            (when (memq visibility '(nil toggle))
              (delete-overlay thumb-ov)
              ;; ★2023-08-17追加 : 表示→非表示
              (run-hook-with-args 'my-image-dired-dired-change-thumb-hook
                                  nil image-file image-pos))
          (when (memq visibility '(t toggle))
            ;; ★2023-08-17修正 : 非表示→表示
            (let ((new-thumb-ov
                   ;; 独自の関数を呼ぶ
                   (my-image-dired-dired-create-thumbnail-overlay
                    image-pos image-file thumb-file)))
              (run-hook-with-args 'my-image-dired-dired-change-thumb-hook
                                  new-thumb-ov image-file image-pos))))))))

こうしておくことで、一つのサムネイルが表示・非表示されるたびに任意の処理を挟むことが出来ます。そこでレイアウトを整えてやろうというわけです。

次にサムネイル画像の生成が終わったとき。画像の生成はimage-dired-external.el内のimage-dired-create-thumb-1関数で行っています。内容を見てみましょう。

;; Emacs 29.1付属のimage-dired-external.elより

(defun image-dired-create-thumb-1 (original-file thumbnail-file)
  "For ORIGINAL-FILE, create thumbnail image named THUMBNAIL-FILE."
  (image-dired--check-executable-exists
   'image-dired-cmd-create-thumbnail-program)
  (let* ((size (number-to-string (image-dired--thumb-size)))
         (modif-time (format-time-string
                      "%s" (file-attribute-modification-time
                            (file-attributes original-file))))
         (thumbnail-nq8-file (replace-regexp-in-string ".png\\'" "-nq8.png"
                                                       thumbnail-file))
         (spec `((?s . ,size) (?w . ,size) (?h . ,size)
                 (?m . ,modif-time)
                 (?f . ,original-file)
                 (?q . ,thumbnail-nq8-file)
                 (?t . ,thumbnail-file)))
         (thumbnail-dir (file-name-directory thumbnail-file))
         process)
    (when (not (file-exists-p thumbnail-dir))
      (with-file-modes #o700
        (make-directory thumbnail-dir t))
      (message "Thumbnail directory created: %s" thumbnail-dir))

    ;; Thumbnail file creation processes begin here and are marshaled
    ;; in a queue by `image-dired-create-thumb'.
    (let ((cmd image-dired-cmd-create-thumbnail-program)
          (args (mapcar
                 (lambda (arg) (format-spec arg spec))
                 (if (memq image-dired-thumbnail-storage
                           image-dired--thumbnail-standard-sizes)
                     image-dired-cmd-create-standard-thumbnail-options
                   image-dired-cmd-create-thumbnail-options))))
      (image-dired-debug "Running %s %s" cmd (string-join args " "))
      (setq process
            (apply #'start-process "image-dired-create-thumbnail" nil
                   cmd args)))

    (setf (process-sentinel process)
          (lambda (process status)
            ;; Trigger next in queue once a thumbnail has been created
            (cl-decf image-dired-queue-active-jobs)
            (image-dired-thumb-queue-run)
            (when (= image-dired-queue-active-jobs 0)
              (image-dired-debug
               (format-time-string
                "Generated thumbnails in %s.%3N seconds"
                (time-subtract nil
                               image-dired--generate-thumbs-start))))
            (if (not (and (eq (process-status process) 'exit)
                          (zerop (process-exit-status process))))
                (message "Thumb could not be created for %s: %s"
                         (abbreviate-file-name original-file)
                         (string-replace "\n" "" status))
              (set-file-modes thumbnail-file #o600)
              (clear-image-cache thumbnail-file)
              ;; PNG thumbnail has been created since we are
              ;; following the XDG thumbnail spec, so try to optimize
              (when (memq image-dired-thumbnail-storage
                          image-dired--thumbnail-standard-sizes)
                (cond
                 ((and image-dired-cmd-pngnq-program
                       (executable-find image-dired-cmd-pngnq-program))
                  (image-dired-pngnq-thumb spec))
                 ((and image-dired-cmd-pngcrush-program
                       (executable-find image-dired-cmd-pngcrush-program))
                  (image-dired-pngcrush-thumb spec))
                 ((and image-dired-cmd-optipng-program
                       (executable-find image-dired-cmd-optipng-program))
                  (image-dired-optipng-thumb spec)))))))
    process))

この関数は引数にoriginal-fileとthumbnail-fileを取ります。分かりやすいですね。

start-processで外部プロセスを起動しています。

その直後にプロセスオブジェクトのsentinelとしてlambda関数を設定することでプロセスの終了を検出しています。sentinelはプロセスの状態が変わったときに呼び出されます。

問題なのはこれがlambda、つまり匿名の関数だということ。この関数にadviceを追加して処理を追加するようなマネはできません。

しかしその後を見ると、このimage-dired-create-thumb-1はプロセスオブジェクトを返却しています。やった! ラッキー!! プロセスオブジェクトが得られるなら、そのsentinelを書き替えることが出来ます。次のように。

(defvar my-image-dired-create-thumb-hook nil)

(defun my-image-dired-create-thumb-1-around-for-call-hook
    (orig-fun original-file thumbnail-file &rest args)
  "`image-dired-create-thumb-1'に対するaround adviceです。"
  (let* (;; 元のimage-dired-create-thumb-1を呼び出す。
         (process (apply orig-fun original-file thumbnail-file args))
         ;; 返ってきたprocessオブジェクトの元のsentinelをとっておく。
         (orig-sentinel (process-sentinel process)))
    ;; sentinelを横取りする。
    (set-process-sentinel
     process
     (lambda (process status)
       (prog1
           ;; 元のsentinelを呼ぶ。
           (funcall orig-sentinel process status)
         ;; 成功だったら、フックを呼び出す。
         (when (and (eq (process-status process) 'exit)
                    (zerop (process-exit-status process)))
           (run-hook-with-args 'my-image-dired-create-thumb-hook
                               original-file thumbnail-file)))))
    process))

(advice-add 'image-dired-create-thumb-1 :around
            #'my-image-dired-create-thumb-1-around-for-call-hook)

つまり、image-dired-create-thumb-1が設定した元のsentinelをとっておいて、独自の関数にsentinelを書き替えてしまい、その独自の関数は元のsentinelを呼び出してから追加の処理をするわけです。

こういう横取りの仕方を見ると私はよく割り込みベクタのフックを思い出します。MS-DOSで常駐プログラム書いたり、システムコールをフックして処理を挟んでみたり。やってることは昔と変わらないなー。

というわけでこれで処理を挟む準備は整いました。後は次のようにすればdired-details-rがレイアウトを整えてくれます。

;; サムネイル画像をdiredバッファに挿入した後、または削除した後の処理。
;; 呼び出されるときのcurrent-bufferはdiredバッファです。

(defun my-image-dired-dired-update-on-change-thumb (_ov _original-file pos)
  (save-excursion
    (goto-char pos)
    (when (fboundp 'dired-details-r-update-current-line)
      (dired-details-r-update-current-line))))

(add-hook 'my-image-dired-dired-change-thumb-hook
          #'my-image-dired-dired-update-on-change-thumb)

;; サムネイル画像を生成し終わったときの処理。
;; 呼び出されるときのcurrent-bufferがどこかは分からないので注意すること。

(defun my-image-dired-dired-update-on-create-thumb (original-path thumb-path)
  ;; 念のため確実にフルパスにする。
  (setq original-path (expand-file-name original-path))
  (setq thumb-path (expand-file-name thumb-path))

  (let ((dired-buffers
         ;; 元画像があるディレクトリを表示するdiredバッファを列挙する。
         ;; サブディレクトリを挿入していると複数あり得る。
         (let ((original-dir (file-name-directory original-path)))
           (cl-loop for buf in (buffer-list)
                    when (and
                          (eq (buffer-local-value 'major-mode buf) 'dired-mode)
                          (assoc original-dir
                                 (buffer-local-value 'dired-subdir-alist buf)))
                    collect buf))))

    ;; 各diredバッファについて:
    (dolist (buf dired-buffers)
      (with-current-buffer buf
        ;; @todo サムネイル左右の余白も更新したい。
        ;; dired-details-rでoriginal-pathの行だけ更新する。
        (when (fboundp 'dired-details-r-update-file)
          (dired-details-r-update-file original-path))))))

(add-hook 'my-image-dired-create-thumb-hook
          'my-image-dired-dired-update-on-create-thumb)

画像生成後の方は、いつ呼び出されるのか予測が出来ないので少し込み入っています。