目录

改善evil-mode在pdf-tools中的搜索体验

问题

之前一直使用evil-collection包以完善evil-mode绑定在各个地方的一致性及覆盖率,特别的,evil-collection会为pdf-tools创建对应的evil-mode绑定,比如 /? 会绑定到 isearch-forward/isearch-backward 上, n/N 则会绑定到 isearch-repeat-forward/isearch-repeat-backward 上,问题在于pdf-tools的整个搜索机制是基于isearch上实现的,故很多行为和isearch一致,但是和evil-mode用户的期望却不匹配,比如:

  1. C-s 开始isearch搜索,然后输入关键字,此时默认匹配项会渐进高亮,这个没问题,问题是按回车后,isearch认为搜索结束,会直接清除高亮,这个行为可以通过 lazy-highlight-cleanup 来控制,问题是,pdf-tools直接禁用了isearch的lazy-highlight机制,导致 lazy-highlight-cleanup 无法控制该行为。由于按 / 也是调用 isearch-forward 进行搜索,因此在输入关键字后按回车,高亮一样会消失,这和平常evil-mode的行为是不一致的。
  2. / 开始搜索,输入关键字,之后按回车后, isearch-mode 会被禁用,这之后按 n/N 调用 isearch-repeat-forward/isearch-repeat-backward 会进一步跳转到下一个匹配项,这点和预期一样,但是当搜索到PDF文件的下一页时,高亮会消失,出现该问题是因为,在搜索当前页时,pdf-tools不会redisplay 该页的内容,但是当搜索进行到PDF文件的下一页后,pdf-tools会进行redisplay,此时检测到 isearch-mode 没有启用,便不会进行高亮了。
  3. 绑定到 n/N 的命令 isearch-repeat-forward/isearch-repeat-backward 不会记住上一次的搜索方向。

思路及解决方案

我在Emacs China中发过完整的解决方案,这里主要讲下思路。

先解决在 / 搜索后,按回车,高亮会消失的问题,回答一个问题:为什么按回车后高亮会被清除?答案在于 pdf-isearch-mode-cleanup 函数,代码如下:

1
2
3
4
5
6
(defun pdf-isearch-mode-cleanup ()
  "Cleanup after exiting Isearch.

This is a Isearch interface function."
  (pdf-isearch-active-mode -1)
  (pdf-view-redisplay))

该函数调用 pdf-view-redisplay 以redisplay当前页面,这时候高亮就没了。该函数会在 isearch-mode-end-hook 的时候被执行,如下:

1
(add-hook 'isearch-mode-end-hook 'pdf-isearch-mode-cleanup nil t)

isearch-mode-end-hook 会在isearch搜索结束后被触发,也就是按回车后的时候触发,这就是为什么按回车后,高亮会消失。我们期望的行为是:搜索如果匹配失败了,则回车后清除高亮,否则,回车后保持高亮,最简单、但是比较hack的方式就是覆盖 pdf-isearch-mode-cleanup 的实现,为此,我们先定义一个函数来判断isearch是否搜索匹配失败,如下:

1
2
(defun osfva-isearch-failed? ()
  (or (not isearch-success) isearch-error))

接着,我们用advice覆盖 pdf-isearch-mode-cleanup 的实现,仅在搜索失败的时候,清除高亮,如下:

1
2
3
4
5
6
7
(defun osfva-pdf-isearch-mode-cleanup ()
  "Don't cleanup highlight if search successfully."
  (pdf-isearch-active-mode -1)
  (when (osfva-isearch-failed?) (pdf-view-redisplay)))

