Hack 记录:emacs-moz-controller 的创造过程

:这是我在写 moz-controller 时的记录,因为是写给自己看的,所以思维比较跳跃,文字也没有雕琢,只是希望能为 Emacs Lisp 爱好者提供一些参考价值。

《在 Emacs 中控制 Firefox 翻页、刷新、关闭页面》 的时候, 感觉这些有共性的函数, 可以放到一个插件里. 正好我还没有从头到尾写过 Emacs 插件, 这也是个练手的机会.

1 初步想法

依赖于: mozrepl plugin, Emacs moz-repl (在 README 里说明)

功能:

说明 函数名 快捷键 (以 C-c m 开头)
refresh   r
close tab   k
scroll down/up   n/p
previsou/next tab   l/f
zoom in/out   +/-

杂:

  • global-moz-controller-mode: 全局开启这个模式, 默认为 nil, 看一下别人是怎么实现的
  • keymap: 键盘布局
  • require moz-repl: 依赖
  • 提供一个 hook
  • (provide ‘moz-controller)
  • 放 github 上
  • 在 el-get, melpa 等发布
  • 在 emacswiki, g+, twitter, HN, reddit, 微博, 豆瓣上面宣传一下
  • 先实现想要的功能, 再写成一个 emacs 插件

2 Get it Run First

2.1 上/下一个标签页

跟李总找了半天, JavaScript console 下好像没有相应的命令, 我看了 KeySnail 的代码, 切到前一个标签页是:

getBrowser().mTabContainer.advanceSelectedTab(-1, true);

但是这个在 console 运行会返回一个错误, 放个书签 , 先弄2.2吧… 缩放弄好了, 现在跳回来继续搞标签页.

