个人 Emacs 核心配置

EOP: 面向 ego(evil,gtd,org) 的 elisp 编程

2024-01-22 一 13:27 2024-06-18 二 16:57

修改历史

  • [2024-06-16 日 18:00] 修复 org-cite 链接在 PROPERIES 中(如:ROAM_REFS: cite:@xxxx)下无法通过 citar 打开的问题。
  • [2024-06-12 三 14:42] 添加 deno 支持的 jupyter-typescript/javascript
  • [2024-06-05 三 10:55] 在 counsel-file-jump 的 M-o 右键菜单中添加 open-with-system-app, 方便用系统应用打开文件
  • [2024-04-21 日 13:40] 将 global-mode-string(主要是 org-clock) 信息从 modeline 移动到 tabbar 右侧显示

总体说明

背景

本文梳理个人使用 emacs 的核心配置,这些代码主要是为了在 org mode 里方便地记录想法、整理笔记和发布,在使用 emacs 之前:

  • 我习惯在一个 markdown 或者 txt 文档里随时记录流水账式的日记,手机上则用自带备忘录,然后不定期地导出到电脑。
  • 用过一年多的 vim (主要是远程服务器上编程), 用于本地写笔记的话,模态编辑的方式对中文输入法切换不太友好,不方便显示 markdown 里的图片,vimscript 定制起来也比较麻烦。
  • 用 jupyter notebook 记录那些带有代码的笔记,尤其是代码需要一点公式说明的情形。 jupyter notebook 弱点在于元信息太多,不方便用 git 来对比差异(不过 vscode 的 git diff 解决了这个问题,只比较其中的内容),此外每次打开也需要启动一个服务,更不容易整理笔记之间的关系。
  • 用 notion 或者为知笔记这样的云服务记录一些不带个人信息的笔记(notion 也可以作为主页进行内容发布),了解到了 roam research ,对其有好感,但 roam 还是无法满足以上提到的需求。

可以看到,那时的记录分散成了三块:

  • jupyter notebook: 以代码为主的笔记。
  • 本地 markdown/txt: 随机的想法,日记,更具有隐私性。
  • 云笔记:不带隐私的知识记录,更容易发布。

当时我就希望能有一个统一以上需求的工具,它要能够保证隐私性、在 vim 模式下方便地输入中文、打开 markdown 还能预览图片和 latex 、 可以比较好地支持 git 版本管理。我想到的方案之一是自己写一个脚本服务,利用 notedown 在 markdown 和 jupyter notebook 之间转换,这样我只需要对 markdown 进行版本管理和 blog 发布,但也可以用 jupyter notebook 编辑(有支持 vim 的 notebook 插件)、执行代码和编写公式。 然而这只能作为一种不得以的选择,在很快了解到 emacs 中有 evil, org, org-roam 以及 org-babel 后,转向 emacs 几乎已经是注定的事了。

具体用 emacs 做什么

正如前文所说,使用 emacs 的最初目的就是写阅读笔记、随记、执行代码(或曰文学编程):

  • org 阅读、记笔记、日记

    pdf-tools, nov-mode, org-roam 等

  • org 中执行代码

    org-babel 和 emacs-jupyter

  • 中文环境

    与 evil 和谐相处的输入法: emacs-rime 。额外发现还可以用拼音来搜索和跳转等,因此加入 pyim, avy,ace-pinyin, ivy/vertico/company 生态,这些都涉及字符串的解析、预测、选择 UI。

  • 窗口管理

    打开的窗口多了,需要对其进行基本的分类,用自带的 tab-bar, 同时写了一些额外记录 context switch 信息的变量和函数,更方便地进行上下文切换后的回退(switch-back)。

  • org 任务分解、文献管理

    这不在最初的需求里,但发现 org 中处理起来也比较方便,主要用来分解任务并且记录各任务大致花费的时间。用原生 org-agenda 生成临时报告,org-dynamic-block 生成可存档的报告,citar 生态进行文献管理。

  • blog 发布

    已经在 orgchange: 文学编程式 org mode 静态网页生成器 中描述, 其配置独立于个人 emacs 配置,因此不在本文范围内。

  • 配置 org 来管理配置本身

    参考: org roam 风格的系统配置文件管理

不用 emacs 做什么

首先如前文所述, org 之外的其他功能都不在我使用 Emacs 的核心需求中,但由于 emacs 的扩展性,在攒配置过程中还是会去尝试一些其他的功能,有一些保留下来,比如 org-agenda/eaf-mindmap,有些则没有。

本节列出几个自己配置过但不再使用或很少使用的功能或场景:

  • 编写项目代码

    我有对部分编程语言配置基础的开发环境,比如 python 、 web/js 、 bash 等,但它们主要是用来支撑 org src block 编辑或者偶尔修改代码用的。在我个人常用的场景里,VSCode 比 emacs 也会更方便一点,比如远程连接加调试。但如果从更广的角度来看待编程活动的话,由于写代码时我会新建一个 org 文件追踪这个项目,记录各阶段问题,又要在 gtd 文件里规划,在 babel 里测试部分函数和类,因此整个项目编写实际是 emacs/org/vscode 混合使用,但编码部分还是以 vscode 为主。

  • 查看网页,rss, 邮件等

    这些对我来说只是搜集信息的活动,并且属于初筛,大部分页面都是快速浏览后就关掉,偶尔需要把内容选中复制到 org 里,涉及操作比较单一,配置它们更多是体验一下 emacs 的扩展性。

    与之相对的,做笔记和写作则涉及规划分解、搜索或跳转到相似笔记、插入公式图片、发布等更多环节,这些功能在 org 里都有很好的支持,核心是把它们流畅地串起来,这就需要一些个人的定制。

  • 尽量少用 capture template: 比如 org-capture-templates, org-roam-capture-templates。

    一方面,这些模板的书写格式类似 C 语言中 printf 对字符串格式化风格,比如各种 %u %a 符号,不太直观,对于更精细一点的操作又要混合 elisp 代码,比如切换到某个 tab 里去记录,这样还不如自己写一段函数来实现,读起来更加清晰。

    另一方面,用 capture 模板去创建文件或插入内容意味着记录时就要做一些抉择,因为不同模板往往对应不同目录和命名方式等,这很容易打断思路。

    实际中,我更偏向一种 "lazy load" 的方式:要记录时,用一个快捷键打开日志文件、跳到最后一行并自动打上时间戳, 把内容写下来,然后就继续回到当时正在做的事情上,至于这些文字最终要放到哪里, 用什么词去作为它们的文件名和搜索关键字(title, tag 等),那是整理阶段才要考虑的,那时在不同文件之间复制元信息还有助于不同内容之间的对比。

阅读说明

本文给出的代码里有 <<basic-ui>> 这种带双尖括号的格式,这是 org-babel noweb 的引用形式, <<basic-ui>> 在最终配置中会被本文 #+name 为 basic-ui 里的代码块中的内容替换掉,可以用 Ctrl-f 在网页中搜索名称进行跳转查看。

本文不是完整的 emacs 配置,例如包管理、外观主题、编程语言 mode 的基础设置等由于比较零散且大部分是从官方拷贝的默认配置,没有太多个人的加工,因此不在此说明。

Emacs 环境

Emacs 编译安装

