去掉 helm/ivy/selectrum 使用原生的 completions buffer

一路从 helm -> ivy -> selectrum,最终决定使用改造后的原生的 completions buffer。

历史总是惊人的相似,ivy 说 helm 太重太慢、selectrum 说 ivy API 太复杂,其实所有 项目成熟了就会显得笨重复杂。

我从 helm 转 ivy,是发现 helm 就像 windows 下的 Menu,用鼠标或键盘瞄来瞄去才能完 中自已要的,非常累。用上 ivy 后发现 ivy 把它做成了一个列表 ListView,但是又不能 用 Emacs 的内置编辑方式随意编辑列表项。然后最近用上了 selectrum,发现 selectrum 是做成了 Combox,它要好很多,让你可以随意编辑列表项。

最终我意识到这些东西都干扰了我常用的编辑状态:盲打,绝大部分时候我都不需要看完成 列表中的内容就可以通过手工输入按 Tab 键来达到我的目的,但是 helm/ivy/selectrum 会主动抢占输入,而且会自做主张的决定完成列表中哪一个被选中(通常是第一个),从此 我在 minibuffer 中按回车键的时候,列表中当前选中的是哪一项就非常关键了。最简单的 一个例子就是将文件拷贝到另一个目录,目标目录在的完成列表很可能被 ivy 等选中了第 一个文件,此时动作快一点就把那个文件给覆盖了。这导致日常的工作变得压力山大。

我发现其实 windows 原生的 completions buffer 只要解决一些小问题就很完美了: 1, 单列且固定高度 2, 能够自动更新 3, 能够快速过滤完成列表、无缝地跳转 minibuffer/completions buffer

前两者 live-completions 就可以解决,最后一点做一些小设置也可以解决,我的配置如下:

