用 Helm 框架开发新的命令

2017-06-14

Helm 本身提供了很多有用的命令,如 helm-M-xhelm-find-fileshelm-mini ,同时用户也可以利用提供的 API1 开发新的 Helm 命令,任何符合下列步骤的需求很容易用 Helm 实现:

  1. 搜集数据
  2. 筛选数据
  3. 执行操作

Helm 是解决这类需求的一个框架,它的存在丰富了 Emacs 应用范围,使得原本跟 Emacs 没什么关系的需求也能在 Emacs 中得到解决。

1 音乐播放器

作为文本编辑器的 Emacs 也有好多个音乐播放器2,甚至还自带了一个 MPD (Music Player Daemon) 客户端。但也不妨碍我们再实现一个:

(defun helm-music-player ()
  "My little Music Player using helm."
  (interactive)
  (require 'helm)
  (helm :sources
        (helm-build-in-buffer-source "Music"
          :data
          (split-string
           (shell-command-to-string "find ~/Music -name '*.mp3'")
           "\n")
          :action
          (lambda (mp3)
            (shell-command (format "mpg123 '%s' &" mp3))))
        :buffer "*helm music*"))

上面先用 find(1) 命令把所有的 MP3 文件找出来:

find ~/Music -name '*.mp3' | head -5
/Users/xcy/Music/iTunes/iTunes Media/Music/Angela Ammons/Angela Ammons/03 Always Getting Over You.mp3
/Users/xcy/Music/iTunes/iTunes Media/Music/Candy/最新热歌慢摇65/01 Bila.mp3
/Users/xcy/Music/iTunes/iTunes Media/Music/薛之谦/意外/03 你还要我怎样.mp3
/Users/xcy/Music/iTunes/iTunes Media/Music/金玟岐/金玟岐作品集/1-10 给我你的爱(demo).mp3
/Users/xcy/Music/网易云音乐/A-Mac - 买买提.mp3

然后用 split-string 函数把上面的输出转化为一个 List。

最后当用户选择了一个 MP3 后,用 mpg123(1) 播放,并且为了不堵塞 Emacs,用了一个异步子进程 (Asynchronous Subprocess)。

2 EWW 书签浏览器

(defun helm-eww-bookmarks ()
  "My EWW bookmarks manager using helm."
  (interactive)
  (require 'helm)
  (require 'eww)
  (helm :sources
        (helm-build-sync-source "EWW Bookmarks"
          :candidates
          (lambda ()
            (cl-loop for elt in (eww-read-bookmarks)
                     collect
                     (cons (plist-get elt :title)
                           (plist-get elt :url))))
          :action (helm-make-actions
                   "Eww" #'eww
                   "Browse-url" #'browse-url
                   "Copy URL" (lambda (url)
                                (kill-new url)
                                (message "Copied: %s" url))))
        :buffer "*Helm EWW Bookmarks*"))

调用 (eww-read-bookmarks) 会读取 EWW 的书签文件,设置 eww-bookmarks 变量的值,并返回它的值。譬如:

((:url "https://translate.google.cn/" :title "Google Translate" :time (200481 16169))
 (:url "https://github.com/" :title "GitHub" :time (200475 29445)))

像这样一个元素为 Plist 的 List,Helm 并不明白什么意思,Helm 需要的是元素为 String 的 List,也可以是 (DISPLAY . REAL) 的 list,比如:

(("Google Translate" . "https://translate.google.cn/")
 ("GitHub"           . "https://github.com/"))

其中 "Google Translate" 和 "GitHub" 是用户会看到和搜索到的,而后面的 URL 是 #'eww#'browse-url 真正会接收到的。

上面还用了 helm-make-actions 函数来构造一个 Action List,它返回的是一个 Alist,你可以手动构造它。

3 插入 .gitignore 模版

新建了一个 Git 仓库,可能会想要生成一个 .gitignore 模板,GitHub 为此提供了 Gitignore API,利用 Helm 可以把整个过程放在一个 Emacs 命令中完成:

(注:下面的函数必须要开启 lexical-binding 才能正常执行。现在似乎 Lexical Binding 是被鼓励默认就开启的,我在 init.el 也开启了)

(defun helm-gitignore-template ()
  (interactive)
  (helm :sources
        (helm-build-in-buffer-source "Gitignore Templates"
          :data
          (split-string
           (shell-command-to-string
            "curl -s https://api.github.com/gitignore/templates | jq -r '.[]'")
           "\n")
          :action
          (let ((new-action
                 (lambda (fun)
                   (lambda (lang)
                     (funcall
                      fun
                      (shell-command-to-string
                       (format
                        "curl -s https://api.github.com/gitignore/templates/%s | jq -r '.source'"
                        lang)))))))
            (helm-make-actions
             "Insert" (funcall new-action #'insert)
             "Copy" (funcall new-action #'kill-new))))
        :buffer "*helm-gitignore-templates*"))

上面 curl(1)-s 也可写成 --silentjq(1)-r 也可写成 --raw-output

4 mdfind(1)

mdfind(1) 可以看着是 Spotlight 的命令行版本,不同的是,它搜索的结果只包含文件,没有应用、计算器、词典之类的。

这个 Helm 命令和前面所有的都不同:它的选项是异步生成的,由 mdfind(1) 根据用户输入决定的:

(defun helm-mdfind ()
  "mdfind(1) within helm."
  (interactive)
  (helm :sources
        (helm-build-async-source "mdfind"
          :candidates-process
          (lambda ()
            (let ((proc
                   (start-process-shell-command
                    "mdfind"
                    helm-buffer
                    (concat "mdfind " helm-pattern))))
              (prog1 proc
                (set-process-sentinel
                 proc
                 (lambda (_process _event))))))
          :nohighlight t
          :requires-pattern 2
          :action helm-find-files-actions)
        :buffer "*helm-mdfind*"))

:candidates-process 所指定的函数应该返回一个 Process 对象,Process 会根据用户输入 helm-pattern 把相应的结果写入 helm-buffer ,由于默认的 Process Sentinel 会在 Process 成功结束后时写入状态信息,为了避免它,上面用了一个空的函数覆盖之。

Helm 默认会在高亮和用户输入相同的部分,这里效果似乎不好,用 :nohighlight t 关掉它。

:requires-pattern 2 要求用户必须输入 2 个或以上个字符,否则不开始搜索,因为只有 1 个字符,一般结果太多了。

5 可能的问题

5.1 helm-build-sync-source 和 helm-build-in-buffer-source 有什么不同?

Helm 提供了创建 Helm Source 的 5 个基本类:

Source Note
helm-source-sync 把 Candidates 存在 List,灵活
helm-source-in-buffer 把 Candidates 存在 Buffer,速度快
helm-source-async 用 Process 获得 Candidates
helm-source-dummy 唯一的 Candidate 就是你的输入
helm-source-in-file 文件中的每一行当作一个 Candidate

其中第一个 helm-source-sync 是最常用的。第二个 helm-source-in-buffer 速度快,但它的 Candidate List 不能是由 (DISPLAY . REAL) 构造,只能是 String 的 List 构成,因为它需要把这些 String 插入 Emacs Buffer 中,再用 re-search-forward 搜索。这就是为什么上面 helm-eww-bookmarks 用的是 helm-build-sync-source ,而不能是 helm-build-in-buffer-source 的缘故。

另外, helm-source-in-buffer 的 Candidate List 一旦创建完成,就不应该根据用户输入动态地更新,而 helm-source-sync 则没有这个限制。

5.2 Candidate List 怎么写?

String List、Cons Cell List,、变量或函数都可以:


;; String List
(helm-build-sync-source "test"
  :candidates '("foo" "bar" "baz"))

;; Cons Cell List
(helm-build-sync-source "test"
  :candidates '(("foo" . 1) ("bar" . 2) ("baz" . 3)))


;; 变量
(setq some-helm-candidates '("foo" "bar" "baz"))

(helm-build-sync-source "test"
  :candidates 'some-helm-candidates)


;; 函数
(defun other-helm-candidates ()
  '("foo" "bar" "baz"))

(helm-build-sync-source "test"
  :candidates 'other-helm-candidates)

(helm-build-sync-source "test"
  :candidates (lambda () '("foo" "bar" "baz")))

5.3 Action 怎么写?

Action 可以是一个函数或者多个函数,这些函数应该接收一个参数,即用户在 Helm 中选中的选项

;; 一个函数
(helm-build-sync-source "test"
  :candidates '("foo" "bar" "baz" )
  :action #'message)

;; 多个函数
(helm-build-sync-source "test"
  :candidates '("foo" "bar" "baz" )
  :action '(("message"     . message)
            ("message-box" . message-box)))

;; 多个函数(使用 `helm-make-actions')
(helm-build-sync-source "test"
  :candidates '("foo" "bar" "baz" )
  :action (helm-make-actions
           "message" 'message
           "message-box" 'message-box))

Helm 中运行用户同时选择不止一个选项,如果打算支持这种用法,在 Action 用 helm-marked-candidates 获得所有被选中的选项,依次处理。

(helm :sources
      (helm-build-sync-source "test"
        :candidates '("foo" "bar" "baz" )
        :action (lambda (_candidate)
                  (mapc #'message (helm-marked-candidates)))))

Footnotes:

Tags: ,

加载 Disqus 评论