先说明两个常见到的编译选项:

  • pgtk: 主要是为了支持 Wayland,和 X11 有些地方不兼容, 比如导致 xmodmap 修改的按键失效,我目前不用 Wayland,因此不需要。
  • native compile: 把 emacs 里 elisp 代码编译成底层指令,可以提高 emacs 运行速度。

    以下是用默认 ./autogen.sh ./configure 编译后 emacs29.2 的编译选项展示:

    system-configuration-features
    
    ACL CAIRO DBUS FREETYPE GIF GLIB GMP GNUTLS GPM GSETTINGS HARFBUZZ JPEG JSON LCMS2 LIBOTF LIBSELINUX LIBSYSTEMD LIBXML2 M17N_FLT MODULES NOTIFY INOTIFY PDUMPER PNG RSVG SECCOMP SOUND SQLITE3 THREADS TIFF TOOLKIT_SCROLL_BARS X11 XDBE XIM XINPUT2 XPM GTK3 ZLIB
    

    首先这里已经有许多默认的选项了,比如 GIF, X11, MODULES(支持动态链接库,比如 emacs-jupyter 需要 zmq.so ,emacs-rime 需要 librime.so), 但没有 NATIVE_COMP 字样,说明这个版本不是 native comp 的。

    为了支持 native compile, 编译前首先需要安装 libgccjit:

    sudo apt install libgccjit-11-dev
    

    以上版本号应根据自己 gcc 的版本来定,如下是 11.4.0 ,所以用 libgccjit-11-dev

    gcc --version
    
    gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
    

    接着进入 emacs 源码目录,开启编译选项并编译:

    ./autogen.sh
    ./configure --with-native-compilation=aot
    make -j16 #根据 lscpu 显示的核数选择数字
    

    编译时间比没有 native-comp 时长很多,应该是花在把 emacs-lisp 编译成字节码上,编译过后启动 emacs 查看编译选项变量:

    system-configuration-features
    
    ACL CAIRO DBUS FREETYPE GIF GLIB GMP GNUTLS GPM GSETTINGS HARFBUZZ JPEG JSON LCMS2 LIBOTF LIBSELINUX LIBSYSTEMD LIBXML2 M17N_FLT MODULES NATIVE_COMP NOTIFY INOTIFY PDUMPER PNG RSVG SECCOMP SOUND SQLITE3 THREADS TIFF TOOLKIT_SCROLL_BARS X11 XDBE XIM XINPUT2 XPM GTK3 ZLIB
    

    其中已经有 NATIVE_COMP 字样了

    由于 .emacs.d 里第三方包是在 emacs 启动并第一次被加载时进行 native 编译,那时候会打印很多警告,在 early-init.el 里加入以下命令取消警告打印:

    (setq native-comp-async-report-warnings-errors nil)`
    

    编译 emacs 的核心命令:

    print_info "采用编译的方式来安装 emacs, 首先要准备安装条件,包括 build 需要的库,包括 build-essential, 剩下的可以用 `sudo apt build-dep --no-install-recommends emacs` 来查看"
    sudo apt install build-essential -y
    sudo apt build-dep emacs -y
    
    sudo apt-get install build-essential autoconf automake libtool texinfo libgtk-3-dev libxpm-dev libjpeg-dev libgif-dev libtiff5-dev libgnutls28-dev libncurses-dev libxml2-dev libgpm-dev libdbus-1-dev libgtk2.0-dev libpng-dev libotf-dev libm17n-dev librsvg2-dev libmagickcore-dev libmagickwand-dev libglib2.0-dev libgirepository1.0-dev -y
    
    # support native compile
    sudo apt install libgccjit-11-dev -y
    
    print_info "sqlite3 用于 org-roam"
    sudo apt install sqlite3 -y
    
    pushd $CODES
    wget http://mirrors.aliyun.com/gnu/emacs/emacs-29.2.tar.gz
    print_notice "下载 emacs-29.2.tar.gz"
    tar -xf emacs-29.2.tar.gz
    pushd $CODES/emacs-29.2
    ./autogen.sh
    ./configure --with-native-compilation=aot
    make -j16 
    popd 
    rm -rf emacs-29.2.tar.gz
    print_notice "删除 emacs-29.2.tar.gz"
    popd
    print_success "emacs29.2 编译完毕"
    

    完整脚本见: metaesc/install_libs/pack-emacs29.2.sh at main · metaescape/metaesc

备份和升级

对 emacs 本身或者 emacs 中重要的插件进行升级之前一般先备份,便于发现升级不满意或出问题后回退,这部分写在 ~/metaesc/Makefile 中:

backup-emacs-org:
	mkdir -p /data/resource/emacs_org_backup
	tar -czvf /data/resource/emacs_org_backup/$$(date +'%Y%m%d')-wemacs.tar.gz ~/.wemacs
	tar -czvf /data/resource/emacs_org_backup/$$(date +'%Y%m%d')-org.tar.gz ~/org
	@echo "Backup completed."

执行 make backup-emacs-org 会对 emacs 配置和 org 笔记目录进行压缩备份,并以日期作为前缀。

如果要调试其他配置,可以复制 .emacs.d 目录,然后用 -–init-directory 去测试(emacs29 及以上)

debug 方式

Emacs 中某个操作出现 error 时,可以执行 toggle-debug-on-error 启动 debug 后再执行之前的命令,或者在设置 (setq debug-on-error t) 后再操作以打印出调试信息。

如果大概知道问题是出在某个函数,比如是 org 内置的 org-open-at-point, 那么可以直接找到这个函数, 将整个函数复制到 scratch buffer 中,在函数里添加一些打印信息,然后 eval buffer 去调试这个函数,找出具体问题。

启动时环境变量

由于可以从 emacs 启动其他进程,因此涉及到程序路径的查询,一般来说,有两类从 emacs 启动外部进程的方式:

  • 通过 shell-command (同步) 或 start-process-shell-command (异步):这两个命令大致都是先启动一个 shell ,然后在 shell 里执行命令,而 shell 寻找路径用的是 PATH 环境变量,通过以下命令查看 emacs 继承到的 PATH 变量值:

    (getenv "PATH")
    

    如果要修改这个值,比如像 bash 里

    export xxx:$PATH
    

    可以用类似以下方式:

    (setenv "PATH" (concat (concat conda_dir "/envs/usr/bin:") (getenv "PATH")))
    

    shell-command* 接口比较方便,只要知道这个程序怎么从命令行执行,照搬过来即可。

  • 通过 start-process 函数,这时 emacs 从 exec-path 变量去找程序的具体路径,由于这是一个 elisp 变量,可以用 describe-variable 查看。start-process 不需要先启动 shell, 执行效率会更高一点,但执行命令的接口更复杂,需要仔细查看文档了解(包括执行二进制对象的文档)

一些案例(经验结果,可能有偏差):

  • quickrun 是根据 PATH 路径来寻找二进制,因为它实际是调用 shell-command 命令来运行程序
  • dired, flycheck, org-babel 是 emacs 内置功能,使用 start-process 根据 exec-path 里的路径去寻找可执行程序。
  • org 里打开 http 链接默认需要 PATH 变量,因为是在 shell-command* 里执行 xdg-open
  • emacs-jupyter 需要找到 jupyter 二进制,通过异步执行 shell 命令启动 jupyter, 因此查找的是 PATH
  • latex 预览同时需要 exec-path 和 PATH ,因为预览涉及多个进程,比如可能 start-process 执行 latex 主函数,shell-command 执行转 svg 的进程,这部分我没有深究,但曾经试过从 exec-path 或 PATH 中分别删除 latex 路径,发现预览 latex 均会出问题。

然而,个人使用场景下没有必要区分 PATH 和 exec-path,因为它们在打开 emacs 时继承的都是启动环境里的 PATH 环境变量。我是通过以下 open_emacs 脚本来启动 emacs 的,需要什么路径(比如 conda)通过 export 导入即可,之后不会再手动修改这两个值,因此它们始终保持一致。

#!/usr/bin/env bash
# 打开不同 配置 的 emacs 方式, 但基本只用默认的

export PATH=$HOME/miniconda3/envs/usr/bin:$PATH
version=${version:-29.2}
em=~/codes/emacs-${version}/src/emacs
emc=~/codes/emacs-${version}/lib-src/emacsclient
if [ "$1" == 'default' ]; then
    if [ ! -z $2 ]; then
        $em --name "$2"
    else
        $em
    fi
	#systemctl restart --user emacsanywhere.service
elif [ "$1" == 'client' ]; then
    $emc -c
elif [ "$1" == 'test' ]; then
	$em --init-directory ~/.wemacs_test/
else 
	$em
fi

这部分在 i3wm: 无关生产力,且远不止窗口管理 文章里也有提到。

python 环境和 eaf

使用 emacs-jupyter 和 eaf (目前主要用 eaf-mindmap 展示 org 文件层级结构) 都依赖 python 环境, 比如 emacs-jupyter 需要在环境中安装 jupyter 。因此最好为 emacs 创建了一个特定的 python 环境(比如名为 usr),先用以下命令安装 jupyter 以支持 emacs-jupyter:

conda install jupyter

python 版本说明

根据 jupyter-available-kernelspecs fails with Python 3.11 · Issue #436 · emacs-jupyter/jupyter 目前 emacs-jupyter 支持的 python 版本需要是 3.10 或以下, 3.11 会有 json 格式不兼容的问题

对于 eaf ,如果执行 ./install-eaf.py 命令来初始化, 在 conda 环境中会采用 pip --user 的方式安装包,从而把 pip 包都安装到 ~/.local/lib 中,该目录是所有 conda 环境共享的,不属于特定 conda 环境,打破 conda 的隔离容易带来混乱,conda list 也不会显示该目录下的包,所以还是手动进入到 conda 环境用以下方式安装 eaf 依赖:

pip install epc tld lxml PyQt6 PyQt6-Qt6 PyQt6-sip PyQt6-WebEngine PyQt6-WebEngine-Qt6

此外用 ./install-eaf.py -i mind-map 安装时由于使用 https 连接,有时候网络很慢,因此手动用 git@ 方式安装具体的 eaf app:

git clone -b master --depth 1 git@github.com:emacs-eaf/eaf-mindmap.git app/mindmap

注意这里命名不需要 eaf- 前缀,否则会找不到。

eaf 核心配置如下

(use-package eaf
  :load-path "site-lisp/emacs-application-framework/"
  :commands eaf-open-mindmap
  :config
  (require 'eaf-mindmap)
  <<eaf-key-bindings>>
  )

以上通过 :commands eaf-open-mindmap 设置令 eaf 只在执行特定命令后启动,以免 emacs 一启动就在后台创建一个 eaf 进程。

<<eaf-key-bindings>> 部分是一些改键设置,不展示具体代码了。

其他 emacs 使用场景

除了前文提到的 open_emacs 脚本,个人还有其他几种 emacs 启动入口:

  • 用 org-publish 发布 blog: orgchange: 文学编程式 org mode 静态网页生成器 。 它不依赖个人 emacs 配置和插件,并且可以对每篇文章设置单独的 emacs 程序。
  • 将 emacs 作为一次性命令来执行

    比如用来 tangle 出配置,更新 org-ids 等,这些操作如果在当前运行的 emacs 中进行,往往会打开大量 buffer 或者比较耗时导致 emacs 卡顿,而它们又只使用到少量的和个人交互无关的配置,因此分离出单独的脚本进行执行,这在 org roam 风格的系统配置文件管理 里也提到过。

    .SILENT:
    .SUFFIXES:
    .PHONY: clean
    
    wemacs-tangle:
          emacs --batch -l ~/metaesc/make_tangle.el --eval "(tangle-config-file \"init\")"
          emacs
    
    update-org-ids:
        emacs --batch \
              --eval '(setq org-directory "~/org")' \
              --eval '(setq org-id-locations-file "~/.wemacs/.org-id-locations")' \
              --eval '(require (quote org-id))' \
              --eval '(org-id-update-id-locations (directory-files-recursively org-directory "\\.\\(org\\|gtd\\)"))'
    

    emacs --batch 是不会去加载 ~/.emacs.d 目录下的配置文件的,因此它也是独立于个人 emacs 配置的。

  • emacsclient 快速启动脚本 emacsclinet 一般只是发信息给已经启动的 emacs server, 因此它的配置就是 emacs 本身的配置。

    • ~/metaesc/lib/i3-emacs-publish.sh

      先用 emacsclient 启动一个窗口,在其中打开 shell buffer, 然后执行 blog 的发布命令,虽然发布命令和个人 emacs 配置无关,但启动 emacsclient 是与本文配置相关的。

    • ~/metaesc/lib/i3-emacs-quicknote.sh

      emacsclient 直接打开日记 buffer, 前文提到的 "lazy load" 记录方式的实现, 依赖于 evil.

    • ~/metaesc/lib/i3-emacs-xwish.sh

      emacsclient 打开 ivy-minibuffer 并且导入当前的窗口信息进行选择交互,依赖 evil 和 ivy ,因此 ivy 配置有比较大变化饿化,最好应该测试一下该脚本。

    这些脚本都放在 metaesc/lib at main · metaescape/metaesc 之中

Evil

evil 是 emacs 中一个用于模拟 vim 编辑风格的插件,该风格被称为模式编辑或模态编辑。

这种模态切换和更常见的输入法切换是类似的,如果直接在模态编辑里用输入法,那么要同时考虑两种切换,非常麻烦,这也是在 vim 下输入中文会遇到的问题。在 emacs 中进行适当地配置后,基本可以把输入法的切换和 Evil 中模态的切换合二为一,在非 insert 模式下采用英文,insert 下则根据上下文自动切换中英文,比如当前如果在代码文件中且不是在 comment 里,那么默认是英文。

evil 总体加载

以下是 evil 配置的总览,一些解释:

  • ctrl-u 在 emacs 中是一个前缀按键,个人很少使用(改成 C-M-u), 通过 evil-want-C-u-scroll 来更改为 page up(half)
  • 如果没有 (setq evil-want-C-i-jump nil) ,会影响 tab 按键的功能, org src block 中换行时都不会自动缩进,比较奇怪。
#+name: evil-basic
(use-package evil
  :init
  (setq evil-want-Y-yank-to-eol t)
  (setq evil-want-integration t)
  (setq evil-want-keybinding nil)
  (setq evil-want-C-u-scroll t)
  (setq evil-want-C-i-jump nil)
  :config 
  (evil-mode 1)
  <<evil-esc>>
  <<evil-undo-fu>>
  <<evil-collections>>
  <<expand-region>>
  <<evil-surround>>
  <<evil-commentary>>
  <<evil-multiedit>>
  <<evil-select-paste>>
  <<snipe-pinyin>>
  <<avy-ace-pinyin>>
  <<evil-9line>>
  <<ex-quit-buffer>>
  (general-define-key
   :keymaps '(visual)
   "DEL" (lambda ()
           (interactive)
           (evil-delete (region-beginning) (region-end) nil ?_))))
#+name: evil-setting
<<evil-basic>>

esc quit/del

esc 绑定的 keyboard-escape-quit 在有些场合下会失效,从 dotfiles/Emacs.org at master · SqrtMinusOne/dotfiles 拷贝了 minibuffer-keyboard-quit 函数作为增强。

#+name: evil-esc
(global-set-key (kbd "<escape>") 'keyboard-escape-quit)
(defun minibuffer-keyboard-quit ()
  "Abort recursive edit.
In Delete Selection mode, if the mark is active, just deactivate it;
then it takes a second \\[keyboard-quit] to abort the minibuffer."
  (interactive)
  (if (and delete-selection-mode transient-mark-mode mark-active)
      (setq deactivate-mark  t)
    (when (get-buffer "*Completions*") (delete-windows-on "*Completions*"))
    (abort-recursive-edit)))

  (general-define-key
   :keymaps '(normal visual global)
   [escape] 'keyboard-quit
   )

(general-define-key
 :keymaps '(minibuffer-local-map
            minibuffer-local-ns-map
            minibuffer-local-completion-map
            minibuffer-local-must-match-map
            minibuffer-local-isearch-map)
 [escape] 'minibuffer-keyboard-quit)

undo-fu

不使用 undo-tree.el (作者不太维护了), undo-fu 为线形 undo 管理

#+name: evil-undo-fu
(use-package undo-fu
  :config
  (define-key evil-normal-state-map "u" 'undo-fu-only-undo)
  (define-key evil-normal-state-map "\C-r" 'undo-fu-only-redo))

更多 major mode 下的 evil

evil 包主要在 emacs 中一般的 buffer 里引入 vim 按键,而 minibuffer 等其他窗口的按键可能并没有被覆盖,因此加入以下额外增强:

  • evil collection 中包含大量的 el 配置文件,针对不同的场景,将 vim 风格推广到其他 mode/window 中,例如使用按键 q 来退出只读 buffer
  • evil 对 orgmode 的融合比较粗糙,用 evil-org 弥补, 这包括:
    • 对 agenda 添加一个 evil 层
    • 删除字符时保持 org-tag 或表格对齐
    • 更多 textobj 例如 vie/vae 选择当前最小的 org element, 比如 org-emphasis-alist 里的对象,时间戳等
    • >> 和 << 进行 org-tree 的 promote 和 demote
    • o/O 会尊重 org 结构,比如在表格里插入一行,则默认下一行是一个空的表格行,而不是纯空行

      不过这里用 go 和 gO 来标示纯结构化插入, o 和 O 还是保持原始的空行,需要用 evil-define-key 来覆盖(参考 evil-org.el 代码)

    • 我只需要其对 agenda 的增强以及 textobj, 因此以下只开启了 (textobjects)
    • 最后修改 enter 键,在 normal 下是打开 org 链接
  • 最后的 hook 是为了在 c-c c-q 或者 clock-done 时进入 log buffer 自动切换到 insert 状态便于马上记录
#+name: evil-collections
(use-package evil-collection
  :after evil
  :config
  (evil-collection-init))

(use-package evil-org
  :after org
  :hook (org-mode . (lambda () (evil-org-mode +1)))
  :config
  (require 'evil-org-agenda)
  (evil-org-agenda-set-keys)
  ;; (evil-org-set-key-theme '(navigation insert textobjects additional calendar))
  (evil-org-set-key-theme '(textobjects))
  
  (general-define-key
   :keymaps '(org-mode-map)
   :states '(normal visual)
   "<return>" 'org-open-at-point)
  
  (evil-define-key 'normal 'evil-org-mode
    (kbd "go") 'evil-org-open-below
    (kbd "gO") 'evil-org-open-above
    (kbd "o") 'evil-open-below
    (kbd "O") 'evil-open-above
    (kbd "<") 'org-promote-subtree
    (kbd ">") 'org-demote-subtree
  ))

(add-hook 'org-log-buffer-setup-hook 'evil-insert-state)

expand region

expand-region 作为 evil text object 的补充,连续按 v 会不断扩大选择,不过一般就按 2 到 3 下

#+name: expand-region
(use-package expand-region
  :general
  (:keymaps 'visual
            "v" 'er/expand-region)
  :config
  (setq expand-region-contract-fast-key "z"))

evil-surround

通过 csi- 替换括号, visual mode 下 S- 添加括号, 这里自己实现额外功能,如果当前选中的是中文,则包裹的是中文符号。

#+name: evil-surround
(use-package evil-surround
  :config
  (global-evil-surround-mode 1)

  (defun insert-around-region (begin end left right)
    "Insert LEFT and RIGHT around the current region."
    (save-excursion
      (goto-char end)
      (insert right)
      (goto-char begin)
      (insert left)))

  (defun surround-with-correct-punctuation (char)
    "Surround region with CHAR. Use Chinese punctuation if the region contains Chinese characters."
    (interactive "cEnter surround character: ")
    (let ((begin (region-beginning))
          (end (region-end))
          (region (buffer-substring-no-properties (region-beginning) (region-end)))
          (chinese-punctuation (pcase char
                                 (?\" "“”")
                                 (?' "‘’")
                                 (?\( "()")
                                 (?\) "()")
                                 (?\[ "【】")
                                 (?\] "【】")
                                 (?\{ "{}")
                                 (?\} "{}")
                                 (?\> "《》")
                                 (?\< "《》")
                                 (_ nil))))
      (if (and chinese-punctuation (string-match-p "\\cc" region))
          (let ((left (substring chinese-punctuation 0 1))
                (right (substring chinese-punctuation 1 2)))
            (insert-around-region begin end left right))
        (evil-surround-region (region-beginning) (region-end) 'char char))))

  (defun my-evil-surround-dwim ()
    "Detect the surrounding character and use the correct punctuation based on the region content."
    (interactive)
    (call-interactively 'surround-with-correct-punctuation))

  (general-define-key
   :keymaps 'general-override-mode-map
   :states '(visual)
   "S" 'my-evil-surround-dwim
   ))

evil-commentary

主要就是按 gcc 后根据不同语言的语法进行注释

#+name: evil-commentary
(use-package evil-commentary
  ;; :diminish evil-commentary
  :config
  (evil-commentary-mode)
)

multiple-cursor/edit

这个包的作用是,当选中某个词后,绑定 n/N 为继续选中 下/上一个相同的词,之后可以同步修改这些选中的词汇。

#+name: evil-multiedit
(use-package evil-multiedit
  :load-path "site-lisp/evil-multiedit"
  ;; git@github.com:metaescape/evil-multiedit.git
  :general
   (:keymaps '(evil-visual-state-map)
   ;; Highlights all matches of the selection in the buffer.
   "R" 'evil-multiedit-match-all
   ;; incrementally add the next unmatched match.
   "n" 'evil-multiedit-match-and-next
   "N" 'evil-multiedit-match-and-prev ;; p for evil past
   )
   (:keymaps '(evil-multiedit-state-map) 
   "SPC" 'evil-multiedit-toggle-or-restrict-region
   "ESC" 'evil-multiedit-abort
   "C-j" 'evil-multiedit-next
   "C-k" 'evil-multiedit-prev
   "n" 'evil-multiedit-match-and-next
   "N" 'evil-multiedit-match-and-prev
   )
   (:keymaps '(evil-multiedit-insert-state-map) 
   "C-j" 'evil-multiedit-next
   "C-k" 'evil-multiedit-prev
   )
  :config
  (define-key evil-motion-state-map (kbd "RET") 'evil-multiedit-toggle-or-restrict-region)
  ;; Ex command that allows you to invoke evil-multiedit with a regular expression, e.g.
  (evil-ex-define-cmd "ie[dit]" 'evil-multiedit-ex-match))

其最新版把 evil-multiedit-state-map 和 evil-multiedit-insert-state-map 合并成了 evil-multiedit-mode-map(只有 insert 情况下), 使得不能用 ESC 结束,而且无法用 n 和 N 来扩大范围。 因此不使用 melpa 包来安装了,直接用个人 fork 下来的旧版本: metaescape/evil-multiedit: forked evil-multiedit

select and paste

选中文本后按 p 粘贴,evil 默认会先先插入粘贴文本,然后将选中文本 kill 掉,因此选中的文本就会覆盖当前的粘贴板(register),也就无法连续替换,以下设置取消这种行为:

#+name: evil-select-paste
(setq-default evil-kill-on-visual-paste nil)

参考 In Evil mode, how can I prevent adding to the kill ring when I yank text, visual mode over other text, then paste over? - Emacs Stack Exchange

evil-snipe for 1 char inline jump

  • snipe 可以用于单字符跳转(对 fFtT 的增强)和双字符跳转,个人只使用行内的单字符跳转。

    这个功能相当与 vim 里的 clever-f 插件功能。

  • evil-find-char-pinyin 包可以给 snipe 提供中文拼音首字母和标点符号跳转,对选择中文很是方便。
#+name: snipe-pinyin
(use-package evil-snipe
  :defer
  :config
  (evil-snipe-mode -1) ;; disabled 2 char jump
  (evil-snipe-override-mode +1) ;; enable 1 char jump
  (setq evil-snipe-scope 'line) ;; jump in line
)

(use-package evil-find-char-pinyin
  :config
  (evil-find-char-pinyin-toggle-snipe-integration t)
  (setq evil-find-char-pinyin-only-simplified t) ;; nil for traditional chinese
  (setq evil-find-char-pinyin-enable-punctuation-translation t) ;;punctuation
  )

avy for global jump

  • snipe 只在行内跳转, avy 则是用于在 window 或 frame 内跳转,当进入 avy 状态时,通过按目标对象(比如英文单词的前两个字母,中文词拼音首字母)就可以进行跳转了。
  • ace-pinyin 之于 avy 正如 evil-find-char-pinyin 之于 evil-snipe, 提供额外中文拼音支持。另外 ace-pinyin 默认不支持 avy-goto-char-timer, 但个人觉得这才是 avy 最好用的接口, 有其他开发者提供这个功能,但 ace-pinyin 作者似乎不太维护没有合并,因此根据 Add function corresponding to avy-goto-char-timer by yangsheng6810 · Pull Request #17 · cute-jumper/ace-pinyin 自己加上这段代码。
#+name: avy-ace-pinyin
(use-package avy
  :bind
  (:map evil-normal-state-map
        ("C-\\" . ace-pinyin-goto-char-timer))
  :config
  (setq avy-all-windows t) ;; jump to any buffer in window
  (setq avy-timeout-seconds 0.5)

  (use-package ace-pinyin
    :config
    (ace-pinyin-global-mode +1))

  (defun ace-pinyin--build-string-regexp (string)
    (pinyinlib-build-regexp-string
     string
     (not ace-pinyin-enable-punctuation-translation)
     (not ace-pinyin-simplified-chinese-only-p)))

  (defun ace-pinyin-goto-char-timer (&optional arg)
    "Read one or many consecutive chars and jump to the first one.
The window scope is determined by `avy-all-windows' (ARG negates it)."
    (interactive "P")
    (let ((avy-all-windows (if arg
                               (not avy-all-windows)
                             avy-all-windows)))
      (avy-with avy-goto-char-timer
        (setq avy--old-cands
              (avy--read-candidates #'ace-pinyin--build-string-regexp))
        (avy-process avy--old-cands))))

  (general-define-key
   :keymaps 'org-agenda-mode-map
   :states 'motion ;; agenda 下用 motion 代替 normal state
   "C-\\" 'ace-pinyin-goto-char-timer
   ))

evil jk buffer up/down

默认 jk 会把折成多行的长句也看作是一行,不太自然,以下使得 jk 按照视觉上的行上下移动(折行以后算做多行),另外用 JK 作为多行移动。

用 evil-define-motion 这个宏来定义新的移动方式,然后用 define-key 在对应模式下绑定按键 这是最基础的绑定按键的方式, 这里 j k 全局设置会影响一些特殊 mode, 例如 image-mode

#+name: evil-9line
;; Use visual line motions even outside of visual-line-mode buffers
;; deal with wrap
(evil-global-set-key 'motion "j" 'evil-next-visual-line)
(evil-global-set-key 'motion "k" 'evil-previous-visual-line)

(evil-define-motion move-9-lines-down () (evil-next-visual-line 9))
(evil-define-motion move-9-lines-up () (evil-previous-visual-line 9))

(general-define-key
 :keymaps 'general-override-mode-map
 :states '(normal visual motion)
 "J" 'move-9-lines-down
 "K" 'move-9-lines-up
 )

evil :q to kill buffer

默认 :q 是直接退出整个 emacs, 改为 kill buffer, 使用 :quit 完整退出(虽然基本不会用)

