优化counsel-bookmark

Emacs的书签非常好用,通过它可以非常快速地跳转到对应位置。虽然使用 ivy-switch-buffer 已经可以非常快速地切换文件。但是书签跳转还要更高效一些:

  1. 书签精确到行级别,通过书签可以直接跳到某个文件的某一行。也可以给不同的行设置书签,然后在不同的行之间来回跳转。

  2. 有些文件路径非常深,记不住文件名和路径,不能通过 ivy-switch-buffer 快速的找到。这时候就可以记录一个书签,起个好记忆的名字,或者给书签打上tag。

Emacs原生书签功能相对简单,因此我使用了 BookmarkPlus,它在原生的基础上做了很多扩展,比如支持给书签打tag,支持函数的书签,支持书签的注释等等。

随着日常使用的积累,书签越来越多,书签的搜索成了一个大问题。一直用 counsel-bookmark 来搜索书签,但它只能按书签名称进行搜索:

书签多了之后经常忘记名称,找不到想要的书签,counsel-bookmark的功能就显得有些鸡肋。其实除了书签的名称之外,文件路径、书签tag都是非常重要的信息,如果把这些内容综合起来再搜索,只需要记得其中一项就可以匹配,效率会高很多。

后来发现了 ivy-richall-the-icons-ivy-rich 这两个包,使用后搜索列表增加了icon、书签类型、文件路径等信息:

比counsel-bookmark好一些了,但仍然不能按文件路径进行搜索。因为ivy-rich只是修改了列表展示,并没有修改原始的候选列表。因此产生了优化counsel-bookmark的想法,实现这几点功能:

  1. 在搜索列表中保留icon、文件路径、标签等丰富的信息。

  2. 文件路径可以搜索。

  3. 增加书签tag展示,支持按tag搜索书签。

优化思路

翻一下counsel-bookmark的代码,发现它的逻辑很简单:通过 ivy-read 让用户选择需要跳转到的书签(这里是把书签名称用作候选列表),再调用 bookmark-jump 函数跳转。

我们的目标就很明确了: 自定义 ivy-read 的候选列表,把丰富的信息展示出来,在用户选择之后,通过原始书签记录进行跳转。 但如果ivy-read接受的是字符串的列表,我们如何把字符串和原始的书签数据绑定到一起呢?通过 ivy-read 文档了解到它不仅可以接受字符串列表,还可以接受alist。

做一个简单的实验:

(ivy-read "x: " '(("a" . 1) ("b" . 2))
          :action (lambda (x) (message "%s" x)))
     ⇒ (a 1)

运行上面这段代码,如果选择了a,则输出结果为 (a . 1) 。因此我们可以构造一个alist传递给ivy-read,key是包含丰富信息的字符串,value是原始的书签数据。

构造候选列表

我们想要构造的字符串要包含这些内容:书签名称、文件的完整路径、tag以及icon,前面三个都可以在emacs的书签数据拿到。emacs的书签存储在 bookmark-alist 变量中,用 (car bookmark-alist) 看一下第一个元素:

(#("jxq-run-bazel" 0 13 (bmkp-full-record #0))
 (tags "emacs" "init")
 (filename . "~/code/jxq_config/emacs/config.org")
 (buffer-name . "config.org")
 (front-context-string . "(defun jxq-run-b")
 (rear-context-string . "compile cmd)))

")
 (front-context-region-string)
 (rear-context-region-string)
 (visits . 8)
 (time 25561 58133 43415 0)
 (created 25560 54274 926647 0)
 (position . 88091))

这就是一个 alist,可以用 alist-get 方法查找对应的数据。

(alist-get 'tags (car bookmark-alist))
    ⇒ ("emacs" "init")
(alist-get 'filename (car bookmark-alist))
    ⇒ "~/code/jxq_config/emacs/config.org"

至于书签icon,可以直接使用 all-the-icons-ivy-rich-bookmark-icon ,我是基于它的代码做了一些小修改,参考下面的 jxq-bookmark-icon 函数。

效果

最终实现效果如下图所示。与开启了ivy-rich的版本相比,一个比较明显的变化是增加了tag。另一个变化是整个候选列表中的内容都可以用来匹配,比如图里第一个书签文件名称是rust-run,文件路径是rust-cargo.el,在原生的版本中是无法通过 cargo 这个关键词来匹配的。

完整实现代码

(defun jxq-bookmark-icon (bmk)
  (all-the-icons-ivy-rich--format-icon
   (let ((file (alist-get 'filename bmk)))
     (cond
      ((alist-get 'function bmk)
       (all-the-icons-fileicon "elisp"))

      ((null file)
       (all-the-icons-material "block"
                               :height 1.0
                               :v-adjust -0.2
                               :face 'all-the-icons-ivy-rich-warn-face))

      ((file-remote-p file)
       (all-the-icons-octicon "radio-tower" :height 0.8 :v-adjust 0.01))

      ((not (file-exists-p file))
       (all-the-icons-material "block"
                               :height 1.0 :v-adjust -0.2
                               :face 'all-the-icons-ivy-rich-error-face))

      ((file-directory-p file)
       (all-the-icons-octicon "file-directory" :height 0.9 :v-adjust 0.01))

      (t (all-the-icons-icon-for-file
          (file-name-nondirectory file) :height 0.9 :v-adjust 0.0))))))

(defun jxq-normalize-string (str len)
  (let ((str-len (string-width str)))
    (cond ((< str-len len)
           (concat str (make-string (- len str-len) ? )))
          ((> str-len len)
           (s-reverse (s-truncate len (s-reverse str) "…")))
          (t str))))

(defun jxq-bookmark-item (bmk)
  (let* ((total-width (window-width (minibuffer-window)))
         (sep (make-string 5 ?\s))
         (name-width (floor (* 0.25 total-width)))
         (filename-width (floor (* 0.5 total-width)))

         (raw-filename (cdr (assoc 'filename bmk)))

         (name (jxq-normalize-string
                (substring-no-properties (car bmk)) name-width))
         (filename (propertize
                    (jxq-normalize-string
                     (or raw-filename "") filename-width)
                    'face font-lock-doc-face))
         (tags (propertize
                (jxq-normalize-string
                 (mapconcat
                  (lambda (tag) (concat "#" tag))
                  (alist-get 'tags bmk)
                  " ")
                 20)
                'face font-lock-keyword-face))
         (icon (jxq-bookmark-icon bmk)))
    (list (concat icon "\t" name sep filename sep tags) bmk)))

(defun jxq-bookmarks-list ()
  (interactive)
  (mapcar (lambda (bmk)
            (jxq-bookmark-item bmk))
          bookmark-alist))

(defun jxq-counsel-bookmark ()
  "counsel-bookmark plus"
  (interactive)
  (ivy-read "bookmark: "
            (jxq-bookmarks-list)
            :action
            (lambda (x)
              (pcase x
                (`(,name ,bmk) (bookmark-jump bmk))
                (_ (bookmark-set x))))))

(define-key modalka-mode-map (kbd "jb") #'jxq-counsel-bookmark)