在 MozRepl 试试, 先用 isend 绑定一下, 以便给 MozRepl 发送代码 (参照: http://wenshanren.org/?p=351): M-x isend-associate *MozRepl*

getBrowser().mTabContainer.advanceSelectedTab(-1, true);

好使!

试试切到下一个标签页:

getBrowser().mTabContainer.advanceSelectedTab(1, true);

也好使, 赞赞赞, 我爱 Emacs !!!

写函数:

(defun moz-tab-previous ()
  "Switch to the previous tab"
  (interactive)
  (moz-send-command "getBrowser().mTabContainer.advanceSelectedTab(-1, true);")
  )

(defun moz-tab-next ()
  "Switch to the next tab"
  (interactive)
  (moz-send-command "getBrowser().mTabContainer.advanceSelectedTab(1, true);")

搞定!

我想要的功能都有了, 可以开始写插件了.

但是发现这些函数都是 defun 跟函数名, 跟文档, 跟 (interactive), 最后 (moz-send-command “function”). 感觉可以写个 macro 了, 学了这个锤子之后, 还没用它钉过钉子. 先把代码 “优化” (很多人觉得 macro 应该尽量少用, 我自己对编程语言学没有研究, 不持任何意见) 一下吧: 3

2.2 缩放

#emacs 上的 average 给了我不少帮助, 告诉我如何通过 MozRepl 对页面进行缩放, 给我推荐了进一步了解 MozRepl 的几个链接, 还顺带推荐了 youtube-dl.org, livestreamer 和 DownloadHelper 等几个很有用的网站和工具.

设置缩放的代码是:

gBrowser.selectedBrowser.markupDocumentViewer.fullZoom = <wanted value>

这个也不能在 JavaScript console 里运行, 但 MozRepl 的功能是超过 console 的.

根据这个, 写了三个函数, 放大, 缩小, 复原.

(defun moz-zoom-in ()
  "Zoom in"
  (interactive)
  (moz-send-command "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom += 0.1;"))

(defun moz-zoom-out ()
  "Zoom out"
  (interactive)
  (moz-send-command "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom -= 0.1;"))

(defun moz-zoom-reset ()
  "Zoom in"
  (interactive)
  (moz-send-command "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom = 1"))

可以用, 但是发现一旦切换到其他标签页再切换回来, 缩放效果就没有了. 先这样吧, 有时间再改. 现在知道了 MozRepl 可以一些执行 console 没法执行的命令, 感觉在 Emacs 中让 Firefox 切换标签页的功能也可以实现. 回去继续弄标签页 2.1.

3 代码优化

想法主要是用 macro 把代码简化一下, macro 在很多文章和书籍中都看到过, 但这还是我头一回在实际中使用.

先看一下说明, C-h f defmacro:

defmacro is a Lisp macro in `byte-run.el'.

(defmacro NAME ARGLIST &optional DOCSTRING DECL &rest BODY)

Define NAME as a macro.
When the macro is called, as in (NAME ARGS...),
the function (lambda ARGLIST BODY...) is applied to
the list ARGS... as it appears in the expression,
and the result should be a form to be evaluated instead of the original.
DECL is a declaration, optional, of the form (declare DECLS...) where
DECLS is a list of elements of the form (PROP . VALUES).  These are
interpreted according to `macro-declarations-alist'.
The return value is undefined.

然后在 el-get 下载的代码中 grep 一下 defmacro, 看看别人是怎么用的. 找到一个 paredit 下的例子, 感觉跟我要做的事情差不多, 都是给 defun 套一层壳:

(eval-and-compile
  (defmacro defun-saving-mark (name bvl doc &rest body)
    `(defun ,name ,bvl
       ,doc
       ,(xcond ((paredit-xemacs-p)
                '(interactive "_"))
               ((paredit-gnu-emacs-p)
                '(interactive)))
       ,@body)))

照虎画猫, 又看了 elisp 的 info, 鼓捣了一段时间才搞定:

(defmacro defun-moz-command (name arglist doc &rest body)
  "Macro for defining moz commands.  Pass in the desired
JavaScript expression as BODY."
  `(defun ,name ,arglist
     ,doc
     (interactive)
     (comint-send-string
      (inferior-moz-process)
      (car (quote ,body)))
     )
  )

然后改写之前的命令, moz-send-command 可以不要了:

(defun-moz-command moz-reload-browser ()
  "Refresh current page"
  "setTimeout(function(){content.document.location.reload(true);}, '500');"
  )

(defun-moz-command moz-page-down ()
  "Scroll down the current window by one page."
  "content.window.scrollByPages(1);"
  )

(defun-moz-command moz-page-up ()
  "Scroll up the current window by one page."
  "content.window.scrollByPages(-1);"
  )

(defun-moz-command moz-tab-close ()
  "Close current tab"
  "content.window.close();"
  )

(defun-moz-command moz-zoom-in ()
  "Zoom in"
  "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom += 0.1;"
  )

(defun-moz-command moz-zoom-out ()
  "Zoom out"
  "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom -= 0.1;"
  )

(defun-moz-command moz-zoom-reset ()
  "Zoom in"
  "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom = 1"
  )

(defun-moz-command moz-tab-previous ()
  "Switch to the previous tab"
  "getBrowser().mTabContainer.advanceSelectedTab(-1, true);"
  )

(defun-moz-command moz-tab-next ()
  "Switch to the next tab"
  "getBrowser().mTabContainer.advanceSelectedTab(1, true);"
  )

可读性…好像不是很好, 就先这样吧, 也算是用了一次 macro.

然后绑定快捷键:

(global-set-key (kbd "C-c m n") 'moz-page-down)
(global-set-key (kbd "C-c m p") 'moz-page-up)
(global-set-key (kbd "C-c m k") 'moz-tab-close)
(global-set-key (kbd "C-c m +") 'moz-zoom-in)
(global-set-key (kbd "C-c m -") 'moz-zoom-out)
(global-set-key (kbd "C-c m 0") 'moz-zoom-reset)
(global-set-key (kbd "C-c m l") 'moz-tab-previous)
(global-set-key (kbd "C-c m f") 'moz-tab-next)

现在可以开始进入 写成 emacs 插件 的环节了.

4 写成 Emacs 插件

看了几个视频, 发现自己上面实现的这些功能, 早就有人写过类似的了, 比如 http://www.youtube.com/watch?v=v78HRi-J2ek

不过没搜到有相应的 Emacs 扩展, 所以还是自己写一个吧.

先看看其他的扩展是怎么写的, 比如 emacs-ctable 和 emacs-edbi (我比较熟悉的扩展).

仿照着先建几个文件, 然后用 magit 初始化:

  • 名字叫 emacs-moz-controller
  • moz-controller.el
  • 中文 README.zh.org
  • 英文 README.org
  • LICENSE (GPL v3)

然后开写 emacs-moz-controller, 首先我需要让它依赖于 moz-repl, 在注释里说明一下, 然后 (require ‘moz).

然后就参照着 edbi 写了.

macro 增加点儿注释.

把函数定义都放进去.

macro 跟函数都改名成 moz-controller 开头

弄个 group, 方便 customize (虽然我没用过这个东西), 但好像也没有什么需要 customize 的东西, 可能让用户自定义每次缩放的程度吧, 这个一会儿再说,放个书签

定义一个 autoload 的 minor-mode (这个是跟 org2blog 学的, 也是我比较熟的一个扩展, 目前我是维护者之一, 虽然贡献不大):

(define-minor-mode moz-controller-mode
  "Toggle moz-controller mode.
With no argument, the mode is toggled on/off.
Non-nil argument turns mode on.
Nil argument turns mode off.

Commands:
\\{moz-controller-mode-map}

Entry to this mode calls the value of `moz-controller-mode-hook'."

  :init-value nil
  :lighter " MozCtrl"
  :group 'moz-controller
  :keymap moz-controller-mode-map

  (if moz-controller-mode
      (run-mode-hooks 'moz-controller-mode-hook)))

看了看文档, 还是不太明白 autoload 的用处.

先写 mode-map 吧, 还是仿照 org2blog 来.

(defvar moz-controller-mode-map nil
  "Keymap for controlling Firefox from Emacs.")

(unless moz-controller-mode-map
  (setq moz-controller-mode-map
    (let ((moz-controller-map (make-sparse-keymap)))
      (define-key moz-controller-map (kbd "C-c m R") 'moz-controller-page-refresh)
      (define-key moz-controller-map (kbd "C-c m n") 'moz-controller-page-down)
      (define-key moz-controller-map (kbd "C-c m p") 'moz-controller-page-up)
      (define-key moz-controller-map (kbd "C-c m k") 'moz-controller-tab-close)
      (define-key moz-controller-map (kbd "C-c m b") 'moz-controller-tab-previous)
      (define-key moz-controller-map (kbd "C-c m f") 'moz-controller-tab-next)
      (define-key moz-controller-map (kbd "C-c m +") 'moz-controller-zoom-in)
      (define-key moz-controller-map (kbd "C-c m -") 'moz-controller-zoom-out)
      (define-key moz-controller-map (kbd "C-c m 0") 'moz-controller-zoom-reset)
      moz-controller-map)))

然后弄个 hook.

(defvar moz-controller-mode-hook nil
  "Hook to run upon entry into moz-controller-mode.")

这个在 hook 在 define-minor-mode 那儿提到了, 有个 run-mode-hooks 的 sexp

可以开始实验了, 把我 init.el 里定义的相关函数都注释掉, 然后:

(add-to-list 'load-path "~/hack/el/emacs-moz-controller")
(require 'moz-controller)

突然想到自己还想实现一个 global-mode,放一个书签。先继续实验, 有基本的功能再说别的, 重启 emacs (不知道还有没有别的好方法)

然后 M-x moz-controller-mode (输入的过程中发现其他 moz-controller 函数也可以用, 这可能是没有用 autoload 的原因).

然后 mode-line 就出现了 MozCtrl, 这是我在 define-minor-mode 的时候指定的 mode 简称.

然后挨个 C-c m R/n/p/k/b/f/+/-, 所有功能试一遍, 都好使, 高兴!!!

5 global mode (书签:4

现在要弄一个 global-mode, 感觉是把 moz-controller-mode 加到一个全局的什么 hook 里? 看看别人怎么实现的吧, C-h f project-global-mode (projectile 是另外一个我常用的扩展), 然后点开其定义:

;;;###autoload
(define-globalized-minor-mode projectile-global-mode
  projectile-mode
  projectile-on)

good, 已经有现成的 define-globalized-minor-mode 了, 我只要:

;;;###autoload
(define-globalized-minor-mode moz-controller-global-mode
  moz-controller-mode
  moz-controller-on)

然后再定义几个相应的函数:

(defun moz-controller-on ()
  "Enable moz-controller minor mode."
  (moz-controller-mode t))

(defun moz-controller-off ()
  "Disable moz-controller minor mode."
  (moz-controller-mode nil))

(defun moz-controller-global-on ()
  "Enable moz-controller global minor mode."
  (moz-controller-global-mode t)
  )

(defun moz-controller-global-off ()
  "Disable moz-controller global minor mode."
  (moz-controller-global-mode nil)
  )

然后再重启 (麻烦).

M-x moz-controller-global-mode 进行 toggle, 好使!!!

git commit 一下

6 用户自定义缩放程度 (书签:4 )

让用户 customize 缩放程度, 感觉挺简单, 先看看 org2blog 里边的 customize 之类的是怎么弄的吧:

(defcustom org2blog/wp-keep-new-lines nil
  "Non-nil means do not strip newlines."
  :group 'org2blog/wp
  :type 'boolean)

这个是 boolean, 我的应该是 floating number 之类的, 但是从哪儿能知道都什么可以放 :type 里呢? Emacs 中有 booleanp, 用于判断一个值是不是 boolean, 也有 numberp, 用于判断一个值是不是 number, 所以我觉得我用 number 作为 :type 的值就可以了.

(defcustom moz-controller-zoom-step 0.1
  "Zoom step, default 0.1, it is supposed to be a positive number."
  :group 'moz-controller
  :type 'number)

然后修改一下 zoom in 函数:

(defun-moz-controller-command moz-controller-zoom-in ()
  "Zoom in"
  (concat "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom += " (number-to-string moz-controller-zoom-step) ";")
  )

eval 一下, 但是发现这个不好使了:

comint-send-string: Wrong type argument: stringp, (concat "gBrowser.selectedBrowser.markupDocumentViewer.fullZoom += " (number-to-string moz-controller-zoom-step) ";")

应该是 macro 的问题, 去看看 macro 的定义, 知道问题是什么: (car (quote ,body)), 当 body 是 ((concat xxx bbb ccc)) 时, concat 这个语句本身没有执行, 只是当成 symbol 被 car 出来了, 所以会有 “Wrong type argument: stringp” 的错误提示.

可以在前边加个 eval, 但是感觉这样做不太好, 先这么着吧.

(defmacro defun-moz-controller-command (name arglist doc &rest body)
  "Macro for defining moz commands.

NAME: function name.
ARGLIST: should be an empty list () .
DOC: docstring for the function.
BODY: the desired JavaScript expression, as a string."
  `(defun ,name ,arglist
     ,doc
     (interactive)
     (comint-send-string
      (inferior-moz-process)
      (eval (car (quote ,body))))
     )
  )

可以用了.

然后再看 defun-saving-mark 这个 macro 的定义, 突然发现 ,@body, 想起来 `@’ 可以用来展开一个 list, 试试:

(defmacro defun-moz-controller-command (name arglist doc &rest body)
  "Macro for defining moz commands.

NAME: function name.
ARGLIST: should be an empty list () .
DOC: docstring for the function.
BODY: the desired JavaScript expression, as a string."
  `(defun ,name ,arglist
     ,doc
     (interactive)
     (comint-send-string
      (inferior-moz-process)
      ,@body)
     )
  )

好使!!! 搞定了. 再 commit.

基本功能全了. 下一步是 README 和注释的完善了.

7 README 和注释

先写中文的 README.zh.org

然后英文的.

然后注释.

8 放到 Github

在 Github 上新创建一个 repo, 然后 push 上去, 轻车熟路.

9 加入包管理系统

这个是我以前没有接触过的东西.

看了看 el-get, 看了看 org2blog 的 pkg.el, 有点儿头绪, 但不知道从什么地方开始, 去 G+ 上问一下吧, 没准有人能给我写个 pull request 呢 :D

自己用 screenkey + gtk-recordmydesktop 录了个视频, 发到了 youtube 上, 更新了一下 README, 先进入 G+ 的 10 阶段.

fork 了 melpa, 按照说明加了个 recipe 进去, 然后 pull request, Steve Purcell 帮我小小改动了一下, 增加了依赖关系 (moz-repl), 然后我的 pull request 就被 merge 进去了. 然后就可以通过 package 来安装了 :D. 我也相应地更新了 README, 这样一来, 基本就只剩下宣传工作了, 回到10.

10 宣传

先放在 G+ 上: https://plus.google.com/100406533905091621888/posts/TvNLNcyRo2L, 等一两天, 看看有什么回应.

收获了几个赞, 发现有人帮我放到了 reddit, 又收获了几颗星(github上).

在微博上问了一下陈斌, 他说可以自己放到 melpa 之类的包管理系统, 做个标记 , 然后回到 9

在豆瓣和微博上都发了一下, 但按照以往经验, 反响不会太高.

然后把 README 稍微改改发到博客上吧, 算是对 moz-controller 的介绍.

等明天晚上发到 hackernews 上去.

11 新功能

使用的过程中, 发现有时候希望能得到 MozRepl 的输出, 比如当前标签页的地址, 网上搜搜, 看到 stackoverflow 上的一个讨论, 试了试, 发现 gBrowser.contentWindow.location.href 可以. 但是我目前定义的这个 macro, 是只负责输入不负责读取输出的.

C-h f 看了好几个 comint 相关的函数, 但是还是不得要领.

继续去 #emacs 问问吧,没什么结果。

后来在 Stackoverflow 上开了个问题: Emacs: what is the conventional way of receiving output from a process? (http://stackoverflow.com/questions/25985569/emacs-what-is-the-conventional-way-of-receiving-output-from-a-process

经指点,看 info (elisp) Filter Functions 一节

A process “filter function” is a function that receives the standard output from the associated process. All output from that process is passed to the filter. The default filter simply outputs directly to the process buffer.

然后自己试试:

(defun ordinary-insertion-filter (proc string)
  (when (buffer-live-p (process-buffer proc))
    (setq wenshan-de-string string)
    (with-current-buffer (process-buffer proc)
      (let ((moving (= (point) (process-mark proc))))
        (save-excursion
          ;; Insert the text, advancing the process marker.
          (goto-char (process-mark proc))
          (insert string)
          (set-marker (process-mark proc) (point)))
        (if moving (goto-char (process-mark proc)))))))

(set-process-filter (get-buffer-process "*MozRepl*") 'ordinary-insertion-filter)

(process-filter (get-buffer-process "*MozRepl*"))

;; 这样就可以得到输出了, 试试怎么把最后的 "\nrepl " 和前后的引号去掉,应该用 substring 就成了

;; #emacs 上的 twb 指点了我一下, 最后用的 replace-regexp-in-string,

(insert (replace-regexp-in-string "\"\\(.+\\)\"\nrepl> " "\\1" wenshan-de-string))

;; 查一下怎么加东西进 kill-ring, http://stackoverflow.com/questions/22454087/insert-something-into-kill-ring-in-emacs, 修改函数如下:
(defun moz-controller-repl-filter (proc string)
  (when (buffer-live-p (process-buffer proc))
    (setq moz-controller-repl-string (replace-regexp-in-string "\"\\(.+\\)\"\nrepl> " "\\1" string))
    (message moz-controller-repl-string)
    (kill-new moz-controller-repl-string)
    (with-current-buffer (process-buffer proc)
      (let ((moving (= (point) (process-mark proc))))
        (save-excursion
          ;; Insert the text, advancing the process marker.
          (goto-char (process-mark proc))
          (insert string)
          (set-marker (process-mark proc) (point)))
        (if moving (goto-char (process-mark proc)))))))

(set-process-filter (get-buffer-process "*MozRepl*") 'moz-controller-repl-filter)

;;; 然后写个 moz-controller 函数,获取当前网址
(defun-moz-controller-command moz-controller-get-current-url ()
  "Get the current tab's URL and add to kill-ring"
  "gBrowser.contentWindow.location.href"
  )

最后把这些代码整合进 moz-controller,见 commit: https://github.com/RenWenshan/emacs-moz-controller/commit/91b3458241777e5747a8653f9eb4c454c48ec1e3

Leave a Reply