#+name: ex-quit-buffer
;; :q should kill the current buffer rather than quitting emacs entirely
(evil-ex-define-cmd "q" 'kill-this-buffer)
(evil-ex-define-cmd "wq" 'kill-this-buffer)
 ;; Need to type out :quit to close emacs
(evil-ex-define-cmd "quit" 'evil-quit)

参考: Making :q Not Kill Emacs : spacemacs

(中文)输入补全和搜索

文本补全、minibuffer 搜索和中文输入法的流程都是类似的: 在窗口里输入少量字符以获取多个候选项,然后从中选择完整的字符串。

目前我使用 company 作为一般文本输入下拉菜单补全,ivy 作为 minibuffer 搜索补全工具,emacs-rime 作为中文输入法。

我将以上三种称为"补全", 而将 yasnippet 或者 emacs 自带的 abbrev 称为 "文本展开", 它们的区别在于,文本展开大多是用户定义一个明确的规则去把短字符串展成长字符串,写起来相对简单,展开结果也是唯一的(可以写随机函数到 yasnippet 中,因此返回结果不一定是确定的)。

而文本补全的结果基本是不确定也不是唯一的,给定部分字符串,可以用各种算法去预测完整的字符串,小到从某个集合里去做关键词搜索(比如搜索 buffer/file/command, isearch,grep),大到 copilot,因此它一般分前端和后端,用户要在前端继续输入一些字符串或上下移动来筛选结果,理想情况下后端的不同结果汇总起来还要根据用户偏好排序,所以配置起来可以很复杂,然而一旦复杂就不存在理想情况了,所以这里的原则还是尽可能保持简单以及可预见性。比如不在 company 里混合 yasnippet,记住几个常见的 yasnippet 输入后手动用 tab 触发;不加入复杂的后端,比如 lsp, tabnine,copilot, 因为 emacs 也不是我写代码的主力编辑器。

company

对输入英文内容补全时交互的核心是:

  • Enter 或者 Tab 选择当前行(company 默认用 Enter 选词,tab 获取候选词里的最长公共前缀)
  • C-j/k 或 M-n/p 上下移动 (company 默认有这两组方式,通过变量 company-active-map 查看按键)

除此之外会关注的就是补全效率和准确度了,但这一般不是用户能控制的,这与补全后端的能力有关,对于 company-backends 的顺序,参考的 Better Company | tychoish

对我来说 org 里最有帮助的 company 后端是 company-files,它在输入时补全文件路径, 对其他后端则没有太强的感知。

(use-package company
 :config
 (setq company-idle-delay 0) ;;default 0.2
 (setq company-echo-delay 0) ;;default 0.01
 (setq company-show-numbers t) 
 (setq company-minimum-prefix-length 2) ;;default 3
 (setq company-dabbrev-minimum-length 2) ;;default 4
 (setq company-selection-wrap-around t) ;;default nil, next of end is begin
 (setq company-dabbrev-downcase nil)
 (setq company-backends '(company-capf
                        company-keywords
                        company-semantic
                        company-files
                        company-dabbrev
                        company-etags
                        company-clang
                        company-cmake
                        company-yasnippet))
 (setq company-require-match nil)
 (global-company-mode)

 (use-package company-posframe
  :after company
  :hook (company-mode . company-posframe-mode)
  :config
  (use-package posframe) 
  )
 )

用 company-posframe 会使得候选框显示更整齐一点,默认的 popup 窗口有时候会影响周围文字的布局,这个包还自带了 company-quickhelp 显示文档的功能, 似乎也会在候选框最下方额外显示当前候选项的来源,比如是由 dabbrevs 从当前文档匹配还是 files 文件名。

ivy

总体载入

说明:

  • ivy-switch-buffer 提供了更多虚拟 buffer, 比如 recently used files, bookmark 等
  • ivy-rich 给 ivy minibuffer 增加更多信息和操作,wgrep 使得可以在 occur 导出 buffer 里修改编辑
  • 用 wgrep 可以在 grep 搜索 minibuffer 下按 c-c c-o 进入 wgrep occor 修改模式
  • use-package 中 diminish 表示在状态栏中不显示 ivy 图标, 以免状态栏太乱
  • comint-mode-map 是 交互 shell map 的统称
#+name: ivy
(use-package ivy
  :defer 1
  :diminish
  :general
  (:keymaps '(global-map Info-mode-map bibtex-mode-map markdown-mode-map comint-mode-map)
            "M-n" 'swiper-isearch
            "s-n" 'projectile-ripgrep)
  :bind ( 
         ("C-s" . swiper)
         ("s-s T" . search-all-tasks)
         ("s-s p" . projectile-ripgrep)
         ("s-s g" . counsel-rg)
         ("s-s v" . counsel-rg-my-vocab)
         :map org-mode-map
         ("s-r c" . counsel-rg-named-src)
         :map ivy-minibuffer-map ;; bind in minibuufer
         ("C-l" . ivy-alt-done) 
         ("C-j" . ivy-next-line)
         ("s-i" . ivy-restrict-to-matches)
         ("C-k" . ivy-previous-line)
         :map ivy-switch-buffer-map
         ("C-k" . ivy-previous-line)
         ("C-l" . ivy-done)
         ("C-d" . ivy-switch-buffer-kill))
  :config
  (ivy-mode 1)
  (setq ivy-use-virtual-buffers t ;;add recent file, book marker, view
        ivy-count-format "(%d/%d) ")
  (general-define-key
   :keymaps '(general-override-mode-map org-agenda-mode-map)
   :states '(motion normal visual) ;; agenda 下用motion 代替 normal state
   "SPC" 'ivy-switch-buffer
   "/" 'swiper-isearch
   )
  <<search-all-tasks>>
  <<ivy-fly>>
  <<counsel-rg-my-vocab>>
  (use-package ivy-rich
    :init
    (ivy-rich-mode 1))
  (use-package wgrep)
  )

search at point

以下代码使得执行 swiper 后默认选择当前的 symbol 作为搜索候选项, 一旦开始输入则会覆盖这个 symbol, 只接受用户输入, 参考: with-emacs · Execute commands like Marty McFly

#+name: ivy-fly
;; @see https://www.reddit.com/r/emacs/comments/b7g1px/withemacs_execute_commands_like_marty_mcfly/
(defvar my-ivy-fly-commands
  '(query-replace-regexp
    flush-lines keep-lines ivy-read
    swiper swiper-backward swiper-all
    swiper-isearch swiper-isearch-backward
    lsp-ivy-workspace-symbol lsp-ivy-global-workspace-symbol
    counsel-grep-or-swiper counsel-grep-or-swiper-backward
    counsel-grep counsel-ack counsel-ag counsel-rg counsel-pt))
(defvar-local my-ivy-fly--travel nil)

(defun my-ivy-fly-back-to-present ()
  (cond ((and (memq last-command my-ivy-fly-commands)
              (equal (this-command-keys-vector) (kbd "M-p")))
         ;; repeat one time to get straight to the first history item
         (setq unread-command-events
               (append unread-command-events
                       (listify-key-sequence (kbd "M-p")))))
        ((or (memq this-command '(self-insert-command
                                  ivy-forward-char
                                  ivy-delete-char delete-forward-char
                                  end-of-line mwim-end-of-line
                                  mwim-end-of-code-or-line mwim-end-of-line-or-code
                                  yank ivy-yank-word counsel-yank-pop))
             (equal (this-command-keys-vector) (kbd "M-n")))
         (unless my-ivy-fly--travel
           (delete-region (point) (point-max))
           (when (memq this-command '(ivy-forward-char
                                      ivy-delete-char delete-forward-char
                                      end-of-line mwim-end-of-line
                                      mwim-end-of-code-or-line
                                      mwim-end-of-line-or-code))
             (insert (ivy-cleanup-string ivy-text))
             (when (memq this-command '(ivy-delete-char delete-forward-char))
               (beginning-of-line)))
           (setq my-ivy-fly--travel t)))))

(defun my-ivy-fly-time-travel ()
  (when (memq this-command my-ivy-fly-commands)
    (let* ((kbd (kbd "M-n"))
           (cmd (key-binding kbd))
           (future (and cmd
                        (with-temp-buffer
                          (when (ignore-errors
                                  (call-interactively cmd) t)
                            (buffer-string))))))
      (when future
        (save-excursion
          (insert (propertize (replace-regexp-in-string
                               "\\\\_<" ""
                               (replace-regexp-in-string
                                "\\\\_>" ""
                                future))
                              'face 'shadow)))
        (add-hook 'pre-command-hook 'my-ivy-fly-back-to-present nil t)))))

(add-hook 'minibuffer-setup-hook #'my-ivy-fly-time-travel)
(add-hook 'minibuffer-exit-hook
          (lambda ()
            (remove-hook 'pre-command-hook 'my-ivy-fly-back-to-present t)))

counsel + smex

smex 能够使得 counsel 显示的菜单按照使用频率排序

#+name: counsel
(use-package counsel
  :bind (("M-x" . counsel-M-x)
         ("C-x f" . counsel-find-file) ;;origin is set-fill-column
         :map org-mode-map
         ("C-c o" . counsel-org-goto)  ;; outline, similar to pdf/epub outline
         :map minibuffer-local-map
         ("C-r" . 'counsel-minibuffer-history))
  :config
  (setq ivy-initial-inputs-alist nil)
  (use-package smex)
  ;;将 c-c c-q(默认 org-set-tags-command) 定向为 counsel-org-tag
  ;;进入 tag 选择菜单后,通过 alt+enter 可以进行多选(选中当前而不退出)
  (global-set-key [remap org-set-tags-command] #'counsel-org-tag)
  )

默认情况下 counsel-M-x 会在初始时显示 "^" 这使得输入时正则搜索的是行首的字母,初始字符串由 ivy-initial-inputs-alist 变量决定,将其直接置为 nil, 参考: search - Counsel M-x always shows "^" - Emacs Stack Exchange

递归查找目录下某个类型文件

默认的 counsel-fzf 查找文件会用 fzf 本身的搜索过滤机制,无法支持拼音,这里自己参照 counsel-fzf 和 swiper-isearch 的代码写一个,用 find 命令去搜索,并且可以给定后缀名,主要用来搜索 pdf:

(defun my/counsel-find-files-with-ext (&optional initial-input initial-directory ext)
  "Open a file using the find shell command.
INITIAL-INPUT can be given as the initial minibuffer input.
INITIAL-DIRECTORY, if non-nil, is used as the root directory for search.
ext, the file ext, recursively search all files when nil"
  (interactive)
  (setq my/counsel--find-dir
        (or initial-directory
            (funcall counsel-fzf-dir-function)))
  (setq my/counsel--finf-ext (if ext (format "*.%s" ext) "*"))
  (ivy-read (format "find *.%s: " ext)
            (my/counsel-find-files-with-ext-function "")
            :initial-input initial-input
            :re-builder #'swiper--re-builder
            :require-match t
            :action #'counsel-fzf-action
            :caller 'counsel-fzf))

(defun my/counsel-find-files-with-ext-function (str)
  (let ((default-directory my/counsel--find-dir))
    (counsel--async-command
     (format "find . -type f -iname %s" my/counsel--finf-ext)))
  nil)

之后发现用 counsel-file-jump 就可以递归搜目录下所有文件,只不过无法给定后缀来过滤,所以以上就当学习和展示怎么写一个自己的 minibuffer 选择过滤工具了,核心就是把一个字符串列表喂给 ivy-read ,如果数据太多要动态地增量式地返回结果,那么参考 counsel-fzf 实现,把 ivy-read 第二个参数改成函数(而不是字符串列表),并添加 :dynamic-collection t

helpful 增强

helpful 是构造了一个新的 buffer 类型,对 emacs 里函数的变量的帮助信息显示得更加丰富,查找 symbol 的时候也有一个搜索框, counsel 对该搜索交互有一定增强。

#+name: helpful
(use-package helpful
  :custom
  (counsel-describe-function-function #'helpful-callable)
  (counsel-describe-variable-function #'helpful-variable)
  :bind
  ([remap describe-function] . counsel-describe-function)
  ([remap describe-command] . helpful-command)
  ([remap describe-variable] . counsel-describe-variable)
  ([remap describe-key] . helpful-key))

vertico 生态说明

vertico 系列比 ivy 控制度更好一点,但由于它在如下几个常用的功能上无法达到 ivy 的效果,所以目前没有使用。

  • 没有 swiper-isearch 等价的功能,也就是搜索完成之后按 n/p 可以在刚才搜索完成的关键词上进行前后跳转
  • rg 全项目搜索文档时支持拼音(首字母或者全拼),consult-grep 似乎无法做到
  • consult-buffer 切换速度没有 ivy-switch-buffer 那么快(可能是其中 UI 更丰富了,对 buffer 和文件做了不同 section 的划分),同样 consult-line 似乎也没有 swiper-isearch 那么流畅。

以下是之前尝试 vertico 的代码,如果以后能够满足以上功能了,会重新考虑。

(use-package vertico
  :init
  (vertico-mode)
  :bind 
  (:map minibuffer-local-map
        ("C-j" . next-line)
        ("C-k" . previous-line)))

(use-package orderless
  :init
  (setq completion-styles '(orderless basic)
        completion-category-defaults nil
        completion-category-overrides '((file (styles partial-completion))))
  :config
  ;;from https://github.com/tumashu/pyim
  (defun my-orderless-regexp (orig-func component)
    (let ((result (funcall orig-func component)))
      (pyim-cregexp-build result)))

  (advice-add 'orderless-regexp :around #'my-orderless-regexp))

(use-package marginalia
  :bind
  (:map minibuffer-local-map
        ("C-l" . marginalia-cycle))
  :init
  ;; Marginalia must be activated in the :init section 
  (marginalia-mode))

(use-package nerd-icons-completion
  :after marginalia
  :config
  (nerd-icons-completion-mode)
  (add-hook 'marginalia-mode-hook #'nerd-icons-completion-marginalia-setup))

(use-package consult
  :config
  (general-define-key
   :keymaps '(general-override-mode-map org-agenda-mode-map)
   :states '(motion normal visual) ;; agenda 下用motion 代替 normal state
   "SPC" 'consult-buffer
   "/" 'consult-line
   )

  (advice-add 'consult--buffer-action :after #'my/buffer-switch-advice)
  (advice-add 'find-file-at-point :after #'my/buffer-switch-advice)

  ;; https://emacs-china.org/t/xxx-thing-at-point/18047/18
  (defun consult-delete-default-contents()
    (remove-hook 'pre-command-hook 'consult-delete-default-contents)
    (cond ((member this-command '(self-insert-command))
           (delete-minibuffer-contents))
          (t (put-text-property (minibuffer-prompt-end) (point-max) 'face 'default))))
  (consult-customize
   consult-line
   :initial (when-let ((string (thing-at-point 'word)))
              (add-hook 'pre-command-hook 'consult-delete-default-contents)
              (propertize string 'face 'shadow)))
  )

(use-package wgrep)

(use-package helpful
:bind
   ([remap describe-key]      . helpful-key)
   ([remap describe-command]  . helpful-command)
   ([remap describe-variable] . helpful-variable)
   ([remap describe-function] . helpful-callable))

emacs pyim

pyim 是一个 emacs 中的中文处理库,提供了许多与中文处理相关的函数,例如检测光标当前字符是否是中文, 中文分词,或者将中文转换成拼音等,输入法是基于这些库实现一个高层的更复杂的应用。

  • 由于 pyim 使用 elisp 实现,其好处是非常灵活,所有源码都在 emacs 包中,可以任意定制有关中文输入的按键交互功能。
  • 同样由于使用 elisp 实现,pyim 把输入法的运行负担从系统转移到了 emacs, emacs 本身已经比较庞大,由于其他大量第三方插件代码,因此 pyim 有时会很卡,cpu 100% 以及 memory 占用超过 1 g 的情况常有发生,关闭 pyim 后性能会变好很多。因此我不把 pyim 作为输入法,而是使用其中提供的几个中文相关的功能,比如分词和按拼音搜索。

pyim as a chinese processing library

几个常用的使用场景是:

  • ivy 中通过拼音首字母来搜索中文
  • 通过分词来删除一个中文词汇
#+name: pyim-minimum
<<pyim-ivy>>
(use-package pyim
  :general
  (:keymaps '(evil-insert-state-map company-active-map)
            "M-DEL" 'pyim-delete-backward-word
            "<C-backspace>" 'pyim-delete-backward-word
            "C-w" 'pyim-delete-backward-word
            )
  :config
  (use-package pyim-basedict
    :after pyim
    :config (pyim-basedict-enable))
  <<pyim-delete-chinese-word>>
  )
#+name: pyim-minimum-setting
<<pyim-minimum>>
  • pyim ivy
    #+name: pyim-ivy
    (require 'pyim-cregexp-utils)
    (setq ivy-re-builders-alist
        '((t . pyim-cregexp-ivy)))
    
  • 删除完整的中文词

    evil insert 下 c-w 按键默认对应的是 evil-delete-backward-word, 对于中文,往往会删除到上一个标点,相当与删除句子,以下将 c-w 修改为删除中文时也是按照词的粒度进行。 该函数在用来修改当前拼音打字输入的错误比较有用,已经成为习惯,它使用到了 pyim 分词功能,需要加载 pyim-basedict 词库。

    #+name: pyim-delete-chinese-word
    (require 'pyim-cstring-utils) ;;  needed in native-comp
    (defun pyim-delete-backward-word ()
        (interactive)
      (let ((cur (point))
            (char (pyim-char-before-to-string 0))
            )
        (if (pyim-string-match-p " " char)
            (skip-chars-backward " ")
            (pyim-backward-word)
        )
        (delete-region (point) cur)
      )
    )
    

emacs-rime

折腾emacs-rime 里所说:"emacs-rime 是 RIME 输入法的 Emacs UI 前端,所有行为都通过 RIME 配置文件来配置", 因此实际上我们只需要安装 librime 库(作为后端)就可以了,对于 ubuntu 使用 sudo apt install librime-dev

更多安装和配置细节参考: rime 输入法

emacs rime 总体

#+name: emacs-rime
(use-package rime
  :general
   (:keymaps '(rime-active-mode-map)
   "M-DEL" 'rime--escape 
   "C-w" 'rime--escape 
   )
   (:keymaps '(evil-insert-state-map minibuffer-local-map ivy-minibuffer-map)
   "C-\\" 'convert-code-or-disable-rime
   )
  :custom
  (default-input-method "rime")
  :config
  (use-package popup) ;; depend
  <<emacs-rime-posframe>>
  (setq default-input-method "rime"
        rime-user-data-dir "~/.config/emacs_rime")
  <<rime-switch-manually>>
  <<rime-switch-auto>>
  <<rime-color-change>>
  (defun sync-ibus-and-emacs-rime-userdb ()
    (interactive)
    (rime-sync)
    (start-process-shell-command
     ""
     nil
     "ibus exit;cd ~/.config/ibus/rime; rime_dict_manager -s;ibus-daemon --xim -d -r")
    (message "ibus-rime and emacs rime sync done")
    )
  )

最后添加了一个 sync-ibus-and-emacs-rime-userdb 函数用来同步系统 ibus-rime 和 emacs-rime 词库

#+name: emacs-rime-setting
<<emacs-rime>>

选词交互和 UI

以下通过 emacs-rime posframe 显示词的候选列表,rime 默认接受逗号 , 和句号 . 进行翻页,

输入法本身的设置要用 rime-open-configuration 命令打开 rime 的配置文件,修改后通过 rime-deploy 重新加载 rime, 比如设置候选词数量为 9 个.

#+name: emacs-rime-posframe
(setq rime-show-candidate 'posframe)

(setq rime-posframe-properties
          (list :background-color "#333333"
                :foreground-color "#dcdccc"
                :internal-border-width 10))

手动中英文切换

pyim 中有一个将光标处的拼音字符串转换为中文的功能比较好用(被称为点石成金),rime 里没有提供默认接口,在 (重新设计)中英文混打:OS输入法管理包 smart-input-source - Emacs-general - Emacs China 第 994 楼有人询问这个配置, 996 楼作者给出的方案如下:

(defun +translate-symbol-to-rime ()
  (interactive)
  (let* ((input (thing-at-point 'symbol))
         (beg (car (bounds-of-thing-at-point 'symbol)))
         (end (point)))
    (delete-region beg end)
    (toggle-input-method)
    (dolist (c (string-to-list input))
      (rime-lib-process-key c 0)
      )
    (rime--redisplay)
    )
  )

个人的一些修改:

  • 以上代码中在选词前先调用了 toggle-input-method, 这是默认认为当前输入法是关闭的,于是先开启输入法。但如果当前已经开启,但正好处于英文输入模式的话,就会关闭输入法,导致不符合预期行为,因此加入一个输入法状态判断。
  • 以上使用 (thing-at-point 'symbol) 来获取 input ,当光标在英文字符串中间时,会将整个英文选择到输入法中,另外如果中英文连在一起,会将中文也涵盖,而我更想要的只是光标前的所有英文(不包含空格),因此改为向前搜索英文字符串
  • 除此之外,我希望同样的按键(绑定为左 shift)能有更多功能:
    • 光标前如果是英文字母,则直接将英文提取到拼音进行选择
    • 光标前如果是中英文标点,则将标点进行中英文转换
#+name: rime-switch-manually
(defun +translate-symbol-to-rime ()
  (interactive)
  (let* ((end (point))
         (beg (+ end (skip-chars-backward "a-z")))
         (input (buffer-substring beg end))
         )
    (delete-region beg end)
    (if current-input-method nil (toggle-input-method))
    (dolist (c (string-to-list input))
      (rime-lib-process-key c 0)
      )
    (rime--redisplay)))

(defun convert-code-or-disable-rime ()
  (interactive)
  (if (not (featurep 'ecb))
      (require 'pyim)
      )
  (let ((str-before-1 (pyim-char-before-to-string 0)))
    (cond ((pyim-string-match-p "[a-z]" str-before-1)
           (+translate-symbol-to-rime))
          ((pyim-string-match-p "[[:punct:]]\\|:" str-before-1)
           (pyim-punctuation-translate-at-point))
          (t (toggle-input-method)))))

pyim-string-match-p "[[:punct:]]" str-before-1 无法识别到中文的 : 符号,需要手动加上。

根据上下文自动切换中英文

在以下模式下自动切换成英文: 更多参考 GitHub - DogLooksGood/emacs-rime: RIME ㄓ in Emacs

  • rime-predicate-after-ascii-char-p 任意英文字符后
  • rime-predicate-evil-mode-p 在 evil-mode 的非编辑状态下
  • rime-predicate-punctuation-after-space-cc-p 中文字符且有空格之后输入符号时
  • rime-predicate-punctuation-after-ascii-p 在任意英文字符之后输入符号时
  • rime-predicate-punctuation-line-begin-p 在行首要输入符号时
  • rime-predicate-space-after-ascii-p 在任意英文字符且有空格之后
  • rime-predicate-space-after-cc-p 在中文字符且有空格之后
  • rime-predicate-current-uppercase-letter-p 将要输入的为大写字母时
  • rime-predicate-tex-math-or-command-p 在 (La)TeX 数学环境中或者输入 (La)TeX 命令时
#+name: rime-switch-auto
(setq rime-disable-predicates
      '(
        rime-predicate-after-ascii-char-p 
        rime-predicate-evil-mode-p 
        rime-predicate-punctuation-after-space-cc-p 
        rime-predicate-punctuation-after-ascii-p 
        rime-predicate-punctuation-line-begin-p 
        rime-predicate-org-in-src-block-p
        rime-predicate-prog-in-code-p
        rime-predicate-org-latex-mode-p
        ;; rime-predicate-space-after-ascii-p 
        rime-predicate-space-after-cc-p 
        rime-predicate-current-uppercase-letter-p 
        rime-predicate-tex-math-or-command-p 
        ))

光标颜色自动变化

在输入中文的时候光标是 input-method-cursor-color 颜色。

#+name: rime-color-change
(advice-add 'toggle-input-method :after 'change-cursor-color-on-input-method)
(defvar input-method-cursor-color "white"
  "Default cursor color if using an input method.")

(defun get-frame-cursor-color ()
  "Get the cursor-color of current frame."
  (interactive)
  (frame-parameter nil 'cursor-color))

(defvar default-cursor-color (get-frame-cursor-color)
  "Default text cursor color.")

(defun change-cursor-color-on-input-method ()
  "Set cursor color depending on whether an input method is used or not."
  (interactive)
  (set-cursor-color (if (and (rime--should-enable-p)
                             (not (rime--should-inline-ascii-p))
                             current-input-method)
                        input-method-cursor-color
                      default-cursor-color)))

(add-hook 'post-command-hook 'change-cursor-color-on-input-method)

list-colors-display 显示颜色列表

参考: [分享] 切换输入法时更换光标颜色 - Emacs-general - Emacs China 结合楼主的回答 + https://emacs-china.org/t/topic/17717/17

窗口管理和 tab-bar

一个 tab 类似 windows/i3wm 里的桌面或workspace, 或者像 tmux 的 window, 它是用来对多个 window 进行分组,可以对某些 tab 中约定固定的功能。

窗口管理总览

#+name: tab-bar
<<dashboard>>
(use-package tab-bar
  :ensure nil
  :config
  <<tab-utils>>
  <<switch-tab-by-name-num>>
  <<quick-switch-back>>
  <<confirm-scratch-delete>>
  <<windmove-ibuffer>>
  (use-package popwin
    :config
    (popwin-mode 1))

  (use-package winner 
    :ensure nil
    :hook (after-init . winner-mode))
  )

以上还开启了 popwin, 这会把一些特殊窗口变成临时窗口,用 C-g 可以退出。 winner-mode 开启之后可以通过 winner-undo 和 winner-redo 来进行切换窗口布局,对应 c-c left 和 c-c right, 重新绑定为 ,h 和 ,l 。

#+name: tab-space
<<tab-bar>>

tab-bar 的基本设置

#+name: tab-utils
(tab-bar-mode 1)
(setq tab-bar-new-button-show nil)
(setq tab-bar-close-button-show nil)
(setq tab-bar-show 1)
(setq tab-bar-tab-hints t) ;; show number
(setq tab-bar-auto-width nil) ;; 取消自动 padding 大小(29.2 引入)
(setq tab-bar-format '(tab-bar-format-tabs tab-bar-separator tab-bar-format-align-right tab-bar-format-global))
(defun my/update-tab-bar-after-theme-change (&rest _args)
  "Update tab bar face attributes after a theme change."
  (set-face-attribute 'tab-bar-tab nil
                      :inherit 'doom-modeline-panel
                      :foreground 'unspecified
                      :background 'unspecified)

  (set-face-attribute 'tab-bar nil
                      :foreground (face-attribute 'default :foreground)))

(advice-add 'load-theme :after #'my/update-tab-bar-after-theme-change)
(my/update-tab-bar-after-theme-change)

说明:

  • tab-bar-format 内容默认是:

    (tab-bar-format-history tab-bar-format-tabs tab-bar-separator tab-bar-format-add-tab)
    
    • tab-bar-format-history 会在 tabbar 最左边显示左右箭头,按历史记录返回之前或之后的 tab, 但一般不用,默认 tab-bar-history-mode 是 nil, 因此即便添加了这个 history format, 也不会显示
    • tab-bar-format-add-tab 是在 tab 名称右侧添加一个 + 号按钮,点击后新建一个 tab, 这个也不用,因此设置了 (setq tab-bar-new-button-show nil), 所以即便 format 里添加了该区域也不会显示,如果真要用鼠标创建 tab ,用右键也能弹出新建 tab 选项。
    • 因此删除了以上两个 section 后添加了 tab-bar-format-align-right tab-bar-format-global, 这可以把 org-clock 信息实时显示在 tab-bar 最右侧,这比显示在每个 buffer 的 modeline 上更合理,因为 org-clock 信息本身就是 emacs 全局的,而不是 buffer local 的(tab-bar-format-global 是 Emacs28 后引入的)
  • 由于每次切换主题之后,tabbar 选中颜色变得很淡,因此要在主题切换后添加一个颜色恢复的函数,这里用 advice 恢复 face

根据名称或者编号切换 tab 的函数

绑定 M-digital 切换 tab, 如果当前已经再第 n 个 tab, 按 M-n 会返回上一个 tab.

#+name: switch-tab-by-name-num
(defun switch-to-tab-by-number (num)
  (let ((current-prefix-arg num)  ;; emulate C-u num
        (current-tab-index (tab-bar--current-tab-index)))
    ;; (update-ivy-tabs)
    (if (equal (1+ current-tab-index) num) ;; num start from 1
        (tab-bar-switch-to-recent-tab)
      (call-interactively 'tab-select))))

(general-define-key
 :keymaps 'general-override-mode-map
 "M-1" '(lambda () (interactive) (switch-to-tab-by-number 1))
 "M-2" '(lambda () (interactive) (switch-to-tab-by-number 2))
 "M-3" '(lambda () (interactive) (switch-to-tab-by-number 3))
 "M-4" '(lambda () (interactive) (switch-to-tab-by-number 4))
 "M-5" '(lambda () (interactive) (switch-to-tab-by-number 5))
 "M-6" '(lambda () (interactive) (switch-to-tab-by-number 6))
 "M-7" '(lambda () (interactive) (switch-to-tab-by-number 7))
 "M-8" '(lambda () (interactive) (switch-to-tab-by-number 8))
 "M-9" '(lambda () (interactive) (switch-to-tab-by-number 9))
 )

跳转到上一个视图

这里视图的定义是:buffer, tab 和 window, 它们有各自的 "跳转到上一个" 的接口:

  • other-buffer
  • tab-bar-switch-to-recent-tab
  • evil-window-mru

本节则根据 context switch 的情况把这几个切换统一起来:

  • 如果上一次是 *-buffer-switch/find-file, 那么应该在当前窗口跳转到切换前的 buffer
  • 如果上一次是 avy-goto, 那么应该执行 avy-popmark, 返回到跳转前位置
  • 如果上一次是 tab switch, 那么应该是 tab-bar-switch-to-recent-tab
  • 如果当前是一些临时窗口,那么直接关闭这个窗口,这时光标会回到打开临时窗口前的 buffer
  • (可选)如果上一次是 windmove* 操作, 那么应该执行 evil-window-mru, 它定位到当前 frame 中上一次光标所在的另外一个窗口中(类似 avy-popmark, 但只会回到另一个窗口)

上节 switch-to-tab-by-number 中已经用 last-switch-action 变量记录操作类型了,本节对更多操作加上记录切换类型的 hook/advice

因为大部分时间是用 ivy-switch-buffer 来切换 buffer 的,于是对 ivy–switch-buffer-action 加 advice 。注意 advice 不应该加在 switch-to-buffer 上, 因为该函数太过"底层", 覆盖面太细,比如打开 hydra buffer 或者 minibuffer 都会记录成 buffer-switch, 但这些 buffer 往往也是临时的,不希望它们被记录为值得回顾的 buffer。

#+name: quick-switch-back
(defvar last-switch-action nil)
(defun my/tab-switch-advice (&rest _) (setq last-switch-action "tab-switch"))
(defun my/buffer-switch-advice (&rest _) (setq last-switch-action "buffer-switch"))
(defun my/avy-goto-advice (&rest _) (setq last-switch-action "avy-goto"))

(advice-add 'switch-to-tab-by-number :after #'my/tab-switch-advice)
(advice-add 'ivy--switch-buffer-action :after #'my/buffer-switch-advice)
(add-hook 'find-file-hook 'my/buffer-switch-advice)
(advice-add 'ace-pinyin-goto-char-timer :after #'my/avy-goto-advice)

(defun my/switch-back ()
  "Switch to the recent buffer, or destoried window configuration."  
  (interactive)
  (cond 
   ((member major-mode '(special-mode
                         messages-buffer-mode
                         helpful-mode
                         magit-status-mode
                         help-mode
                         Custom-mode
                         ibuffer-mode
                         debugger-mode
                         org-roam-mode
                         compilation-mode))
    (quit-window))
   ((member major-mode '(package-menu-mode))
    (switch-to-buffer (other-buffer (current-buffer) nil)))
   ((equal last-switch-action "window-switch")
    (evil-window-mru))
   ((equal last-switch-action "avy-goto")
    (avy-pop-mark))
   ((equal last-switch-action "tab-switch")
    (tab-bar-switch-to-recent-tab))
   ((equal last-switch-action "buffer-switch")
    (switch-to-buffer (other-buffer (current-buffer) nil)))))

(general-define-key
 :keymaps '(general-override-mode-map org-agenda-mode-map eaf-mode-map*)
 :states '(motion normal visual) ;; agenda 下用motion 代替 normal state
 "gq" 'my/switch-back)

谨慎删除 scratch buffer

一般来说,不要删除这个 buffer, 因为里面会放许多尝试性代码,因此加一个确认提醒

#+name: confirm-scratch-delete
(defun my/confirm-kill-scratch-buffer (orig-fun &rest args)
  "Confirm before killing the *scratch* buffer."
  (let ((buffer-to-kill (car args)))
    (if (and (get-buffer "*scratch*") 
             (equal buffer-to-kill (get-buffer "*scratch*"))
             (not (yes-or-no-p "*scratch* Don't want to be Killed, Confirm? ")))
        (message "Aborted")
      (apply orig-fun args))))

(advice-add 'kill-buffer :around #'my/confirm-kill-scratch-buffer)

dashboard

  • 用于显示启动页面,可以添加图片、展示启动时间等,此外绑定 R 为 desktop-read
  • initial-buffer-choice 是保证已经启动 emacs server 情况下再启动一个 emacs 还是会显示 dashboard, 方便修改配置后新启动一个 emacs 进程看启动时间。
#+name: dashboard
(use-package dashboard
  :config
  (dashboard-setup-startup-hook)
  (setq dashboard-center-content t)
  (setq dashboard-items '((recents  . 5)
                          (bookmarks . 5)
                          (projects . 5)
                          ))
  (setq initial-buffer-choice (lambda () (get-buffer-create "*dashboard*")))
  (setq dashboard-startup-banner "/data/resource/pictures/dashboard/moutain.png")
  (setq dashboard-image-banner-max-width 300)
  (setq dashboard-banner-logo-title "don't panic") ;;nil to delete

  (setq dashboard-startupify-list '(dashboard-insert-banner
                                    dashboard-insert-newline
                                    dashboard-insert-banner-title
                                    dashboard-insert-newline
                                    dashboard-insert-navigator
                                    dashboard-insert-newline
                                    dashboard-insert-init-info
                                    dashboard-insert-items
                                    dashboard-insert-newline
                                    dashboard-insert-footer))

  ;; Format: "(icon title help action face prefix suffix)"
  (setq dashboard-navigator-buttons
        `(;; line1
          ((,(nerd-icons-mdicon
              "nf-md-dock_window"
              :height 1.1 :v-adjust 0.0)
            "desktop-read (R)"
            ""
            (lambda (&rest _) (desktop-read))))))

  (setq dashboard-set-file-icons t)
  (setq dashboard-footer-messages '("-------------------"))
  (setq dashboard-display-icons-p t) ;; display icons on both GUI and terminal
  (setq dashboard-icon-type 'nerd-icons) ;; use `nerd-icons' package

  (general-define-key
   :keymaps '(dashboard-mode-map)
   :states 'normal
   ;; "gd" 'evil-goto-definition
   "R" 'desktop-read)
  )

窗口 buffer 相关按键设置

  • 窗口和 buffer 操作都是全局的,emacs 中由于有非常多 mode,每个 mode 有各自的 keybinding 无法对每个 mode 都进行改建,如果要覆盖所有 mode, 用以下 general-override-mode-map
  • 对 Ibuffer mode 修改, 默认 , 是 sort, 取消掉
#+name: windmove-ibuffer
(general-define-key
 :keymaps 'general-override-mode-map
 :states '(normal insert visual motion)
 "M-h" 'windmove-left
 "M-l" 'windmove-right
 "M-j" 'windmove-down
 "M-k" 'windmove-up)

(use-package ibuffer
  :defer
  :config
  (general-define-key
   :keymaps 'ibuffer-mode-map
   :states 'normal
   "," nil 
   "s," 'ibuffer-toggle-sorting-mode
   "SPC" 'switch-to-buffer))

org 基础配置

文章篇幅过半,终于进入到真正的 Org mode 配置了😅

查看 org 版本

org-version
9.6.15

org-mode 总体

#+name: org
(use-package org
  :ensure nil
  :init 
  (setq org-ellipsis " ▾")
  (global-set-key (kbd "C-c l") 'org-store-link)
  (bind-key "C-c n" 'narrow-or-widen-dwim)
  :hook (org-mode . org-mode-ui-setup)
  :config
  (require 'ol-man) ;; support follow link in woman buffer
  (setq org-id-method 'ts)
  (setq org-image-actual-width nil)
  <<org-basic-ui>>
  <<narrow-or-widen-dwim>>
  <<org-file-apps>>
  <<read-org-block-by-name>>
  )

说明:

  • org-ellipsis : org header 折叠时默认显示 … 改成倒三角形。
  • (setq org-image-actual-width nil) 使得可以通过 #+ATTR_ORG: :width 100 来定制 org inline image 大小
  • org-id-method 有三个选项:
    • org: 生成内容是一个对时间的 hash 值 :ID: he4fkbc0lhj0
    • ts: 时间戳方式 :ID: 20220615T100541.162502, 这种方式至少有意义的
    • uuid: 全局的 hash 值
  • c-c l快捷绑定是 org mode 文档里所推荐的 Activation (The Org Manual)
#+name: org-basic-setting
<<org>>

org mode UI 设置

#+name: org-basic-ui
(defun org-mode-ui-setup ()
  (org-indent-mode)
  (setq word-wrap nil) 
  (visual-line-mode 1)
  (setq org-emphasis-alist
        '(("*" (bold :slant italic :weight black )) ;; this make bold both italic and bold, but not color change
          ("/" (italic :foreground "dark salmon" )) ;; italic text, the text will be "dark salmon"
          ("_" underline :foreground "cyan" ) ;; underlined text, color is "cyan"
          ("=" (org-verbatim :background "black" :foreground "deep slate blue" )) ;; background of text is "snow1" and text is "deep slate blue"
          ("~" (org-code :background "dim gray" :foreground "PaleGreen1" ))
          ("+" (:strike-through t :foreground "dark orange" ))))

  (use-package org-appear
    :after org
    :hook (org-mode . org-appear-mode)
    :config
    (setq org-hide-emphasis-markers t)
    (setq org-appear-autolinks t)
    )

  (defun org-mode-visual-fill ()
    (setq visual-fill-column-width 100
          visual-fill-column-center-text t)
    (visual-fill-column-mode 1))

  (use-package visual-fill-column
    :hook (org-mode . org-mode-visual-fill))
  )

该函数参考了 Org Mode Basics - System Crafters ,在 org-mode 加载后执行。解释:

  • org-indent-mode 是对 org tree 按层级缩进,并隐藏多层 heading 里的前几个 star
  • word-wrap 是在空格或者 tab 后进行自动折叠 ,对中文不友好,因此取消。
  • visual-line-mode 表示在视觉上换行,实际不换行,而 auto-fill-mode 则是强行逻辑上换行
  • org-emphasis-alist 默认值如下:

    (("*" bold)
     ("/" italic)
     ("_" underline)
     ("=" org-verbatim verbatim)
     ("~" org-code verbatim)
     ("+"
      (:strike-through t)))
    

    设置中主要是在此基础上加入了前后背景颜色, 另外 italic 不显示斜体的原因在于使用的 Noto Sans Mono:size=16 字体中没有斜体

  • 使用 org-appear 包,它与 org-fragtog 功能类似。前者使得当光标放在 org 链接或者特殊强调字符,例如 bold 上时,自动显示成纯文本,便于修改, 而光标离开后则只展示文字效果;后者则是光标移动到 org inline latex 上显示纯文本,移开光标则渲染出 latex 公式。
  • 使用 visual-fill-column 包,将 org 内容宽度设为 100 字符并且居中

narrow or winden org tree

以下拷贝自 https://gist.github.com/mwfogleman/95cc60c87a9323876c6c, 用于在 narrow subtree 或 winden 之间切换

#+name: narrow-or-widen-dwim
(defun narrow-or-widen-dwim ()
  "If the buffer is narrowed, it widens. Otherwise, it narrows to region, or Org subtree."
  (interactive)
  (cond ((buffer-narrowed-p) (widen))
        ((region-active-p) (narrow-to-region (region-beginning) (region-end)))
        ((equal major-mode 'org-mode) (org-narrow-to-subtree))
        (t (error "Please select a region to narrow to"))))

org-file-apps

通过该变量控制 org 连接里文件的打开方式,比如 mp4 会通过 vlc 打开

#+name: org-file-apps
(setq org-file-apps
      '((auto-mode . emacs)
        (directory . emacs)
        ("\\.mp4\\'" . "vlc \"%s\"")
        ("\\.mp3\\'" . "vlc \"%s\"")
        ("\\.mm\\'" . default)
        ("\\.x?html?\\'" . default)
        ("\\.pdf\\'" . default)))

搜索特定 name 的 block 内容

这属于操作 org 中 src block 的常用库函数,之后会用到,比如 clock out 的时候

#+name: read-org-block-by-name
(defun my/read-org-block-by-name (file block-name)
  "Read the content of a named org-mode block from
a file."
  (with-temp-buffer
    (insert-file-contents file)
    (goto-char (point-min))
    (if (re-search-forward (concat "^#\\+name: " (regexp-quote block-name)) nil t)
        (when (re-search-forward "^#\\+begin_src" nil t)
          (forward-line)
          (beginning-of-line)
          (let ((start (point)))
            (if (re-search-forward "^#\\+end_src" nil t)
                (string-trim (buffer-substring-no-properties
                              start
                              (match-beginning 0)))
              (error "End of source block not found"))))
      (error "Named block not found"))))

(defun my/jump-to-org-block-by-name (file block-name)
  "Jump inside the src block with name: block-name in the specified file."
  (find-file file)  ; 打开文件
  (goto-char (point-min))  ; 移动到 buffer 的开始位置
  (if (re-search-forward (concat "^#\\+name: " (regexp-quote block-name)) nil t)
      (when (re-search-forward "^#\\+begin_src" nil t)
        (forward-line 1)  ; 移动到 #+begin_src 的下一行
        (beginning-of-line))
    (error "Named block not found")))
(my/jump-to-org-block-by-name "~/org/historical/projects.gtd" "timeout")

有以上函数之后,还可以精确地 tangle 出 org 文件里不同的 src block, 但实际不是很有必要,因为对于零散的脚本,直接用 include 方式引用即可,跳转也是方便的。

任务管理

对我来说,使用 org 管理任务的核心在于对任务进行分解以及随时记录想法,而不是对未来要做的事件做日程记录和定时提醒(也会附带一点这方面功能,但这部分用手机更加方便)。其特点为:

  • 只在 projects.gtd 文件里记录正在进行或有待处理的任务
  • projects.gtd 里用 subtree 把一个任务分成多个子任务,并且只对子任务设置 TODO, NEXT, DONE, CANCEL 几种状态。例如

    * 整理 emacs 核心配置
    ** DONE org todo list
    ** TODO org babel
    
  • 当一个任务的所有子任务都变成 DONE 状态,或者这个任务没必要做了,将该任务 archive 掉。archive 文件的名称是动态计算出来的,每半年新建一个独立的文件,比如如果当前是 2023 年 4 月,那么 archive 任务时会保存到 archive/2023a.org 中,如果是 2023 年 10 月,则保存到 archvie/2023b.org。
  • 当 projects.gtd 里某些任务停滞了(比如发现有重要的事情要做,这些任务可能推迟数周甚至数月),那么手动把任务移动到 inbox.gtd 目录里,它相当于一个 cache 文件。
  • agenda 只从 projects.gtd 和 inbox.gtd 里读取任务信息进行临时地展示。

    因为 agenda 是每次执行 org-agenda 命令后动态生成的,如果 agenda-file 太多,会影响读取效率,并且 agenda 主要用来看当天已经做了什么、接下来要做什么。对于已经完成的任务,不需要经常显示出来,因此 agenda-files 中不包括 archive/ 目录下的文件。

  • 相比于 agenda 的临时显示特点,用 dynamic block 生成的 clocktable 是直接插入到 org mode 文件中的。 如果到了周末,要查看过于一周做了什么,那么可以在本周总结的 org section 下插入一个 clocktable block, 由于使用频率不高,构建 clocktable 不对效率有要求,因此它可以扫描更多的 achive 文件来生成报告, 甚至可以生成一整年的报告。
  • 用"j+年份a/b"的格式自动生成日志文件,命令方式和 archive 文件基本一样,只是所在路径不同,比如:

    ~/org/journal/j2023a.org
    ~/org/journal/j2023b.org
    

    用快捷键绑定打开当前日志 buffer 的功能,用于随时记录想法。

任务管理总览

#+name: org-agenda
(use-package org-agenda
  :ensure nil
  :init
  <<agenda-archive-gtd-files>>
  :config
  <<todo-list>>
  <<first-todo-in-proj>>
  <<main-agenda>>
  <<clocktable-db-block>>
  <<org-clock-agenda>>
  <<agenda-keybinding>>
  <<gtd-oriented-tab>>
  )
#+name: org-agenda-setting
<<org-agenda>>

agenda, archive 和日志文件路径设置

  • 首先取消默认的 C-'C-, 两个按键,他们默认都帮定为 org-cycle-agenda-files, 但由于我只有两个 agenda 文件,并且几乎不会用它取循环查看文件,因此解除绑定。
  • 接着就是设置两个核心文件为 org-agenda-files, 并且设置一些根据年月份动态获取 archive 文件的函数
  • get-current-archive-file-location 是返回当前时间对应的单个 archive 文件,同时包括 "::" 后缀,这是 org-archive-location 变量的要求。
  • get-gtd-files-by-year 是包括了 org-agenda-files 以及当年的 archive 文件,用于设置 clocktable 的搜索范围
  • get-current-journal-file 和 get-current-archive-file-location 类似,只不过这里纯粹是获得文件路径
#+name: agenda-archive-gtd-files
(global-unset-key (kbd "C-'"))
(global-unset-key (kbd "C-,"))

(setq gtd-project-file "~/org/historical/projects.gtd"
      gtd-inbox-file "~/org/historical/inbox.gtd")

(setq org-agenda-files (list gtd-project-file gtd-inbox-file))

(defun get-archive-files-by-year (&optional year)
  (let ((year (or year "")))
    (file-expand-wildcards (format "~/org/historical/archive/%s*.org" year))))

(defun get-current-archive-file-location ()
  (format
   "~/org/historical/archive/%s%s.org::"
   (format-time-string "%Y")
   (if (<= (string-to-number (format-time-string "%m")) 6) "a" "b")))

(defun get-current-journal-file ()
  (format
   "~/org/self/journal/j%s%s.org"
   (format-time-string "%Y")
   (if (<= (string-to-number (format-time-string "%m")) 6) "a" "b")))

(defun get-gtd-files-by-year (&optional year)
  (append org-agenda-files
          (get-archive-files-by-year year)))

(defun my/set-archive-location (&rest _)
  "设置 org-archive-location 为当前存档文件的位置。"
  (setq org-archive-location (get-current-archive-file-location)))

;; defalult (time file olpath category todo itags)
(setq org-archive-save-context-info '(time))
(advice-add 'org-archive-subtree :before #'my/set-archive-location)

org-archive-save-context-info 只设置为 time,这样 archive 后,subtree 中只保留 archive 的时间,因为:

  • 所有 archive 的任务来自于 projects.gtd,所以不需要 file 关键字来记录它是从哪个文件转移成存档的
  • 我都是在某个大任务下的所有子任务都标记为 DONE 后才对整个任务树进行存档,因此 olpath 也不需要(它会记录父节点等信息)
  • 同样由于只存档已经完成的大任务,因此 todo 信息也不需要
  • 基本也不用 category 修饰 org tree, 故 category 字段也不需要。

最后,在 org-archive-subtree 函数加入一个 advice, 在执行该函数前,先设置动态的 archive 文件路径

以下命令可以查看 agenda files 以及 agenda files 关联的 archive 文件:

(org-agenda-files t org-id-search-archives)

任务 TODO 状态和 Tags

只需要以下四个任务状态:

  • TODO 还没开始的任务
  • NEXT 已经开始正在进行的任务
  • DONE 已经完成的事情
  • CANCELED 取消的任务

定义类型时,如果有 ! 修饰表示进入该状态后会自动生成 timestamp, @表示会要求添加状态转移记录

#+name: todo-list
(setq org-todo-keywords
      '((sequence "TODO(t)" "NEXT(n)" "|" "DONE(d@/!)" "CANCEL(c@/!)")))

;; TODO color
(setq org-todo-keyword-faces
   '(
    ("TODO" .      (:foreground "orange" :weight bold))
    ("NEXT" .      (:foreground "yellow" :weight bold))
    ("DONE" .      (:foreground "green" :weight bold))
    ("CANCEL" .     (:foreground "gray40"))
))

(setq org-log-into-drawer t) ;; add logbook
(setq org-log-done 'time) ;; add CLOSED: timestamp when task done

(setq org-tag-alist '( ("noexport" . ?n)))

可以通过 counsel-org-tag (c-c c-q) 选择添加 org-tag-alist 列表里定义的 tag, 但实际上自己很少使用 tag, 基本只需要 noexport 用来控制 blog 导出。

org-agenda 显示设置

agenda 可以显示不同时间段的任务完成和计划情况,但我主要用它来查看当天的概览,将其成为 main-agenda

主 agenda 视图分为三栏:

  • 当天已经处理的任务概览(Day Agenda): 需要设置 org-agenda-start-with-log-mode 为 t
  • 各个大任务中下一个不是 DONE 状态的任务(标题为 GO)
  • inbox 中的项目(可选,目前不太用,一般直接打开 inbox.gtd 即可)
#+name: main-agenda
(setq org-agenda-block-separator nil)
(setq org-agenda-start-with-log-mode t) ;; show log

(setq org-agenda-todo-view
      `(" " "Agenda"
        ((agenda ""
                 ((org-agenda-span 'day)
                  (org-agenda-start-with-log-mode t)
                  (org-agenda-log-mode-items '(closed clock state))
                  (org-deadline-warning-days 14)))
         (todo "TODO|NEXT"
               ((org-agenda-overriding-header "GO")
                (org-agenda-sorting-strategy '(priority-down effort-up))
                (org-agenda-files '(,gtd-project-file))
                (org-agenda-skip-function #'org-agenda-skip-all-siblings-but-first)
                ))
         (search "^* \\.*"
               ((org-agenda-overriding-header "Inbox Processing")
                (org-agenda-files '(,gtd-inbox-file))))
         nil)))

(defun org-agenda-skip-all-siblings-but-first ()
  "Skip all but the first non-done entry."
  (let (should-skip-entry)
    (unless (org-current-is-todo-or-next)
      (setq should-skip-entry t))
    (save-excursion
      (while (and (not should-skip-entry) (org-goto-sibling t))
        (when (org-current-is-todo-or-next)
          (setq should-skip-entry t))))
    (when should-skip-entry
      (or (outline-next-heading)
          (goto-char (point-max))))))

(defun org-current-is-todo-or-next ()
  (or (string= "TODO" (org-get-todo-state))
      (string= "NEXT" (org-get-todo-state))))

(setq org-agenda-prefix-format
      '((agenda . "%?-8t%-3e% s")
        (todo . "%-6:c %-6e")
        (tags . "%-12:c")
        (search . "%-12:c")))

(add-to-list 'org-agenda-custom-commands `,org-agenda-todo-view)

org-agenda-skip-all-siblings-but-first 是查看文件中项目第一个未完成子任务的函数,它参考了 https://emacs.cafe/emacs/orgmode/gtd/2017/06/30/orgmode-gtd.html

以上 org-agenda-prefix-format 默认如下,为了适合小窗口显示,删除了 %c(文件名前缀,因为都来自 projects.gtd),并且调整间距。

((agenda . " %i %-12:c%?-12t% s")
 (todo . " %i %-12:c")
 (tags . " %i %-12:c")
 (search . " %i %-12:c"))

参考:.emacs.d/init.el at master · jethrokuan/.emacs.d

ctrl-c-ctrl-c 执行 clocktable

每次调用 org-agenda 时,它都要扫描 org-agenda-files 来临时构造一个视图 buffer, 而 clocktable 则是 org 里的一个可执行的代码块,可以指定需要扫描的文件从中抽取出某个时间段的任务情况总结。

clocktable 格式如下(通过 yasnippet 插入):

#+BEGIN: clocktable :maxlevel 1 :emphasize nil :scope (lambda () (get-gtd-files-by-year 2023)) :block 2023-W3

#+END: clocktable

其中年份 2023 和周 2023-W3 是根据插入时的年份动态生成的(yasnippet 里设置), :scope 里是一个函数,执行该函数后会返回 projects.gtd, inbox.gtd 以及当年的 archive 文件。

以下函数使得按 ctrl-c-ctrl-c 的时候,如果识别到当前光标在 clocktable block 内, 则执行 org-dblock-update 更新其中的内容,

#+name: clocktable-db-block
(defun my/update-clocktable-if-appropriate ()
  "Update clocktable if the point is in a paragraph within a clocktable block."
  (let ((element-type (org-element-type (org-element-context))))
    ;;(prin1 element-type)
    (when (and
           (or (eq element-type 'paragraph)
               (eq element-type 'table-row))
           (save-excursion
             (let ((current-point (point))
                   (begin-found (search-backward "#+BEGIN: clocktable" nil t)))
               (if begin-found
                   (progn
                     ;; Reset position for the next search
                     (goto-char current-point)
                     (let ((end-found (search-forward "#+END: clocktable" nil t)))
                       (and end-found
                            (<= begin-found current-point)
                            (>= end-found current-point))))
                 nil))))
      (org-dblock-update)
      t)))  ; Return non-nil to indicate the hook has done something.

(add-hook 'org-ctrl-c-ctrl-c-hook 'my/update-clocktable-if-appropriate)

org-clock in 和 out timer

这部分想达到的一个效果是:

  • 每次 clock in 后自动设置默认 org-effort 为 25 分钟,类似番茄时钟。目的是让自己对每个任务都尽可能分解到 25 分钟以内完成,如果超过则拆分成多个, 这样可以促使自己去思考如何更细地分解任务,另一方面自己投入太长时间其实无法专注,反而容易陷入细节。

    通过在 org-clock-in-hook 中添加 set-org-clock-effort-when-nil 函数实现

  • 设置一个 my/clock-in-long-focus 函数,用该函数进行 clock in 之后,则当前周期为 52 分钟,这个适合少量需要较长时间专注的任务。
  • clock in 之后创建一个计时器,从 0 开始,每 9 分钟更新计时器,如果超过 org-effort 时间则每 9 分钟做一次提醒。
  • 在 clock out 的时候初始一个新的 clock-out-idle-timer, 该 timer 是全局变量,clock in 的时候通过 cancel-timer 来取消,提醒自己有多久没有 clock 了。
  • 当把任务状态设置为 Done 的时候,也需要 clock out, 通过对 org-after-todo-state-change-hook 添加 org-clock-out-if-done 函数实现。
#+name: org-clock-agenda
(use-package org-clock
  :ensure nil
  ;; :hook (org-clock-in-prepare . my/org-mode-ask-effort )
  :custom
  (org-clock-continuously nil)
  (org-clock-in-switch-to-state "NEXT")
  (org-clock-out-remove-zero-time-clocks t)
  (org-clock-display-default-range 'untilnow) ;; show statistic in all range
  (org-clock-persist t)
  (org-log-note-clock-out nil) ;; add log when clockout
  ;; (org-clock-persist-file (expand-file-name (format "%s/emacs/org-clock-save.el" xdg-cache)))
  (org-clock-persist-query-resume nil)
  ;; (org-clock-report-include-clocking-task t)
  (org-show-notification-timeout 10)
  :config
  (defun org-clock-out-if-done ()
    "Clock out when the task is marked DONE"
    (when (and (string= org-state "DONE")
               (equal (marker-buffer org-clock-marker) (current-buffer))
               (< (point) org-clock-marker)
               (> (save-excursion (outline-next-heading) (point))
                  org-clock-marker)
               (not (string= org-last-state org-state)))
      (org-clock-out)))

    (setq my/org-clock-effort 25)
    
    (defun set-org-clock-effort-when-nil ()
      (setq org-clock-effort (format "00:%s" my/org-clock-effort))
      (org-clock-update-mode-line))

  (defun my/clock-in-long-focus ()
    (interactive)
    ;; (setq my/org-clock-effort 
    ;;       (completing-read "Select efforts: " '("0:52")))
    (let ((my/org-clock-effort 52))
      (org-clock-in)))
  
  (add-hook 'org-clock-in-hook #'set-org-clock-effort-when-nil)
  (add-hook 'org-after-todo-state-change-hook 'org-clock-out-if-done)

  (defun clock-in-focus-notify ()
    (let ((current-focus-time
           (/ (float-time
               (time-subtract (current-time) org-clock-start-time))
              60)))
      (when (>= current-focus-time my/org-clock-effort)
        (start-process-shell-command
         ""
         nil
         (my/read-org-block-by-name "~/org/historical/projects.gtd" "timeout")))
      (org-notify (if (org-clock-is-active)
                      (string-trim
                       (substring-no-properties
                        (org-clock-get-clock-string)))
                    ""))))

  (setq my/clock-in-focus-notify-gap (* 60 9))

  (defun clock-in-focus-timer-start ()
    "设置一个计时器,每 my/clock-in-focus-notify-gap 触发一次 clock-in-focus-notify"
    (setq clock-in-focus-timer
          (run-at-time
           my/clock-in-focus-notify-gap
           my/clock-in-focus-notify-gap
           #'clock-in-focus-notify)))

  (defun clock-in-focus-timer-stop ()
    (when (boundp 'clock-in-focus-timer)
      (cancel-timer clock-in-focus-timer)))

  (add-hook 'org-clock-in-hook #'clock-in-focus-timer-start)
  (add-hook 'org-clock-out-hook #'clock-in-focus-timer-stop)

  (defun clock-out-idle-notify ()
    (setq clock-out-idle-time-duration (+ 10 clock-out-idle-time-duration))
    (org-notify (format "%d minutes idle" clock-out-idle-time-duration)))

  (defun clock-out-idle-timer-start ()
    "设置一个计时器,每 600 秒(10分钟)触发一次 clock-out-idle-notify,
     用 clock-out-idle-time-duration 全局变量大致记录 timer 已经运行的时间"

    (setq clock-out-idle-time-duration 0)
    (setq clock-out-idle-timer (run-at-time 600 600 #'clock-out-idle-notify)))

  (defun clock-out-idle-timer-stop ()
    (when (boundp 'clock-out-idle-timer)
      (cancel-timer clock-out-idle-timer)))

  (add-hook 'org-clock-out-hook #'clock-out-idle-timer-start)
  (add-hook 'org-clock-in-hook #'clock-out-idle-timer-stop)
  )
  • 对 timer 的手动处理方式:有时候 timer 可能没有正常结束,这时可以通过执行 list-timers 命令打开一个 timers buffer, 然后将光标移动到需要取消的 timer 那一行,执行 timer-list-cancel 命令来取消这个 timer.

agenda processing keybindings

本节对常用 agenda 中命令进行封装,比如默认 org-agenda-clock-in/out/done 的时候不会更新状态,那么就在 这些操作之后加入 agenda 刷新

#+name: agenda-keybinding
(defun my/org-agenda-clock-in-and-refresh ()
  (interactive)
  (org-agenda-clock-in) (org-agenda-redo) (org-agenda-goto))

(defun my/org-agenda-clock-out-and-refresh ()
  (interactive)
  (org-agenda-clock-out) (org-agenda-redo) (org-agenda-goto))

(defun my/org-agenda-done ()
  (interactive)
  (org-agenda-todo "DONE") (org-agenda-redo) (org-agenda-goto))

(defun org-agenda--jump-to-now ()
  "Search for the keyword 'now' in the current buffer and jump to it."
  (goto-char (point-min))  ; 从 buffer 的开头开始搜索
  (re-search-forward "now" nil t))  ; 搜索 'now' 关键词

(defun org-agenda-clockreport-mode-enhenced ()
  (interactive)
  (if org-agenda-clockreport-mode
      (progn
        (org-agenda-clockreport-mode)
        (org-agenda--jump-to-now)
        (beginning-of-line))
    (progn
      (org-agenda-clockreport-mode)
      (re-search-forward "Time" nil t)
      (next-line 2))))

(general-define-key
 :keymaps 'org-agenda-mode-map
 :states 'motion ;; agenda 下用 motion 代替 normal state
 "i" 'my/org-agenda-clock-in-and-refresh
 "o" 'my/org-agenda-clock-out-and-refresh
 "d" 'my/org-agenda-done
 "a" 'org-agenda-columns
 "R" 'org-agenda-clockreport-mode-enhenced
 )

gtd 为核心的 tab

绑定 S-F10 按键,进入到 gtds tab, 该 tab 会打开 4 个 buffer:

  • agenda: 从时间上看到当天做了什么
  • projects.gtd: 当前任务总体进展(agenda file)
  • entry.org: 常用信息备忘,包括临时创建的待整理的笔记(类似 index.html)
  • 日记文件,比如 j2024a.org: 快速记录

这样可以快速记录和查看任务信息。

#+name: gtd-oriented-tab
(defun create-or-switch-to-tab (tab-name)
  "Create or switch to a tab with the given TAB-NAME."
  (let ((tab-exists (tab-bar--tab-index-by-name tab-name)))
    (if tab-exists
        (tab-bar-switch-to-tab tab-name)
      (tab-new)
      (tab-rename tab-name))))
  (setq org-agenda-window-setup 'only-window)

(defun gtd-setup-window-layout ()
  "Sets up a window layout with one window on the left and two windows stacked vertically on the right."
  (interactive)
  (progn
    (delete-other-windows)
    ;; 打开 agenda 跳到 clock 上
    (org-agenda "" " ")
    (split-window-horizontally) ;; 切分左右窗口
    (split-window-vertically)  ;; 在左侧 agenda 切分上下窗口
    (select-window (next-window)) ;; 在左侧下方窗口打开 entry 文件
    (find-file "~/org/historical/entry.org")
    ;; 跳转到右侧窗口打开项目文件, 跳转到最近 clock 的任务
    (select-window (next-window))
    (ignore-errors
      (org-clock-goto))
    (let ((target-file gtd-project-file))
      (unless (equal (buffer-file-name) (expand-file-name target-file))
        (switch-to-buffer (find-file target-file))))
    (split-window-vertically) ;; 右侧窗口继续切分出上下
    (select-window (next-window))
    (switch-to-buffer
     (find-file (get-current-journal-file)))))

(defun gtd-oriented-tab-switch (gtd-tab-name)
  "if current tab is gtd-tab-name, execute other-window, else goto gtd-tab"
  (let ((current-tab (cdr (assq 'name (tab-bar--current-tab)))))
    (if (equal current-tab "gtds")
        (tab-bar-switch-to-recent-tab)
      (progn (create-or-switch-to-tab "gtds")
               (gtd-setup-window-layout)))
    (setq last-switch-action "tab-switch")))
(bind-key (kbd "s-<f10>") (lambda () (interactive) (gtd-oriented-tab-switch "gtds")))

阅读和笔记

笔记应该能够方便地搜索和关联,避免记录完之后就再也不看了,其最终目的是整理输出,可以但不限于以下形式:

  • 一篇主题比较明确的总结多个笔记内容的 org 文档
  • 一篇发布的 html blog
  • 一个代码项目(包括至少过了几个月后自己能看懂的技术文档)
  • 一篇论文甚至一本书

因此笔记形式对我来说不是很重要,不管是分散在不同文件里,还是用一个 org 记录整本书,只要整理的时候能够提供足够便捷的搜索选项,能根据自己模糊印象检索到,那么就是好的方式。

因为有 grep/ripgrep 这类的全目录搜索的工具,加上拼音支持,只要 org 目录里没有放大量的非笔记文本,大部分笔记用 counsel-projectile-rg 就能找到,随着笔记增多可能会遇到候选项过多的情况。而 org-roam 对 org 文件提供了一个内存里的数据库接口,笔记里结构化的部分比如 title/tag 和笔记之间的链接关系 就可以单独抽出来作为查询对象,因此能更聚焦地的去搜索 org 文件的元信息,此外数据库方式使得查询反向链接、可视化等变得高效。

pdf 阅读

emacs 默认使用 DocView 打开 pdf, pdf-tool 对此有一些增强,比如搜索,outline 跳转等都。

第一次安装 pdftools 时需要调用 pdf-tools-install 自动安装系统依赖,如 libpoppler automake 等

有了以下设置后,常用的操作是:

  • 按 f 后弹出 isearch 窗口,输入链接关键词,pdftool 高亮出对应链接,按 enter follow link, 比如可以看一眼注脚或者参考文献,然后按 o 或 C-o 回到刚才的位置。
  • 按 O 进入 outline 后,可以通过 M-ret 让 pdf 同步到光标所在的 heading 章节位置, RET 则是光标跳转到 pdf 对应的 heading 位置。
  • 按 / 搜索后会进入 pdf-occur buffer, 这和 outline 很类似,只不过每一行不是 heading 而是匹配到关键字的行,因此以上将 M-ret 绑定为 pdf-occur-view-occurrence 后,也可以让 pdf 同步到光标所在搜索结构的位置,和 pff-outline mode 下行为一样。
  • org-pdftools 提供了对 pdf 的链接格式,可以直接用一个 hyperlink 跳转到精准的pdf页面
#+name: pdf-tools
(use-package pdf-tools
  :mode ("\\.pdf\\'" . pdf-tools-install)
  :custom
  (pdf-annot-activate-created-annotations t "automatically annotate highlights")
  :config
  (setq mouse-wheel-follow-mouse t)
  (setq pdf-view-resize-factor 1.10)
  (setq-default pdf-view-display-size 'fit-width)
  (setq image-cache-eviction-delay 30) ; clear image cache after 30 sec, origin 300
  ;; (pdf-tools-install)

  (general-define-key
   :keymaps 'pdf-view-mode-map
   :states '(normal)
   "C-c C-g"  'pdf-sync-forward-search
   "o" 'pdf-history-backward
   "C-o" 'pdf-history-backward
   "C-t" 'pdf-history-backward
   "i" 'pdf-history-forward
   "C-i" 'pdf-history-forward
   "H" '(lambda () (interactive) (image-backward-hscroll 10))
   "L" '(lambda () (interactive) (image-forward-hscroll 10))
   "O" 'pdf-outline
   "C-c o" 'pdf-outline ;; same as outline in org/epub
   "d" 'pdf-view-scroll-up-or-next-page
   "b" 'pdf-view-scroll-down-or-previous-page
   "u" 'pdf-view-scroll-down-or-previous-page
   "gp" 'pdf-view-goto-page
   "C-f" 'pdf-view-next-page
   "C-b" 'pdf-view-previous-page
   "/" 'pdf-occur
   "C-s" 'isearch-forward
   )

  (general-define-key
   :keymaps 'pdf-occur-buffer-mode-map
   "M-RET" 'pdf-occur-view-occurrence
   )
  )

(use-package org-pdftools
  :hook (org-mode . org-pdftools-setup-link))

epub 阅读

  • 核心的按键绑定和 pdf-tools 类似,翻页,前后跳转,搜索,好处是可以直接像普通 buffer 那样复制文本
  • 默认的 nov-scroll-up 会翻整个页,根据其实现改写成 half,另外在翻页之前将光标先移动到顶部或底部,
  • nov-scroll-down/up 里 down 和 up 是反的, scroll-down 意味着往回看
  • 注意如果没有 :mode 是不会延迟加载的
#+name: nov
(use-package nov
  :mode ("\\.epub\\'" . nov-mode)
  :config
  (defun my-nov-font-setup ()
    (face-remap-add-relative 'variable-pitch :height 1.25))
  (add-hook 'nov-mode-hook 'my-nov-font-setup)
  (setq nov-text-width 80)
  (add-hook 'nov-mode-hook 'visual-line-mode)
  (add-hook 'nov-mode-hook 'visual-fill-column-mode)

  (defun nov-scroll-up-half (arg)
    (interactive "P")
    (cond
     ((= (point) (point-max)) (nov-next-document))
     ((>= (window-end) (point-max)) (goto-char (point-max)))
     (t (scroll-up-line 15))))

  (defun nov-scroll-down-half (arg)
    (interactive "P")
    (cond
     ((= (point) (point-min)) 
      (nov-previous-document)
      (goto-char (point-max)))
     ((and (<= (window-start) (point-min))
           (> nov-documents-index 0))
      (goto-char (point-min)))
     (t (scroll-down-line 15))))

  (general-define-key
   :keymaps 'nov-mode-map
   :states '(normal)
   "C-c o" 'nov-goto-toc ;; same as outline in org/pdf
   "O" 'nov-goto-toc
   "o" 'nov-history-back
   "i" 'nov-history-forward
   "d" 'nov-scroll-up-half
   "u" 'nov-scroll-down-half)
  )

org 中 latex 公式预览

  • 编译程序用 XelaTex
  • 调整(放大) latex 公式预览的尺寸
  • 不开启 latex inline image (打开公式较多的页面时会卡), 因此如果需要全部预览使用 s-i 按键来手动开启 preview
  • 开启 org-fragtog 后,鼠标放在 latex 公式上时自动进入编辑状态,离开后则显示预览,这样可以一个一个公式按需预览
#+name: org-latex
(use-package org
  :ensure nil
  :custom
  (org-preview-latex-image-directory "/tmp/ltximg/")
  :config
  (setq org-latex-pdf-process '(
      "xelatex -interaction nonstopmode %f"
      "xelatex -interaction nonstopmode %f"))

  (setq org-format-latex-options (plist-put org-format-latex-options :scale 1.5))
  (setq org-startup-with-latex-preview nil)
  (define-key org-mode-map (kbd "s-i") 'org-latex-preview) ;default C-c C-x l
  )

(use-package org-fragtog
  :after org
  :hook (org-mode . org-fragtog-mode))

模板展开

当输入某几个字符时就可以自动根据模板扩展出一段文本,并且可以编写函数插入一些动态数据。

YASnippet 和自定义插入信息函数

yasnippet 从 yasnippet-snippets 包提供的函数模板中寻找扩展片段,因此两个都带上。

  • org-mode 下的一些默认例子尝试: 输入 <da ,然后 tab 会显示日期,首先在 年 的位置,输入后继续按 tab 可以跳转到月和日等。<ta 则是生成 table
  • 使用 yas-new-snippet 来创建 snippet, 但一般还是通过 yas/describe-table 命令查看所有 snippet, 然后打开一个现有的片段,复制这个文件进行修改
  • 在 ~/.wemacs/snippets 中维护自己的代码片段,大部分是针对 org
  • 用固定快捷键 s-s s-s 去触发 snippet 插入,这有点类似于在 insert 模式下执行 M-x(并且只能选择插入模板相关的命令),另外这里定义了一些辅助函数,比如 my/insert-time 用于按键绑定或者直接通过命令来插入时间戳,这是记录流水日记比较常用的
#+name: yasnippet-setting
(use-package yasnippet
  :diminish yas-minor-mode
  :hook ((prog-mode LaTeX-mode org-mode markdown-mode) . yas-minor-mode)
  :init
  (use-package yasnippet-snippets)
  (setq yas-snippet-dirs '("~/.wemacs/snippets"))
  :general
  (defun my/insert-time ()
    (interactive)
    (insert (format-time-string "=%H:%M= ")) (evil-insert-state))

  (defun my/insert-now-date ()
    (interactive)
    (insert (format-time-string "[%Y-%m-%d %a %H:%M] ")) (evil-insert-state))

  (general-define-key
   :keymaps 'general-override-mode-map
   :states '(insert normal)
   "s-r s-w" 'my/insert-now-date
   "s-r s-e" 'my/insert-time
   "s-r s-s" 'yas-insert-snippet)
  )

Auto activating snippets

Yasnippet 在输入字符后要按 tab, 但 aas 则使得不用按 tab, 输入到部分字符串后直接展开。

lass 则是针对 latex 公式加了其他功能,比如设置 laas-wrap-previous-object 操作可以包裹住之前的一些文本对象,比如输入 M 然后要把它变成 \mathbb{M}, 就可以输入 M'B

以下设置中在 org-inside-LaTeX-fragment-p 条件下会加入一些自动展开操作,比如给下划线之后马上添加花括号,但 org-inside-LaTeX-fragment-p 会把 把还没有关闭的 $ 也识别为 latex 输入环境,这有时候会出现不符合预期行为,比如我只是输入 bash 变量 file_name, 这时候输入到下划线的时候,会自动加上括号: $file_{name}, 而且它不会区分 org 里 bash src block, 因此在以下 bash block 里写下 $file_ 也会出现这个问题

$file_{}

在 org-inside-LaTeX-fragment-p 函数的文档里也提到了这个问题,比如英文环境下输入美元 $23.2 也会被识别成 latex 环境。由于我输入公式的时候都是先用 yasnippet 快速展开一个 \( \)\[ \] 环境,基本也不会用 $ 作为环境边界,因此只要把 $ 从 latex 边界字符列表里删除就可以,这里发现 org-inside-LaTeX-fragment-p 是通过 org-format-latex-options 来读取边界的,因此以下对此做了修改。(这个修改也适用于 rime-predicate-org-latex-mode-p)

#+name: ass-setting
(use-package aas
  :hook (LaTeX-mode . aas-activate-for-major-mode)
  :hook (org-mode . aas-activate-for-major-mode))

(setq org-format-latex-options
      '(:foreground default :background default :scale 1.5 :html-foreground "Black" :html-background "Transparent" :html-scale 1.0 :matchers
                    ("begin" "$1" "$$" "\\(" "\\[")))

(use-package laas
  :hook (org-mode . laas-mode)
  :config
  ;; 自动插入空格
  (setq laas-enable-auto-space t)

  (aas-set-snippets
      'laas-mode
    ;; 只在 org latex 片段中展开
    :cond #'org-inside-LaTeX-fragment-p
    ;; 内积
    "<>" (lambda () (interactive)
           (yas-expand-snippet "\\langle $1\\rangle$0"))
    "`s" "^{\\star }"
    ;; 还可以绑定函数,用 yasnippet 展开
    "^" (lambda () (interactive)
          (yas-expand-snippet "^{$1}$0"))
    "_" (lambda () (interactive)
          (yas-expand-snippet "_{$1}$0"))
    "Sum" (lambda () (interactive)
            (yas-expand-snippet "\\sum_{$1}^{$2}$0"))
    "Int" (lambda () (interactive)
            (yas-expand-snippet "\\int_{$1}^{$2}$0"))
    "Prod" (lambda () (interactive)
             (yas-expand-snippet "\\prod_{$1}^{$2}$0"))
    "Sqrt" (lambda () (interactive)
             (yas-expand-snippet "\\sqrt[]{$1}"))
    "Gam" (lambda () (interactive)
            (yas-expand-snippet "\\Gamma($1)$0"))
    ;; 这是 laas 中定义的用于包裹式 latex 代码的函数,实现 \bm{a}
    :cond #'laas-object-on-left-condition
    "'B" (lambda () (interactive) (laas-wrap-previous-object "mathbb"))
    ",b" (lambda () (interactive) (laas-wrap-previous-object "boldsymbol"))
    ".d" (lambda () (interactive) (laas-wrap-previous-object "bm"))))

图片插入

org-download

截图的方式向 org mode 里插入图片

#+name: download
(use-package org-download
  :after org
  :defer nil
  :bind (:map org-mode-map
              ("C-M-y" . org-download-screenshot))
  :custom
  (org-download-method 'my-org-download-method)
  (org-download-annotate-function (lambda (_link) ""))
  (org-download-image-dir "imgs")
  (org-download-heading-lvl nil)
  (org-download-timestamp "%Y%m%d-%H%M%S_")
  (org-download-image-attr-list
      '("#+ATTR_HTML: :width 600 :align center"))
  :config
  (org-download-enable)
 )

org imagine

命令行方式插入图片或者代码片段

#+name: org-imagine
(use-package org-imagine
  :load-path "site-lisp/org-imagine/"
  :config
  (setq org-imagine-cache-dir "./.org-imagine"
        org-imagine-is-overwrite 1))

参考 org imagine

org roam

  • 正如最初提到的,我避免用 org 模板,因此 org-roam-capture 只有一个元素,它把笔记先记录在 entry.org 文件的 heading 里,整理的时候再把笔记复制到需要的位置,并手动修改元信息。
  • 定义了查找和插入链接(分别对应 evil normal 和 insert 状态)的函数,
    • 比如 knowledge-node 会搜索数据库中所有层级小于 4 的 header 。
#+name: org-roam
(use-package org-roam
  :init
  (setq org-roam-directory "~/org/"
        org-id-link-to-org-use-id t
        org-roam-v2-ack t)
  (setq org-roam-capture-templates
        '(("d" "default" plain
           "* ${title}\n :PROPERTIES:\n :ID: %(org-id-new)\n :#+CREATED: %U\n :END:\n%?\n"
           :target (file+olp "~/org/historical/entry.org" (""))
           :unnarrowed t)))
  :general
  (:keymaps 'normal 
            "s-r s-r" 'org-roam-buffer-toggle
            "s-r f"  'org-roam-find-file
            "s-r g"  'org-roam-node-find ;; graph node find
            )
  (:keymaps 'org-mode-map
            :states 'insert
            "s-r f" 'org-roam-insert-file
            "s-r g"  'org-roam-node-insert
            )
  :config
  (org-roam-db-autosync-mode)
  (setq org-roam-node-display-template
        (concat "${title:*} " (propertize "${tags:16}" 'face 'org-tag)))

  (defun org-roam-find-file ()
    (interactive)
    (org-roam-node-find nil nil (lambda (node)
                                  (= 0 (org-roam-node-level node)))))
  
  (defun org-roam-insert-file ()
    (interactive)
    (org-roam-node-insert (lambda (node)
                            (= 0 (org-roam-node-level node))))))
  

roam ui

可视化笔记链接,可以通过 org-roam-ui-mode 启动,然后访问 http://127.0.0.1:35901/

默认情况下,该界面会展示所有笔记之间的关联,显得过于庞杂,因此更好地方式是有目的性地去看某个文件下所有的关联或者某些主题下的关联。可以通过页面中的 filter 来设置白名单或者黑名单,比如其中 tag filter 里有 bocklist 和 allowlist, 而这些设置是会在浏览器中缓存的,这比较方便。

#+name: roam-ui
(use-package org-roam-ui
  :after org-roam
  :commands org-roam-ui-mode
  :config
  (setq org-roam-ui-sync-theme t
        org-roam-ui-follow t
        org-roam-ui-update-on-save nil
        org-roam-ui-open-on-start t)
  (add-to-list 'desktop-minor-mode-table
               '(org-roam-ui-mode nil))
  (add-to-list 'desktop-minor-mode-table
               '(org-roam-ui-follow-mode nil))
  )

注意: org-roam-ui 会破坏 desktop-mode 保存 emacs 窗口结构,因此以上声明在 desktop 中不保存 roam-ui 相关设置,参考: [BUG] desktop.el and org-roam-ui-mode: Cannot bind server socket: Address already in use · Issue #202 · org-roam/org-roam-ui

org 文献管理

我对文献管理的流程理解是:

  • 搜集文献:把文献的元信息保存下来,这一般是放在一个 bib 文件里,以一种特殊的 key:value 字典对象(后称 bib entry),比如以下是 python3.8 的 lib 说明书,我在 bib 文件里为它手动编写一个 bib entry ,同时下载了其 pdf 到对应目录:

    @book{py38libreference,
    title = {library reference of python3.8.14},
    author = {Guido van Rossum el},
    year = {2022},
    file = {/data/resource/readings/manual/python/py38docs/library.pdf},
    }
    
  • 当 bib 文件(可以有多个)里有大量 bib entry 之后,就需要一个工具去读这些文件并按以上格式解析,之后弹出一个搜索框供用户过滤选择。而以上 title, author, year 就提供了可供匹配的关键词(和 roam 把这些元信息提取出来供 org-roam-node-find 检索是一样的),在搜索框找到了目标文献后就可以插入 citekey 链接, org9.5 后内置的 org-cite 默认格式为 [cite:@py38libreference] 形式。
  • 通过点击以上链接,可以继续弹出一个小的框让用户选择是打开这个链接对应的 pdf 、笔记或是跳转到该 cite 的 bib entry 上。

把以上几个阶段对应到具体工具上:

  • 文献搜集

    可以用 zotero, 主要是 zotero 的浏览器插件,当用浏览器打开某个论文页面后,点击插件可以自动将文献信息保存到某个 bib 文件,同时下载 pdf. emacs 里 biblio (org-ref 集成) 和 ebib 都有从某些论文库网站抓取论文列表然后用户选择并插入到 bib 文件的功能。

    然而,这个阶段手动操作也可以,而且还有不少优势,因为一般的网站都会提供复制 BibTeX entry 的按钮,或者有时候也会从 arxiv 上下载其他人的论文源码再从其 bibliography 里拷贝出 bib entry (因为从别人发表论文里找过来会更准确一点),唯一麻烦点的就是下载 pdf 并且把 file 字段添加到 bib entry 中去,不过这相比于阅读一篇完整论文的时间来说基本可以忽略不计。

    自己下载和整理元信息也有助于多看几眼文献的信息,比如确认其期刊和会议是否正确等, 也可以手动修改或删除一些信息,比如加上便于自己搜索的 keywords。

  • 读取和解析 bib 文件,提供用户搜索和插入 citekey 的功能

    读取和解析的话,org-ref 通过 parsebib 完成, org9.5 后自带的 org-cite 包本身有此能力,执行 org-cite-insert 就会把 bib 解析(一般第一次读完后会缓存到内存),解析结果需要传给一个能够交互的前端,比如 ivy-mode 或 vertico-mode 开启后直接可以接住 org-cite-insert 的解析结果供用户选择,一些额外的工具可以在其中加入更多搜索字段,比如 ivy-bibtex 或 vertico 对应的 marginalia。

  • 点击链接弹出交互窗口

    默认 org-ref 自身提供了这个功能,它使用 hydra 或者 ivy/helm 来显示。如果是 citar, 则通过 embark 显示。

  • 此外,org-roam 提供了对 citekey 的解析,将其作为一个链接看待,于是可以通过 org roam 来查看用 citekey 引用的文献和笔记之间的关系,可视化引用关系等。
  • 至于导出,由于我不在 org 里写需要导成正式 pdf 的文档,因此不考虑。

参考: Comparisons · emacs-citar/citar Wiki

以下是这部分的配置,补全菜单如果开启了 ivy-mode 则是 ivy 接管,如果开启 vertico-mode 则 vertico 接管, 目前使用 ivy 。

#+name: org-cite
(use-package citar
  :bind (:map org-mode-map
              ("C-c r" . org-cite-insert))
  :custom
  (org-cite-global-bibliography '("~/org/lib/zotero.bib" "~/org/lib/manual.bib"))
  (citar-bibliography org-cite-global-bibliography)
  (org-cite-follow-processor 'citar) ;; open-at-point -> citar-at-point-function
  (org-cite-activate-processor 'citar)
  (citar-at-point-function 'embark-act) ;; -> citar-at-point-function -> citar-embark
  :hook
  (LaTeX-mode . citar-capf-setup)
  (org-mode . citar-capf-setup)
  :config 
  (setq citar-org-roam-capture-template-key "d"))

(use-package embark
  :bind
  (("C-." . embark-act)         ;; pick some comfortable binding
   ("C-;" . embark-dwim)        ;; good alternative: M-.
   ("C-h B" . embark-bindings)) ;; alternative for `describe-bindings'
  :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)))))

(use-package citar-embark
  :after citar embark
  :no-require
  :config (citar-embark-mode))

(use-package citar-org-roam
  :after (citar org-roam)
  :config
  (citar-org-roam-mode +1)) ;; this will setup roamdb

(defun my/open-roam-notes-advice (&rest _)
  (unless org-roam-db-autosync-mode
    (org-roam-db-autosync-mode 1)))

;; 确保在执行 org-citar-open-notes 前加载 org-roam, 这样才能使用 roamdb 查笔记
(advice-add 'citar-open-notes :before #'my/open-roam-notes-advice)

(defun my/org-open-at-point-cite-first ()
  "Check if `thing-at-point` contains `cite:` and call `org-cite-follow` if it does.
If found, copy the citation to a new temporary Org buffer and call `org-cite-follow`."
  (let ((tap-email (thing-at-point 'email)))
    (if (and tap-email (string-match "cite:" tap-email))
        (let ((temp-buffer (generate-new-buffer "*temp-org-citation-buffer*")))
          (with-current-buffer temp-buffer
            (org-mode)
            (insert (format "[%s]" tap-email))
            (goto-char (point-min))
            (let ((context (org-element-context)))
              (org-cite-follow context nil)))
          (kill-buffer temp-buffer)
          t)
      nil)))

(defun my/org-open-at-point-advice (orig-fun &rest args)
  "Advice to call `my/org-open-at-point-cite-first` before executing `org-open-at-point-global`."
  (unless (my/org-open-at-point-cite-first)
    (apply orig-fun args)))

(advice-add 'org-open-at-point-global :around #'my/org-open-at-point-advice)

一些解释:

  • embark 使得点击 citekey 后弹出一个右键菜单,可以打开 pdf 和 notes 等。
  • embark 中是 citar-open-files 函数具体负责打开对应文件的 pdf 文件,该文件的路径是通过 bib etnry 里的 file 字段找到的,因此 citar 加载中设置了 citar-bibliography。
  • 但 notes 的路径是希望从 org-roam 数据库查询的,因此使用 citar-org-roam 包来修改 citar-open-notes 里查找路径的方式。但只有先加载 roam 的数据库之后才能查询,而 org-roam 已经设置成通过按键延迟加载,因此当第一次调用 citar-open-notes 时,应该要先去加载 roam db,所以 给 citar-open-notes 加一个提前检查 db 是否加载的 advice 。
  • my/org-open-at-point-advice 的原因: 当 cite 写在 org-property 中时(比如 :ROAM_REFS: 后), org-open-at-point 读取到的光标上下文(context)是 node-property,因此调用的是 org-open-at-point-global ,其中只会继续检查当前是否为一般链接或 email, 由于 citation 中包括 @xxx , 所以被识别为 email, 从而弹出发送 email 的程序。这里对 org-open-at-point-global 添加一个提前检查, 如果发现是 email 格式但还包括 "cite:" 字符,那么把 citation 写在一个新 buffer 中(并去除上下文), 然后调用打开 citation 的函数。

org babel 和 emacs jupyter

这部分的核心是 emacs-jupyter

babel 和 jupyter 总览

  • 在 emacs 启动完 2s 后加载所有配置,因为并不会一打开 emacs 就去 org 里执行代码。
#+name: org-babel
(use-package jupyter
  :defer 2
  :after org
  :init
  <<ob-templates>>
  :general
  (:keymaps 'org-mode-map
            "s-j s-j" 'jupyter-change-session
            )
  :config
  ;; don't ask when c-c c-c
  ;; show pictures in results, otherwise only print image path
  ;; (add-hook 'org-babel-after-execute-hook 'org-display-inline-images 'append)
  <<global>>
  <<load-language>>
  <<ob-org>>
  <<block-fontface>>
  <<jupyter-python-args>>
  <<kernel-name-modeline>>
  <<jupyter-change-session>>
  <<ob-jupyter-racket>>
  <<ob-jupyter-deno>>
  <<tangle-block>>
  <<babel-results-ansi-color>>
  )

安装 emacs-jupyter 时可能的问题

emacs-jupyter 依赖 zmq 包以提供动态链接库 emacs-zmq.so 使得 emacs org 能和 jupyter keneral 通信,eamcs-jupyter 第一次启动时会自动从 github/release 中下载 emacs-zmq-x86_64-linux-gnu .tar.gz 包解压编译,但这可能会有网络问题,需要自行解决,或者参考 nnicandro/emacs-zmq: Emacs bindings to ØMQ 手动编译。

src block 基本全局设置

#+name: global
(add-hook 'org-babel-after-execute-hook 'org-redisplay-inline-images 'append)
(setq org-src-fontify-natively t
      org-fontify-quote-and-verse-blocks t
      evil-indent-convert-tabs nil
      org-src-preserve-indentation nil
      org-edit-src-content-indentation 0
      org-export-use-babel t
      org-confirm-babel-evaluate nil)

(setq org-babel-default-header-args
      '((:session . "none")
        (:async . "yes")
        (:results . "replace")
        (:exports . "both")
        (:cache . "no")
        (:noweb . "no")
        (:hlines . "no")
        (:tangle . "no")))

对以上参数的解释:

  • org-src-fontify-natively 是 t, 根据语言的 major mode 高亮代码,这比较关键
  • org-fontify-quote-and-verse-blocks 是高亮 quote 和 verse(背景色), 但是 comment 没法覆盖,可以自己修改 org-fontify-meta-lines-and-blocks-1 来高亮更多 block 的背景颜色
  • evil-indent-convert-tabs 默认是 t, 用 = 会对空格和 tab 进行转换,但对于给人在 org 里写 demo 代码,不太必要考虑空格或 tab 统一,能够执行即可。
  • org-edit-src-content-indentation 默认是 2, 但个人不喜欢在 src 里再进行 indentation, 有时还会与 python 的 indention 混淆,例如 python 是 4 个空格,而在其之前又有 2 个空格,比较奇怪。因此设置为 0
  • org-src-preserve-indentation 默认是 nil, 表示在 C-c' 的编辑模式下的 leading space 在 org block 里会自动删除,这是比较合理的,demo 编程情况下没有必要有 leading space 。
  • org-src-tab-acts-natively 默认是 t, 表示在 block 中使用 tab 和在对应的语言环境用 tab 是一样的。
  • indent-tabs-mode 为 non nil 表示在 indentatin 的时候可以使用 tab
  • org-export-use-babel 导出时是否执行代码,默认执行,也可以在 buffer 里单独设置
  • header-args 中设置 async 会自动下载安装 ob-async, 代码异步执行

org-babel-default-header-args 默认如下,以上将 exports 改成了 both

((:session . "none")
 (:results . "replace")
 (:exports . "code")
 (:cache . "no")
 (:noweb . "no")
 (:hlines . "no")
 (:tangle . "no"))

org block

org 代码块进行一般用来跨文件 tangle, 作为对其他 block 的一个 wrapper, 因此 export 的时候不需要导出(否则会冗余)

#+name: ob-org
(setq org-babel-default-header-args:org '((:exports . "none")
                                          (:noweb . "yes")))

jupyter python

这部分使用非常频繁,我想达到的效果是:

  • 每个 buffer 可以单独连接一个后台的 jupyter kernel, 可以是远程的、本地已经启动的或者是 emacs 自己创建的。
  • 将当前 buffer 所对应的 kernel 显示在 modeline 上。(通过 mode-line-misc-info 来设置)
  • 通过 s-j s-j 快捷键弹出一个 kernel 选择列表,用于切换 session/kernel

jupyter-python heard-args 设置

jupyter-python block header 的全局设置的解释:

  • session 设置为 'py' 会将所有的 jupyter-python block 都连接到同一个 session 名为 py 的解释器环境,体现在 emacs 中就是创建一个 py 命名的 python 解释器 buffer, 比如:

    *jupyter-repl[python 3.8.2]-py*
    
  • 所有 jupyter-python block 都默认使用 zshot kernel, 之后用函数来切换这个值
  • 设置完 org-babel-default-header-args:jupyter-python 后必须再执行 override 才能使得 python block 仍然使用 jupyter. 另外,修改 header-args 必须用 org-babel-default-header-args:jupyter-python, 而 org-babel-header-args:jupyter-python 没效果。
#+name: jupyter-python-args
(setq-default org-babel-default-header-args:jupyter-python
              '((:kernel . "zshot")
                (:eval . "never-export")
                (:session . "py")
                ;; (:session . "/tmp/kernel.json")
                ))

(org-babel-jupyter-override-src-block "python")

kernel 是 zshot 意味着当第一次执行 jupyter-python block 时,emacs 会去以下目录找名为 zhsot 的子目录,其中有一个 kernel.json 文件,根据该文件 emacs 会启动一个后台的 ipython 进程,然后 org 的 src block 会把代码发给这个进程,从而可以执行代码并返回结果(通过 zmq)。

ls ~/.local/share/jupyter/kernels/
| racket          |
| rik_ssh_129_129 |
| usr             |
| zshot           |

创建 jupyter kernel 的方式是,先用 conda 切换到需要 kernel 化的环境,比如是 usr 环境,那么

conda activate usr

再执行以下命令,之后就能找到 ~/.local/share/jupyter/kernels/usr 目录了

python -m ipykernel install --user --name usr --display-name "usr"

kernel name on modeline

为了能够切换 kernel, 这里先定义一个 jupyter-current-buffer-kernel 变量(之后会转成 buffer-local ,记录当前 buffer 的 kernel , 同时把 org-babel-default-header-args:python 也转为 buffer local

#+name: kernel-name-modeline
(setq jupyter-current-buffer-kernel "zshot")
(setq mode-line-misc-info
    '((t (concat "{" jupyter-current-buffer-kernel "}"))
     (pyvenv-mode pyvenv-mode-line-indicator)
     ("" so-long-mode-line-info)))

(make-variable-buffer-local 'org-babel-default-header-args:python)

mode-line-misc-info 默认值为如下,这里只直接删除了 global-mode-string, 因为已经放在 tab-bar 上了

(defvar mode-line-misc-info
  '((global-mode-string ("" global-mode-string)))

change jupyter session

#+name: jupyter-change-session
(defun jupyter-change-session ()
  (interactive)
  (let* ((jupyter-kernel-alist
          '(
            ("129" . "/ssh:129:/tmp/kernel.json")
            ("me" . "/ssh:me:/tmp/kernel.json")
            ("local" . "/tmp/kernel.json")
            ("zshot" . "zshot")
            ("usr" . "usr")
            ("pure" . "pure")))
         (kernel-list (mapcar #'car jupyter-kernel-alist))
         (kernel (completing-read "Select kernels: " kernel-list))
         (session (cdr (assoc kernel jupyter-kernel-alist))))
    (setq-local jupyter-current-buffer-kernel kernel)
    (setq-local org-babel-default-header-args:python
                `((:eval . "never-export")
                  (:kernel . ,kernel)
                  (:session . ,session)))))

执行以上 jupyter-change-session 函数会弹出 kernel 选择列表,jupyter-kernel-alist 里第二个值描述的是 kernel 的启动方式:

  • /ssh 开头表示连接到远程 kernel, 这需要通过 json 文件来连接,因此:
    • 先 ssh 到远程服务器上,激活某个想要启动的 conda 环境,然后执行

      python -m ipykernel_launcher -f /tmp/kernel.json
      

      这个时候会启动一个 python 交互进程,将进程信息写入到了 /tmp/kernel.json 文件。以上命令执行后会占用整个 shell ,因此最好在一个单独终端或者 tmux pane 里执行。 emacs-jupyter 里用 "/ssh:me:/tmp/kernel.json" 就可以找到这个文件,读取进程信息并与运行的 kernel 通信了。

    • 由于这种 kernel 是远程启动的,emacs-jupyter 前端无法管理它的生命周期,因此如果要重启 kernel,需要先到远程服务器停止,然后关闭本地 jupyter buffer, 如:

      *jupyter-repl[python 3.10.11]-/ssh:me:/tmp/kernel.json*
      
  • 类似的, ("local" . "/tmp/kernel.json") 是本地自己启动的一个 kernel 动态文件。
  • ("usr" . "usr") 则是 emacs-jupyter 自己启动管理的 kernel, 大部分时候还是用这种方式,因为不需要额外到命令行去启动, 但对于远程 kernel ,只能用以上描述方式

jupyter iracket

主要是以前用来执行 little schemer 或 sicp 里的代码练习的

#+name: ob-jupyter-racket
(use-package racket-mode
  :config
  (setq org-babel-default-header-args:jupyter-racket
        '((:session . "jrack")
          (:display . "plain")
          (:kernel . "racket")))

  (org-babel-jupyter-override-src-block "racket")
)

racket 和 iracket kernel 安装

官网 Download 页面 下载一个 shell 脚本来安装 racket(根据版本自行修改):

sudo sh racket-8.2-x86_64-linux-cs.sh

第一步选择安装在 unix 目录下而不是单独一个文件夹下,第二步选择安装在 /usr/local 下,之后选项都默认

封装的安装脚本为(包括 mit-scheme): metaesc/install_libs/pack-scheme.sh at main · metaescape/metaesc

iracket kernel 安装参考 rmculpepper/iracket: Jupyter kernel for Racket 中的命令,如下:

raco pkg install iracket
raco iracket install
Kernel installed in "~/.local/share/jupyter/kernels/racket"

其中内容是:

{"argv":["racket","-l","iracket/iracket","--","{connection_file}"],"display_name":"Racket","language":"racket"}

以上设置的 org-babel-default-header-args:jupyter-racket 会自动去寻找这个 kernerl 文件从而启动 iracket 后台。

jupyter deno

可以在 jupyter 中创建或连接 javascript 和 Typescript 的 kernl.

安装完 deno 后通过以下命令创建 kernel

deno jupyter --install

kernel.json 文件内容为

cat ~/.local/share/jupyter/kernels/deno/kernel.json
{
  "argv": [
    "/home/pipz/.deno/bin/deno",
    "jupyter",
    "--kernel",
    "--conn",
    "{connection_file}"
  ],
  "display_name": "Deno",
  "language": "typescript"
}

注意以上的 language 是 typescript, 因此设置时应该用 jupyter-typescript, 并且 kernel 是 deno 。typesrcipt 可以覆盖 js 语法,所以在 typescript 块里可以写 ts 或 js。

#+name: ob-jupyter-deno
(use-package typescript-mode
  :config
  (setq org-babel-default-header-args:jupyter-typescript
      '((:session . "deno-ts")
        (:display . "plain")
        (:kernel . "deno")))

  (org-babel-jupyter-override-src-block "typescript")
)

structure Templates

c-c c-, 后弹出选择以下不同的 template, 这个基本是备用的,常用的都在 snippet 中了

#+name: ob-templates
(setq org-structure-template-alist
      '(
        ("j" . "src jupyter-python")
        ("r" . "src jupyter-racket")
        ("a" . "export ascii")
        ("cc" . "src C")
        ("ct" . "center")
        ("on" . "src org :noweb yes")
        ("E" . "export")
        ("l" . "export latex")
        ("v" . "verse"))
      )

org-babel-do-load-languages

#+name: load-language
(org-babel-do-load-languages
    'org-babel-load-languages
    '(
    (emacs-lisp . t)
    (shell . t)
    (org . t)
    (scheme . t)
    (python . t)
    (makefile . t)
    (dot . t)
    (C, t)
    (js . t)
    (jupyter . t) ;; last
    ))

jupyter 本身支持几十种语言的 kernel,因此最后一个 jupyter 可以扩展到更多,比如前文提到的 jupyter-racket, 完整参考 Jupyter kernels · jupyter/jupyter Wiki

results 控制

  • ansi 颜色
#+name: babel-results-ansi-color
(defun ek/babel-ansi ()
  (when-let ((beg (org-babel-where-is-src-block-result nil nil)))
    (save-excursion
      (goto-char beg)
      (when (looking-at org-babel-result-regexp)
        (let ((end (org-babel-result-end))
              (ansi-color-context-region nil))
          (ansi-color-apply-on-region beg end))))))
(add-hook 'org-babel-after-execute-hook 'ek/babel-ansi)

以上 hook 可以使得报错的时候显示更丰富颜色,然而复制的时候会把控制符号也复制到,如下形式

: 
: TypeErrorTraceback (most recent call last)

不过比直接打出控制符号要好。

常用按键绑定

大部分按键是分散在各个包的设置中的,用 bind-key 或者 use-package 中的 :bind 关键字绑定的按键可以通过 describe-personal-keybindings 查看,但 general 绑定的 evil 按键则无法统计。不过即便能搜集到个人设置的所有按键,其中大部分可能也是不太常用的,本节梳理自己最常用的快捷按键和 hydra 。没有提到的命令则基本都是 evil 默认按键(比如 hjkl)触发或是用 M-x 查找出来执行。

我只关注自己常用的命令(20 个左右),对于最常用命令直接用 1 到两个按键绑定,次常用则集中地用 hydra 设置

最常用按键

以下如果是不带控制按键的,没有特别说明的话都是在 evil 的非 insert 模式下的绑定

  • F10: 进入 gtds tab
  • SPC: 切换 buffer, *-switch-buffer
  • / 是搜索:
    • 通用场景是 swiper-isearch
    • pdf-view 下是 pdf-occur
  • M-数字 切换 tab; M-hjkl 切换窗口;
  • gq: 返回上一个视图
  • 左 Shift (经 xmodmap/xcape 改为 Redo): evil insert 切换输入法,normal 下执行 avy-timer
  • c-c o 打开 pdf/epub/org outline
  • C-r f 搜索或插入 roam 文件链接, C-c r 插入 citation

常用函数的 hydra 界面

常用操作中有些是需要连续按键的,比如调整字体大小:

  • 按一个组合键进入到调整窗口大小的模式
  • 连续按向方向键(hjkl)可以调整大小
  • 通过 ESC/q 退出调整模式

general-create-definer 的方式不太能处理这种需求,因此用 hydra 来设置按键菜单,并且只在 evil normal 下绑定 , 作为按键菜单入口。

  • 主菜单里继续按 . 则进入那些需要重复进行的操作菜单(句号在 evil 中也是重复上一个操作的功能)
  • 因为 winner mode 太常用了(比如弹出临时 buffer 破环了当前 tab 里窗口结构,就要马上用 winner-undo 恢复,和 evil-undo/redo 类似),因此也放在主按键菜单中,并且用 :exit nil 保证可以重复操作。
(use-package hydra)

(setq text-scale-mode-step 1.1) ;; text scalle ratio

(defhydra main-hydra (:color red :hint nil :exit t :timeout 3 :idle 0.2)
  "
    most common used functions
    _f_: projectile-find-file        _m_: maximize current window
    _r_: open files in reading dir   _b_: add bookmark and save
    _w_: save-buffer                 _q_: quickly quitely quit window
    _-_: split-window-below          _\\_: split-window-right
    _h_: winner-undo                 _l_: winner-redo
    _i_: org-clock-in                _o_: org-clock-out
    _d_: org-clock done              _v_: org-imagine-view
    _._: repeat operations           _x_: kill-this-buffer               
    "
  ;;("d" debug-test-hydra/body)
  ("r" (lambda () (interactive) (counsel-file-jump " " "/data/resource/readings/")))
  ("f" counsel-file-jump)
  ;; bookmark-set is temporary, you need save
  ("b" (lambda () (interactive) (bookmark-set) (bookmark-save)))
  ("p" nil)
  ("m" delete-other-windows)
  ("q"  delete-window)
  ("w"  save-buffer)
  ("x"  kill-this-buffer)
  ("\\"  split-window-right)
  ("-"  split-window-below)
  ("i" (lambda () (interactive) (org-clock-in) (org-agenda "" " ")
         (evil-window-mru)))
  ("o" (lambda () (interactive) (org-clock-out) (org-agenda "" " ")
         (evil-window-mru)))
  ("I" (lambda () (interactive) (my/clock-in-long-focus) (org-agenda "" " ")
         (evil-window-mru)))
  ("d" (lambda () (interactive) (org-todo "DONE") (org-agenda "" " ")
         (evil-window-mru)))
  ("v" org-imagine-view)
  ("," narrow-or-widen-dwim)
  ("h" winner-undo :exit nil)
  ("l" winner-redo :exit nil)
  ("." hydra-repeats/body))

(general-define-key
 :keymaps '(general-override-mode-map org-agenda-mode-map)
 :states '(normal visual motion)
 "," 'main-hydra/body)

(use-package ivy
  :config 
  (defun open-with-system-app (file)
    (dired-open--start-process file "masterpdfeditor5"))
  (ivy-add-actions 'counsel-file-jump '(("p" open-with-system-app "open file with system app")))
  )

其他细节:

  • 以上 clock-in/out/done 之后重建一个 agenda ,否则已有 agenda 试图不会更新,但这会跳到 agenda 上,可以用 evil-window-mru 跳转回来
  • 如果要用 posframe 显示: (setq hydra-hint-display-type 'posframe)
  • 用 ivy-add-actions 设置一个用外部程序打开文件的方式,这里主要是用 masterpdf 打开 pdf, 进入 ivy 列表后通过 M-o p 打开,也可以扩展该函数,用不同 app 打开不同后缀文件

重复操作的子按键 hydra

在该 hydra 下有一定的持续时间 (5s),只要在持续时间中,按键可以进行重复,适合字体放大,调整窗口大小,交换窗口等可能连续尝试的操作

(defhydra hydra-repeats (:timeout 5 :hint nil)
  "
   repeated actions
    _-_: window-zoom-in        _h_: shrink-window
    _=_: window-zoon-out       _l_: enlarge-window     
    _s_: swap window
    "
  ("s" window-swap-states)
  ("-" text-scale-decrease)
  ("=" text-scale-increase)
  ("h" shrink-window-horizontally)
  ("l" enlarge-window-horizontally)
  ("H" (lambda () (interactive)
         (shrink-window-horizontally 5)))
  ("L" (lambda () (interactive)
         (enlarge-window-horizontally 5)))
  ("q" nil :exit t))

此时在 Normal 模式下按 ,r 后,可以连续按 i/o 来调整字体大小

模板展开按键

yasnippet 实际是一套在 evil insert 模式下的快捷按键绑定,主要都是在 org 下使用:

  • s-s s-s 弹出所有 yasnippet minibuffer 供用户选择
    • 其中常用的有 weekly plan, 用于构造本周的 dynamic clocktable
  • 逗号 , 开头的常用触发词(逗号在 evil-normal-state 下是 hydra 触发),主要用来取代 org-tempo 的模板(按 tab 最终触发):
    • ,,: 插入当前的时间,用于快速记录流水日记
    • ,p: python 代码块
    • ,c: comment 块
    • ,el: emacs-lisp 块
    • ,sh: bash 块
    • ,q: quote 块
    • ,ex: example 块
    • ,d2: 提问框
    • ,g: gpt 回答框
    • ,js: javascript 块
    • ,.: inline latex block
    • ,..: multiline latex block
    • ,t: 二级 TODO header
  • --: 展开成 checkbox

所有展开片段见: metaesc/.wemacs/snippets/org-mode at main · metaescape/metaesc

radioLinkPopups

如对本文有任何疑问,欢迎通过 github issue 邮件 metaescape at foxmail dot com 进行反馈