(advice-add #'pdf-isearch-mode-cleanup
            :override #'osfva-pdf-isearch-mode-cleanup)

解决完回车后清除高亮的问题后,我们继续解决第二个问题,即按 n/N 在搜索进行到下一页后,高亮会消失的问题,造成这个问题的原因是 pdf-isearch-hl-matches ,它的实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
(defun pdf-isearch-hl-matches (current matches &optional occur-hack-p)
  "Highlighting edges CURRENT and MATCHES."
  (cl-check-type current pdf-isearch-match)
  (cl-check-type matches (list-of pdf-isearch-match))
  (cl-destructuring-bind (fg1 bg1 fg2 bg2)
      (pdf-isearch-current-colors)
    (let* ((width (car (pdf-view-image-size)))
           (page (pdf-view-current-page))
           (window (selected-window))
           (buffer (current-buffer))
           (tick (cl-incf pdf-isearch--hl-matches-tick))
           (pdf-info-asynchronous
            (lambda (status data)
              (when (and (null status)
                         (eq tick pdf-isearch--hl-matches-tick)
                         (buffer-live-p buffer)
                         (window-live-p window)
                         (eq (window-buffer window)
                             buffer))
                (with-selected-window window
                  (when (and (derived-mode-p 'pdf-view-mode)
                             (or isearch-mode
                                 occur-hack-p)
                             (eq page (pdf-view-current-page)))
                    (pdf-view-display-image
                     (pdf-view-create-image data :width width))))))))
      (pdf-info-renderpage-text-regions
       page width t nil
       `(,fg1 ,bg1 ,@(pdf-util-scale-pixel-to-relative
                      current))
       `(,fg2 ,bg2 ,@(pdf-util-scale-pixel-to-relative
                      (apply 'append
                             (remove current matches))))))))

重点看那个lambda里面的内容,这个lambda会在 pdf-info-renderpage-text-regions 渲染完成后,异步执行,由于渲染是异步做的,因此渲染完成后,我们的PDF缓冲区、窗口可能已经被kill或者 delete掉了,甚至major-mode等被改变了,故它在lambda中做了一系列检查,比如 (buffer-live-p buffer)(window-live-p window)(derived-mode-p 'pdf-view-mode) 等等,检查通过才进行高亮,特别重要的一个就是,它检查了 isearch-mode 是否处于启用状态,然而前面说了,回车后, isearch-mode 会被禁用,故该判断会失败,不过注意到它还判断参数 occur-hack-p 是否为真,如果为真,则即使 isearch-mode 处于禁用状态时也进行高亮,那这个 occur-hack-p 是做什么的?这个参数实际上是供 pdf-isearch-occur 命令使用的,该命令可以在 C-s 或者 / 开始搜索后,输入关键字,然后不要按回车,按 M-s o 触发,它用 occur 以进行搜索和显示,会有一个专门的缓冲区列出PDF文件中所有匹配关键字的位置(整个文件,不止当前页),你可以在该缓冲区对应项按回车以跳转到对应页的对应位置,不仅如此,整个过程中当前页面的匹配项都是处于高亮状态的,这个 pdf-isearch-occur 会间接传递 occur-hack-ptpdf-isearch-hl-matches ,从而实现即使 isearch-mode 处于禁用状态时也显示高亮,那我们可以模仿它,注入一个变量以控制高亮的显示,如下:

1
2
3
4
5
6
7
(defvar-local osfva-pdf-isearch-highlight-matches nil)

(defun osfva-pdf-isearch-hl-matches-controllable-highlight (orig-fun current matches &optional occur-hack-p)
  (funcall orig-fun current matches (or osfva-pdf-isearch-highlight-matches occur-hack-p)))

(advice-add #'pdf-isearch-hl-matches
            :around #'osfva-pdf-isearch-hl-matches-controllable-highlight)

接着,我们创建自己的 isearch-repeat-forward/isearch-repeat-backward ,不同于原函数,我们的函数会设置 osfva-pdf-isearch-hl-matches-controllable-highlight 为non-nil以保证 isearch-mode 处于禁用状态时也显示高亮,如下:

1
2
3
4
5
6
7
8
9
(defun osfva-pdf-isearch-repeat-forward (&optional arg)
  (interactive "P")
  (setq osfva-pdf-isearch-highlight-matches t)
  (isearch-repeat-forward arg))

(defun osfva-pdf-isearch-repeat-backward (&optional arg)
  (interactive "P")
  (setq osfva-pdf-isearch-highlight-matches t)
  (isearch-repeat-backward arg))

最后,我们绑定这两个函数到 n/N 键,如下:

1
2
3
(evil-define-key* 'normal pdf-view-mode-map
  (kbd "n") #'osfva-pdf-isearch-repeat-forward
  (kbd "N") #'osfva-pdf-isearch-repeat-backward)

至此,就实现了按 n/N 保持高亮了,但是在搜索过程中,我们会发现一个问题,正常搜索超过PDF结尾时,isearch会暂停,再按一下搜索才会wrap到PDF开头重新开始搜索,这里的问题是,暂停的时候,结尾的高亮会不见,要再按一下搜索,使其wrap到PDF开头高亮才会再次正常显示,一个简单的修复方法就是改成搜索超过PDF结尾时,直接wrap到PDF开头开始搜索,不要暂停,如下:

1
2
(add-hook 'pdf-view-mode-hook
          (lambda () (setq-local isearch-wrap-pause 'no)))

为了避免 osfva-pdf-isearch-hl-matches-controllable-highlight 对正常使用isearch进行搜索产生影响,我们要在pdf-tools正常使用isearch进行搜索时,重置该变量,如下:

1
2
3
4
5
(advice-add #'pdf-isearch-mode-initialize
            :before
            (lambda (&rest args)
              "Reset `osfva-pdf-isearch-highlight-matches'."
              (setq osfva-pdf-isearch-highlight-matches nil)))

针对 pdf-isearch-occur 也可以进行类似的处理,不过读者思考下,会发现 osfva-pdf-isearch-hl-matches-controllable-highlight 的具体值和 pdf-isearch-occur 间接传递 occur-hack-ptpdf-isearch-hl-matches 并不冲突,所以可以不处理。

除此之外,我们还得考虑清除高亮的问题,因为高亮一直都在,我们希望按某个键可以清除高亮,比如 ESC 就是一个合适的选择,先定义清除高亮的函数,如下:

1
2
3
(defun osfva-pdf-isearch-cleanup-highlight ()
  (setq osfva-pdf-isearch-highlight-matches nil)
  (pdf-view-redisplay))

就是调用 pdf-view-redisplay redisplay下,高亮就清除了,还得把 osfva-pdf-isearch-highlight-matches 重置了。

接下来定义另外一个函数,该函数用于替代原始 ESC 的功能,原始 ESC 是进入evil normal state,现在我们替换成进入evil normal state的同时,清除高亮,如下:

1
2
3
4
5
(evil-define-command osfva-pdf-view-force-normal-state ()
  :repeat abort
  :suppress-operator t
  (evil-normal-state)
  (osfva-pdf-isearch-cleanup-highlight))

将该函数绑定到 ESC 上,和之前的绑定一起写,如下:

1
2
3
4
(evil-define-key* 'normal pdf-view-mode-map
  (kbd "n") #'osfva-pdf-isearch-repeat-forward
  (kbd "N") #'osfva-pdf-isearch-repeat-backward
  (kbd "<escape>") #'osfva-pdf-view-force-normal-state)

继续解决下一个问题,即绑定到 n/N 的命令 isearch-repeat-forward/isearch-repeat-backward 不会记住上一次的搜索方向,定义我们自己的搜索函数,让它们记住上一次的搜索方向,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(defvar-local osfva-pdf-isearch-forward? t)

(defun osfva-pdf-isearch-forward (&optional regexp-p no-recursive-edit)
  "Like `isearch-forward', but remember the previous search direction."
  (interactive "P\np")
  (setq osfva-pdf-isearch-forward? t)
  (isearch-forward regexp-p no-recursive-edit))

(defun osfva-pdf-isearch-backward (&optional regexp-p no-recursive-edit)
  "Like `isearch-backward', but remember the previous search direction."
  (interactive "P\np")
  (setq osfva-pdf-isearch-forward? nil)
  (isearch-backward regexp-p no-recursive-edit))

接下来,修改之前定义的 osfva-pdf-isearch-repeat-forward 以及 osfva-pdf-isearch-repeat-backward ,让它们根据记住的搜索方向,自己选择合适方向的搜索函数,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(defun osfva-pdf-isearch-repeat-forward (&optional arg)
  (interactive "P")
  (setq osfva-pdf-isearch-highlight-matches t)
  (if osfva-pdf-isearch-forward?
      (isearch-repeat-forward arg)
    (isearch-repeat-backward arg)))

(defun osfva-pdf-isearch-repeat-backward (&optional arg)
  (interactive "P")
  (setq osfva-pdf-isearch-highlight-matches t)
  (if osfva-pdf-isearch-forward?
      (isearch-repeat-backward arg)
    (isearch-repeat-forward arg)))

将新的搜索函数绑定到 /? 上,和之前的绑定一起写,如下:

1
2
3
4
5
6
(evil-define-key* 'normal pdf-view-mode-map
  (kbd "/") #'osfva-pdf-isearch-forward
  (kbd "?") #'osfva-pdf-isearch-backward
  (kbd "n") #'osfva-pdf-isearch-repeat-forward
  (kbd "N") #'osfva-pdf-isearch-repeat-backward
  (kbd "<escape>") #'osfva-pdf-view-force-normal-state)

至此,所有想要的功能都完成了,完整代码如下(外层得用 with-eval-after-load 包裹住,否则一些函数可能未定义,也可以放 use-package的 :config 区域):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
(with-eval-after-load 'pdf-tools
  (defun osfva-isearch-failed? ()
    (or (not isearch-success) isearch-error))

  (defvar-local osfva-pdf-isearch-highlight-matches nil)
  (defun osfva-pdf-isearch-cleanup-highlight ()
    (setq osfva-pdf-isearch-highlight-matches nil)
    (pdf-view-redisplay))

  (defun osfva-pdf-isearch-hl-matches-controllable-highlight (orig-fun current matches &optional occur-hack-p)
    (funcall orig-fun current matches (or osfva-pdf-isearch-highlight-matches occur-hack-p)))
  (advice-add #'pdf-isearch-hl-matches
              :around #'osfva-pdf-isearch-hl-matches-controllable-highlight)

  (defvar-local osfva-pdf-isearch-forward? t)

  (defun osfva-pdf-isearch-forward (&optional regexp-p no-recursive-edit)
    "Like `isearch-forward', but remember the previous search direction."
    (interactive "P\np")
    (setq osfva-pdf-isearch-forward? t)
    (isearch-forward regexp-p no-recursive-edit))

  (defun osfva-pdf-isearch-backward (&optional regexp-p no-recursive-edit)
    "Like `isearch-backward', but remember the previous search direction."
    (interactive "P\np")
    (setq osfva-pdf-isearch-forward? nil)
    (isearch-backward regexp-p no-recursive-edit))

  (defun osfva-pdf-isearch-repeat-forward (&optional arg)
    (interactive "P")
    (setq osfva-pdf-isearch-highlight-matches t)
    (if osfva-pdf-isearch-forward?
        (isearch-repeat-forward arg)
      (isearch-repeat-backward arg)))

  (defun osfva-pdf-isearch-repeat-backward (&optional arg)
    (interactive "P")
    (setq osfva-pdf-isearch-highlight-matches t)
    (if osfva-pdf-isearch-forward?
        (isearch-repeat-backward arg)
      (isearch-repeat-forward arg)))

  (add-hook 'pdf-view-mode-hook
            (lambda () (setq-local isearch-wrap-pause 'no)))

  (evil-define-command osfva-pdf-view-force-normal-state ()
    :repeat abort
    :suppress-operator t
    (evil-normal-state)
    (osfva-pdf-isearch-cleanup-highlight))

  (advice-add #'pdf-isearch-mode-initialize
              :before
              (lambda (&rest args)
                "Reset `osfva-pdf-isearch-highlight-matches'."
                (setq osfva-pdf-isearch-highlight-matches nil)))

  (defun osfva-pdf-isearch-mode-cleanup ()
    "Don't cleanup highlight if search successfully."
    (pdf-isearch-active-mode -1)
    (when (osfva-isearch-failed?) (pdf-view-redisplay)))
  (advice-add #'pdf-isearch-mode-cleanup
              :override #'osfva-pdf-isearch-mode-cleanup)

  (evil-define-key* 'normal pdf-view-mode-map
    (kbd "/") #'osfva-pdf-isearch-forward
    (kbd "?") #'osfva-pdf-isearch-backward
    (kbd "n") #'osfva-pdf-isearch-repeat-forward
    (kbd "N") #'osfva-pdf-isearch-repeat-backward
    (kbd "<escape>") #'osfva-pdf-view-force-normal-state))

还有一个问题,上面的代码仅适用于没有使用evil-collection包的情况,如果使用evil-collection,还得考虑按键绑定被evil-collection覆盖的情况,我们定义一个宏来处理,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
(defmacro osfva-with-eval-after-evil-collection-mode (mode &rest body)
  (declare (indent defun))
  (let ((feature-name-var (gensym "feature-name"))
        (mode-var (gensym "mode"))
        (mode-str-var (gensym "mode-str"))
        (hook-fun-name-var (gensym "hook-fun-name-var")))
    `(let* ((,mode-var ,mode)
            (,mode-str-var (symbol-name ,mode-var))
            (,feature-name-var (intern (concat "evil-collection-" ,mode-str-var))))
       (if (featurep ,feature-name-var)
           ;; In current implmentation of evil-collection, when the
           ;; file is already loaded, the setup function has also
           ;; called, so we can evaluate the body directly in this
           ;; case.
           (progn ,@body)
         (let ((,hook-fun-name-var
                ;; Intern the generated symbol, otherwise,
                ;; evil-collection cannot find the definition of the
                ;; hook function (this generally won't cause
                ;; conflicts because `gensym' will append a global
                ;; counter to the generated symbol).
                (intern
                 (symbol-name
                  (gensym (concat "osfva-with-eval-after-evil-collection-mode-"
                                  ,mode-str-var))))))
           (defalias ,hook-fun-name-var
             (lambda (mode keymaps)
               (when (eq mode ,mode-var)
                 ,@body))
             (concat "Override the things set up by the evil-collection mode " ,mode-str-var "."))
           (add-hook 'evil-collection-setup-hook ,hook-fun-name-var))))))

它做的就是判断evil-collection在对应mode的按键绑定设置完没,如果还没有,那就安排在它设置完之后再执行body设置我们的按键绑定,如果已经设置完了,那就直接执行body设置我们的按键绑定。这里使用gensym生成hook函数名,由于生成的符号是未intern的,这会导致evil-collection无法调用我们的hook函数,于是我们需要提前intern下,同时由于gensym会在符号名后面附加一个全局计数器,加上我们的函数有前缀 osfva- ,因此一般不会产生名称冲突。 hook函数则是用 defalias 定义的,因为我们的函数名是在运行时动态生成的,而 defun 不会eval函数名参数。

使用evil-collection包对应的完整代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
(with-eval-after-load 'pdf-tools
  (defun osfva-isearch-failed? ()
    (or (not isearch-success) isearch-error))

  (defvar-local osfva-pdf-isearch-highlight-matches nil)
  (defun osfva-pdf-isearch-cleanup-highlight ()
    (setq osfva-pdf-isearch-highlight-matches nil)
    (pdf-view-redisplay))

  (defun osfva-pdf-isearch-hl-matches-controllable-highlight (orig-fun current matches &optional occur-hack-p)
    (funcall orig-fun current matches (or osfva-pdf-isearch-highlight-matches occur-hack-p)))
  (advice-add #'pdf-isearch-hl-matches
              :around #'osfva-pdf-isearch-hl-matches-controllable-highlight)

  (defvar-local osfva-pdf-isearch-forward? t)

  (defun osfva-pdf-isearch-forward (&optional regexp-p no-recursive-edit)
    "Like `isearch-forward', but remember the previous search direction."
    (interactive "P\np")
    (setq osfva-pdf-isearch-forward? t)
    (isearch-forward regexp-p no-recursive-edit))

  (defun osfva-pdf-isearch-backward (&optional regexp-p no-recursive-edit)
    "Like `isearch-backward', but remember the previous search direction."
    (interactive "P\np")
    (setq osfva-pdf-isearch-forward? nil)
    (isearch-backward regexp-p no-recursive-edit))

  (defun osfva-pdf-isearch-repeat-forward (&optional arg)
    (interactive "P")
    (setq osfva-pdf-isearch-highlight-matches t)
    (if osfva-pdf-isearch-forward?
        (isearch-repeat-forward arg)
      (isearch-repeat-backward arg)))

  (defun osfva-pdf-isearch-repeat-backward (&optional arg)
    (interactive "P")
    (setq osfva-pdf-isearch-highlight-matches t)
    (if osfva-pdf-isearch-forward?
        (isearch-repeat-backward arg)
      (isearch-repeat-forward arg)))

  (add-hook 'pdf-view-mode-hook
            (lambda () (setq-local isearch-wrap-pause 'no)))

  (evil-define-command osfva-pdf-view-force-normal-state ()
    :repeat abort
    :suppress-operator t
    (evil-normal-state)
    (osfva-pdf-isearch-cleanup-highlight))

  (advice-add #'pdf-isearch-mode-initialize
              :before
              (lambda (&rest args)
                "Reset `osfva-pdf-isearch-highlight-matches'."
                (setq osfva-pdf-isearch-highlight-matches nil)))

  (defun osfva-pdf-isearch-mode-cleanup ()
    "Don't cleanup highlight if search successfully."
    (pdf-isearch-active-mode -1)
    (when (osfva-isearch-failed?) (pdf-view-redisplay)))
  (advice-add #'pdf-isearch-mode-cleanup
              :override #'osfva-pdf-isearch-mode-cleanup)

  (defmacro osfva-with-eval-after-evil-collection-mode (mode &rest body)
    (declare (indent defun))
    (let ((feature-name-var (gensym "feature-name"))
          (mode-var (gensym "mode"))
          (mode-str-var (gensym "mode-str"))
          (hook-fun-name-var (gensym "hook-fun-name-var")))
      `(let* ((,mode-var ,mode)
              (,mode-str-var (symbol-name ,mode-var))
              (,feature-name-var (intern (concat "evil-collection-" ,mode-str-var))))
         (if (featurep ,feature-name-var)
             ;; In current implmentation of evil-collection, when the
             ;; file is already loaded, the setup function has also
             ;; called, so we can evaluate the body directly in this
             ;; case.
             (progn ,@body)
           (let ((,hook-fun-name-var
                  ;; Intern the generated symbol, otherwise,
                  ;; evil-collection cannot find the definition of the
                  ;; hook function (this generally won't cause
                  ;; conflicts because `gensym' will append a global
                  ;; counter to the generated symbol).
                  (intern
                   (symbol-name
                    (gensym (concat "osfva-with-eval-after-evil-collection-mode-"
                                    ,mode-str-var))))))
             (defalias ,hook-fun-name-var
               (lambda (mode keymaps)
                 (when (eq mode ,mode-var)
                   ,@body))
               (concat "Override the things set up by the evil-collection mode " ,mode-str-var "."))
             (add-hook 'evil-collection-setup-hook ,hook-fun-name-var))))))

  (osfva-with-eval-after-evil-collection-mode 'pdf
    (evil-define-key* 'normal pdf-view-mode-map
      (kbd "/") #'osfva-pdf-isearch-forward
      (kbd "?") #'osfva-pdf-isearch-backward
      (kbd "n") #'osfva-pdf-isearch-repeat-forward
      (kbd "N") #'osfva-pdf-isearch-repeat-backward
      (kbd "<escape>") #'osfva-pdf-view-force-normal-state)))