;; live-completions
(use-package live-completions
  :quelpa (live-completions :fetcher github :repo "oantolin/live-completions" :files ("*"))
  :custom
  (live-completions-columns 'single)
  (completions-format 'vertical)
  (live-completions-sort-order 'cycle)
  :defer nil
  :bind
  (:map minibuffer-local-completion-map
        ("C-p" . my/live-completions-previous-line)
        ("C-n" . my/live-completions-next-line)
        ("C-v" . my/live-completions-next-page)
        ("<C-return>" . my/live-completions-force-complete)
        ("M-g" . 'my/live-completions-goto-line))
  (:map completion-list-mode-map
        ("C-g" . quit-window)
        ("TAB" . other-window)
        ("<C-return>" . my/live-completions-force-complete)
        ("M-g" . 'my/live-completions-goto-line)
        ("C-c C-o" . embark-export))
  :config
  (live-completions-mode +1)
  ;; set completions buffer with fixed height
  (defvar my/live-completions--old-temp-buffer-max-height temp-buffer-max-height)
  (defun my/live-completions--temp-buffer-max-height (buffer)
    (if (string= (buffer-name buffer) "*Completions*")
        12
      (funcall my/live-completions--old-temp-buffer-max-height buffer)))
  (setq temp-buffer-max-height #'my/live-completions--temp-buffer-max-height)
  (temp-buffer-resize-mode +1)
  (defun my/completion-list-mode-hook-function ()
    ;; Fix line numbers which start from 5 in completions list.
    (setq-local display-line-numbers-offset -4)
    (display-line-numbers-mode +1)
    (setq-local mode-line-format nil)
    ;; Default to 10 lines even if candidates less than 10.
    (set-window-text-height (get-buffer-window (current-buffer)) 12)
    (setq-local window-size-fixed t)
    (hl-line-mode +1)
    (face-remap-add-relative 'line-number :background 'unspecified :foregound 'unspecified :inherit 'default)
    (face-remap-add-relative 'line-number-current-line :background 'unspecified :foregound 'unspecified :inherit 'default))
  (add-hook 'completion-list-mode-hook #'my/completion-list-mode-hook-function)
  (defun my/live-completions-next-line ()
    (interactive)
    (let ((old-line-number (line-number-at-pos)))
      (ignore-errors
        (next-line))
      (when (eq old-line-number (line-number-at-pos))
        (switch-to-completions)
        (unless (eq (point-min) (point))
          (next-line)))))
  (defun my/live-completions-previous-line ()
    (interactive)
    (let ((old-line-number (line-number-at-pos)))
      (ignore-errors
        (previous-line))
      (when (eq old-line-number (line-number-at-pos))
        (switch-to-completions)
        (unless (eq (point-min) (point))
          (previous-line)))))
  (defun my/live-completions-next-page ()
    (interactive)
    (switch-to-completions)
    (scroll-up-command))
  (defun my/live-completions-goto-line (n)
    "Select candidate by M-<number> or input a line number."
    (interactive
     (list (let* ((type (event-basic-type last-command-event))
                  (char (if (characterp type)
                            ;; Number on the main row.
                            type
                          ;; Keypad number, if bound directly.
                          (car (last (string-to-list (symbol-name type))))))
                  (n (- char ?0)))
             (if (zerop n) 10 n))))
    (switch-to-completions)
    (let ((line (if (<= n 10) n
                  (read-number "Select line: "))))
      (goto-line (- line display-line-numbers-offset))
      (choose-completion)))
  (defun my/live-completions-force-complete ()
    (interactive)
    (if (active-minibuffer-window)
        (select-window (active-minibuffer-window)))
    (minibuffer-force-complete)
    (minibuffer-complete-and-exit))
  (defun my/live-completions-self-insert-command (n)
    (interactive "p")
    (if (active-minibuffer-window)
        (select-window (active-minibuffer-window)))
    (self-insert-command n))
  (define-key completion-list-mode-map [remap self-insert-command] 'my/live-completions-self-insert-command)
  ;; M-<number> to select Nth candidate.
  (dotimes (i 10)
    (define-key minibuffer-local-completion-map (read-kbd-macro (format "M-%d" i)) 'my/live-completions-goto-line)
    (define-key completion-list-mode-map (read-kbd-macro (format "M-%d" i)) 'my/live-completions-goto-line)))

;; orderless
(use-package orderless
  :custom (completion-styles '(basic partial-completion orderless)))

;; consult
(use-package consult
  :custom
  (consult-project-root-function #'projectile-project-root)
  :bind
  (("C-x b" . consult-buffer)
   ("C-c C-s" . consult-line))
  :config
  (defun my/consult-recent-file ()
    (interactive)
    (recentf-mode +1)
    (consult-recent-file)))

;; consult-flycheck
(use-package consult-flycheck)

;; marginalia
(use-package marginalia
  :config
  (marginalia-mode +1))

;; embark
(use-package embark
  :bind
  (:map minibuffer-local-completion-map
        ("C-c C-o" . embark-export)
        ("M-o" . embark-act))
  (:map completion-list-mode-map
        ("C-c C-o" . embark-export)
        ("M-o" . embark-act))
  :config
  ;; Hide the mode line of the Embark live/completions buffers
  (add-to-list 'display-buffer-alist
               '("\\`\\*Embark Collect \\(Live\\|Completions\\)\\*"
                 nil
                 (window-parameters (mode-line-format . none))))
  (setq embark-action-indicator
      (lambda (map _target)
        (which-key--show-keymap "Embark" map nil nil 'no-paging)
        #'which-key--hide-popup-ignore-command)
      embark-become-indicator embark-action-indicator))

;; embark-consult
(use-package embark-consult
  :after (embark consult)
  :demand t
  :hook
  (embark-collect-mode . embark-consult-preview-minor-mode))

主要是 minibuffer 按上一行、下一行、翻页时自动转到在 completions 中执行, C-return 用于快速选择 completions buffer 的强制候选项,completions buffer 中的按 键输入自动做为 minibuffer 中的过滤输入 。

由于 consult marginalia embark 等可以用于原生的 completions,所以功能方面是不用 担心的,目前用起来感觉非常好。

4 个赞

试试icomplete + icomplete-vertical?

我为了达到你不太喜欢的行为还特地改了改键位

;; 不太建议用fido-mode
;; 动了好多`completion-*'的变量
;; 这里的配置是为了模拟ivy的行为,原生icomplete的行为比较类似1f描述的
  (define-key icomplete-minibuffer-map (kbd "RET") 'icomplete-fido-ret)
  (define-key icomplete-minibuffer-map (kbd "DEL") 'icomplete-fido-backward-updir)
  (define-key icomplete-minibuffer-map (kbd "M-m") 'minibuffer-complete-and-exit)

(general-define-key
   :keymaps 'icomplete-minibuffer-map
   [?\t] 'icomplete-force-complete ; keep up with ivy or selectrum
   "C-c C-o" 'embark-export
   )

试用了一下 fido-mode 感觉 icomplete-fido-ret 这个行为有点怪异,其实想要的行为就是直观的选择,目前是在用 vertico

我上面的键位是为了贴近ivy的设置,

只用icomplete不用fido即可

略微尝试了一下 icomplete icomplete-vertical 感觉在盲打输入方面要比 ivy selectrum 好一些,但是选择候选项的直观性比 ivy selectrum 要差一些,还有就是 echo 中的干扰提示信息比较多。当然估计也是我没有做细致的定制的原因。

1 个赞

image 定制一下face和几个变量吧

  (setq icomplete-separator (propertize "₩\n" 'face  '(foreground-color . "SlateBlue1")) 
		icomplete-compute-delay 0
		icomplete-show-matches-on-no-input t
		icomplete-hide-common-prefix nil
		icomplete-tidy-shadowed-file-names t
		)
  ;; highlight current selected
  (custom-set-faces '(icomplete-first-match ((t (:inherit highlight)))))

干扰信息可能是marginalia的问题?

icomplete-modeselectrum 卡,我本来也是用的 icomplete-mode,后来体验了一下 selectrum 发现在 tramp 的情况下还能快速补全就切换过去了。

在看邮件列表的时候看到了 prot 分享的配置

https://lists.gnu.org/archive/html/emacs-devel/2021-04/msg01324.html

这里有个附件可以用 emacs -Q, eval-buffer 体验一下。它的效果与楼主想要的应该差不多,都是使用的自带的 completion 机制, 并且它完全没有使用 icomplete.

重新发明 helm/ivy/selectrum 的前奏。

icomplete-mode 确实在启动minibuffer的时候会卡一秒左右,不知道是我配置的问题不

helm ivy selectrum 之类的工具因为接管了 completing-read,所以它们被使用的上下文是未知的,它们自行决定选中某个候选项这种行为本身是很危险的,我看到这些工具对比只字不提,更多是在代码设计、交互体检、功能完备度上做文章。Helm 的 Menu 式、Ivy 的 ListView 式和 Selectrum 的 ComBox 式,它们都是 GUI 一种风格,毕竟人各有所好。只是不适合做为 completing-read 的统一交互方式。

是跟 live-complections 的单列很相似。原生的 completion buffer 体验太差了,单列加上交互性改进一下,就基本具有可用性了。但另一方面 helm ivy selectrum 又做过头了,失去了 emacs 文本编辑功能方面的精华。

icomplete 卡的原因大概是因为:

  1. 一次性处理所有匹配的条目。

    在“零”配置情况下,函数约 7.8k 个,命令约 2.8k 个,M-x i 也有 200 多项,全配置就更多了。

  2. 重复迭代。

    icomplete 会在 completion- 迭代的结果上重复迭代:

    completion-all-sorted-completions	;; 返回匹配的条目
        |
        V
    icomplete--sorted-completions      	;; 再次迭代
        |
        V
    icomplete-completions             	;; 显示到 minibuffer (直到这时才根据显示宽度进行截短)
    

如果把 icomplete 改造成下拉列表的样式,然后在每个符号旁边都显示一行文档,那么可以肯定它比 ivy/selectrum 都慢,甚至可能比 helm 还慢(helm 虽慢但是有缓存)。

ivy 在取得列表之后,并不全部显示,而是根据弹窗的高度,取 n 条显示(selectrum 好像也是这么做的)。并提供 API 方便用户对这 n 项可见条目进行再次处理而几乎不影响性能。

2 个赞

所以内置的不见得是最好的,目前我还是选择ivy,从用户体验从生态上讲目前对我是最合适的。

这是因为 ivy 的 counsel-M-x 对 M-x 做了优化处理,但是在这个例子中却没多少实用价 值,有谁会按 M-x 然后直接从里面选一个的?一般都是会立即输入一些内容的,等你输入 几个字符,候选列表早就变了,即使是看起来很慢的 live-completions 实际上用起来也完 全感觉不到延迟。

原生的completion要严格按顺序输入,按tab才可以,这点很不方便啊。

elisp处理这种结构的这种东西不会慢吧,

而且icomplete默认的delay设的还蛮高的

(defcustom icomplete-compute-delay .3
  "Completions-computation stall, used only with large-number completions.
See `icomplete-delay-completions-threshold'."
  :type 'number)

可以参考我上面的配置。

;; orderless
(use-package orderless
  :custom (completion-styles '(basic partial-completion orderless)))

这个是 live-completions 来实现的。

原生的 completion buffer 不代表不使用任何第三方的包来增强 completion,而是在保持 Emacs 原生编辑体验的情况下满足需求(对于我来说盲打就是很强烈的需求),特别是不希望在 heml/ivy/selectrum 等之类的方案间被强制选择一种,试错成本太高。

其实现在就是很尴尬的情况,helm ivy selectrum … 都是不能共存的,越来越有全家桶的感觉,而很多有用的功能却不通用,如:

helm-dash、counsel-M-x、swiper

对于 Emacs 用户来说,一开始就会面临盲目的选择,有一定的试错成本,好在 consult、orderless 等是通用的。

我个人感觉,如果你的模式确实不错,应该好好规划一下,做成一个 package