跳过正文

My Emacs Dotfile

·
目录

Emacs 是 Hacker 的一种生活方式,而这是符合我口味的私人定制。

更多 Emacs 内容请访问我的博客

1 编译安装
#

从源码编译、安装最新 Emacs 31 版本:

brew tap d12frosted/emacs-plus
brew uninstall emacs-plus@31
brew install emacs-plus@31 --HEAD --with-xwidgets --with-imagemagick
brew unlink emacs-plus@31 && brew link emacs-plus@31
ln -sf /opt/homebrew/opt/emacs-plus@31/Emacs.app /Applications/

2 初始化
#

安装 native 编译所需的 gcc 工具链:

brew install gcc
current_gcc="/opt/homebrew/opt/gcc/lib/gcc/current"
export LIBRARY_PATH="${LIBRARY_PATH}:${current_gcc}"

early-init.el 是 Emacs 启动早期执行的第一个初始化文件:

;;; -*- lexical-binding: t; -*-

;; 个人信息。
(setq user-full-name "zhangjun")
(setq user-mail-address "geekard@qq.com")

(set-language-environment "UTF-8")

(setq-default lexical-binding t)
(setq lexical-binding t)
;; 不报告缺少 lexical-binding 文件头的警告。
(add-to-list 'warning-suppress-types '(files missing-lexbind-cookie))

;; 在独立文件保存 Emacs 自动写入的配置参数,避免污染 ~/.emacs 文件。
(setq custom-file (expand-file-name "~/.emacs.d/custom.el"))
(add-hook 'after-init-hook
  	  (lambda ()
  	    (when (file-exists-p custom-file)
  	      (load custom-file))))

;; 非系统标准路径的二进制目录列表(越靠后,查找优先级越高)
(setq my-bin-path
      '("/opt/homebrew/bin"
        "/opt/homebrew/opt/findutils/libexec/gnubin"
        "/opt/homebrew/opt/openjdk/bin"
        "/Users/alizj/go/bin"
  	  ;; .cargo/bin 的优先级要高于 rustup/bin,这样优先使用编译安装的命令。
        "/opt/homebrew/opt/rustup/bin"
        "/Users/alizj/.cargo/bin"))

;; 添加到 PATH 环境变量。
(mapc (lambda (p)
  	(setenv "PATH" (concat p ":" (getenv "PATH"))))
      my-bin-path)

;; 添加到 Emacs 搜索路径:Emacs 查找外部程序时使用 `exec-path` 而非 `PATH` 变量。
(let ((paths my-bin-path))
  (dolist (path paths)
    (setq exec-path (cons path exec-path))))

设置软件源:

  • M-x use-package-report : 查看包加载时间(按 S 排序);
  • M-x package-vc-upgrade-all :更新使用 :vc 指令配置 github 包;
;;; -*- lexical-binding: t; -*-

(require 'package)

;; gnu 软件源有限速,这里替换为清华镜像源。
(setq package-archives
      '(("elpa" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/gnu/")
        ("elpa-devel" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/gnu-devel/")
        ("melpa" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/melpa/")
        ("nongnu" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/nongnu/")
        ("nongnu-devel" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/nongnu-devel/")))
(package-initialize)
;; 首次安装或更新包时手动执行 M-x package-refresh-contents,启动不刷新归档。

(setq use-package-verbose t)
(setq use-package-always-ensure t)
(setq use-package-always-demand t)
(setq use-package-compute-statistics t)
(setq use-package-vc-prefer-newest t)

;; 允许升级 Emacs 内置软件包。
;;(setq package-install-upgrade-built-in t)

启用 native 编译和确保使用编译后的字节码文件,提升性能:

;; 启用 native 编译。
(setq package-native-compile t)

(when (fboundp 'native-compile-async)
  (setenv "LIBRARY_PATH"
  	  (concat (getenv "LIBRARY_PATH")
  		  ":/opt/homebrew/opt/gcc/lib/gcc/current/"
		  ))
  (setq native-comp-speed 2)
  (setq native-comp-async-jobs-number 3)
  ;;(setq inhibit-automatic-native-compilation t)
  (setq native-comp-async-report-warnings-errors 'silent))

;; 确保 Emacs 加载编译后的最新 .elc 文件。
(setq-default load-prefer-newer t)
(setq load-prefer-newer t)

(setq native-comp-async-query-on-exit t)
(setq confirm-kill-processes t)

;; 确保所有 Elisp 文件都被原生直接码编译,提升 Emacs 性能。
(use-package compile-angel
  :config
  (setq compile-angel-verbose t)

  ;;只对本地 el 文件启用编译,防止远程卡住。
  (defun my/compile-angel-local-only ()
    "Enable save-time compilation only for local Emacs Lisp buffers."
    (compile-angel-on-save-local-mode
     (if (file-remote-p (or buffer-file-name default-directory)) -1 1)))
  ;; Remove the old hook when re-evaluating this configuration.
  (remove-hook 'emacs-lisp-mode-hook #'compile-angel-on-save-local-mode)
  (add-hook 'emacs-lisp-mode-hook #'my/compile-angel-local-only)

  ;;在 load 或 require .el 文件时进行编译.
  (compile-angel-on-load-mode 1))

提升 IO 性能(参考 doom core 配置参数):

(setq process-adaptive-read-buffering nil)

;; Emacs 通过 Pipe 从子进程一次读取的最大数据量。
;; 对于 GNU/Linux,设置的是 /proc/sys/fs/pipe-max-size 值。
;; 可以提升大项目的 eglot 性能(与 LSP 通过 JSON-RPC 交换数据时,一次可以读取更多数据)。
(setq read-process-output-max (* 1024 1024 1)) ;; default: 4kb

;; This reduces log clutter to improves performance.
(setq jsonrpc-event-hook nil)

;; Garbage Collector Magic Hack, 提升 GC 性能。
(use-package gcmh
  :init
  ;;(setq gcmh-verbose t)
  (setq gcmh-idle-delay 'auto) ;; 缺省 15s
  (setq gcmh-auto-idle-delay-factor 10)
  (setq gcmh-high-cons-threshold (* 64 1024 1024)) ;; 64mb
  (gcmh-mode 1)
  ;;(gcmh-set-high-threshold)
  )

;;(setq garbage-collection-messages t)
(add-hook 'after-init-hook #'garbage-collect t)

安全的自动关闭不活跃的 buffer,节省资源:

(use-package buffer-terminator
  :custom
  (buffer-terminator-verbose nil)
  (buffer-terminator-inactivity-timeout (* 30 60)) ; 30 minutes
  (buffer-terminator-interval (* 10 60)) ; 10 minutes

  :init
  (buffer-terminator-mode 1))

其它性能优化:

;; A second, case-insensitive pass over `auto-mode-alist' is time wasted.
;; No second pass of case-insensitive search over auto-mode-alist.
(setq auto-mode-case-fold nil)

;; Disable bidirectional text scanning for a modest performance boost.
(setq-default bidi-display-reordering 'left-to-right
              bidi-paragraph-direction 'left-to-right)

;; Give up some bidirectional functionality for slightly faster re-display.
(setq bidi-inhibit-bpa t)

;; 用内存换取字体 redisplay CPU;macOS、Nerd Fonts、图标较多时可以保留。
(setq inhibit-compacting-font-caches t)

(setq-default message-log-max 16384)

;; Resizing the Emacs frame can be costly when changing the font. Disable this
;; to improve startup times with fonts larger than the system default.
(setq frame-resize-pixelwise t)

;; Without this, Emacs will try to resize itself to a specific column size
(setq frame-inhibit-implied-resize t)

(setq whitespace-line-column nil)  ; Use the value of `fill-column'.

;; 关闭全局高亮当前行,性能优先。
;;(global-hl-line-mode t)
;;(setq global-hl-line-sticky-flag t)
(global-hl-line-mode -1)
(add-hook 'prog-mode-hook #'hl-line-mode)
;;(add-hook 'text-mode-hook #'hl-line-mode)

;; 关闭全局显示行号,性能优先。
;;(global-display-line-numbers-mode t)
;; 仅编程模式显示行号。
(add-hook 'prog-mode-hook #'display-line-numbers-mode)

;; 避免 undo-more: No further undo information 报错.
;; 10X bump of the undo limits to avoid issues with premature.
;; Emacs GC which truncages the undo history very aggresively
;; 不能设置太大,否则多个大 buffer 同时编辑时,可能显著抬高内存和 GC 压力。
(setq undo-limit (* 1 1024 1024)
      undo-strong-limit (* 16 1024 1024)
      undo-outer-limit (* 64 1024 1024))

(setq global-mark-ring-max 600)
(setq mark-ring-max 600)
(setq kill-ring-max 600)

;; 大文件按当前 buffer 的字符数判断,避免每次检查都访问磁盘或 TRAMP。
(defcustom my-large-file-threshold (* 1024 1024)
  "启用大文件性能配置的字符数阈值。"
  :type 'integer
  :group 'files)

(defvar-local my-large-file-saved-state nil
  "进入大文件配置前的局部设置,退出时恢复。")

(defun my-large-file-performance-setup ()
  "按字符数切换大文件配置;重复执行不会覆盖原始状态。"
  (let ((large (and buffer-file-name
                    (save-restriction
                      (widen)
                      (> (buffer-size) my-large-file-threshold))))
        (modes '(display-line-numbers-mode rainbow-delimiters-mode
                 hl-line-mode show-paren-local-mode)))
    (cond
     (large
      (unless my-large-file-saved-state
        (setq my-large-file-saved-state
              (list (list 'corfu-auto (local-variable-p 'corfu-auto)
                          (boundp 'corfu-auto)
                          (and (boundp 'corfu-auto) corfu-auto))
                    (mapcar (lambda (mode)
                              (cons mode (and (boundp mode) (symbol-value mode))))
                            modes))))
      (setq-local corfu-auto nil)
      (dolist (mode modes)
        (when (fboundp mode) (funcall mode -1))))
     (my-large-file-saved-state
      (let ((corfu-state (car my-large-file-saved-state))
            (mode-state (cadr my-large-file-saved-state)))
        (if (nth 1 corfu-state)
            (setq-local corfu-auto (nth 3 corfu-state))
          (kill-local-variable 'corfu-auto))
        (dolist (entry mode-state)
          (when (fboundp (car entry))
            (funcall (car entry) (if (cdr entry) 1 -1))))
        (setq my-large-file-saved-state nil))))))

(dolist (hook '(find-file-hook after-revert-hook after-save-hook))
  (add-hook hook #'my-large-file-performance-setup))

;; 开启全局自动 revert。
(global-auto-revert-mode 1)
(setq auto-revert-remote-files nil)
(setq revert-without-query (list "\\.png$" "\\.svg$"))
(setq auto-revert-verbose nil)
;; 自动 revert buffer(更新周期由 auto-revert-interval 配置),确保 modeline 上的分支名正确。
(setq auto-revert-check-vc-info t)
(setq auto-revert-interval 30) ;; 缺省:5s,对于大型项目如 zed 会引起卡顿。

设置 GPG 加解密:

;;保存 tramp 登录 machine 等的认证信息(账号密码)。
(setq auth-sources '("~/.authinfo.gpg"))
;;(setq auth-source-debug t)

(use-package epa
  :ensure nil
  :config
  (setq-default
   ;; 缺省使用 email 地址加密。
   epa-file-encrypt-to user-mail-address
   ;; 使用 minibuffer 输入 GPG 密码。
   epa-pinentry-mode 'loopback)

  (require 'epa-file)
  (epa-file-enable))

按键调整:

;; command 作为 Meta 键。
(setq mac-command-modifier 'meta)

;; option 作为 Super 键。
(setq mac-option-modifier 'super)

;; fn 作为 Hyper 键。
(setq ns-function-modifier 'hyper)

;; 关闭容易误操作的按键。
;; s- 表示 Super,S- 表示 Shift, H- 表示 Hyper:
(let ((keys '(
              "s-w"
              "C-z"
              "<mouse-2>"
              "s-k"
              "s-,"
              "s-."
              "s--"
              "s-+"
              "C-<wheel-down>"
              "C-<wheel-up>"
              "C-M-<wheel-down>"
              "C-M-<wheel-up>"
              ;;"<down-mouse-1>"
              ;;"<drag-mouse-1>"
              )))
  (dolist (key keys)
    (global-unset-key (kbd key))))

3 代理
#

MacOS 自带的 curl 不支持 socks5 代理, 这里安装支持 socks5GNU curl 版本:

brew install curl
export PATH="/opt/homebrew/opt/curl/bin:$PATH"

GNU curl 添加到 EmacsPATH 环境变量和 exec-path 变量中:

(setq my-coreutils-path "/opt/homebrew/opt/curl/bin/")
(setenv "PATH" (concat my-coreutils-path ":" (getenv "PATH")))
(setq exec-path (cons my-coreutils-path  exec-path))

Emacs 使用 url-retrieve 访问 URL ,默认 HTTP 代理支持 socks5 ,但是 HTTPS 代理不支持。这里设置它使用 curl 后端,这样全局可使用 socks5 代理:

;; socks5 代理信息。
(setq my/socks-host "127.0.0.1")
(setq my/socks-port 1080)
(setq my/socks-proxy (format "socks5h://%s:%d" my/socks-host my/socks-port))

;; 传给外部程序的 NO_PROXY/no_proxy 列表。curl 支持 CIDR 和域名后缀。
(setq my/no-proxy-env
      '(
        "127.0.0.1/32"
        "10.0.0.0/8"
        "172.0.0.0/8"
        "0.0.0.0/32"
        "localhost"
        "192.168.0.0/16"
        ".cn"
        ".alibaba-inc.com"
        ".taobao.com"
        ".antfin-inc.com"
        ".openai.azure.com"
        ".baidu.com"
        ".aliyun-inc.com"
        ".aliyun-inc.test"
        ))

;; `socks-noproxy' 只接受主机名正则表达式,不识别 CIDR。
;; IP 段用前缀正则;域名正则同时匹配根域和子域,并锚定字符串结尾。
(setq my/socks-noproxy
      '("\\`127\\.0\\.0\\.1\\'"
        "\\`10\\."
        "\\`172\\."
        "\\`0\\.0\\.0\\.0\\'"
        "\\`localhost\\'"
        "\\`192\\.168\\."
        "\\.cn\\'"
        "\\(?:\\`\\|\\.\\)alibaba-inc\\.com\\'"
        "\\(?:\\`\\|\\.\\)taobao\\.com\\'"
        "\\(?:\\`\\|\\.\\)antfin-inc\\.com\\'"
        "\\(?:\\`\\|\\.\\)openai\\.azure\\.com\\'"
        "\\(?:\\`\\|\\.\\)baidu\\.com\\'"
        "\\(?:\\`\\|\\.\\)aliyun-inc\\.com\\'"
        "\\(?:\\`\\|\\.\\)aliyun-inc\\.test\\'"))

(setq my/user-agent
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36")

(use-package mb-url-http
  :vc (:url "https://github.com/dochang/mb-url")
  :init
  (require 'auth-source)
  (let ((credential (auth-source-user-and-password "api.github.com")))
    (setq github-user (car credential)
          github-password (cadr credential))
    (setq github-auth (concat github-user ":" github-password))
    (setq mb-url-http-backend 'mb-url-http-curl
          mb-url-http-curl-program "/opt/homebrew/opt/curl/bin/curl"
          mb-url-http-curl-switches
          `(
	    ;;关闭服务端证书校验。
	    "-k"
            "-x" ,my/socks-proxy
            "--keepalive-time" "60"
            "--keepalive"
            "--max-time" "300"
            ;;防止 POST 超过 1024 Bytes 时发送 `Expect: 100-continue` 导致 1s 延迟。
            "-H" "Expect:"
            ;;"-u" ,github-auth
            "--user-agent" ,my/user-agent
            ))))

;; 开启 socks5 代理。
(defun proxy-socks-enable ()
  (interactive)
  (require 'socks)
  (setq url-gateway-method 'socks
        socks-noproxy my/socks-noproxy
        socks-server `("Default server" ,my/socks-host ,my/socks-port 5))
  (let ((no-proxy (mapconcat #'identity my/no-proxy-env ",")))
    (setenv "no_proxy" no-proxy)
    (setenv "NO_PROXY" no-proxy))
  (setenv "all_proxy" my/socks-proxy)
  (setenv "ALL_PROXY" my/socks-proxy)
  (setenv "HTTP_PROXY" nil)
  (setenv "HTTPS_PROXY" nil)
  (advice-add 'url-http :around 'mb-url-http-around-advice))

;; 关闭 socks5 代理。
(defun proxy-socks-disable ()
  (interactive)
  (require 'socks)
  (setq url-gateway-method 'native socks-noproxy nil)
  (setenv "all_proxy" "")
  (setenv "ALL_PROXY" ""))

;; 默认启动时开启 socks5 代理。
(proxy-socks-enable)

4 界面
#

关闭部分 UI 元素:

(when (memq window-system '(mac ns x))
  (tool-bar-mode -1)
  (scroll-bar-mode -1)
  (menu-bar-mode -1)
  ;; 不使用系统对话框,因为不同系统风格完全不一样。
  (setq use-file-dialog nil)
  (setq use-dialog-box nil))

光标和行号:

;; 设置光标样式。
(setq-default cursor-type 'bar)

;; 光标和字符宽度一致(如 TAB)。
(setq x-stretch-cursor t)

frame 设置:

;; frame 边角样式:undecorated, round corner: undecorated-round
(add-to-list 'default-frame-alist '(undecorated . t))
(add-to-list 'default-frame-alist '(ns-transparent-titlebar . t))
(add-to-list 'default-frame-alist '(selected-frame) 'name nil)
(add-to-list 'default-frame-alist '(ns-appearance . dark))
;; 新建 frame window 的大小。
(add-to-list 'default-frame-alist '(height . 24))
(add-to-list 'default-frame-alist '(width . 80))

;; 不在新 frame 打开文件(如 Finder 的 "Open with Emacs") 。
(setq ns-pop-up-frames nil)

;; 复用当前 frame。
(setq display-buffer-reuse-frames t)
(setq frame-resize-pixelwise t)

;; 30: 左右分屏, nil: 上下分屏。
(setq split-width-threshold nil)

;; 刷新显示。
(global-set-key (kbd "<f5>") #'redraw-display)

(setq switch-to-buffer-obey-display-actions t)

;; 帮助和工具 buffer 默认放在底部侧窗;传给 regexp-opt 的是未转义名称。
(add-to-list 'display-buffer-alist
             `(,(concat "\\`" (regexp-opt
                               '("*compilation*" "*Apropos*" "*Help*" "*helpful"
                                 "*info*" "*Summary*" "*vt" "*Org"
                                 "*Google Translate*" "*EGLOT" " *eglot" "*Shell Command Output*")))
               (display-buffer-reuse-window display-buffer-in-side-window)
               (side . bottom)
               (window-height . 0.35)))

;; 启动后显示模式,加 t 参数让 togg-frame-XX 最后运行,这样才生效:
(add-hook 'window-setup-hook 'toggle-frame-maximized t) ;; toggle-frame-fullscreen

窗口和滚动:

;; 切换窗口。
(global-set-key (kbd "s-o") #'other-window)

(setq window-combination-resize t)

;; 像素平滑滚动。
(pixel-scroll-precision-mode t)
(setq fast-but-imprecise-scrolling t)

dashboard:

(use-package dashboard
  :config
  (dashboard-setup-startup-hook)
  (setq-local global-hl-line-mode nil)
  (setq dashboard-banner-logo-title "Happy Hacking & Writing 🎯")
  (setq dashboard-projects-backend #'project-el)
  (setq dashboard-center-content t)
  (setq dashboard-set-heading-icons t)
  (setq dashboard-set-navigator t)
  (setq dashboard-set-file-icons t)
  (setq dashboard-path-max-length 30)
  ;; 显示 org-mode agenda。
  (add-to-list 'dashboard-items '(agenda) t)
  (setq dashboard-items '((recents . 20) (projects . 8) (agenda . 3))))

nerd-icons:

;; doom-modeline 使用 nerd-icons 显示图标。
;; 默认使用 "Symbols Nerd Font Mono" 字体(需要单独安装)。
(use-package nerd-icons
  :custom
  ;; 使用统一的 IoskeleyMonoTerm Nerd Font Mono 字体,它包含的 Nerd Font 含有常见图标和符号。
  (nerd-icons-font-family "IoskeleyMonoTerm Nerd Font Mono"))

;; nerd-icons-dired 为 dired 和 dired-sidebar 提供图标显示能力。
(use-package nerd-icons-dired
  :init
  (defun my/nerd-icons-dired-local-only ()
    (unless (file-remote-p default-directory)
      (nerd-icons-dired-mode 1)))
  :hook (
	 ;; 只对 local mode 启用 nerd-icons-dired-mode,防止查看远程目录时卡住。
	 (dired-mode . my/nerd-icons-dired-local-only)
         ;; 子树展开/收起时执行 nerd-icons-dired 刷新,这样才为子树显示图标。
         (dired-subtree-after-insert . nerd-icons-dired--refresh)
         (dired-subtree-after-remove . nerd-icons-dired--refresh)))

doom-modeline:

(use-package doom-modeline
  :demand t
  :hook (after-init . doom-modeline-mode)
  :custom
  (doom-modeline-buffer-encoding nil)
  (doom-modeline-env-version nil)
  (doom-modeline-env-enable-rust nil)
  (doom-modeline-env-enable-go nil)
  ;;显示文件相对于项目的精简路径(鼠标 hover 时显示完整路径)。truncate-nil 显示完整路径。
  (doom-modeline-buffer-file-name-style 'truncate-with-project)
  (doom-modeline-vcs-max-length 30)
  (doom-modeline-github nil)
  (doom-modeline-time-icon nil)
  (doom-modeline-check-simple-format t)
  :config

  ;; 对于远程目录或文件不查找对应的 project,防止卡住。
  (defun my/doom-modeline-remote-file-name (orig &rest args)
    "Render remote file names without resolving symlinks or project roots."
    (if (and buffer-file-name (file-remote-p buffer-file-name))
        (propertize
         (concat (file-remote-p buffer-file-name 'host)
                 ":" (file-name-nondirectory buffer-file-name))
         'face 'doom-modeline-buffer-file
         'mouse-face 'mode-line-highlight
         'help-echo buffer-file-name
         'local-map mode-line-buffer-identification-keymap)
      (apply orig args)))
  (advice-add 'doom-modeline-buffer-file-name :around
              #'my/doom-modeline-remote-file-name)

  (display-battery-mode 0)
  (column-number-mode t)
  (display-time-mode t)
  (setq display-time-24hr-format t)
  (setq display-time-default-load-average nil)
  (setq display-time-load-average-threshold 20)
  (setq display-time-format "%H:%M ") ;; 默认:"%m/%d[%w]%H:%M "
  (setq indicate-buffer-boundaries (quote left)))

dired-sidebar:使用 dired 显示目录,相比 treemacs/neotree 的优势是速度快和使用 dired 按键:

;;; dired
;; MacOS 安装 gnu coreutils 提供的 ls 命令。
(setq my-coreutils-path "/opt/homebrew/opt/coreutils/libexec/gnubin")
(setenv "PATH" (concat my-coreutils-path ":" (getenv "PATH")))
(setq exec-path (cons my-coreutils-path  exec-path))
(use-package emacs
  :config
  (setq dired-dwim-target t)
  ;; @see
  ;; https://emacs.stackexchange.com/questions/5649/sort-file-names-numbered-in-dired/5650#5650
  ;; 下面的参数只对安装了 coreutils (brew install coreutils) 的包有效,否则会报错。
  (setq dired-listing-switches "-laGh1v --group-directories-first"))

;; diredfl 为 dired 提供丰富的高亮特性(如文件时间、扩展名等高亮)。
(use-package diredfl :config (diredfl-global-mode))

;; dired-subtree 提供目录的原地展开能力,像文件树一样浏览,而不用进入另一个目录 buffer。
;; 在 dired-mode 中按 ( 来关闭 dired detail mode,从而只显示目录树。
(use-package dired-subtree
  :ensure t
  :commands (dired-subtree-toggle dired-subtree-cycle)
  :bind (:map dired-mode-map
	      ;; 在 dire-mode 中使用 subtree 显示目录。
              ("TAB" . dired-subtree-toggle)
	      ;; 循环递归展开或收起子树目录。<backtab> 对应 Shift-TAB。
              ("<backtab>" . dired-subtree-cycle))
  :config
  (setq dired-subtree-line-prefix " ") ;;子层级缩进前缀
  (setq dired-subtree-use-backgrounds nil)

  ;; 对于 tramp 远程 dired 列表,子目录行的权限字段前有 4 个空格,而 dired-subtree 固定检查第 3 个字符是否为 d 或 l,
  ;; 因此误判成非目录,从而导致按 TAB 的子目录展开失败。这里修复该问题。
  (defun my/dired-subtree-directory-or-link-p ()
    "Recognize directory and symlink lines with variable listing padding."
    ;; TRAMP 的 listing 自带空格,子树插入后权限字段不一定在第 3 列。
    ;; 保留首列的 Dired 标记位;只读当前行,避免额外远程文件查询。
    (save-excursion
      (beginning-of-line)
      (looking-at-p "^.[ \t]+[dl]")))
  (advice-add 'dired-subtree--dired-line-is-directory-or-link-p :override
              #'my/dired-subtree-directory-or-link-p))

(use-package dired-sidebar
  :ensure t
  :bind (("C-M-0" . dired-sidebar-toggle-sidebar))
  :commands (dired-sidebar-toggle-sidebar)
  :init
  (add-hook 'dired-sidebar-mode-hook
            (lambda ()
	      (display-line-numbers-mode -1)
              (unless (file-remote-p default-directory)
                (auto-revert-mode))))
  :config
  (push 'toggle-window-split dired-sidebar-toggle-hidden-commands)
  (push 'rotate-windows dired-sidebar-toggle-hidden-commands)
  ;; 使用 nerd-icons 和 nerd-icons-dired 显示图标。
  (setq dired-sidebar-theme 'nerd-icons)
  (setq dired-sidebar-use-term-integration t)
  (setq dired-sidebar-use-one-instance t)
  (setq dired-sidebar-use-custom-font t)
  ;; 可以手动调整宽度和高度。
  (setq dired-sidebar-window-fixed nil)
  (setq dired-sidebar-resize-on-open t)

  ;; 对于远程 sidebar,不 follow 文件或目录变化,防止卡住。
  (defun my/dired-sidebar-follow-local-only (orig &rest args)
    "Skip automatic following when either source or sidebar is remote."
    (with-selected-window (selected-window)
      (let ((sidebar (dired-sidebar-buffer)))
        (unless (or (file-remote-p default-directory)
                    (and (buffer-live-p sidebar)
                         (with-current-buffer sidebar
                           (file-remote-p default-directory))))
          (apply orig args)))))
  ;; Guard the callback itself, including already-running follow timers.
  (advice-add 'dired-sidebar-follow-file :around
              #'my/dired-sidebar-follow-local-only)

  ;; 本地继续自动跟随;远程由上面的 callback guard 跳过。
  (setq dired-sidebar-should-follow-file t)
  (setq dired-sidebar-follow-file-idle-delay 1.5))

字体:

  • 英文和图标符号等缺省字体: IoskeleyMonoTerm Nerd Font Mono
  • 中文:霞鹜文楷屏幕阅读版 LxgwWenKai-Screen,对字体做了加粗,便于屏幕阅读。

常用字体命令:

  • 查看 Emacs 支持的字体名称: M-: (print (font-family-list))
  • 查看光标处字体: M-x describe-char
(use-package fontaine
  :config
  (setq fontaine-latest-state-file (locate-user-emacs-file "fontaine-latest-state.eld"))
  (setq fontaine-presets
	'((regular) ;; 使用缺省配置。
	  (t
	   :default-family "IoskeleyMonoTerm Nerd Font Mono"
	   :default-weight regular
	   :default-height 160 ;; 默认字号, 需要是偶数才能实现中英文等宽等高。
	   :fixed-pitch-family "IoskeleyMonoTerm Nerd Font Mono"
	   :fixed-pitch-weight nil
	   :fixed-pitch-height 1.0
	   :fixed-pitch-serif-family "IoskeleyMonoTerm Nerd Font Mono"
	   :fixed-pitch-serif-weight nil
	   :fixed-pitch-serif-height 1.0
	   :variable-pitch-family "IoskeleyMonoTerm Nerd Font Mono"
	   :variable-pitch-weight nil
	   :variable-pitch-height 1.0
	   :line-spacing nil)))
  (fontaine-mode 1)
  (fontaine-set-preset (or (fontaine-restore-latest-preset) 'regular))
  (add-hook 'kill-emacs-hook #'fontaine-store-latest-preset))

;; 设置 emoji/symbol 和中文字体。
(defun my/set-font ()
  (interactive)
  (when window-system
    (setq use-default-font-for-symbols nil)
    ;; Noto Color Emoji
    (set-fontset-font t 'emoji (font-spec :family "Apple Color Emoji"))
    ;; Apple Symbols, Symbola,Symbols Nerd Font Mono, IoskeleyMonoTerm Nerd Font Mono
    (set-fontset-font t 'symbol (font-spec :family "Symbols Nerd Font Mono"))
    (let ((font (frame-parameter nil 'font))
	  (font-spec (font-spec :family "LXGW WenKai Mono Screen")))
      (dolist (charset '(kana han hangul cjk-misc bopomofo))
	(set-fontset-font font charset font-spec)))))

;; Emacs 启动后或 fontaine preset 切换时设置字体。
(add-hook 'after-init-hook 'my/set-font)
(add-hook 'fontaine-set-preset-hook 'my/set-font)

;; 设置字体缩放比例,设置为 1.172 可以确保 2 倍放大后对应的是 22 号偶数字体,这样表格
;; 可以对齐。16 * 1.172 * 1.172 = 21.97(Emacs 取整为 22)。
(setq text-scale-mode-step 1.172)

;; org-table 只使用中英文严格等宽的 LXGW WenKai Mono Screen 字体, 避免中英文不对齐。
(custom-theme-set-faces 'user '(org-table ((t (:family "LXGW WenKai Mono Screen")))))

ef-themes: Emacs 主题列表:https://emacsthemes.com/popular/index.html

(use-package ef-themes
  :ensure t
  :init
  ;; 最新的 ef-theme 基于 modus theme 重构。
  ;; 启用后,所有 modus theme 命令只考虑 Ef themes。
  (ef-themes-take-over-modus-themes-mode 1)
  :config
  (setq modus-themes-italic-constructs t)
  (setq modus-themes-bold-constructs t)
  ;;使用混合字体(variable-pitch-mode)显示空格敏感的内容,如 org-mode 或 code block。
  ;;需要事先在 fontaine 中设置 default, fixed-pitch 和 variable-pitch 字体类型。
  (setq modus-themes-mixed-fonts t)
  ;;使用 variable-pitch font 来显示 UI 元素,如 modeline/headline/tabbar/tabline 等。
  (setq modus-themes-variable-pitch-ui t)
  ;; 启用 ef/modus theme 时关闭其他 theme,防止不同 theme 间冲突。
  (setq modus-themes-disable-other-themes t)
  (setq modus-themes-headings
        '(
          ;; level 0 是文档 title,1-8 是文档 header,t 是缺省。
          (0 . (variable-pitch light 1.8))
          (1 . (variable-pitch light 1.7))
          (2 . (variable-pitch regular 1.6))
          (3 . (variable-pitch regular 1.5))
          (4 . (variable-pitch regular 1.4))
          (5 . (variable-pitch 1.6))
          (4 . (variable-pitch 1.3))
          (7 . (variable-pitch 1.2))
          (8 . (variable-pitch 1.1))
          (agenda-date . (1.3))
          (agenda-structure . (variable-pitch light 1.8))
          (section-minibuffer . (variable-pitch light 0.9))
          (section-other . (regular 1.3))
          (commit-summary . (bold 1.1))
          (t . (variable-pitch 1.1))))
  ;;(modus-themes-load-theme 'ef-summer)
  )

;; 定制 ef/modus theme 显示效果。
(setq modus-themes-common-palette-overrides
      '(
	;; tab-bar:浅蓝色背景。
	(bg-tab-bar bg-cyan-nuanced)
        (bg-tab-current bg-cyan-intense)
        (bg-tab-other bg-cyan-subtle)

	;; header:彩色标题。
	(fg-heading-1 blue)
        (fg-heading-2 cyan)
        (fg-heading-3 green)

	;; code block:使用浅蓝色背景。
	(bg-prose-block-contents bg-cyan-nuanced)
        (bg-prose-block-delimiter bg-cyan-nuanced)
        (fg-prose-block-delimiter cyan-cooler)
	))

自动切换深浅主题:

(defun my/load-theme (appearance)
  (interactive)
  (pcase appearance
    ('light (load-theme 'ef-light t))
    ('dark (load-theme 'ef-elea-dark t))))
(add-hook 'ns-system-appearance-change-functions 'my/load-theme)
(add-hook 'after-init-hook (lambda () (my/load-theme ns-system-appearance)))

tab-bar:

(use-package tab-bar
  :custom
  (tab-bar-close-button-show nil)
  (tab-bar-new-button-show nil)
  (tab-bar-history-limit 20)
  (tab-bar-new-tab-choice "*dashboard*")
  (tab-bar-show 1)
  ;; 使用 super + N 切换 tab。
  (tab-bar-select-tab-modifiers '(super))
  :config
  ;; 去掉最左侧的 < 和 > 。
  (setq tab-bar-format '(tab-bar-format-tabs tab-bar-separator))

  ;; 开启 tar-bar history mode 后才支持 history-back/forward 命令。
  ;; tab-bar-history-mode 可以替换老的 winner mode(它不感知 tab 切换)。
  (tab-bar-history-mode t)
  (global-set-key (kbd "s-f") 'tab-bar-history-forward)
  (global-set-key (kbd "s-b") 'tab-bar-history-back)
  (global-set-key (kbd "s-t") 'tab-bar-new-tab)
  (keymap-global-set "s-n" 'tab-bar-switch-to-next-tab)
  (keymap-global-set "s-p" 'tab-bar-switch-to-prev-tab)
  (keymap-global-set "s-w" 'tab-bar-close-tab)

  ;; 为 tab 添加序号,用于快速切换。
  (defvar ct/circle-numbers-alist
    '((0 . "⓪")
      (1 . "①")
      (2 . "②")
      (3 . "③")
      (4 . "④")
      (5 . "⑤")
      (6 . "⑥")
      (7 . "⑦")
      (8 . "⑧")
      (9 . "⑨"))
    "Alist of integers to strings of circled unicode numbers.")
  (setq tab-bar-tab-hints t)
  (defun ct/tab-bar-tab-name-format-default (tab i)
    (let ((current-p (eq (car tab) 'current-tab))
          (tab-num (if (and tab-bar-tab-hints (< i 10))
                       (alist-get i ct/circle-numbers-alist) "")))
      (propertize
       (concat tab-num
               " "
               (alist-get 'name tab)
               (or (and tab-bar-close-button-show
                        (not (eq tab-bar-close-button-show
                                 (if current-p 'non-selected 'selected)))
                        tab-bar-close-button)
                   "")
               " ")
       'face (funcall tab-bar-tab-face-function tab))))
  (setq tab-bar-tab-name-format-function #'ct/tab-bar-tab-name-format-default)

  (global-set-key (kbd "s-1") 'tab-bar-select-tab)
  (global-set-key (kbd "s-2") 'tab-bar-select-tab)
  (global-set-key (kbd "s-3") 'tab-bar-select-tab)
  (global-set-key (kbd "s-4") 'tab-bar-select-tab)
  (global-set-key (kbd "s-5") 'tab-bar-select-tab)
  (global-set-key (kbd "s-6") 'tab-bar-select-tab)
  (global-set-key (kbd "s-7") 'tab-bar-select-tab)
  (global-set-key (kbd "s-8") 'tab-bar-select-tab)
  (global-set-key (kbd "s-9") 'tab-bar-select-tab))

5 输入法
#

使用 RIME 输入法 + iDevel/rime-ice 雾凇拼音输入法方案。

安装 RIME 输入法后端引擎 librimeemacs-rime 直接和该引擎打交道,不需要安装前端程序"鼠须管 squirrel"。

wget Https://github.com/rime/librime/releases/download/1.17.0/rime-33e7814-macOS-universal.tar.bz2
tar -xvf rime-33e7814-macOS-universal.tar.bz2
#mv ~/.emacs.d/librime/dist{,.bak}
mv dist ~/.emacs.d/librime
# 如果 MacOS Gatekeeper 阻止第三方软件运行,可以暂时关闭它:
sudo spctl --master-disable
# 后续再开启:sudo spctl --master-enable

下载 iDvel/rime-ice 雾凇拼音输入法方案:

mv ~/Library/Rime ~/Library/Rime.bak
git clone https://github.com/iDvel/rime-ice --depth=1
mv rime-ice ~/Library/Rime
# 后续可以 git pull 更新 rime-ice。
cd ~/Library/Rime
# 自定义词频文件
cp custom_phrase.txt  opsnull_custom_phrase.txt
# 修改其中的 db_name
sed -i -e 's/custom_phrase.txt/opsnull_custom_phrase/g' opsnull_custom_phrase.txt

rime_ice 如模糊音(拼音方案调整,动态词频,自定义词语文件等):

  • 自定义短语:向自定义短语词典文件 opsnull_custom_phrase.txt 添加自定义短语,custom_prase/db_class 为 stabledb,是只读的,不会动态调频。(可以设置为 tabledb 来动态调频)。
  • 首次添加该文件后需要执行 M-x rime-deployM-x rime-sync 生效。
patch:
  switches:
  - name: ascii_mode
    states: [ 中, A ]
  - name: ascii_punct  # 中英标点
    states: [ ¥, $ ]
  # 下面这些开关一般用不到, 故关闭(如候选词中不再显示 emoji).
  # - name: traditionalization
  #   states: [ 简, 繁 ]
  #   reset: 0
  # - name: emoji
  #   states: [ 💀, 😄 ]
  #   reset: 1
  # - name: full_shape
  #   states: [ 半角, 全角 ]
  #   reset: 0
  # - name: search_single_char  # search.lua 的功能开关,辅码查词时是否单字优先
  #   abbrev: [词, 单]
  #   states: [正常, 单字]
  #   reset: 0

  translator/spelling_hints: 0           # 不显示候选词的拼音。
  translator/always_show_comments: false #不显示候选者的拼音。
  translator/enable_user_dict: true      # 根据上屏自动调整词频, 否则根据 *.dict.yaml 中的静态定义的词频率。
  custom_phrase/user_dict: "opsnull_custom_phrase"  # 自定义短语词典文件,权重最高。

  speller/algebra:
  # 模糊拼音
  # 声母
  - derive/^([zcs])h/$1/          # z c s → zh ch sh
  - derive/^([zcs])([^h])/$1h$2/  # zh ch sh → z c s
  #- derive/^l/n/  # n → l
  #- derive/^n/l/  # l → n
  # 韵母
  - derive/eng$/en/
  - derive/en$/eng/
  - derive/in/ing/
  - derive/ing/in/

  # 自动纠错(后者用前者替换)
  # ai
  - derive/^([wghk])ai$/$1ia/  # wia → wai
  # ei
  - derive/([wfghkz])ei$/$1ie/  # wie → wei
  # ie
  - derive/([jqx])ie$/$1ei/  # jei → jie

Rime 输入法全局配置,详细参考 iDvel/rime-ice

patch:
  schema_list:
  - schema: rime_ice  # 只启用 rime_ice 雾凇拼音输入法方案。
  menu/page_size: 9   # 显示 9 个候选词。
  # 方案选单切换
  switcher/hotkeys:
  - F4
  - "Control+plus" # 按 C-Shit-= 调出方案选单。
  switcher/fold_options: false # 呼出时不折叠。
  switcher/abbreviate_options: false # 折叠时不缩写选项
  ascii_composer: # 中英文切换
    switch_key:   # 关闭左边 Shift 中西文切换,而是使用右侧 Shift(避免频繁误按)。
      Shift_L: noop
      Shift_R: commit_code
  key_binder/bindings:
  - { when: has_menu, accept: equal, send: Page_Down }             # 下一页:=
  - { when: paging, accept: minus, send: Page_Up }                 # 上一页:-
  - { when: always, accept: "Control+period", toggle: ascii_mode}  # 中英文切换:ctrl-.
  - { when: always, accept: "Control+comma", toggle: ascii_punct}  # 中英文标点切换:ctrl-,
  #- { when: always, accept: "Control+comma", toggle: full_shape}  # 全角/半角切换

  # 开启 emacs 绑定惯例,这样可以使用 C-x 来修正拼音。需要将这些按键加到
  # rime-translate-keybindings变量里后才会生效。 composing 指的是出现候选词列表的时机。
  - { when: composing, accept: Control+p, send: Up }
  - { when: composing, accept: Control+n, send: Down }
  - { when: composing, accept: Control+b, send: Left }
  - { when: composing, accept: Control+f, send: Right }
  - { when: composing, accept: Control+a, send: Home }
  - { when: composing, accept: Control+e, send: End }
  - { when: composing, accept: Control+d, send: Delete }
  # 从用户数据库中删除误上屏的词语
  - { when: composing, accept: Control+k, send: Shift+Delete }
  - { when: composing, accept: Control+h, send: BackSpace }
  - { when: composing, accept: Control+g, send: Escape }
  - { when: composing, accept: Control+bracketleft, send: Escape }
  - { when: composing, accept: Control+y, send: Page_Up }
  - { when: composing, accept: Alt+v, send: Page_Up }
  - { when: composing, accept: Control+v, send: Page_Down }

  # 更多按键名称参考: https://github.com/LEOYoon-Tsaw/Rime_collections/blob/master/Rime_description.md

配置 Emacs:

  • rime-disable-predicates 定义了一组断言函数,当任一函数断言成立时,Rime 自动将输入法切换为英文(inline、ascii-inline、ascii-mode 都指的是英文)。如果同时定义了rime-inline-predicates 变量,则当这两组函数都至少有一个断言成立时才会切换为英文。
  • rime-predicate-after-alphabet-char-prime-predicate-in-code-string-p 条件都会导致不能正确的中英文混排。
(use-package rime
  :custom
  (rime-user-data-dir "~/Library/Rime/")
  (rime-librime-root "~/.emacs.d/librime/dist")
  (rime-emacs-module-header-root "/opt/homebrew/opt/emacs-plus@31/include")
  :hook
  (emacs-startup . (lambda () (setq default-input-method "rime")))
  :bind
  (
   :map rime-active-mode-map
   ;; 在已经激活 Rime 候选菜单时,强制切换到英文直到按回车。
   ("M-j" . 'rime-inline-ascii)
   :map rime-mode-map
   ;; 强制切换到中文模式.
   ("M-j" . 'rime-force-enable)
   ;; 下面这些快捷键需要发送给 rime 来处理, 需要与 default.custom.yaml 文件中的
   ;; key_binder/bindings配置相匹配。
   ("C-+" . 'rime-send-keybinding)      ;; 输入法菜单
   ("C-." . 'rime-send-keybinding)      ;; 中英文切换
   ("C-," . 'rime-send-keybinding)      ;; 中英文标点切换
   ;;("C-," . 'rime-send-keybinding)    ;; 全半角切换
   )
  :config
  ;; 在 modline 高亮输入法图标, 可用来快速分辨分中英文输入状态。
  (setq mode-line-mule-info '((:eval (rime-lighter))))
  ;; 将如下快捷键发送给 rime,同时需要在 rime 的 key_binder/bindings 的部分配置才会生效。
  (add-to-list 'rime-translate-keybindings "C-h") ;; 删除拼音字符
  (add-to-list 'rime-translate-keybindings "C-d")
  (add-to-list 'rime-translate-keybindings "C-k") ;; 删除误上屏的词语
  (add-to-list 'rime-translate-keybindings "C-a") ;; 跳转到第一个拼音字符
  (add-to-list 'rime-translate-keybindings "C-e") ;; 跳转到最后一个拼音字符support
  ;; shift-l, shift-r, control-l, control-r, 只有当使用系统 RIME 输入法时才有效。
  (setq rime-inline-ascii-trigger 'shift-r)

  ;; 临时英文模式, 该列表中任何一个断言返回 t 时自动切换到英文。如果
  ;; rime-inline-predicates 不为空,则当其中任意一个断言也返回 t 时才会自动切换到英文
  ;; (inline 等效于 ascii-mode)。自定义 avy 断言函数。
  (defun rime-predicate-avy-p () (bound-and-true-p avy-command))
  (setq rime-disable-predicates
        '(rime-predicate-ace-window-p
          rime-predicate-hydra-p
          ;;rime-predicate-current-uppercase-letter-p
          ;; 在上一个字符是英文时才自动切换到英文,适合字符串中中英文混合的情况。
          ;;rime-predicate-in-code-string-after-ascii-p
          ;; 代码块内不能输入中文, 但注释和字符串不受影响。
          ;;rime-predicate-prog-in-code-p
          ;;rime-predicate-avy-p
          ))

  (setq rime-show-candidate 'posframe)
  (setq default-input-method "rime")

  ;;设置输入法 frame 样式。
  (setq rime-posframe-properties
        (list :background-color "#333333"
              :foreground-color "#dcdccc"
              :internal-border-width 2))

  ;; 只在进入模式时设置初始状态;切换 buffer 不覆盖用户手动选择。
  (defun my/rime-disable-in-special-buffer ()
    ;; 保留 default-input-method,用户仍可用 C-\\ 手动开启 Rime。
    (deactivate-input-method))

  (dolist (hook '(ghostel-mode-hook dired-mode-hook image-mode-hook compilation-mode-hook))
    (add-hook hook #'my/rime-disable-in-special-buffer)))

6 补全和预览
#

本部分使用的三方包说明:

  1. vertico:minibuffer 补全;
  2. corfu:光标处补全;
  3. orderless:提供 flex/regex 过滤风格;
  4. consult:实时预览和高级过滤;
  5. embark:为 minibuffer/buffer 选中的内容提供快捷操作;
  6. marginalia:为候选者提供元数据(如 dired 风格的权限、修改时间等);

vertico 提供 minibuffer 自动补全(corfu 提供光标处的自动补全), 可以使用 orderless 过滤候选者。

常用快捷键:

  • C-] (abort-recursive-edit) :快速关闭 minibuffer、echo-area 的编辑输入或补全状态。
  • M-RET(vertico-exit-input) :退出输入候选模式,直接使用输入的内容,可以是 file 或 buffer 名称。
  • M-} (vertico-next-group) :选择候选者列表中的下一个分组,如不同的 file 或 project。
  • TAB (vertico-insert) :插入当前选中的候选者;
(use-package vertico
  :config
  (setq vertico-count 15)
  (vertico-mode 1)
  ;;使用鼠标滚动选择列表中的候选项,左键点击确认(相当于 RET),右键插入候选(相当于 TAB)。
  (vertico-mouse-mode 1)
  (define-key vertico-map (kbd "<backspace>") #'vertico-directory-delete-char)
  (define-key vertico-map (kbd "RET") #'vertico-directory-enter))

(use-package emacs
  :init
  ;; minibuffer 不显示光标。
  (setq minibuffer-prompt-properties '(read-only t cursor-intangible t face minibuffer-prompt))
  (add-hook 'minibuffer-setup-hook #'cursor-intangible-mode)
  ;; M-x 只显示当前 mode 支持的命令。
  (setq read-extended-command-predicate #'command-completion-default-include-p)
  ;; 开启 minibuffer 递归编辑。
  (setq enable-recursive-minibuffers t))

corf 在光标处显示候选列表和文档, 可以使用 orderless 来过滤候选者:

(use-package corfu
  :init
  (global-corfu-mode 1)
  (corfu-popupinfo-mode 1) ;; 显示候选者文档。
  :bind
  ;; 滚动显示 corfu-popupinfo 内容。
  (:map corfu-popupinfo-map
        ("C-M-j" . corfu-popupinfo-scroll-up)
        ("C-M-k" . corfu-popupinfo-scroll-down))
  :custom
  (corfu-cycle t)                ;; 自动轮转。
  (corfu-auto t)                 ;; 自动补全(不需要按 TAB)。
  (corfu-auto-prefix 2)          ;; 触发自动补全的前缀长度。
  (corfu-auto-delay 0.25)        ;; 触发自动补全的延迟, 当满足前缀长度或延迟时, 都会自动补全。
  (corfu-separator ?\s)          ;; 使用 Orderless 过滤分隔符。
  (corfu-preselect 'prompt)      ;; Preselect the prompt
  (corfu-scroll-margin 5)
  (corfu-on-exact-match nil)     ;; 默认不选中候选者(即使只有一个)。
  (corfu-popupinfo-delay '(0.5 . 0.2)) ;; 候选者帮助文档显示延迟。
  (corfu-popupinfo-max-width 80)
  (corfu-popupinfo-max-height 50)
  (corfu-popupinfo-direction '(force-right)) ;; 强制在右侧显示文档。
  :config
  (defun corfu-enable-always-in-minibuffer ()
    (setq-local corfu-auto nil)
    (corfu-mode 1))
  (add-hook 'minibuffer-setup-hook #'corfu-enable-always-in-minibuffer 1)

  ;; corfu 支持 eshell 的 pcomplete 自动补全。
  (add-hook 'eshell-mode-hook
            (lambda ()
              (setq-local corfu-auto nil)
              (corfu-mode))))

;; 记录 minibuffer 和 corfu 补全历史,后续显示候选者时按照频率排序。
(use-package savehist
  :hook (after-init . savehist-mode)
  :config
  (setq history-length 100)
  (setq savehist-save-minibuffer-history t)
  (setq savehist-autosave-interval 300)
  (add-to-list 'savehist-additional-variables #'corfu-history)
  (add-to-list 'savehist-additional-variables 'mark-ring)
  (add-to-list 'savehist-additional-variables 'global-mark-ring)
  (add-to-list 'savehist-additional-variables 'extended-command-history))

(use-package emacs
  :init
  ;; 总是在弹出菜单中显示候选者。
  (setq completion-cycle-threshold nil)
  ;; 使用 TAB 来 indentation + completion(completion-at-point 默认是 M-TAB) 。
  (setq tab-always-indent 'complete))

orderless 补全风格:使用空格分割一个或多个匹配模式,模式无顺序,是 AND 关系。

orderless 默认使用 orderless-matching-styles 变量配置的 正则和字面量 匹配方式,通过给各模式指定前缀或后缀字符, 也可以灵活指定其它匹配模式:

  • = : orderless-literal, 字面量匹配;
  • ~ : orderless-flex, 模糊匹配,如 abc 实际对应正则 a.*b.*c;
  • ^ : orderless-literal-prefix ,前缀匹配;
  • & : orderless-annotation ,使用 marginalia 等提供的 meta 信息来过滤;
  • , : orderless-initialism, 首字母缩写,如 ,abc 实际对应正则 \<a.*\<b.*\c;
  • ! : 使用 orderless-without-literal 对模式的其余部分进行匹配,即 !badbad! 都会匹配不包含字面量子串 bad 的字符串。
  • % : 匹配时忽略变音符号及类似的字符变体,内部使用 char-fold-to-regexp 函数实现。

! 只能对 字面量 匹配取反(orderless-without-literal) ,和其他 dispatch 字符连用时, ! 需要前缀形式,如 !=.go 将不匹配含有字面量 .go 的候选者。

(use-package orderless
  :demand t
  :config
  ;; https://github.com/minad/consult/wiki#minads-orderless-configuration
  (defun +orderless--consult-suffix ()
    "Regexp which matches the end of string with Consult to support."
    (if (and (boundp 'consult--tofu-char) (boundp 'consult--tofu-range))
        (format "[%c-%c]*$"
                consult--tofu-char
                (+ consult--tofu-char consult--tofu-range -1))
      "$"))

  ;; Recognizes the following patterns:
  ;; * .ext (file extension)
  ;; * regexp$ (regexp matching at end)
  (defun +orderless-consult-dispatch (word _index _total)
    (cond
     ;; Ensure that $ works with Consult commands, which add disambiguation suffixes
     ((string-suffix-p "$" word)
      `(orderless-regexp . ,(concat (substring word 0 -1) (+orderless--consult-suffix))))
     ;; File extensions
     ((and (or minibuffer-completing-file-name
               (derived-mode-p 'eshell-mode))
           (string-match-p "\\`\\.." word))
      `(orderless-regexp . ,(concat "\\." (substring word 1) (+orderless--consult-suffix))))))

  ;; 在 orderless-affix-dispatch 的基础上添加上面支持文件名扩展和正则表达式的 dispatchers。
  (setq orderless-style-dispatchers
        (list #'+orderless-consult-dispatch
              #'orderless-affix-dispatch))

  ;; 自定义名为 +orderless-with-initialism 的 orderless 风格。
  (orderless-define-completion-style +orderless-with-initialism
    (orderless-matching-styles '(orderless-initialism orderless-literal orderless-regexp)))

  ;; 使用 orderless 和 Emacs 原生的 basic 补全风格,但 orderless 的优先级更高。
  (setq completion-styles '(orderless basic))
  (setq completion-category-defaults nil)

  ;; 设置 Emacs minibuffer 各 category 使用的补全风格。
  (setq completion-category-overrides
        '(
          ;; buffer name 补全
          ;;(buffer (styles +orderless-with-initialism))

          ;; 文件名和路径补全, partial-completion 提供了 wildcard 支持。
          (file (styles partial-completion))
          (command (styles +orderless-with-initialism))
          (variable (styles +orderless-with-initialism))
          (symbol (styles +orderless-with-initialism))

          ;; eglot will change the completion-category-defaults to flex, BAD!
          ;; https://github.com/minad/corfu/issues/136#issuecomment-eglot
          ;; 使用 M-SPC 来分隔光标处的多个筛选条件。
          (eglot (styles . (orderless basic)))
          (eglot-capf (styles . (orderless basic)))
          ))

  ;; 使用 SPACE 来分割过滤字符串。
  (setq orderless-component-separator #'orderless-escapable-split-on-space))
  • partial-completion 支持 shell wildcards 和部分文件路径,如 /u/s/l 对应 /usr/share/local;
  • 已知的 completion categories;

在多个过滤模式间插入分隔符:

  • 对于 buffer 中光标处的连续输入, 使用 M-SPC(corfu-insert-separator) 插入 orderless 分隔符;
  • 对于 minibuffer 区域的补全, 使用 SPC (orderless 默认的分隔符) 分割多个过滤条件,如果要插入 SPC 本身,需要使用 \ 转义,如 results\ no.

consult:提供候选者实时过滤和预览等功能:

(use-package consult
  :hook
  (completion-list-mode . consult-preview-at-point-mode)
  :init
  ;; 如果搜索字符少于 3,可以添加后缀 # 开始搜索,如 #gr#。
  (setq consult-async-min-input 3)
  ;; 从头开始搜索(而非前位置)。
  (setq consult-line-start-from-top t)
  ;; 寄存器预览。
  (setq register-preview-function #'consult-register-format)
  (advice-add #'register-preview :override #'consult-register-window)
  :config
  ;; 不搜索常见语言的缓存目录。
  (setq consult-ripgrep-args
        (concat consult-ripgrep-args
                " --glob !vendor/**"
                " --glob !target/**"
                " --glob !node_modules/**"
                " --glob !**/vendor/**"
                " --glob !**/target/**"
                " --glob !**/node_modules/**"))
  ;; 按 C-l 才激活预览,否则 Buffer 列表中有大文件或远程文件时会卡住。
  (setq consult-preview-key "C-l")
  ;; 不对 consult-line 结果进行排序(按行号排序)。
  (consult-customize consult-line :prompt "Search: " :sort nil)
  ;; Buffer 列表中不显示的 Buffer 名称。
  (mapcar
   (lambda (pattern) (add-to-list 'consult-buffer-filter pattern))
   '("\\*scratch\\*"
     "\\*Warnings\\*"
     "\\*helpful.*"
     "\\*Help\\*"
     "\\*Org Src.*"
     "Pfuture-Callback.*"
     "\\*epc con"
     "\\*dashboard"
     "\\*Ibuffer"
     "\\*sort-tab"
     "\\*Google Translate\\*"
     "\\*straight-process\\*"
     "\\*[Nn]ative-compile-[Ll]og\\*"
     "\\*Async-native-compile-log\\*"
     "\\*EGLOT"
     "[0-9]+.gpg")))

;; 执行 consult-line 命令时自动展开 org 内容。
;; https://github.com/minad/consult/issues/563#issuecomment-1186612641
(defun my/org-show-entry (fn &rest args)
  (interactive)
  (when-let* ((pos (apply fn args)))
    (when (derived-mode-p 'org-mode)
      (org-fold-show-entry))))
(advice-add 'consult-line :around #'my/org-show-entry)

;; 显示 mode 相关的命令。
(global-set-key (kbd "C-c M-x") #'consult-mode-command)

;; 搜索 Emacs 各 package/mode 的 info 和 man 文档。
(global-set-key (kbd "C-c i") #'consult-info)
(global-set-key (kbd "C-c m") #'consult-man)

;; 使用 savehist 持久化保存的 minibuffer 历史。
(global-set-key (kbd "C-M-;") #'consult-complex-command)

;; consult-buffer 显示的 File 列表来源于变量 recentf-list。
(global-set-key (kbd "C-x b") #'consult-buffer)
(global-set-key (kbd "C-x 4 b") #'consult-buffer-other-window)
(global-set-key (kbd "C-x 5 b") #'consult-buffer-other-frame)
(global-set-key (kbd "C-x r b") #'consult-bookmark)
(global-set-key (kbd "C-x p b") #'consult-project-buffer)

(global-set-key (kbd "M-y") #'consult-yank-pop)
(global-set-key (kbd "M-Y") #'consult-yank-from-kill-ring)

(global-set-key (kbd "M-g g") #'consult-goto-line)
(global-set-key (kbd "M-g o") #'consult-outline)

;; 寄存器,保存 point、file、window、frame 的位置。
(global-set-key (kbd "C-'") #'consult-register-store)
(global-set-key (kbd "C-M-'") #'consult-register)

;; 显示编译错误列表。
(global-set-key (kbd "M-g e") #'consult-compile-error)
;; 显示 flymake 诊断错误列表。
(global-set-key (kbd "M-g f") #'consult-flymake)

;; consult-buffer 默认已包含 recent file。
;;(global-set-key (kbd "M-g r") #'consult-recent-file)

(global-set-key (kbd "M-g m") #'consult-mark)
(global-set-key (kbd "M-g k") #'consult-global-mark)

;; 预览当前 buffer 的 imenu。
(global-set-key (kbd "M-g i") #'consult-imenu)
;; 预览当前 project 打开的所有 buffer 的 imenu。
(global-set-key (kbd "M-g I") #'consult-imenu-multi)

;; 搜索文件内容。
(global-set-key (kbd "M-s g") #'consult-grep)
(global-set-key (kbd "M-s G") #'consult-git-grep)
(global-set-key (kbd "M-s r") #'consult-ripgrep)

;; 搜索文件名(正则匹配)。
(global-set-key (kbd "M-s d") #'consult-find)
(global-set-key (kbd "M-s D") #'consult-locate)

;; 搜索当前 buffer
(global-set-key (kbd "M-s l") #'consult-line)
(global-set-key (kbd "M-s M-l") #'consult-line)
;; 搜索多个 buffers,默认为 project 的多个 buffers。
;; 如果使用前缀参数,则搜索所有 buffers。
(global-set-key (kbd "M-s L") #'consult-line-multi)

;; Isearch 集成。
(global-set-key (kbd "M-s e") #'consult-isearch-history)
;;:map isearch-mode-map
(define-key isearch-mode-map (kbd "M-e") #'consult-isearch-history)
(define-key isearch-mode-map (kbd "M-s e") #'consult-isearch-history)
(define-key isearch-mode-map (kbd "M-s l") #'consult-line)
(define-key isearch-mode-map (kbd "M-s L") #'consult-line-multi)

;; Minibuffer 历史。
;;:map minibuffer-local-map
(define-key minibuffer-local-map (kbd "M-s") #'consult-history)
(define-key minibuffer-local-map (kbd "M-r") #'consult-history)

;; 使用 consult 来预览 xref 的引用定义和跳转。
(setq xref-show-xrefs-function #'consult-xref)
(setq xref-show-definitions-function #'consult-xref)

embark:为选中的内容提供快捷操作命令:

(use-package embark
  :init
  ;; 使用 C-h 显示 key preifx 绑定。
  (setq prefix-help-command #'embark-prefix-help-command)
  :config
  (setq embark-prompter 'embark-keymap-prompter)
  (global-set-key (kbd "C-;") #'embark-act) ;; embark-dwim
  ;; 根据当前 buffer 的 mode,显示可以使用的快捷键。
  (define-key global-map [remap describe-bindings] #'embark-bindings))

;; embark-consult 导出的 grep/line 结果使用内置 Grep/Occur Edit 编辑。
(use-package embark-consult
  :after (embark consult)
  :hook  (embark-collect-mode . consult-preview-at-point-mode))

;; Emacs 31:Grep 结果按 e 编辑,C-c C-c 返回 Grep 模式。
;; 修改即时同步到源 buffer;用 C-x s 选择保存,不自动写盘或绕过只读保护。

marginalia:为候选者提供元数据(如 dired 风格的权限、修改时间等):

(use-package marginalia
  :init
  ;; 显示绝对时间。
  (setq marginalia-max-relative-age 0)
  (marginalia-mode))

7 org
#

配置 org-mode:

(use-package org
  :config
  (setq
   org-ellipsis "..." ;; " ⭍"

   ;; 使用 UTF-8 显示 LaTeX 或 \xxx 特殊字符,M-x org-entities-help 查看所有特殊字符。
   org-pretty-entities t
   org-highlight-latex-and-related '(latex)

   ;; 技术文档默认排版公式;展示 LaTeX 源码时使用代码块或文档级 tex:verbatim。
   org-export-with-latex t
   org-export-with-broken-links 'mark
   ;; export 时不处理 super/sub scripting, 等效于 #+OPTIONS: ^:nil 。
   org-export-with-sub-superscripts nil
   org-export-default-language "zh-CN"
   org-export-coding-system 'utf-8

   ;; 使用 R_{s} 形式的下标(默认是 R_s, 容易与正常内容混淆) 。
   org-use-sub-superscripts '{}

   ;; 文件链接使用相对路径, 解决 hugo 等 image 引用的问题。
   org-link-file-path-type 'relative
   org-html-validation-link nil
   ;; 关闭鼠标点击链接。
   org-mouse-1-follows-link nil

   org-hide-emphasis-markers t
   org-hide-block-startup t
   org-hidden-keywords '(title)
   org-hide-leading-stars t

   org-cycle-separator-lines 2
   org-cycle-level-faces t
   org-n-level-faces 4
   org-indent-indentation-per-level 2

   ;; 内容缩进与对应 headerline 一致。
   org-adapt-indentation t
   org-list-indent-offset 2

   ;; 代码块缩进。
   org-src-preserve-indentation t
   org-edit-src-content-indentation 0

   ;; TODO 状态更新记录到 LOGBOOK Drawer 中。
   org-log-into-drawer t
   ;; TODO 状态更新时记录 note.
   org-log-done 'note ;; note, time

   ;; 不显示图片(手动点击显示更容易控制大小)。
   org-startup-with-inline-images nil
   org-startup-folded 'content
   org-cycle-inline-images-display nil

   ;; 只控制编辑器中的 org-num-mode,与导出编号和目录无关。
   org-startup-numerated nil
   org-startup-indented t

   ;; 先从 #+ATTR.* 获取宽度,如果没有设置则默认为 300 。
   org-image-actual-width '(300)

   ;; org-timer 到期时发送声音提示。
   org-clock-sound t
   ;; 关闭容易误按的 archive 命令。
   org-archive-default-command nil

   ;; 不自动对齐 tag。
   org-tags-column 0
   org-auto-align-tags nil

   ;; 显示不可见的编辑。
   org-catch-invisible-edits 'show-and-error
   org-fold-catch-invisible-edits t

   ;; 使用 ID property 作为 internal link target(默认是 CUSTOM_ID 属性)
   ;; 这会在格 section 下面自动添加 :ID: 属性。
   org-id-link-to-org-use-id t
   org-M-RET-may-split-line nil

   ;; 关闭频繁弹出的 org-element-cache 警告 buffer 。
   ;;org-element-use-cache nil

   org-todo-keywords
   '((sequence "TODO(t!)" "DOING(d@)" "|" "DONE(D)")
     (sequence "WAITING(w@/!)" "NEXT(n!/!)" "SOMEDAY(S)" "|" "CANCELLED(c@/!)"))

   org-special-ctrl-a/e t
   org-insert-heading-respect-content t)

  ;;(add-hook 'org-mode-hook 'turn-on-auto-fill)
  (add-hook 'org-mode-hook (lambda () (display-line-numbers-mode 0))))

(global-set-key (kbd "C-c l") #'org-store-link)
(global-set-key (kbd "C-c a") #'org-agenda)
(global-set-key (kbd "C-c c") #'org-capture)
(global-set-key (kbd "C-c b") #'org-switchb)

;; 关闭 org-mode 的 C-c C-j 快捷键, 与 journal 冲突.
(define-key org-mode-map (kbd "C-c C-j") nil)
;; 关闭 org-mode 的 C-' 对应的 org-cycle-agenda-files 命令, 与 consult-register-store 冲突。
(define-key org-mode-map (kbd "C-'") nil)

;; C-c C-f 保留 Org 同级标题导航;C-c d f 格式化当前源码块。
(defun my/format-src-block ()
  "缩进当前 Org 源码块,保留编辑异常时的源码编辑 buffer。"
  (interactive)
  (unless (org-in-src-block-p)
    (user-error "光标不在 Org 源码块中"))
  (org-edit-special)
  (indent-region (point-min) (point-max))
  (org-edit-src-exit))
(define-key org-mode-map (kbd "C-c d f") #'my/format-src-block)

;; 建立 org 相关目录。
(dolist (dir '("~/docs/org" "~/docs/org/journal"))
  (unless (file-directory-p dir)
    (make-directory dir)))

配置 babel:

;; 关闭 C-c C-c 触发执行代码.
(setq org-babel-no-eval-on-ctrl-c-ctrl-c t)

;; 确认执行代码的操作。
(setq org-confirm-babel-evaluate t)

;; 使用语言的 mode 来格式化代码.
(setq org-src-fontify-natively t)

;; 使用各语言的 Major Mode 来编辑 src block。
(setq org-src-tab-acts-natively t)

;; yaml 从外部的 yaml-mode 切换到内置的 yaml-ts-mode,告诉 babel 使用该内置 mode,否则编辑 yaml src
;; block 时提示找不到 yaml-mode。
(add-to-list 'org-src-lang-modes '("yaml" . yaml-ts))
(add-to-list 'org-src-lang-modes '("cue" . cue))

(require 'org)
;; org bable 完整支持的语言列表(ob- 开头的文件):
;; https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp 对于官方不支持的语言,可以通过
;; use-pacakge 来安装。
(use-package ob-go)
(use-package ob-rust)
(org-babel-do-load-languages
 'org-babel-load-languages
 '((shell . t)
   (js . t)
   (makefile . t)
   (go . t)
   (emacs-lisp . t)
   (rust . t)
   (python . t)
   (C . t) ;; 支持 C/C++/D
   (java . t)
   (awk . t)
   (css . t)))

(use-package org-contrib)

org-mode 内容居中显示:

(use-package olivetti
  :config
  ;; 文本区域宽度,超过后自动折行。
  (setq-default olivetti-body-width 130)
  (add-hook 'org-mode-hook 'olivetti-mode))

;; fill-column 值要小于 olivetti-body-width 才能正常折行。
(setq-default fill-column 100)

;; 由于 auto-fill 可能会打乱代码的字符串和注释,故为 prog-mode/text-mode 等全局关闭 auto-fill。
;;(add-hook 'text-mode-hook 'turn-on-auto-fill)

org-modern 和 org-appear 美化:

(use-package org-modern
  :after (org)
  :config
  ;; 各种符号字体:https://github.com/rime/rime-prelude/blob/master/symbols.yaml
  ;;(setq org-modern-star '("◉" "○" "✸" "✿" "✤" "✜" "◆" "▶"))
  (setq org-modern-star '("⚀" "⚁" "⚂" "⚃" "⚄" "⚅"))
  (setq org-modern-block-fringe nil)
  (setq org-modern-block-name
        '((t . t)
          ("src" "»" "«")
          ("SRC" "»" "«")
          ("example" "»–" "–«")
          ("quote" "❝" "❞")))
  ;; 美化表格。
  (setq org-modern-table t)
  (setq org-modern-list
        '(
          (?* . "✤")
          (?+ . "▶")
          (?- . "◆")))
  (with-eval-after-load 'org (global-org-modern-mode)))

;; 显示转义字符。
(use-package org-appear
  :custom
  (org-appear-autolinks t)
  :hook (org-mode . org-appear-mode))

org-download:拖拽图片或将剪贴板中图片插入到 org-mode buffer 中:

  • 需要编译 Emacs 时指定 --with-imagemagick 参数,Emacs 使用 imagemagick 命令来实时转换图片大小。
  • 安装依赖的 pngpaste 工具: brew install pngpaste
(use-package org-download
  :config
  ;; 保存路径包含 /static/ 时, ox-hugo 在导出时保留后面的目录层次。
  (setq-default org-download-image-dir "./static/images/")
  (setq org-download-method 'directory
        org-download-display-inline-images 'posframe
        org-download-screenshot-method "pngpaste %s"
        org-download-image-attr-list '("#+ATTR_HTML: :width 400 :align center"))
  (add-hook 'dired-mode-hook 'org-download-enable)
  (org-download-enable)
  (global-set-key (kbd "<f6>") #'org-download-screenshot)
  ;; 不添加 #+DOWNLOADED: 注释。
  (setq org-download-annotate-function (lambda (link) (previous-line 1) "")))

7.1 PDF 导出
#

使用 LuaLaTeX + CTeX,代码高亮由 engrave-faces 提供,固定使用 ef-light 主题。安装、模板、写作和排错见 Org 导出 PDF 使用手册

回归命令与样张见 PDF 导出回归说明;字体或复制层变更还需在阅读器中验证选择与复制。

在 macOS 安装 TeX Live: brew install texlive 和缩放图片需要的 ImageMagick: brew install imagemagick

安装如下 PDF 使用的字体,注意 Noto 需要下载和安装静态 OTC 字体(而不能是可变字体):

字体家族 需要的字重 用途与来源
Noto Serif CJK SC Regular、Bold 中文及西文正文;Noto CJK Serif 的简体中文静态 OTF
Noto Sans CJK SC Regular、Bold 无衬线文字;Noto CJK Sans 的简体中文静态 OTF
Noto Sans Mono CJK SC Regular、Bold 中文代码与复制层;同一 Sans 下载页中的 Mono 字体
Menlo 系统常规、粗体及斜体 macOS 西文代码字体
Apple Color Emoji 系统字形 macOS emoji 回退

7.1.1 导出配置
#

使用 lualatex 编译器,需要在文档 header 指定: #+LATEX_COMPILER: lualatex#+LATEX_CLASS: ctexart (见后文的 Tempel 模板)。

;; MacTeX 使用此目录;Homebrew TeX Live 使用已有的 /opt/homebrew/bin。
(setq my-tex-path "/Library/TeX/texbin")
(when (file-directory-p my-tex-path)
  (add-to-list 'exec-path my-tex-path)
  (unless (member my-tex-path (parse-colon-path (getenv "PATH")))
    (setenv "PATH" (concat my-tex-path path-separator (getenv "PATH")))))

(defcustom my/org-pdf-style-directory (expand-file-name "~/emacs/")
  "PDF 样式和模板的加载目录;迁移时还需修改对应 :tangle 目标。"
  :type 'directory :group 'org-export-latex)

(defun my/org-pdf-preflight ()
  "检查讲义导出的工具、样式和 LuaLaTeX 字体。"
  (interactive)
  (dolist (tool '("latexmk" "lualatex" "luaotfload-tool"))
    (unless (executable-find tool)
      (user-error "缺少 %s;请安装 TeX Live 并检查 exec-path" tool)))
  (dolist (file '("mystyle.sty" "org-pdf-copy.sty" "org-pdf-copy.lua"))
    (unless (file-readable-p (expand-file-name file my/org-pdf-style-directory))
      (user-error "缺少 %s;请先 tangle dotemacs.org 的 PDF 样式" file)))
  (dolist (font '("Noto Serif CJK SC" "Noto Sans CJK SC"
                  "Noto Sans Mono CJK SC" "Menlo" "Apple Color Emoji"))
    ;; luaotfload-tool 找不到字体也可能返回 0,必须检查实际解析结果。
    (unless (with-temp-buffer
              (and (eq 0 (call-process "luaotfload-tool" nil (list t t) nil
                                      "--no-reload" (concat "--find=" font)))
                   (progn (goto-char (point-min))
                          (search-forward "Resolved file name" nil t))))
      (user-error "LuaLaTeX 找不到字体 %s;安装后运行 luaotfload-tool --update" font)))
  (message "PDF 导出预检通过"))

(defun my/org-export-pdf (&optional publish)
  "预检后导出当前讲义并打开 PDF。
带前缀参数 PUBLISH 时,Org 无法解析的内部引用立即报错。
不检查外部网址或扫描排版警告;编译日志始终保留。"
  (interactive "P")
  (require 'ox-latex)
  (unless (derived-mode-p 'org-mode) (user-error "请在 Org 文档中执行"))
  (my/org-pdf-preflight)
  (let* ((info (org-export-get-environment 'latex))
         (org-export-with-broken-links (if publish nil org-export-with-broken-links))
         (org-export-filter-options-functions
          (if publish
              (cons (lambda (options _backend)
                      (plist-put options :with-broken-links nil))
                    org-export-filter-options-functions)
            org-export-filter-options-functions)))
    (unless (and (equal (plist-get info :latex-class) "ctexart")
                 (equal (plist-get info :latex-compiler) "lualatex"))
      (user-error "讲义需要 LATEX_CLASS: ctexart 和 LATEX_COMPILER: lualatex;请插入 my-latex 模板"))
    (let ((pdf (org-latex-export-to-pdf nil nil nil nil
                 (when publish '(:with-broken-links nil)))))
      (when pdf (org-open-file pdf)))))

;; 使用各语言模式的 font-lock 高亮,不需要外部语法着色程序。
(use-package engrave-faces
  :after ox-latex
  :config
  (require 'engrave-faces-latex)
  (setq org-latex-src-block-backend 'engraved)
  ;; 代码块左侧添加行号。
  (add-to-list 'org-latex-engraved-options '("numbers" . "left"))
  ;; PDF 始终默认使用 ef-light 原始语法高亮,与编辑器主题无关。
  (setq org-latex-engraved-theme 'ef-light))

(defun my/export-pdf (backend)
  "保留 LaTeX/PDF 的三级标题,避免三级标题退化成列表项。"
  (when (org-export-derived-backend-p backend 'latex)
    (setq-local org-export-headline-levels 3)))
(add-hook 'org-export-before-processing-functions #'my/export-pdf)

;; 仅对明确标为照片/装饰图的图片生成 PDF 用 JPEG;保留原图和 HTML 导出。
;; 用法:图片前添加 #+ATTR_LATEX: :pdf-photo t :width 0.7\linewidth
;; 依赖 ImageMagick;未安装时继续使用原图。缓存可随时删除。
(defun my/org-pdf-optimize-photos (backend)
  "仅在导出副本中压缩显式标记的照片;失败时提示并保留原图。"
  (when (org-export-derived-backend-p backend 'latex)
    (let (photos)
      (org-element-map (org-element-parse-buffer) 'link
        (lambda (link)
          (when (and (equal (org-element-property :type link) "file")
                     (equal "t" (org-export-read-attribute
                                 :attr_latex (org-export-get-parent-element link) :pdf-photo)))
            (push (list (org-element-property :begin link)
                        (org-element-property :end link)
                        (org-element-property :path link)) photos))))
      (if (and photos (not (executable-find "magick")))
          (display-warning 'org-pdf "未安装 ImageMagick;照片使用原图。")
        ;; 从后向前替换,保持尚未处理链接的位置有效。
        (dolist (photo photos)
          (pcase-let* ((`(,beg ,end ,path) photo)
                       (source (expand-file-name (org-link-unescape path))))
            (condition-case err
                (let* ((attrs (file-attributes source))
                       (cache (expand-file-name ".org-pdf-images/"))
                       (key (secure-hash 'sha256
                              (format "%s:%s:%s:1800:88" (file-truename source)
                                      (file-attribute-size attrs)
                                      (file-attribute-modification-time attrs))))
                       (target (expand-file-name (concat key ".jpg") cache)))
                  (unless (file-readable-p source) (error "原图不可读"))
                  (make-directory cache t)
                  (unless (file-exists-p target)
                    (let ((tmp (make-temp-file (expand-file-name "photo-" cache) nil ".jpg")))
                      (unwind-protect
                          (if (eq 0 (call-process "magick" nil nil nil source
                                      "-auto-orient" "-resize" "1800x1800>"
                                      "-background" "white" "-alpha" "remove"
                                      "-alpha" "off" "-strip" "-quality" "88" tmp))
                              (rename-file tmp target t)
                            (error "ImageMagick 转换失败"))
                        (when (file-exists-p tmp) (delete-file tmp)))))
                  (goto-char beg)
                  (when (search-forward (concat "file:" path) end t)
                    (replace-match (concat "file:" (org-link-escape
                                                   (file-relative-name target))) t t)))
              (error (display-warning 'org-pdf
                       (format "照片 %s 未优化,保留原图:%s" path (error-message-string err)))))))))))
(add-hook 'org-export-before-parsing-functions #'my/org-pdf-optimize-photos)


;; ox- 为 org-mode 的导出后端包的惯例前缀。
(use-package ox-gfm :defer t) ;; github flavor markdown

(require 'ox-latex)
(with-eval-after-load 'ox-latex
  ;; Org 的 longtable 续页提示缺少中文翻译;仅补充中文,不改变其它语言。
  (dolist (entry '(("Continued from previous page" . "接上页")
                   ("Continued on next page" . "续下页")))
    (let ((translations (assoc (car entry) org-export-dictionary)))
      (dolist (language '("zh" "zh-CN" "zh-TW"))
        (setf (alist-get language (cdr translations) nil nil #'equal)
              (list :default (cdr entry))))))
  ;; latex image 的默认宽度, 可以通过 #+ATTR_LATEX :width xx 配置。
  (setq org-latex-image-default-width "0.7\\linewidth")
  ;; 默认使用 booktabs 横线;隔行底色通过 DocumentTable 环境按需启用。
  (setq org-latex-tables-booktabs t)
  ;; 保留日志,便于检查缺字、溢出、未解析引用等编译警告。
  (setq org-latex-remove-logfiles nil)
  ;; 模板指定 LuaLaTeX;engraved 和当前字体回退不需要 unrestricted shell escape。
  ;; %o/%f 的 shell 引用由 Org 负责,支持带空格的导出路径。
  (setq org-latex-pdf-process
	'("latexmk -%latex -interaction=nonstopmode -halt-on-error -output-directory=%o %f"))
  (add-to-list 'org-latex-classes
	       '("ctexart"
                 "\\documentclass[11pt,a4paper,table,fontset=none]{ctexart}
                    [NO-DEFAULT-PACKAGES]
                    [PACKAGES]
                    [EXTRA]"
                 ("\\section{%s}" . "\\section*{%s}")
                 ("\\subsection{%s}" . "\\subsection*{%s}")
                 ("\\subsubsection{%s}" . "\\subsubsection*{%s}")
                 ("\\paragraph{%s}" . "\\paragraph*{%s}")
                 ("\\subparagraph{%s}" . "\\subparagraph*{%s}"))))

;; org export html 时需要 htmlize.el 包来格式化代码。
(use-package htmlize)

7.1.2 LaTeX 样式
#

样式控制字体、版心、图表和代码框;高亮颜色由 Org 提供。

需要在文档 header 指定使用该样式: #+LATEX_HEADER: \usepackage{/Users/alizj/emacs/mystyle} (见后文的 Tempel 模板)。

% 技术讲义样式。支持中英文混排、emoji 字符、代码块原样复制和长行复制。
\usepackage{iftex}
\RequireLuaTeX
\usepackage{xcolor}
\definecolor{winered}{rgb}{0.5,0,0}
\definecolor{lightgrey}{gray}{0.96}
\definecolor{tableheadcolor}{gray}{0.92}
\definecolor{commentcolor}{RGB}{0,100,0}
\definecolor{frenchplum}{RGB}{190,20,83}
\definecolor{documentlink}{HTML}{245A81}

\usepackage{fontspec}
% 定义 emoji fallback 字体指令:appleemoji
\directlua{
  luaotfload.add_fallback("appleemoji", {
    "Apple Color Emoji:mode=harf;script=DFLT;"
  })
}

% 设置英文字体
% Noto CJK 字体没有原生西文斜体:明确使用合成倾斜,避免隐式字形替代。
% Noto CJK 也没有 emoji 字体,需要 fallback 到 appleemoji。
\setmainfont{Noto Serif CJK SC}[
  ItalicFont=*, ItalicFeatures={FakeSlant=0.15},
  SlantedFont=*, SlantedFeatures={FakeSlant=0.15},
  BoldItalicFont=* Bold, BoldItalicFeatures={FakeSlant=0.15},
  BoldSlantedFont=* Bold, BoldSlantedFeatures={FakeSlant=0.15},
  RawFeature={fallback=appleemoji}
]
\setsansfont{Noto Sans CJK SC}[
  Scale=MatchLowercase,
  ItalicFont=*, ItalicFeatures={FakeSlant=0.15},
  SlantedFont=*, SlantedFeatures={FakeSlant=0.15},
  BoldItalicFont=* Bold, BoldItalicFeatures={FakeSlant=0.15},
  BoldSlantedFont=* Bold, BoldSlantedFeatures={FakeSlant=0.15},
  RawFeature={fallback=appleemoji}
]
% 英文 mono(如格式化代码块场景)使用苹果的 Menlo 字体。
\setmonofont{Menlo}[Scale=MatchLowercase,RawFeature={fallback=appleemoji}]

% 设置中文字体。
% 中文强调使用粗体,不伪造中文斜体。
\setCJKmainfont{Noto Serif CJK SC}[ItalicFont=* Bold,SlantedFont=* Bold]
\setCJKsansfont{Noto Sans CJK SC}[ItalicFont=* Bold,SlantedFont=* Bold]
\setCJKmonofont{Noto Sans Mono CJK SC}[Scale=0.9,ItalicFont=*,SlantedFont=*]
\ltjdefcharrange{100}{"2600-"27BF,"1F000-"1FAFF,"FE0E-"FE0F,"200D}
\ltjsetparameter{jacharrange={-100}}

\usepackage[left=18mm,right=18mm,top=22mm,bottom=22mm,headheight=15pt]{geometry}
\usepackage{parskip}
\setlength{\parskip}{0.5em plus 0.1em minus 0.1em}
\setlength{\parindent}{0pt}
\linespread{1.1}
\clubpenalty=10000
\widowpenalty=10000
\displaywidowpenalty=10000
\setlength{\emergencystretch}{1.5em}
\usepackage{etoolbox}
\usepackage{needspace}
% 普通节连续排版;需要另起页的大篇章在正文显式调用 \DocumentPartBreak。
\newcommand{\DocumentPartBreak}{\clearpage}
\newcommand{\DocumentContents}{\begingroup\setlength{\parskip}{0pt}\linespread{1}\selectfont\tableofcontents\endgroup}
\ctexset{
  section={format=\normalfont\Large\bfseries,beforeskip=1.2ex plus .3ex,afterskip=.8ex},
  subsection={format=\normalfont\large\bfseries,beforeskip=1ex plus .2ex,afterskip=.6ex},
  subsubsection={format=\normalfont\normalsize\bfseries,beforeskip=.8ex,afterskip=.4ex}
}
\AddToHook{cmd/section/before}{\Needspace{5\baselineskip}}
\AddToHook{cmd/subsection/before}{\Needspace{4\baselineskip}}
\AddToHook{cmd/subsubsection/before}{\Needspace{4\baselineskip}}

\usepackage{hyperref}
\usepackage{xurl}
\hypersetup{pdfborder={0 0 0},colorlinks=true,
  bookmarksopen=false,bookmarksnumbered=true,
  linkcolor=black,urlcolor=documentlink,filecolor=documentlink,
  citecolor=documentlink,linktoc=all}
\urlstyle{same}
\newcommand\email[1]{\href{mailto:#1}{\nolinkurl{#1}}}
\usepackage{graphicx}
\usepackage{wallpaper} % 兼容已有文档的封面命令。
\graphicspath{{image/}{figure/}{fig/}{img/}{images/}}
\usepackage{titling}
\makeatletter
% title:nil 仅抑制 Org 自动封面,不应清空 PDF 的标题元数据。
% Org 把副标题格式合入 \@title;元数据中将其转为空格,避免 hyperref 警告。
\pdfstringdefDisableCommands{\def\\{ }\def\medskip{}\def\large{}}
\AtBeginDocument{\hypersetup{pdftitle={\@title},pdfauthor={\@author},
  pdfcreator={Org mode / LuaLaTeX}}}
\newcommand{\MakeDocumentCover}[1]{%
  \begin{titlepage}\thispagestyle{empty}\centering
  \vspace*{0.10\textheight}
  {\fontsize{24}{34}\selectfont\bfseries\@title\par}
  \vspace{1.5em}
  {\normalsize\@author\par\vspace{0.5em}\@date\par}
  \ifstrempty{#1}{}{\vspace{3em}%
    \includegraphics[width=0.72\linewidth,height=0.35\textheight,keepaspectratio]{#1}\par}
  \vfill
  \end{titlepage}}
\makeatother

\usepackage{rotfloat}
\usepackage{tabularx}
\usepackage{longtable} % 跨页表格;tabularx 本身不支持跨页。
\usepackage{wrapfig} % Org :float wrap 图片。
\usepackage{booktabs}
% 隔行底色只在显式选择的表格分组内生效,不污染标题内部的 tabular。
% 用法:\begin{DocumentTable} ... tabular/tabularx ... \end{DocumentTable}
\newenvironment{DocumentTable}{\begingroup\rowcolors{2}{lightgrey}{white}}{\endgroup}
\renewcommand{\arraystretch}{1.15}
\usepackage{calc}
\usepackage{amsthm}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{multicol}
\usepackage{multirow}
\usepackage{fancyvrb}
\usepackage{abstract}
\renewcommand{\abstractnamefont}{\normalfont\Large\bfseries}
\renewcommand{\abstracttextfont}{\normalfont\normalsize}
\setlength{\abstitleskip}{0.8em}
\usepackage{hologo}
\usepackage[font=small,labelfont=bf]{caption}
\captionsetup{skip=6pt,hypcap=false}
\usepackage[normalem]{ulem}
\usepackage[shortlabels,inline]{enumitem}
\setlist{topsep=0.4em,itemsep=0.2em,parsep=0.1em,
  beginpenalty=10000,midpenalty=2000}
\setlist[itemize]{label=$\bullet$}

% Org 在样式包之后声明 Code;这里只调整代码框,保留主题的高亮宏。
\makeatletter
\AtBeginDocument{%
  \@ifundefined{Code}{}{%
    % 默认在空格和标点处折行,但是对于 URL 和路径,可能不满足该要求,导致不会被折行而显示溢出。
    % 解决办法:设置在空格、/ 和 - 字符处折行。
    \fvset{breaklines=true,breakanywhere=false,breakafter={/-},breaknonspaceingroup=true}%
    \RenewTColorBox{Code}{o}{%
      colback=EfD,colframe=EfD!80!EFD,colupper=EFD,
      fontupper=\fontsize{9.5}{12}\selectfont\setlength{\fboxsep}{0pt},
      boxsep=2pt,arc=2pt,boxrule=0.4pt,left=4pt,right=4pt,top=3pt,bottom=3pt,
      before skip=0.6em,after skip=0.6em,breakable,lines before break=5,
      title after break={代码(续)},fonttitle=\normalfont\small,
      coltitle=EFD,colbacktitle=EfD!90!EFD,
      IfNoValueTF={#1}{}{boxrule=0pt,leftrule=1pt}}
  }%
}
\makeatother

% 从 mystle.sty 所在目录加载自定义的 org-pdf-copy package。
% org-pdf-copy 提供代码块复制能力:
% 1. 不复制行号;
% 2. 保留代码的缩进和换行;
% 3. 支持长行复制(视觉显示换行,但是拷贝后是物理单行);
% 文档可在加载样式前用 \newcommand{\OrgPDFCopyDisabled}{} 关闭复制特性。
\makeatletter
\@ifundefined{OrgPDFCopyDisabled}{\RequirePackage{\@currpath org-pdf-copy}}{}
\makeatother

7.1.3 代码复制层
#

代码复制层包含 org-pdf-copy.styorg-pdf-copy.lua ,它们由 mystyle.sty 自动加载(位于同一个目录下)。

自定义的 org-pdf-copy package 提供代码块原样复制能力:

  1. 不复制行号;
  2. 保留代码的缩进和换行;
  3. 支持长行复制(视觉显示换行, 但拷贝后是物理单行);

\NeedsTeXFormat{LaTeX2e}
\ProvidesPackage{org-pdf-copy}[2026/09/12 Copyable numbered code]
% Load from mystyle.sty; no changes to Org source blocks are required.
\RequirePackage{iftex}
\RequireLuaTeX
\RequirePackage{fvextra}
\RequirePackage{xcolor}
\directlua{orgpdfcopy=dofile("\luaescapestring{\@currpath}org-pdf-copy.lua")}
\RequirePackage{fontspec}
\newfontfamily\OrgPDFCopyAnchorFont{Noto Sans Mono CJK SC}[Scale=0.7]
\begingroup\OrgPDFCopyAnchorFont
\directlua{orgpdfcopy.anchorfont=font.current()}
\endgroup
\newcount\OrgPDFCopyBlock
\AddToHook{env/Verbatim/before}{\global\advance\OrgPDFCopyBlock by 1 }
\AtBeginDocument{%
  \fvset{breaksymbolleft={\hbox{\directlua{orgpdfcopy.arrow()}}},breaksymbolright={},breakanywheresymbolpre={},breakbeforesymbolpre={},breakaftersymbolpre={}}%
  \let\OrgPDFCopyFormatText\FancyVerbFormatText
  \renewcommand\FancyVerbFormatText[1]{%
    \begingroup
    \directlua{tex.setattribute(orgpdfcopy.block_attr,\number\OrgPDFCopyBlock)
      tex.setattribute(orgpdfcopy.line_attr,\number\c@FancyVerbLine)}%
    \directlua{orgpdfcopy.begin_line()}%
    \let\OrgPDFCopySpace\FV@Space
    % Flush TeX's pending character before writing a marker (notably after >).
    \def\FV@Space{\relax\directlua{orgpdfcopy.space()}\OrgPDFCopySpace}%
    \let\OrgPDFCopyTab\FV@Tab
    \def\FV@Tab{\relax\directlua{orgpdfcopy.space(true)}\OrgPDFCopyTab}%
    \OrgPDFCopyFormatText{#1}%
    \endgroup}%
  \renewcommand\theFancyVerbLine{%
    \footnotesize\color{black!40!white}%
    \directlua{orgpdfcopy.number("\arabic{FancyVerbLine}")}}%
}
\AddToHook{shipout/before}{\directlua{orgpdfcopy.shipout(tex.box[\number\ShipoutBox])}}
\endinput

-- Copyable code for Org's engraved/fvextra backend (LuaLaTeX).
-- Only attributed code glyphs are changed, after shaping and page breaking.
-- Preview ignores ActualText, so whitespace is carried by private ToUnicode
-- mappings. Original fonts and ordinary document text are never mutated.
local M = {}
local glyph_id, hlist_id, vlist_id = node.id('glyph'), node.id('hlist'), node.id('vlist')
local marker_id = luatexbase.new_whatsit('orgpdfcopy.space')
M.block_attr = luatexbase.new_attribute('orgpdfcopy.block')
M.line_attr = luatexbase.new_attribute('orgpdfcopy.line')
M.unit_attr = luatexbase.new_attribute('orgpdfcopy.spacewidth')
local copies, shapes, states, blocks = {}, {}, {}, {}
local serial = 0
local generatedfonts = {}
local sources = {}
local outline
local bitmapfonts = {}
local function linekey(b,l) return b .. ":" .. l end
local function shallow(t)
  local c = {}; for k, v in pairs(t) do c[k] = v end; return c
end
local function hex(cp)
  if cp < 0x10000 then return string.format('%04X', cp) end
  cp = cp - 0x10000
  return string.format('%04X%04X', 0xD800 + (cp // 1024), 0xDC00 + (cp % 1024))
end
local function prefix_glyph(n, prefix, suffix)
  suffix = suffix or ''
  if prefix == '' and suffix == '' then return end
  local key = n.font .. ':' .. n.char .. ':' .. prefix .. ':' .. suffix
  local fid = copies[key]
  if not fid then
    local old = generatedfonts[n.font] or font.getfont(n.font)
    assert(old, 'org-pdf-copy: missing source font ' .. tostring(n.font))
    local char = old.characters[n.char]
    if not char or char.commands then return end
    local f, c = shallow(old), shallow(char)
    c.tounicode = prefix .. (type(c.tounicode) == 'string' and c.tounicode or hex(n.char)) .. suffix
    -- A sparse, uniquely named font prevents mappings for different indentation
    -- levels from being merged. Keep generated tables: getfont() can return nil
    -- for a newly defined font before the PDF backend has initialized it.
    f.characters = {[n.char] = c}
    -- LuaTeX derives fallback ascent/cap height/descent/stem width from h/H/y/.
    -- Keep those metrics even when their glyphs are not drawn by this font.
    for _, cp in ipairs({72, 104, 121, 46}) do
      if cp ~= n.char and old.characters[cp] then
        f.characters[cp] = shallow(old.characters[cp])
      end
    end
    serial = serial + 1
    f.name = 'OrgCodeCopy' .. serial
    f.fullname, f.psname = f.name, f.name
    f.shared, f.resources, f.descriptions, f.hb, f.properties = nil, nil, nil, nil, nil
    f.specification, f.unscaled = nil, nil
    fid = font.define(f)
    generatedfonts[fid] = f
    copies[key] = fid
  end
  n.font = fid
end
function M.space(tab)
  -- Marker values: 0 = source-line start, 1 = space, -1 = rendered tab.
  local n = node.new('whatsit', 'user_defined')
  n.user_id, n.type, n.value = marker_id, 100, tab and -1 or 1
  n.attr = node.current_attr()
  node.set_attribute(n, M.unit_attr, math.floor(font.getfont(font.current()).parameters.space + 0.5))
  node.write(n)
end
function M.begin_line()
  local n = node.new('whatsit', 'user_defined')
  n.user_id, n.type, n.value = marker_id, 100, 0
  n.attr = node.current_attr()
  node.write(n)
end
local function state(n)
  local b, l = node.has_attribute(n, M.block_attr), node.has_attribute(n, M.line_attr)
  if not b or not l or b < 1 then return end
  local key = b .. ':' .. l
  if not states[key] then states[key] = {spaces = 0, started = false} end
  return states[key], b, l
end
local function unicodechar(n)
 local f=font.getfont(n.font)
 local c=f.characters[n.char]
 local h=c and c.tounicode
 if type(h) ~= 'string' then return utf8.char(n.char) end
 local out={};local high
 for chunk in h:gmatch('%x%x%x%x') do
  local cp=tonumber(chunk,16)
  if cp>=0xD800 and cp<=0xDBFF then high=cp
  elseif cp>=0xDC00 and cp<=0xDFFF and high then
   out[#out+1]=utf8.char(0x10000+(high-0xD800)*1024+cp-0xDC00);high=nil
  else out[#out+1]=utf8.char(cp) end
 end
 return table.concat(out)
end
local function capture(head, parts)
 for n in node.traverse(head) do
  local st,b,l=state(n)
  if st then
   local k=linekey(b,l);parts[k]=parts[k] or {}
   if n.id==glyph_id then parts[k][#parts[k]+1]=unicodechar(n)
   elseif n.id==node.id('whatsit') and n.subtype==node.subtype('user_defined') and n.user_id==marker_id then
    if n.value==1 then parts[k][#parts[k]+1]=' '
    elseif n.value==-1 then parts[k][#parts[k]+1]='\t' end
   end
  end
  if (n.id==hlist_id or n.id==vlist_id) and n.head then capture(n.head,parts)
  elseif n.id==node.id('disc') and n.replace then capture(n.replace,parts) end
 end
end
luatexbase.add_to_callback('pre_linebreak_filter', function(head)
 local parts={};capture(head,parts)
 for k,p in pairs(parts) do sources[k]=table.concat(p) end
 return head
end, 'orgpdfcopy.capture')
local function countrows(head,row)
 for n in node.traverse(head) do
  if n.id==hlist_id or n.id==vlist_id then
   local childrow=n.id==hlist_id and n.subtype==1 and n or row
   if n.head then countrows(n.head,childrow) end
  elseif n.id==glyph_id then
   local st,b,l=state(n)
   if st and row then
    st.rows=st.rows or {};st.rows[row]=true
    local count=0;for _ in pairs(st.rows) do count=count+1 end
    if count>1 then st.wraptext=sources[linekey(b,l)] end
   end
  end
 end
end
local function literal(data)
 local n=node.new('whatsit','pdf_literal');n.mode=0;n.data=data;return n
end
-- Short mappings keep horizontal font stretching within LuaTeX's limits.
-- Keep surrogate pairs together and avoid whitespace-only chunks, which
-- PDFKit may discard (in particular the source line's indentation).
local function chunks(text)
 local result,part,units={}, {}, 0
 for _,cp in utf8.codes(text) do
  local size=cp>0xFFFF and 2 or 1
  if units+size>6 and table.concat(part):match("%S") then result[#result+1]=table.concat(part);part={};units=0 end
  part[#part+1]=utf8.char(cp);units=units+size
 end
 if #part>0 then result[#result+1]=table.concat(part) end
 return result
end
-- luaotfload paints bitmap emoji with image commands plus a searchable dummy
-- glyph. Keep the image and omit the duplicate text in the display layer.
local function bitmaponly(n)
 local f=font.getfont(n.font);local c=f.characters[n.char]
 if not c.commands then return false end
 local image=false
 for _,cmd in ipairs(c.commands) do if cmd[1]=='image' then image=true end end
 if not image then return false end
 local key=n.font..':'..n.char
 local fid=bitmapfonts[key]
 if not fid then
  local commands={}
  for _,cmd in ipairs(c.commands) do
   if cmd[1]~='char' and cmd[1]~='slot' then commands[#commands+1]=cmd end
  end
  local ch=shallow(c);ch.commands=commands
  serial=serial+1
  fid=font.define{type='virtual',name='OrgCopyImage'..serial,size=f.size,
    parameters=f.parameters,characters={[n.char]=ch},fonts={}}
  bitmapfonts[key]=fid
 end
 n.font=fid;return true
end
-- Reuse Noto's full-block glyph (U+2588) as invisible copy geometry.
-- Its ink fills the advance width, avoiding inferred spaces between chunks.
-- Separate font instances give the same glyph different Unicode mappings.
local function wrapanchors(n,parts,width)
 local old=font.getfont(M.anchorfont)
 local step=math.floor(width/#parts)
 local result={}
 for _,part in ipairs(parts) do
  local f=shallow(old);local c=shallow(old.characters[0x2588])
  c.tounicode='';for _,u in utf8.codes(part) do c.tounicode=c.tounicode..hex(u) end
  f.characters={[0x2588]=c}
  for _,cp in ipairs({72,104,121,46}) do f.characters[cp]=shallow(old.characters[cp]) end
  f.extend=math.ceil(step/c.width*1000)
  serial=serial+1;f.name='OrgCopyLine'..serial;f.fullname=f.name;f.psname=f.name
  f.shared,f.resources,f.descriptions,f.hb,f.properties=nil,nil,nil,nil,nil
  f.specification,f.unscaled=nil,nil
  local g=node.new('glyph');g.font=font.define(f);g.char=0x2588;result[#result+1]=g
 end
 return result
end

local function walk(head, x, parent, row)
  x = x or 0
  local n=head
  while n do
    local nextnode=n.next
    local originalwidth=n.width
    local removed=false
    if n.id == hlist_id or n.id == vlist_id then
      local childx = x + (parent and parent.id == vlist_id and n.shift or 0)
      local childrow = n.id == hlist_id and n.subtype == 1 and n or row
      if n.head then n.head=walk(n.head, childx, n, childrow) end
    elseif n.id == node.id('disc') then
      if n.replace then n.replace=walk(n.replace, x, parent, row) end
    elseif n.id == node.id('whatsit') and n.subtype == node.subtype('user_defined') and n.user_id == marker_id then
      local s,b,l = state(n)
      if s then
        if n.value == 0 then
          s.origin = s.origin or x
          local block = blocks[b] or {blank = 0}
          if block.line ~= l then
            if block.previous and not block.previous.started then block.blank = block.blank + 1 end
            block.line, block.previous = l, s
          end
          blocks[b] = block
        else
          if n.value < 0 then s.tab = true else s.spaces = s.spaces + n.value end
          s.unit = node.has_attribute(n, M.unit_attr) or s.unit
        end
      end
    elseif n.id == glyph_id then
      local s,b = state(n)
      if s then
        local block = blocks[b]
        local blanks = not s.started and block and block.blank or 0
        if s.wraptext then
          if not s.started then
            local prefix=string.rep('\n',blanks)
            local width=(row and row.width or 200*65536)-(x-(s.origin or x))
            -- Do not wrap these text nodes in q/Q: that would invalidate
            -- LuaTeX's cached text matrix and misplace subsequent contents.
            head=node.insert_before(head,n,literal('3 Tr'))
            local parts=chunks(prefix..s.wraptext)
            local usedwidth=0
            for _,anchor in ipairs(wrapanchors(n,parts,width)) do
              head=node.insert_before(head,n,anchor);usedwidth=usedwidth+anchor.width
              local k=node.new('kern');k.kern=math.floor(width/#parts)-anchor.width
              head=node.insert_before(head,n,k);usedwidth=usedwidth+k.kern
            end
            head=node.insert_before(head,n,literal('0 Tr'))
            local k=node.new('kern');k.kern=-usedwidth;head=node.insert_before(head,n,k)
          end
          if not bitmaponly(n) then
            local path,scale=outline(n.font,n.char)
            head=node.insert_before(head,n,literal(string.format('q %.10f 0 0 %.10f 0 0 cm\n%s\nf Q',scale,scale,path)))
            local k=node.new('kern');k.kern=n.width;head=node.insert_before(head,n,k)
            head=node.remove(head,n);removed=true
          end
          if block then block.blank=0 end
          s.started=true;s.spaces=0;s.row=row
        else
        if s.tab then
          s.spaces = math.max(0, math.floor((x - (s.last_end or s.origin)) / s.unit + 0.5))
          s.tab = false
        end
        -- A soft-wrapped line copies as displayed, including its visual indent.
        -- It is intentionally not reconstructed as a single source-code line.
        local continuation = s.started and row ~= s.row
        if continuation then
          local unit = s.unit or font.getfont(n.font).parameters.space
          local indent = math.max(0, math.floor((x - s.origin) / unit + 0.5))
          prefix_glyph(n, string.rep('0020', indent))
        elseif s.started and s.lastglyph then
          -- A suffix prevents PDFKit from synthesizing an additional gap before
          -- the next glyph. Leading whitespace instead belongs to that glyph.
          -- PDFKit reconstructs ordinary ASCII single spaces. Mapping those
          -- too creates unnecessary font runs and can strand punctuation.
          -- CJK gaps and repeated spaces still need explicit Unicode mappings.
          if s.spaces > 1 or (s.spaces > 0 and s.lastglyph.char > 127) then prefix_glyph(s.lastglyph, '', string.rep('0020', s.spaces)) end
        else
          prefix_glyph(n, string.rep('000A', blanks) .. string.rep('0020', s.spaces))
        end
        if block then block.blank = 0 end
        s.spaces = 0
        s.started = true
        s.lastglyph = n
        s.last_end = x + n.width
        s.row = row
        end
      end
    end
    if parent and parent.id == hlist_id then
      if n.id == node.id('glue') then x = x + node.effective_glue(n, parent)
      elseif n.id == node.id('kern') then x = x + n.kern
      elseif n.id == node.id('math') then x = x + n.surround
      elseif originalwidth and originalwidth > -1000000000 then x = x + originalwidth end
    end
    if removed then node.free(n) end
    n=nextnode
  end
  return head
end
function M.shipout(box)
  if box and box.head then countrows(box.head,nil);box.head=walk(box.head, 0, box, nil) end
  -- The engine frees shipped nodes; never retain their pointers across pages.
  for _,s in pairs(states) do s.lastglyph, s.row, s.rows = nil, nil, nil end
end
function M.arrow()
  local literal = node.new('whatsit', 'pdf_literal')
  literal.mode = 0
  literal.data = 'q 0.65 G 0.4 w 0 3 m 0 0 l 5 0 l 3.5 1.5 m 5 0 l 3.5 -1.5 l S Q'
  node.write(literal)
  local k = node.new('kern'); k.kern = math.floor(6 * 65536 * 72.27 / 72); node.write(k)
end
outline = function(fid, ch)
  -- Preserve the existing number font using its actual vector outlines.
  local f = font.getfont(fid)
  assert(f.filename, 'no outline file: '..tostring(f.name)..' char '..ch)
  local key = f.filename .. ':' .. tostring(f.subfont)
  local data = shapes[key]
  if not data then
    data = fonts.handlers.otf.readers.loadshapes(f.filename, f.subfont)
    shapes[key] = data
  end
  local glyph = data.glyphs[f.characters[ch].index]
  assert(glyph and glyph.segments, 'org-pdf-copy: font has no outlines for character '..ch)
  local ops, x, y = {}, 0, 0
  for _, s in ipairs(glyph.segments) do
    local op = s[#s]
    if op == 'm' or op == 'l' then
      ops[#ops+1] = string.format('%g %g %s', s[1], s[2], op)
      x, y = s[1], s[2]
    elseif op == 'c' then
      ops[#ops+1] = string.format('%g %g %g %g %g %g c', table.unpack(s,1,6))
      x, y = s[5], s[6]
    elseif op == 'q' then
      ops[#ops+1] = string.format('%g %g %g %g %g %g c', x+2*(s[1]-x)/3, y+2*(s[2]-y)/3,
        s[3]+2*(s[1]-s[3])/3, s[4]+2*(s[2]-s[4])/3, s[3], s[4])
      x, y = s[3], s[4]
    end
  end
  return table.concat(ops, '\n'), f.size / data.units / (65536 * 72.27 / 72), f.characters[ch]
end
function M.number(s)
  for _, ch in utf8.codes(s) do
    local path, scale, c = outline(font.current(), ch)
    local literal = node.new('whatsit', 'pdf_literal')
    literal.mode = 0
    literal.data = string.format('q %.10f 0 0 %.10f 0 0 cm\n%s\nf Q', scale, scale, path)
    node.write(literal)
    local k = node.new('kern'); k.kern = c.width; node.write(k)
  end
end
return M

7.1.4 文档模板
#

为了方便为 org-mode 文档添加导出 pdf 的 latex header,如 #+LATEX_COMPILER: lualatex#+LATEX_CLASS: ctexart=, =#+LATEX_HEADER: \usepackage{/Users/alizj/emacs/mystyle} 等,这里定义两个 Tempel 模板(保存到 ~/emacs/templates-pdf 文件):

  1. my-latex :插入长技术文档 pdf 配置(如包含封面图片、摘要、目录);
  2. my-latex-short :插入短技术文档 pdf 配置;

org-mode

(my-latex "#+TITLE: " (p "标题") n
 "#+SUBTITLE: " (p "副标题(可留空)") n
 "#+AUTHOR: 张俊(zj@opsnull.com)" n
 "#+DATE: " (format-time-string "%Y-%m-%d") n
 "#+LANGUAGE: zh-CN
#+OPTIONS: prop:nil title:nil H:3 num:3 toc:nil tex:t ^:nil
#+LATEX_COMPILER: lualatex
#+LATEX_CLASS: ctexart
"
 (format "#+LATEX_HEADER: \\usepackage{%s}\n"
         (file-name-sans-extension (expand-file-name "mystyle.sty" my/org-pdf-style-directory)))
 "
#+begin_export latex
% 空参数省略封面图,也可填入图片路径。Org 默认将 SUBTITLE 合入标题。
\\hypersetup{pageanchor=false}
\\MakeDocumentCover{}
\\pagenumbering{roman}
\\hypersetup{pageanchor=true}
\\begin{abstract}
这是一个摘要。
\\end{abstract}
\\clearpage
% 编号深度由 num 控制;目录深度单独控制。
\\setcounter{tocdepth}{2}
\\DocumentContents
\\clearpage
\\pagenumbering{arabic}
#+end_export

"
 "代码复制:折行代码请从逻辑行的第一显示行开始选择,续行或局部选择可能不完整;完整示例见 [["
 (p "配套源码 URL(发布前填写)") "][配套源码]]。" n n q)


(my-latex-short "#+TITLE: " (p "标题") n
 "#+AUTHOR: 张俊(zj@opsnull.com)" n
 "#+DATE: " (format-time-string "%Y-%m-%d") n
 "#+LANGUAGE: zh-CN
#+OPTIONS: prop:nil title:t H:3 num:3 toc:nil tex:t ^:nil
#+LATEX_COMPILER: lualatex
#+LATEX_CLASS: ctexart
"
 (format "#+LATEX_HEADER: \\usepackage{%s}\n"
         (file-name-sans-extension (expand-file-name "mystyle.sty" my/org-pdf-style-directory)))
 n
 "代码复制:折行代码请从逻辑行的第一显示行开始选择,续行或局部选择可能不完整;完整示例见 [["
 (p "配套源码 URL(发布前填写)") "][配套源码]]。" n n q)

7.2 幻灯片与其它导出
#

slide 演示:org-tree-slide 不再活跃维护了,dslide 是它的的替代品。

(use-package dslide
  :vc(:url "https://github.com/positron-solutions/dslide.git")
  :hook
  ((dslide-start
    .
    (lambda ()
      (org-fold-hide-block-all)
      (setq-default x-stretch-cursor -1)
      (redraw-display)
      (blink-cursor-mode -1)
      (setq cursor-type 'bar)
      ;;(hl-line-mode -1)
      (text-scale-increase 2)
      (read-only-mode 1)))
   (dslide-stop
    .
    (lambda ()
      (blink-cursor-mode +1)
      (setq-default x-stretch-cursor t)
      (setq cursor-type t)
      (text-scale-increase 0)
      ;;(hl-line-mode 1)
      (read-only-mode -1))))
  :config
  ;; 隐藏整行区域时,首个可见行仍需使用它自己的 Org 缩进。
  ;; 不切换 org-indent-mode:它会在间接缓冲区重启 font-lock 并导致着色失效。
  (defun my/dslide-preserve-indentation (overlay)
    "Preserve the next visible line's indentation after a hidden OVERLAY."
    (when (and (bound-and-true-p org-indent-mode)
               (equal (overlay-get overlay 'display) "")
               (save-excursion (goto-char (overlay-end overlay)) (bolp)))
      (dolist (property '(line-prefix wrap-prefix))
        (overlay-put overlay property
                     (get-text-property (overlay-end overlay) property))))
    overlay)
  (advice-add 'dslide-hide-region :filter-return #'my/dslide-preserve-indentation)

  ;; 演示时只展示代码而不提示执行 babel 代码块。
  (setq dslide-default-actions
        (remq 'dslide-action-babel dslide-default-actions))

  ;; 默认的 U+1F892 箭头在当前字体中缺字。
  (setq dslide-breadcrumb-separator " > ")
  (setq dslide-margin-content 0.9)
  (setq dslide-animation-duration 0.5)
  (setq dslide-margin-title-above 0.3)
  (setq dslide-margin-title-below 0.3)
  (setq dslide-header-email nil)
  (setq dslide-header-date nil)
  (define-key org-mode-map (kbd "<f8>") #'dslide-deck-start)
  (define-key dslide-mode-map (kbd "<f9>") #'dslide-deck-stop))

journal 日记:

(use-package org-journal
  :commands org-journal-new-entry
  :bind (("C-c j" . org-journal-new-entry))
  :init
  (setq org-journal-prefix-key "C-c j")
  (defun org-journal-save-entry-and-exit()
    (interactive)
    (save-buffer)
    (kill-buffer-and-window))
  :config
  (define-key org-journal-mode-map (kbd "C-c C-e") #'org-journal-save-entry-and-exit)
  (define-key org-journal-mode-map (kbd "C-c C-j") #'org-journal-new-entry)
  (global-set-key (kbd "C-c C-j") #'org-journal-new-entry)

  ;; 设置日志文件头。
  (defun org-journal-file-header-func (time)
    "Custom function to create journal header."
    (concat
     (pcase org-journal-file-type
       (`daily "#+TITLE: Daily Journal\n#+STARTUP: showeverything")
       (`weekly "#+TITLE: Weekly Journal\n#+STARTUP: folded")
       (`monthly "#+TITLE: Monthly Journal\n#+STARTUP: folded")
       (`yearly "#+TITLE: Yearly Journal\n#+STARTUP: folded"))))
  (setq org-journal-file-header 'org-journal-file-header-func)
  (setq org-journal-file-type 'daily) ;; 按天记录。

  (setq org-journal-dir "~/docs/org/journal")
  (setq org-journal-find-file 'find-file)

  ;; 加密日记文件。
  (setq org-journal-enable-encryption t)
  (setq org-journal-encrypt-journal t)
  (defun my-old-carryover (old_carryover)
    (save-excursion
      (let ((matcher (cdr (org-make-tags-matcher org-journal-carryover-items))))
	(dolist (entry (reverse old_carryover))
          (save-restriction
            (narrow-to-region (car entry) (cadr entry))
            (goto-char (point-min))
            (org-scan-tags '(lambda ()
                              (org-set-tags ":carried:"))
                           matcher org--matcher-tags-todo-only))))))
  (setq org-journal-handle-old-carryover 'my-old-carryover))

创建一个 templ 模板,便于在文件开头添加内容,可避免每次打开时提示选择 GPG key:

;; 插入自己的 GnuPG 加密 key。
(my-gpg "# -*- mode:org; epa-file-encrypt-to: (\"geekard@qq.com\") -*-")

ox-hugo 博客:

(use-package ox-hugo
  :demand
  :config
  (setq org-hugo-base-dir (expand-file-name "~/blog/blog.opsnull.com/"))
  (setq org-hugo-section "posts")
  (setq org-hugo-front-matter-format "yaml")
  (setq org-hugo-export-with-section-numbers t)
  (setq org-export-backends '(go md gfm html latex man hugo))
  (setq org-export-with-properties nil) ;; 不导出 section 下的 property,如 ID。
  (setq org-hugo-auto-set-lastmod t))

8 编程开发
#

8.1 缩进
#

缩进层次可视化:

(use-package indent-bars
  :vc (:url "https://github.com/jdtsmith/indent-bars")
  :config
  (require 'indent-bars-ts)
  :custom
  (indent-bars-treesit-support t)
  (indent-bars-treesit-ignore-blank-lines-types '("module"))
  (indent-bars-treesit-scope
   '((python
      function_definition
      class_definition
      for_statement
      if_statement
      with_statement
      while_statement)))
  :hook
  ((python-base-mode
    yaml-ts-mode
    json-ts-mode
    js-ts-mode) . indent-bars-mode))

缩进风格:c/c++/go-mode/kernel 均使用 tab 缩进:

;;(setq indent-tabs-mode t)
(setq c-ts-mode-indent-offset 8)
(setq c-ts-common-indent-offset 8)
(setq c-basic-offset 8)
;; kernel 风格:table 和 offset 都是 tab 缩进,而且都是 8 字符。
;; https://www.kernel.org/doc/html/latest/process/coding-style.html
(setq c-default-style "linux")
(setq tab-width 8)

8.2 括号
#

彩色括号:

(use-package rainbow-delimiters
  :hook (prog-mode . rainbow-delimiters-mode))

高亮匹配的括号:

(use-package paren
  :hook (after-init . show-paren-mode)
  :init
  (setq show-paren-delay 0.1)
  (setq show-paren-when-point-inside-paren t
        show-paren-when-point-in-periphery t)
  (setq show-paren-style 'parenthesis) ;; parenthesis, expression
  (set-face-attribute 'show-paren-match nil :weight 'extra-bold))

智能补全括号:

(electric-pair-mode 1)
(setq electric-pair-pairs
      '(
        (?\" . ?\")
        (?\{ . ?\})))
(setq electric-pair-preserve-balance t
      electric-pair-delete-adjacent-pairs t
      electric-pair-skip-self 'electric-pair-default-skip-self
      electric-pair-open-newline-between-pairs t)

8.3 project
#

project 使用 top-down 方式来检查项目路径中是否存在 .project 文件,所以在上层各路径的目录中不应该存在 .project 文件,否则会导致判断失败。

  1. 手动标记项目根目录:在目录下创建 .project 文件
  2. 查看当前项目的 project root: (project-current)
  3. 手动添加 project 目录: M-x project-remember-projects-under
  4. 调试目录的 project root 识别情况: (my/project-try-local "/path/to/directory")
(use-package project
  :custom
  (project-switch-commands
   '(
     (consult-project-buffer "buffer" ?b)
     (project-dired "dired" ?d)
     (magit-project-status "magit status" ?g)
     (project-find-file "find file" ?p)
     (consult-ripgrep "rigprep" ?r)))
  (project-vc-merge-submodules nil)
  :config
  ;; project-find-file 忽略的目录或文件列表。
  (add-to-list 'vc-directory-exclusion-list "vendor") ;; go
  (add-to-list 'vc-directory-exclusion-list "node_modules") ;; node
  (add-to-list 'vc-directory-exclusion-list "target") ;; rust
  )

(defun my/project-try-explicit-marker (dir)
  (when-let* ((root (locate-dominating-file dir ".project")))
    (cons 'local root)))

(defun my/project-try-local (dir)
  "Determine if DIR is a non-Git project."
  (catch 'ret
    (let ((pr-flags '(
		      ;; 顺着目录 top-down 查找第一个匹配的文件。所以中间目录不能有
		      ;; .project 等文件,否则判断 project root 错误。
		      ("go.mod" "Cargo.toml" "pom.xml" "package.json")
                      ;; 以下文件容易导致 project root 判断错误, 故不添加。
                      ;; ("Makefile" "README.org" "README.md")
                      )))
      (dolist (current-level pr-flags)
        (dolist (f current-level)
          (when-let* ((root (locate-dominating-file dir f)))
            (throw 'ret (cons 'local root))))))))

;; 先查找 .project 文件,然后根据 git 查找项目 root,最后使用自定义逻辑。
(setq project-find-functions
      '(my/project-try-explicit-marker
        project-try-vc
        my/project-try-local))

(cl-defmethod project-root ((project (head local)))
  (cdr project))

(defun my/project-discover ()
  (interactive)
  ;; 去掉 "~/go/src/k8s.io/*" 目录。
  (dolist (search-path
	   '("~/go/src/github.com/*"
	     "~/go/src/github.com/*/*"
	     "~/go/src/gitlab.*/*/*"))
    (dolist (file (file-expand-wildcards search-path))
      (when (file-directory-p file)
        (message "dir %s" file)
        ;; project-remember-projects-under 列出 file 下的目录, 分别加到
        ;; project-list-file 中。
        (project-remember-projects-under file nil)
        (message "added project %s" file)))))

;; 在记录前排除远程项目,避免 dashboard 扫描远程路径。
(defun my/project-remote-p (project)
  "判断 PROJECT 是否位于远程文件系统。"
  (file-remote-p (project-root project)))
(add-to-list 'project-list-exclude #'my/project-remote-p)

;; 在 project-current 入口跳过远程目录,避免向上查找 .git 等项目标记,本地项目功能保留:
;; 自动探测时返回 nil;显式执行需要项目的命令时给出提示。file-remote-p 在这里仅判断路径,不建立远程连接。
(with-eval-after-load 'project
  (defun my/project-current-local-only
      (orig &optional maybe-prompt directory)
    "禁止在远程目录中查找项目。"
    (if (file-remote-p
         (or directory
             project-current-directory-override
             default-directory))
        (when maybe-prompt
          (user-error "远程目录已禁用项目定位"))
      (funcall orig maybe-prompt directory)))

  (advice-add 'project-current :around
              #'my/project-current-local-only))

8.4 magit
#

(setq vc-follow-symlinks t)

(use-package magit
  :custom
  ;; 在当前 window 中显示 magit buffer。
  (magit-display-buffer-function #'magit-display-buffer-same-window-except-diff-v1)
  (magit-log-arguments '("-n256" "--graph" "--decorate" "--color"))
  ;; 按照 word 展示 diff。
  (magit-diff-refine-hunk t)
  (magit-clone-default-directory "~/go/src/")
  :config
  ;; diff org-mode 时展开内容。
  (add-hook 'magit-diff-visit-file-hook (lambda() (when (derived-mode-p 'org-mode)(org-fold-show-entry)))))

git-link 为光标位置生成 git 仓库 URL:

(use-package git-link
  :config
  (setq git-link-use-commit t)
  ;; 重写 gitlab 的 format 字符串以匹配内部系统。
  (defun git-link-commit-gitlab (hostname dirname commit)
    (format "%s/%s/commit/%s" hostname dirname commit))
  (defun git-link-gitlab (hostname dirname filename branch commit start end)
    (format "%s/%s/blob/%s/%s" hostname dirname
	    (or branch commit)
            (concat filename
                    (when start
                      (concat "#"
                              (if end
                                  (format "L%s-%s" start end)
				(format "L%s" start))))))))

8.5 treesit
#

Emacs 31 内置 tree-sitter 模式切换和缺失 grammar 的安装提示。 treesit-enabled-modes 通过 Custom setter 更新模式映射,不能只用 setq。

(use-package treesit
  :ensure nil
  :demand t
  :custom
  (treesit-auto-install-grammar 'ask)
  (treesit-enabled-modes t))

grammar 默认安装到 ~/.emacs.d/tree-sitter

  • 打开相应文件时,按提示安装缺失的 grammar。
  • 手动安装或更新: M-x treesit-install-language-grammar ,选择语言。

Emacs 31 的 buffer 在启用使用 tree-sitter 的 XX-ts-mode 以及启用 hs-minor-mode 后,才能按语法折叠。

C-c f f/o 折叠/展开当前块, C-c f F/u 折叠/展开全部, C-c f t 切换。 C-c f O 同样展开当前块(含其内部折叠)。

(use-package hideshow
  :ensure nil
  :hook (
	 ;;为主要编程语言启用。其他支持 Hideshow 的模式可手动执行 M-x hs-minor-mode。
	 (c-ts-mode
	  c++-ts-mode
	  go-ts-mode
	  rust-ts-mode
          python-ts-mode
	  yaml-ts-mode
	  json-ts-mode
	  bash-ts-mode) . hs-minor-mode)
  :bind (("C-c f f" . hs-hide-block) ;; hide 当前 block
         ("C-c f o" . hs-show-block)
         ("C-c f c" . hs-cycle) ;; Cycle the visibility state of the current block.
	 ("C-c f l" . hs-hide-level) ;;Hide all blocks ARG levels below this block.
         ("C-c f F" . hs-hide-all) ;; hide 所有 top level 的 blocks
         ("C-c f u" . hs-show-all)
         ("C-c f t" . hs-toggle-hiding)))

8.6 flymake
#

flymake 为当前 buffer 提供错误检查/诊断功能,它在如下情况检查(诊断)buffer 是否有错误,错误信息直接显示在 buffer 区域,并发送给 eldoc:

  1. 执行 M-x flymake-start;
  2. 超过 flymake-no-changes-timeout(默认 0.5)
  3. 保存 buffer 时 (除非设置 flymake-start-on-save-buffer 为 nil);

将 flymake-no-changes-timeout 设置为 nil 后,eglot 不会显示 LSP 实时诊断消息,而是当保存 buffer 后经过 eglot-send-changes-idle-time 时间后才显示诊断消息,这样可以避免显示编码过程无意义的错误。

(use-package flymake
  :config
  ;; 不自动检查 buffer 错误。
  (setq flymake-no-changes-timeout nil)

  ;; 在行尾显示诊断消息(Emacs 30 开始支持), 'short 只显示一条最重要信息,t 显示所有信息。
  (setq flymake-show-diagnostics-at-end-of-line 'short)

  ;; 如果 buffer 出现错误的诊断消息,执行 flymake-start 重新触发诊断。
  (define-key flymake-mode-map (kbd "C-c d e") #'flymake-start)

  ;; 显示诊断错误列表
  (global-set-key (kbd "C-s-l") #'consult-flymake)
  (define-key flymake-mode-map (kbd "C-s-n") #'flymake-goto-next-error)
  (define-key flymake-mode-map (kbd "C-s-p") #'flymake-goto-prev-error))

;; 解决 flymake-no-changes-timeout 为 nil 时诊断延迟的问题。
;;; https://github.com/joaotavora/eglot/issues/1296
;; (cl-defmethod eglot-handle-notification :after
;;   (_server (_method (eql textDocument/publishDiagnostics)) &key uri
;;            &allow-other-keys)
;;   (when-let* ((buffer (find-buffer-visiting (eglot-uri-to-path uri))))
;;     (with-current-buffer buffer
;;       (if (and (eq nil flymake-no-changes-timeout)
;;                (not (buffer-modified-p)))
;;           (flymake-start t)))))

8.7 eglot
#

elgot 使用 Emacs 内置的 flymake(而非 flycheck)、xref、eldoc、project 等包。

eglot 使用 flymake 来接收和显示 LSP Server 发送的 publishDiagnostics 事件,这是通过向 flymake-diagnostic-functions hook 添加 ’eglot-flymake-backend 实现的。

eglot 默认将 flymake 的 backend 清空,只保留 eglot 自身,可以通过配置 (add-to-list 'eglot-stay-out-of 'flymake) 来关闭 eglot 对 flymake 的清空行为,这样可以使用自定义的 flymake backends,但后续需要添加 hook 来手动启动和配置 eglot-flymake-backend。

(use-package eglot
  :demand
  :after (flymake)
  :preface
  (defun my/eglot-eldoc ()
    ;; eglot will change the completion-category-defaults to flex, BAD!
    ;; https://github.com/minad/corfu/issues/136#issuecomment-eglot
    ;; 这里将 completion-category-defaults 设置为 nil,然后在 completion-category-overrides
    ;; 中设置 eglot 使用 orderless 补全风格。
    (setq completion-category-defaults nil)

    ;; 在 eldoc buffer 开始优先显示 flymake 诊断信息。
    ;; 自动 Eldoc 保留诊断/签名;hover 由 C-c d d 显式请求。
    (setq-local eldoc-documentation-functions
                (cons #'flymake-eldoc-function
                      (remq #'eglot-hover-eldoc-function
                            (remq #'flymake-eldoc-function eldoc-documentation-functions))))
    )
  :hook ((eglot-managed-mode . my/eglot-eldoc))
  :bind
  (:map eglot-mode-map
        ("C-c C-a" . eglot-code-actions)
        ("C-c C-f" . my/format-buffer)
        ("C-c C-r" . eglot-rename))
  :config

  ;;; 性能优化:https://www.jamescherti.com/emacs-eglot-performance/
  ;; 将 eglot event log buffer 设置为 0 后将关闭显示 *EGLOT event* bufer,不便于调
  ;; 试问题。但不能设置的太大,否则影响性能。
  ;; 注意:需要直接设置 :size 的数值,而不能使用 :size (* 1024 1) 的计算方式。
  (setq eglot-events-buffer-config '(:size 1048576 :format short))
  ;; 降低最大文件 watch 数量,节省资源。
  (setq eglot-max-file-watches 5000) ;; 缺省:10000
  ;; 当 project 的最后一个源码 buffer 关闭时自动关闭 eglot server,节省资源。
  (customize-set-variable 'eglot-autoshutdown t)
  ;; xref 打开的项目外源码复用来源项目的服务器,避免为依赖另启服务器,加快代码文件打开和关闭时间。
  (setq eglot-extend-to-xref t)
  ;; 不在 mode-line 上显示进展。
  (setq eglot-report-progress nil)
  ;; 关闭后台的自动 code action 查询(按需手动触发 code action)。
  (setq eglot-code-action-indications nil)
  ;; 关闭一些 LSP 服务端能力。
  (setq eglot-ignored-server-capabilities
        '(
  	     ;;:documentOnTypeFormattingProvider ;; 自动格式化
         :semanticTokensProvider ;; 使用 Tree-sitter 提供的语法高亮
         :documentHighlightProvider ;; 高亮当前符号
         :inlayHintProvider ;; 显示 inlay hint 提示

	 ;;忽略 :hoverProvider 后,内置 hover 文档函数不会请求文档;即使手动按
	 ;; eldoc-box-help-at-point,也不会自动恢复这项能力
        ))

  ;; 将 flymake-no-changes-timeout 设置为 nil 后,eglot 保存 buffer 内容后,经过 idle
  ;; time 才会向 LSP 发送诊断请求。
  (setq eglot-send-changes-idle-time 0.5)

  ;; eglot-sync-connect 设置为 nil 表示 LSP 初始化是不 block Emacs UI。
  ;; 缺省:3。先 block UI 3s,然后后台初始化,等待 eglot-connect-timeout 后超时。
  (setq eglot-sync-connect nil)
  (customize-set-variable 'eglot-connect-timeout 60)

  (defun my/eglot-ensure-local ()
    "本地自动启动 Eglot,远程由用户按需启动。"
    (unless (file-remote-p default-directory)
      (eglot-ensure)))

  ;;不给所有 prog-mode 都开启 eglot,否则当它没有 language server 时 eglot 报错。
  ;;
  ;;由于内置 treesit 已经对 major-mode 做了 remap ,需要对 xx-ts-mode-hook 添加 hook,
  ;;而不是以前的 xx-mode-hook, 否则添加到 xx-mode-hook 的内容不会被自动执行。
  (add-hook 'c-ts-mode-hook #'my/eglot-ensure-local)
  (add-hook 'c++-ts-mode-hook #'my/eglot-ensure-local)
  (add-hook 'go-ts-mode-hook #'my/eglot-ensure-local)
  (add-hook 'bash-ts-mode-hook #'my/eglot-ensure-local)
  (add-hook 'python-mode-hook #'my/eglot-ensure-local)
  (add-hook 'python-ts-mode-hook #'my/eglot-ensure-local)
  (add-hook 'rust-ts-mode-hook #'my/eglot-ensure-local)
  (add-hook 'yaml-mode-hook #'my/eglot-ensure-local)
  (add-hook 'yaml-ts-mode-hook #'my/eglot-ensure-local)

  ;; 加强高亮的 symbol 效果。
  ;;(set-face-attribute 'eglot-highlight-symbol-face nil :background "#b3d7ff")
)

consult-eglot 提供 consult-eglot-symbols 函数,方便选择 workspace 中的 symbol:

(use-package consult-eglot
  :after (eglot consult))

下载 emacs-lsp-booster 可执行程序,然后使用 emacs-lsp-booster 来加速 eglot 的响应性能:

(use-package eglot-booster
  :disabled
  :vc (:url "https://github.com/jdtsmith/eglot-booster")
  :after (eglot)
  :config (eglot-booster-mode))

8.8 xref
#

xref 配置:

;; 限制 xref history 仅局限于当前窗口(默认全局)。
(setq xref-history-storage 'xref-window-local-history)

;; xref 返回项目后,关闭刚离开的项目外源码 buffer。
;; 需要设置 (setq eglot-extend-to-xref t),否则每次都打开和新建 LSP Server,关闭 buffer 时延迟较大。
(defun my/xref-back-and-close-external-buffer (orig-fun &rest args)
  "返回后关闭项目外、未修改且不再显示的文件 buffer。
以返回位置的 project.el 根目录为边界;无法识别项目时保留。"
  (let ((source (current-buffer))
        (file buffer-file-name))
    (prog1 (apply orig-fun args)
      (when (and file
                 (buffer-live-p source)
                 (not (eq source (current-buffer)))
                 (not (buffer-modified-p source))
                 (not (get-buffer-window source t))
                 (ignore-errors
                   (when-let* ((project (project-current nil)))
                     (not (file-in-directory-p file (project-root project))))))
        (kill-buffer source)))))

(with-eval-after-load 'xref
  (require 'project)
  (unless (advice-member-p #'my/xref-back-and-close-external-buffer
                          'xref-go-back)
    (advice-add 'xref-go-back :around
                #'my/xref-back-and-close-external-buffer)))


;; 在其它窗口查看定义。
(global-set-key (kbd "C-M-.") 'xref-find-definitions-other-window)

8.9 eldoc
#

:ID: 42001BF2-8B96-44F6-A92A-86412A702603

eldoc 在 minibuffer echo-area 或 eldoc buffer 中显示文档和函数签名信息。

(use-package eldoc
  :after (eglot)
  :config
  (setq eldoc-idle-delay 0.4)

  ;;; 设置默认在 minibuffer 的 echo-area 单行显示 eldoc 信息,防止多行信息
  ;; 打开 eldoc-buffer 时关闭 echo-area 显示,
  (setq eldoc-echo-area-prefer-doc-buffer t)

  ;;; 将窗口设置为 1 后,很多场景显示的信息不全,故关闭。
  ;; 将 minibuffer 窗口高度设为 1,确保只显示一行(默认为小数,表示 frame 高度占比,会导致显示多行)。
  ;;(setq max-mini-window-height 1)
  ;; 为 nil 时只单行显示 eldoc 信息.
  ;;(setq eldoc-echo-area-use-multiline-p nil)

  ;; 在屏幕右侧显示 eldoc-buffer,这样内容比较多时方便查看。
  ;; eldoc-buffer 会跟随显示当前光标出的信息, 如函数签名。
  (add-to-list 'display-buffer-alist
               '("^\\*eldoc.*\\*"
                 (display-buffer-reuse-window display-buffer-in-side-window)
                 (dedicated . t)
                 (side . right)
                 (inhibit-same-window . t)))
  ;; 使用实际文档 buffer,不依赖会随文档内容变化的名字。
  (global-set-key (kbd "M-`")
                  (lambda ()
                    (interactive)
                    (let* ((buffer (eldoc-doc-buffer))
                           (window (get-buffer-window buffer)))
                      (if window (quit-window nil window)
                        (eldoc-doc-buffer t))))))

自动 Eldoc 显示诊断和签名;=C-c d d= 按需请求 hover 文档,=M-`= 切换文档窗口。

eldoc-box 在 frame 右上角或光标位置显示 eldoc-doc-buffer 的内容。

(use-package eldoc-box
  :after (eglot eldoc)
  :bind (:map eglot-mode-map ("C-c d d" . my/eglot-documentation))
  :preface
  (defun my/eglot-documentation ()
    "按需请求 hover 文档;在原位置显示 Eldoc 浮窗。"
    (interactive)
    (let ((buffer (current-buffer)) (position (point))
          (tick (buffer-chars-modified-tick)))
      (unless (eglot-hover-eldoc-function
               (lambda (doc &rest properties)
                 (when (and doc (buffer-live-p buffer))
                   (with-current-buffer buffer
                     (when (and (= (point) position)
                                (= (buffer-chars-modified-tick) tick)
                                (eq (window-buffer (selected-window)) buffer))
                       (eldoc-display-in-buffer (list (cons doc properties)) nil)
                       (eldoc-box-quit-frame)
                       (eldoc-box-help-at-point))))))
        (user-error "当前语言服务器不提供 hover 文档"))))

  :config
  (setq eldoc-box-max-pixel-height 600)
  (setq eldoc-box-max-pixel-width 1200)

  ;; C-g 关闭弹出的 child frame。
  (setq eldoc-box-clear-with-C-g t)

  ;; 美化 TypeScript 报错信息。
  (add-hook 'eldoc-box-buffer-setup-hook #'eldoc-box-prettify-ts-errors)

  ;; 在右上角显示 eldoc 帮助;
  ;;(add-hook 'eglot-managed-mode-hook #'eldoc-box-hover-mode t)

  ;; 在光标位置显示 eldoc 帮助;
  ;;(add-hook 'eglot-managed-mode-hook #'eldoc-box-hover-at-point-mode t)

  ;; eldoc-box 还支持 eldoc-box-mouse-mode(需要启用 track-mouse),但是可能会让 Emacs 变慢。
  )

8.10 python
#

使用 uv 管理 python 环境和项目。

创建一个用户级别的 python 虚拟环境目录 ~/.venv , 然后将 pip 包安装到该环境中: uv venv ~/.venv

配置 Emacs 使用内置的 python-ts-mode 和 venv 虚拟环境:

;; 将 ~/.venv/bin 添加到 PATH 环境变量和 exec-path 变量中。
(setq my-venv-path "/Users/alizj/.venv/bin")
(setenv "PATH" (concat my-venv-path ":" (getenv "PATH")))
(setq exec-path (cons my-venv-path  exec-path))

;; 指定 python.el 使用虚拟环境目录。
(setq python-shell-virtualenv-root "/Users/alizj/.venv")

(defun my/python-setup-shell (&rest args)
  (if (executable-find "ipython3")
      (progn
        ;; 使用 ipython3 作为 python shell.
        (setq python-shell-interpreter "ipython3")
        (setq python-shell-interpreter-args "--simple-prompt -i --InteractiveShell.display_page=True"))
    (progn
      ;; 查找 python-shell-virtualenv-root 中的解释器.
      (setq python-shell-interpreter "python3")
      (setq python-interpreter "python3")
      (setq python-shell-interpreter-args "-i"))))

;; 使用内置 python mode 和 LSP 来格式化代码(不适用 yapfify)
(use-package python
  :init
  ;;(setq python-indent-guess-indent-offset t)
  ;;(setq python-indent-guess-indent-offset-verbose nil)
  ;;(setq python-indent-offset 2)
  :hook
  (python-base-mode . my/python-setup-shell))

Python LSP Server 使用 basedpyright,它是微软 VSCode 使用的 pyright 和 pyglance 的开源 fork 版本。

安装 basepyright:

❯ uv tool install basedpyright

❯ which basedpyright
/Users/alizj/.local/bin/basedpyright

❯ ls -l /Users/alizj/.local/bin/basedpyright
lrwxr-xr-x 1 alizj 64  9月  7 14:07 /Users/alizj/.local/bin/basedpyright -> /Users/alizj/.local/share/uv/tools/basedpyright/bin/basedpyright*

配置 eglot 使用 basedpyright:

(add-to-list 'eglot-server-programs
             '((python-mode python-ts-mode)
               "basedpyright-langserver" "--stdio"))

;;设置 basedpyright 的 eglot 规则。
;;这里设置全局诊断检查规则。也可以在项目的 pyproject.toml 中的 [tool.basedpyright] 部分配置检查规则。
(with-eval-after-load 'eglot
  (setq-default
   eglot-workspace-configuration
   (cons
    '(:basedpyright
      . (:analysis
         (:typeCheckingMode "standard" ;; 缺省检查基准

          :diagnosticSeverityOverrides ;; 覆盖默认基准的规则。这些 reportXX 规则名称可以从 flymake 错误列表中查看。
          (:reportUnusedImport "warning" ;; 未使用的导入:警告
           :reportMissingTypeStubs "none" ;; 缺少类型存根:不报告
           :reportUnknownMemberType "none" ;; 成员类型未知:不报告
           :reportArgumentType "error" ;; 参数类型不匹配:错误
	   ))))
    (assq-delete-all
     :basedpyright
     (default-value 'eglot-workspace-configuration)))))

8.11 go
#

安装最新 gopls 工具:

go install golang.org/x/tools/gopls@latest

使用 Emacs 内置的 go-ts-mode, 故不需要再单独安装 go-mode 包:

(require 'go-ts-mode)
;; go 使用 TAB 缩进。
(add-hook 'go-ts-mode-hook (lambda () (setq indent-tabs-mode t)))

;; t: true, false: :json-false(注意:不是 nil)。
;; gopls 配置参数: https://github.com/golang/tools/blob/master/gopls/doc/settings.setq
(with-eval-after-load 'eglot
  (setq-default
   eglot-workspace-configuration
   (cons
    '(:gopls
      . ((staticcheck . t)
         (usePlaceholders . :json-false)
         ;; gopls 默认设置 GOPROXY=Off, 可能会导致 package 缺失进
         ;; 而引起补全异常. 开启 allowImplicitNetworkAccess 后将
         ;; 关闭 GOPROXY=Off.
         ;;(allowImplicitNetworkAccess . t)
         ))
    (assq-delete-all
     :gopls
     (default-value 'eglot-workspace-configuration)))))

设置 go 环境变量, eglot 启动 gopls 时传递它们:

(dolist (env '(("GOPATH" "/Users/alizj/go")
               ("GOPROXY" "http://goproxy.alibaba-inc.com,direct")
               ("GONOSUMDB" "*.alibaba-inc.com")
	       ("GOOS" "linux")
	       ("GOARCH" "arm64")))
  (setenv (car env) (cadr env)))

查看本地和在线 go 文档:

(require 'go-ts-mode)
;; 查看光标处符号的本地文档.
(define-key go-ts-mode-map (kbd "C-c d .") #'godoc-at-point)

;; 查看 go std 文档。
(defun my/browser-gostd ()
  (interactive)
  (xwidget-webkit-browse-url "https://pkg.go.dev/std"))
(define-key go-ts-mode-map (kbd "C-c d s") 'my/browser-gostd)

;; 搜索 pkg.go.dev 在线 web 文档。
(defun my/browser-pkggo (query)
  (interactive "ssearch: ")
  (xwidget-webkit-browse-url
   (concat "https://pkg.go.dev/search?q=" (string-replace " " "%20" query)) t))
(define-key go-ts-mode-map (kbd "C-c d w") 'my/browser-pkggo) ;; 助记: w -> web

安装或更新工具:

;; (setq gofmt-command "golangci-lint")
;; (setq gofmt-args "run --config /Users/alizj/.golangci.yml --fix")

(defvar go--tools '("golang.org/x/tools/gopls"
                    "github.com/rogpeppe/godef"
                    "golang.org/x/tools/cmd/goimports"
                    "honnef.co/go/tools/cmd/staticcheck"
                    "github.com/go-delve/delve/cmd/dlv"
                    "github.com/zmb3/gogetdoc"
                    "github.com/josharian/impl"
                    "github.com/cweill/gotests/..."
                    "github.com/fatih/gomodifytags"
                    "github.com/golangci/golangci-lint/cmd/golangci-lint"
                    "github.com/davidrjenni/reftools/cmd/fillstruct"))

(defun go-update-tools ()
  (interactive)
  (unless (executable-find "go")
    (user-error "Unable to find `go' in `exec-path'!"))
  (message "Installing go tools...")
  (dolist (pkg go--tools)
    (set-process-sentinel
     (start-process "go-tools" "*Go Tools*" "go" "install" "-v" "-x" (concat pkg "@latest"))
     (lambda (proc _)))))

(use-package go-fill-struct)

(use-package go-impl)

;; 自动为 struct field 添加 json tag。
(use-package go-tag
  :init
  (setq go-tag-args (list "-transform" "camelcase"))
  :config
  (require 'go-ts-mode)
  (define-key go-ts-mode-map (kbd "C-c t a") #'go-tag-add)
  (define-key go-ts-mode-map (kbd "C-c t r") #'go-tag-remove))

(use-package go-playground
  :demand nil
  :defer t
  :commands (go-playground go-playground-mode)
  :config
  (setq go-playground-init-command "go mod init"))

调试:

  1. 如果一个 git 项目下有多个 go module, 则需要在上层目录创建 workspace, 并将各 module 加入其中,否则可能出现 package import 失败的情况:

    go work init
    go work use ./path/to/module1 ./path/to/module2
    
  2. 如果补全或自动提示异常, 执行 M-x eglot-events-buffer 看是否有报错(例如 GOPROXY=Off导致的问题.)

8.12 rust
#

将 Rust 工具链目录添加到 PATH 环境变量和 Emacs 变量 exec-path 中:

  • ~/.cargo/bin/opt/homebrew/opt/rustup/bin 目录已经在初始化时添加到 PATH 和 exec-path 中。

使用 Emacs 内置的 rust-ts-mode ,需要 Rust tree-sitter grammar:

(use-package rust-ts-mode
  :ensure nil
  :mode ("\\.rs\\'" . rust-ts-mode)
  :init
  (add-to-list 'major-mode-remap-alist '(rust-mode . rust-ts-mode))
  :config
  (require 'eglot)
  ;; Rust 建议使用空格而非 TAB 来缩进。
  (add-hook 'rust-ts-mode-hook (lambda () (setq-local indent-tabs-mode nil)))

  ;; 参数列表参考:https://rust-analyzer.github.io/manual.html#configuration
  (add-to-list
   'eglot-server-programs
   '(rust-ts-mode .
     ("rust-analyzer"
      :initializationOptions
      (
       :rustfmt
       (
	:extraArgs ["+nightly"]
	)
       ;;:completion (:fullFunctionSignatures (:enable t)) ;; 默认关闭,启用后会增加补全文档,影响性能。
       ;; 20240910 不能关闭 checkOnSave,否则 flymake diagnose 可能不生效。
       ;;:checkOnSave :json-false
       :check
       (
        :command "check" ;; clippy 比 cargo check 更重,影响性能。
        ;;https://esp-rs.github.io/book/tooling/visual-studio-code.html#using-rust-analyzer-with-no_std
        :allTargets :json-false
	;; 不发送 --workspace 给 cargo check, 只检查当前 package.
	;; 20240910 可能导致基于 workspace 的 标准库 lsp 不生效,故不能设置。
        :workspace :json-false
        )
       ;;:procMacro (:attributes (:enable t) :enable :json-false)
       :cargo
       (
        ;;:buildScripts (:enable :json-false)
        ;;:features "all"
        ;;:noDefaultFeatures t
        :cfgs ["tokio_unstable"]
        ;;:autoreload :json-false
        )
       :diagnostics
       (
	;;:enable :json-false
	:disabled ["unresolved-proc-macro" "unresolved-macro-call"]
	)
       :inlayHints
       (
	:bindingModeHints (:enable t)
	:closureCaptureHints (:enable t)
	:closureReturnTypeHints (:enable t)
	:lifetimeElisionHints (:enable t)
	:expressionAdjustmentHints (:enable t)
	)
       ;; :linkedProjects
       ;; [
       ;;  "/Users/alizj/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/std/Cargo.toml",
       ;;  "/Users/alizj/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/Cargo.toml",
       ;;  "/Users/alizj/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/proc_macro/Cargo.toml",
       ;;  "/Users/alizj/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/test/Cargo.toml"
       ;;  ]
       )))))

创建如下全局 rustfmt 配置文件;

# 配置选项参考:
# https://rust-lang.github.io/rustfmt/?version=v1.7.1&search=#chain_width
edition = "2021"
# 确保方法链式调用时,每行一个方法调用,这样 inlayhint 会显示上一个方法的返回值类型。
chain_width = 0
# 是否格式化为单行函数。
fn_single_line = true
# 最大行长度,超过后自动折行。
max_width = 80
# 函数各参数单独一行
fn_args_layout = "Vertical"

# 以下是 unstable features,只能在 nightly channel 使用。
wrap_comments = true
normalize_comments = true
format_code_in_doc_comments = true
comment_width = 80
format_strings = true
imports_granularity = "Crate"
enum_discrim_align_threshold = 20

rust-playground 快速测试环境:

  1. BUGFIX: https://github.com/grafov/rust-playground/pull/11/files
  2. 设置 Cargo.toml 模板文件变量中 edition 值为 2021(默认是 2018);
(use-package rust-playground
  :demand nil
  :defer t
  :commands rust-playground
  :config
  (setq rust-playground-cargo-toml-template
        "[package]
name = \"foo\"
version = \"0.1.0\"
authors = [\"opsnull <geekard@qq.com>\"]
edition = \"2021\"

[dependencies]"))

eglot-x 为 Rust 提供了几个好用的命令:

  1. M-x eglot-x-reload-workspace :在 Cargo.toml 文件发生变化时手动执行而不需要重启 eglot;
  2. M-x eglot-x-expand-macro :展开宏定义(或者使用 cargo expand 命令来显示宏展开后的定义);
  3. M-x eglot-x-open-external-documentation :用浏览器查看光标处的 rust 文档;
(use-package eglot-x
  :after (eglot rust-ts-mode)
  :vc (:url "https://github.com/nemethf/eglot-x")
  :config
  (eglot-x-setup))

查看本地和在线文档:

(with-eval-after-load 'rust-ts-mode
  ;; 使用 xwidget 打开光标处 symbol 的本地 crate 文档(需要先执行 cargo doc 命令来生成本地文档)
  ;; RA bug 导致查看 macro 文档的链接是错的:https://github.com/rust-lang/rust-analyzer/issues/16724
  (define-key rust-ts-mode-map (kbd "C-c d .") #'eglot-x-open-external-documentation)

  ;; 查看本地 rust std 文档;
  (defun my/browser-ruststd ()
    (interactive)
    (xwidget-webkit-browse-url "file:///Users/alizj/.rustup/toolchains/stable-aarch64-apple-darwin/share/doc/rust/html/std/index.html"  t))
  (define-key rust-ts-mode-map (kbd "C-c d s") 'my/browser-ruststd)

  ;; 在线 https:://docs.rs/ 搜索文档.
  (defun my/browser-docsrs (query)
    (interactive "ssearch: ")
    (xwidget-webkit-browse-url
     (concat "https://docs.rs/releases/search?query=" (string-replace " " "%20" query)) t))
  (define-key rust-ts-mode-map (kbd "C-c d w") 'my/browser-docsrs) ;; 助记: w -> web

  ;; 在线搜索 crate 包。
  (defun my/search-crates.io (query)
    (interactive "ssearch: ")
    (xwidget-webkit-browse-url
     (concat "https://crates.io/search?q=" (string-replace " " "%20" query)) t))
  (global-set-key (kbd "C-c d c") 'my/search-crates.io) ;; 助记: c -> crates.io
  )

cargo package 不再维护, 故切换到 cargo-mode package, 它提供了 Cargo.toml 管理命令。

  • C-c a e(cargo-execute-task) :列出所有支持的 cargo task,如 build/test 等,同时可以添加和删除依赖(需要指定 PREFIX 命令来输入依赖名称)。
(use-package cargo-mode
  :after (rust-ts-mode)
  :custom
  ;; cargo-mode 缺省为 compilation buffer 使用 comint mode, 设置为 nil 使用 compilation。
  (cargo-mode-use-comint nil)
  :hook
  (rust-ts-mode . cargo-minor-mode)
  :config
  ;; 自动滚动显示 compilation buffer 内容。
  (setq compilation-scroll-output t))

其他技巧:

  1. 创建一个 struct 对象时, 可以使用 eglot code-action 来自动填充对象成员;
  2. Cargo.toml 文件发生变化时, rust-analyzer 不会自动更新处理, 需要重启 eglot 才能自动补全新的 crate。两个解决办法:
    1. 使用 eglot-x 中的 M-x eglot-x-reload-workspace 命令;
    2. 或者先将 所有依赖 提前添加到 Cargo.toml 文件, 然后再启动 eglot;

8.13 markdown
#

使用 Emacs 31 内置的 markdown-ts-mode 打开 Markdown 文件(含 README.md)。会自动安装 markdownmarkdown-inline 两个 tree-sitter grammar。

(use-package markdown-ts-mode
  :ensure nil ;; Emacs 31 内置。
  :mode (("\\.md\\'" . markdown-ts-mode)
         ("\\.markdown\\'" . markdown-ts-mode))
  :init
  (add-to-list 'major-mode-remap-alist '(markdown-mode . markdown-ts-mode))
  (add-to-list 'major-mode-remap-alist '(gfm-mode . markdown-ts-mode))
  :custom
  (markdown-ts-unchecked-checkbox '("󰄱" . "□"))
  (markdown-ts-checked-checkbox '("󰄲" . "■"))
  (markdown-ts-fontify-code-blocks-natively t))

使用内置目录功能:先在目录位置执行 M-x markdown-ts-toc-insert-template ,选择 basic,再按 C-c r 生成或刷新目录。

(use-package markdown-ts-mode-x
  :ensure nil
  :after markdown-ts-mode
  :commands markdown-ts-toc-insert-template
  :bind (:map markdown-ts-mode-map
              ("C-c r" . markdown-ts-toc-generate)))

markdown 在线渲染: M-x markdown-ts-view-mode

后续使用 ruff 来格式化 markdown 文件。

8.14 yaml
#

使用 Emacs 31 内置的 yaml-ts-mode 打开 YAML 文件。

eglot 对于 YAML 使用 yaml-language-server 语言服务器:

which yaml-language-server || npm install -g yaml-language-server

yaml 格式说明:

  1. 不使用 TAB 而使用空格缩进;
  2. 对于多行字符串,使用 name: | 格式,后续第一行缩进必须大于 name: 所在行,而且以后续以第一行缩进为准来删除后续各行前面的空白,所以各行的缩进必须大于等于第一行的缩进,超过的部分空白得以保留;

后续使用 ruff 来格式化 YAML 文件。

8.15 shell
#

Emacs 使用 bash-ts-mode 来编辑 shell 脚本。

安装 bash-language-server:

npm i -g bash-language-server

配置 eglot 给 bash-language-server 传递的 shfmt 格式化规则:

(with-eval-after-load 'eglot
  (setq-default
   eglot-workspace-configuration
   (cons
    '(:bashIde ;; bashIde 对应的 sh/bash 代码配置。
      . (:shfmt ;; 配置 shfmt
         (
	  ;;:ignoreEditorconfig t 表示统一采用语言服务器配置;设为 :json-false 则允许使用 .editorconfig
	  :ignoreEditorconfig t

          ;;设置格式化规则。
          :indent_style "space" ;; 使用空格缩进(默认是 TAB)
          :indent_size 4
          :binaryNextLine t
          :caseIndent t
          :spaceRedirects t)))
    (assq-delete-all
     :bashIde
     (default-value 'eglot-workspace-configuration)))))

bash-language-server 使用 shellcheck 工具来做语法检查和静态分析,使用 shfmt 做格式化:

brew install shellcheck shfmt

设置 shell 脚本缩进规则:

(defun my/shell-indent-setup ()
  (setq-local indent-tabs-mode nil)
  (setq-local tab-width 4)
  (setq-local sh-basic-offset 4)
  (setq-local sh-indentation 4))

(add-hook 'sh-mode-hook #'my/shell-indent-setup)
(add-hook 'bash-ts-mode-hook #'my/shell-indent-setup)

参考:

  1. Google Shell Style Guide

8.16 format
#

Python 使用 Ruff;前端代码、YAML、JSON、Markdown 使用 Prettier;其他语言使用 Eglot。

语言 LSP 服务端 格式化方式
Bash bash-language-server 默认使用 shfmt
Go gopls 默认使用 gofmt
Rust rust-analyzer 默认调用 rustfmt

安装相关工具:

brew install shfmt shellcheck
rustup component add rustfmt
uv tool install ruff
npm install -g prettier

配置:

(require 'cl-lib)

(defconst my/prettier-mode-extensions
  '((tsx-ts-mode . "tsx") (typescript-ts-mode . "ts")
    (typescript-mode . "ts") (js-jsx-mode . "jsx")
    (js-ts-mode . "js") (js-mode . "js") (js2-mode . "js")
    (json-ts-mode . "json") (json-mode . "json")
    (scss-mode . "scss") (less-css-mode . "less")
    (css-ts-mode . "css") (css-mode . "css")
    (html-ts-mode . "html") (html-mode . "html")
    (mhtml-mode . "html") (vue-mode . "vue")
    (yaml-ts-mode . "yaml") (yaml-mode . "yaml")
    (markdown-ts-mode . "md") (markdown-mode . "md")
    (gfm-mode . "md"))
  "使用 Prettier 的模式,以及无文件 buffer 使用的扩展名。")

(defun my/prettier-extension ()
  "返回当前 buffer 对应的 Prettier 扩展名,不适用时返回 nil。"
  (let ((extension (and buffer-file-name
                        (file-name-extension buffer-file-name))))
    (if (member extension '("js" "mjs" "cjs" "jsx" "ts" "mts" "cts"
                            "tsx" "html" "htm" "css" "scss" "less" "vue"
                            "yaml" "yml" "json" "jsonc" "json5" "md"
                            "markdown" "mdx"))
        extension
      (cdr (cl-find-if (lambda (entry) (derived-mode-p (car entry)))
                       my/prettier-mode-extensions)))))

(defun my/format-with-command (program &rest args)
  "将整个 buffer 送入 PROGRAM,成功后用其标准输出替换内容。"
  (barf-if-buffer-read-only)
  (when (file-remote-p default-directory)
    (user-error "此格式化命令仅支持本地 buffer"))
  (unless (executable-find program)
    (user-error "找不到 %s,请检查 exec-path" program))
  (let ((output (generate-new-buffer " *formatter-output*"))
        (errors (make-temp-file "formatter-errors-"))
        (coding-system-for-read 'utf-8-unix)
        (coding-system-for-write 'utf-8-unix))
    (unwind-protect
        (save-restriction
          (widen)
          (let ((status (apply #'call-process-region
                               (point-min) (point-max) program
                               nil (list output errors) nil args)))
            (if (equal status 0)
                (atomic-change-group (replace-buffer-contents output))
              (user-error "%s 格式化失败(%s):%s" program status
                          (with-temp-buffer
                            (insert-file-contents errors)
                            (buffer-string))))))
      (kill-buffer output)
      (delete-file errors))))

(defun my/python-ruff-format-buffer ()
  "用 Ruff 格式化当前 Python buffer。"
  (interactive)
  (my/format-with-command
   "ruff" "format" "--stdin-filename"
   (or buffer-file-name (expand-file-name "stdin.py" default-directory))
   "-"))

(defun my/prettier-format-buffer ()
  "用 Prettier 格式化当前 buffer,读取项目格式化配置。"
  (interactive)
  (let ((extension (my/prettier-extension)))
    (unless extension (user-error "当前 buffer 不属于已配置的 Prettier 类型"))
    (my/format-with-command
     "prettier" "--stdin-filepath"
     (or buffer-file-name
         (expand-file-name (concat "stdin." extension) default-directory)))))

(defun my/format-buffer ()
  "Python 用 Ruff,前端、YAML、JSON、Markdown 用 Prettier,其余用 Eglot。"
  (interactive)
  (cond
   ((derived-mode-p 'python-mode 'python-ts-mode)
    (my/python-ruff-format-buffer))
   ((my/prettier-extension) (my/prettier-format-buffer))
   (t (call-interactively #'eglot-format-buffer))))

(defun my/setup-format-key ()
  "在支持的 buffer 中绑定统一格式化快捷键。"
  (when (or (derived-mode-p 'python-mode 'python-ts-mode)
            (my/prettier-extension))
    (local-set-key (kbd "C-c C-f") #'my/format-buffer)))

;; 未启动 Eglot 的 Markdown、YAML 等 buffer 也能使用此快捷键。
(add-hook 'after-change-major-mode-hook #'my/setup-format-key)
(with-eval-after-load 'eglot
  (define-key eglot-mode-map (kbd "C-c C-f") #'my/format-buffer))

8.17 outline
#

使用 imenu-list 在一个 side-window 中显示当前 buffer 的标题、源码结构:

  • 使用 major-mode 的 Imenu 索引,不依赖 project 和 LSP。
  • 支持层级折叠、跳转、自动刷新。
(use-package imenu-list
  :ensure t
  :commands imenu-list-smart-toggle
  :custom
  (imenu-list-position 'right)
  (imenu-list-size 0.25)
  (imenu-list-focus-after-activation t))

执行 M-x imenu-list-smart-toggle 开关侧栏。在侧栏中:

• RET:跳转到条目。• SPC:显示条目对应位置,焦点保留在侧栏。• TAB:展开或折叠。• g:手动刷新。• q:关闭侧栏。

imenu-list 默认启用 Emacs 内置的 hs-minor-mode(Hideshow) ,因此可以直接使用它的折叠命令来按照级别、block 等方式折叠。

8.18 clang
#

:ID: 823F4EF0-F04F-4FA7-9E45-48FD2E55D3CD

安装 llvm/clang/clang-format 包:

brew install llvm lld clang-format

export LDFLAGS="-L/opt/homebrew/opt/llvm/lib"
export CPPFLAGS="-I/opt/homebrew/opt/llvm/include"
export PATH="/opt/homebrew/opt/llvm/bin:$PATH"

# 打印格式化配置参数
clang-format --dump-config

将 llvm bin 目录添加到 emacs:

(setq my-llvm-path "/opt/homebrew/opt/llvm/bin")
(setenv "PATH" (concat my-llvm-path ":" (getenv "PATH")))
(setq exec-path (cons my-llvm-path  exec-path))

创建全局 ~/.clang-format 文件,也可以在各 project root 目录创建项目配置文件,主要配置的是:

  1. Tab 和 Indent 缩进;
  2. 不对头文件进行排序,防止编译报错;
# clang-format configuration file. Intended for clang-format >= 11.
#
# For more information, see:
#
#   Documentation/process/clang-format.rst
#   https://clang.llvm.org/docs/ClangFormat.html
#   https://clang.llvm.org/docs/ClangFormatStyleOptions.html

# linux 内核开发风格:
# https://raw.githubusercontent.com/torvalds/linux/master/.clang-format
---
# 基本配置
DisableFormat: false
Language:        Cpp  # 可以是 'Cpp', 'Java', 'JavaScript', 'Proto', 'TableGen' 等等
BasedOnStyle:    WebKit  # 基于 WebKit 风格,因为它最接近 Linux 内核的风格

# 缩进
IndentWidth:     8  # 缩进宽度
TabWidth:        8  # 制表符宽度
UseTab:          ForIndentation  # 使用制表符进行缩进,空格用于对齐

# 换行
AllowShortIfStatementsOnASingleLine: false  # 禁止 if 语句在单行内
AllowShortLoopsOnASingleLine: false  # 禁止循环语句在单行内
AlwaysBreakBeforeMultilineStrings: true  # 多行字符串之前总是换行
BreakBeforeBraces: Linux  # 使用 Linux 风格的大括号位置
ColumnLimit: 100  # 每行的字符限制

# 空格
SpaceBeforeParens: ControlStatements  # 在控制语句的括号前加空格(if, for, while, 等)
SpaceAfterCStyleCast: true  # C 风格的强制类型转换后加空格

# 注释
CommentPragmas: '^ IWYU pragma:'  # 支持 IWYU pragmas 注释
IndentExternBlock: AfterExternBlock  # 在 extern "C" 块后进行缩进

# 包含文件
SortIncludes: false  # 禁用头文件排序,防止编译出错。
IncludeBlocks: Preserve  # 保持头文件分组

# 函数定义
AlignTrailingComments: true  # 尾部注释对齐
AlignConsecutiveDeclarations: true  # 连续的声明对齐

# 其他
NamespaceIndentation: All  # 所有命名空间内的代码都缩进
ReflowComments: true  # 重新格式化注释

# 特定于 C 语言的设置
Standard:        Cpp11  # 使用 C++11 标准

8.19 tempel
#

每次补全时都会调用,可能影响 xref 性能。

(use-package tempel
  :bind
  (("M-+" . tempel-complete)
   ("M-*" . tempel-insert))
  :init
  (defun tempel-setup-capf ()
    (setq-local completion-at-point-functions (cons #'tempel-expand completion-at-point-functions)))

  ;; 自定义模板文件。
  (setq tempel-path (list (expand-file-name "templates" my/org-pdf-style-directory)
                         (expand-file-name "templates-pdf" my/org-pdf-style-directory)))
  (add-hook 'conf-mode-hook 'tempel-setup-capf)
  (add-hook 'prog-mode-hook 'tempel-setup-capf)
  (add-hook 'text-mode-hook 'tempel-setup-capf)
  ;; 确保 tempel-setup-capf 位于 eglot-managed-mode-hook 前,这样 corfu 才会显示
  ;; tempel 的自动补全。
  ;; https://github.com/minad/tempel/issues/103#issuecomment-1543510550
  (add-hook #'eglot-managed-mode-hook 'tempel-setup-capf))

(use-package tempel-collection
  :disabled)

8.20 compilation
#

;; https://gitlab.com/skybert/my-little-friends/-/blob/master/emacs/.emacs#L295
(setq compilation-ask-about-save nil
      compilation-always-kill t
      compilation-scroll-output 'first-error ;; 滚动显示到第一个出错位置。
      compilation-context-lines 10
      compilation-skip-threshold 2
      ;;compilation-window-height 100
      )

(define-key compilation-mode-map (kbd "q") 'delete-window)

;; 显示 shell 转义字符的颜色。
(add-hook 'compilation-filter-hook
          #'ansi-color-compilation-filter)

;; 编译结束且失败时自动切换到 compilation buffer。
(setq compilation-finish-functions
      (lambda (buf str)
        (if (null (string-match ".*exited abnormally.*" str))
            ;; 没有错误, 什么也不做。
            nil
          ;; 有错误时切换到 compilation buffer。
          (switch-to-buffer-other-window buf)
          (end-of-buffer))))

8.21 citre
#

citre 是基于 Ctags(Universal Ctags 版本)的代码浏览器工具,也支持集成使用 GNU global 的 GTAGS 文件

安装 GNU global 和 pygments, global 依赖并自动安装 universal-ctags, 通过 pygments 能生成更丰富的TAG 内容,同时支持 reference 搜索。

brew install global pygments # 提供 global、gtags 命令, gtags 使用 pygments 支持多语言

# 在 ~/.bashrc 中添加如下配置:
# 统一的 tags 文件目录
export GTAGSOBJDIRPREFIX=~/.cache/gtags/
mkdir $GTAGSOBJDIRPREFIX
export GTAGSCONF=/opt/homebrew/opt/global/share/gtags/gtags.conf
# 使用 pygments 支持更多的语言,支持 reference 搜索。
export GTAGSLABEL=pygments

创建和更新 GNU global GTAGS 文件(保存到 GTAGSOBJDIRPREFIX 环境变量指定的位置,如 ~/.cache/gtags/):

  • M-x citre-global-create-database
  • M-x citre-global-update-database
zj@a:~/.cache/gtags/Users/alizj/go/src/**/bp-agent$ ls -l
total 2.8M
-rw-r--r-- 1 alizj  32K  3 30 11:53 GPATH
-rw-r--r-- 1 alizj 600K  3 30 11:53 GRTAGS  # reference tags
-rw-r--r-- 1 alizj 1.9M  3 30 11:53 GTAGS   # tags

注意:以下两个命令创建 Universal Ctags 的 ctags 文件(项目有 .tags/ 目录或 .tags 或 tags 文件),而非 GNU global GTAGS 文件,不支持 references,故不建议使用:

  • M-x citre-create-tags-file
  • M-x citre-update-tags-file

如果误使用了上面的命令创建 ctags 文件则后续使用 xref-find-references 会 hang,需要删除。

对于开启了 citre-mode 的 buffer,citre 向 xref-backend-functions 中添加 citre-xref-backend, 而且位于列表的开始,这样 xref 先使用 citre-xref-backend,当无返回结果时再查找其它注册的 xref backend 如eglot-xref-backend 等。

  • xref-backend-functions 可能会被添加多个 backend,但 xref 使用第一个返回非空数据的 backend,而忽略后续 backend。

新版本的 citre-xref-backend 支持自动集成 eglot,它先使用 eglot 的结果,如果为空,再使用 tags 文件的结果作为后备,所以不管项目是否存在 tags 文件,都可以为所有 prog-mode 开启 citre-mode。

其他 xref 特性,如 imenu/xref-find-references/xref-find-definitions 等都会使用 citre 提供的输入。同时 xref 和 consult 结合,可以使用 consult 来预览 xref 的结果。

配置 citre:

(setenv "GTAGSOBJDIRPREFIX" (expand-file-name "~/.cache/gtags/"))
(setenv "GTAGSCONF" (car (file-expand-wildcards "/opt/homebrew/opt/global/share/gtags/gtags.conf")))
(setenv "GTAGSLABEL" "pygments")

(use-package citre
  :after (eglot)
  :config
  ;; 只使用支持 reference 的 GNU Global tags。
  (setq citre-completion-backends '(global))
  (setq citre-find-definition-backends '(global))
  (setq citre-find-reference-backends '(global))
  (setq citre-tags-in-buffer-backends  '(global))
  (setq citre-auto-enable-citre-mode-backends '(global))
  (setq citre-use-project-root-when-creating-tags t)
  (setq citre-peek-file-content-height 20)

  ;; 打开列表中的 major mode 文件且项目具有 global tags 文件时,才自动开启 citre。
  (setq citre-auto-enable-citre-mode-modes
	'(
	  c-mode
	  c-ts-mode
	  rust-ts-mode
	  ;; go-mode
	  ;; go-ts-mode
	  ))

  ;; 使用 eglot-managed-mode-hook 而非 find-file-hook,从而确保 citre-mode 在 eglot
  ;; 启动后才开启。

  ;; 执行 citre-auto-enable-citre-mode 而非 citre-mode 命令:

  ;; 1. 前者会检查 citre-auto-enable-citre-mode-modes 变量中的 major mode 和项目是否
  ;; 有 global tags文件,只有两者均满足时,才开启 citre。

  ;; 2. 后者是不管 major mode 类型和是否有 tags 文件,均开启 citre。
  (add-hook 'eglot-managed-mode-hook #'citre-auto-enable-citre-mode)

  (define-key citre-mode-map (kbd "s-.") 'citre-jump)
  (define-key citre-mode-map (kbd "s-,") 'citre-jump-back)
  (define-key citre-mode-map (kbd "s-?") 'citre-peek-reference)
  (define-key citre-mode-map (kbd "C-c d p") 'citre-peek)
  (define-key citre-peek-keymap (kbd "s-n") 'citre-peek-next-line)
  (define-key citre-peek-keymap (kbd "s-p") 'citre-peek-prev-line)
  (define-key citre-peek-keymap (kbd "s-N") 'citre-peek-next-tag)
  (define-key citre-peek-keymap (kbd "s-P") 'citre-peek-prev-tag))

9 agent-shell
#

agent-shell 使用 ACP 来连接 code agent。

安装 codex acp: npm install -g @agentclientprotocol/codex-acp

Qoder CLI 使用 qoder --acp 接入,先在终端运行 qoder login 登录,ACP 会复用登录状态。

agent-shell :

  • /rename session-name :自定义 codex 保存的 session 名称,这样后续新建 shell 时可以方便的识别和恢复历史回话。
(use-package agent-shell
  :ensure t
  :config
  (setq agent-shell-openai-authentication (agent-shell-openai-make-authentication :login t))

  ;; 配置 qoder agent 支持。
  (defun my/agent-shell-qoder-make-config ()
    "Create a Qoder ACP agent configuration."
    (agent-shell-make-agent-config
     :identifier 'qoder
     :mode-line-name "Qoder"
     :buffer-name "Qoder"
     :shell-prompt "Qoder> "
     :shell-prompt-regexp "Qoder> "
     :welcome-function (lambda (_config) "Qoder CLI via ACP")
     :needs-authentication nil
     :client-maker
     (lambda (buffer)
       (agent-shell--make-acp-client
        :command "qoder"
        :command-params '("--acp")
        :environment-variables
        (agent-shell-make-environment-variables :inherit-env t)
        :context-buffer buffer))))

  ;; 只启用 codex 和 qoder agent 类型。
  (setq agent-shell-agent-configs
        '(agent-shell-openai-make-codex-config
          my/agent-shell-qoder-make-config))

  (setq agent-shell-openai-codex-environment (agent-shell-make-environment-variables :inherit-env t))
  (setq agent-shell-session-strategy 'prompt
        agent-shell-session-restore-verbosity 'first-last ;; 恢复回话时默认显示第一个和最后一个消息。
        agent-shell-show-session-id t)
  (setq agent-shell-activity-group-expand-by-default 'latest))

;; Agent 主缓冲区和 viewport 依赖活动会话,不能作为普通 buffer 恢复。
(with-eval-after-load 'persp-mode
  (defun my/persp-agent-shell-buffer-p (buffer)
    "Return non-nil when BUFFER requires a live Agent Shell session."
    (with-current-buffer buffer
      (derived-mode-p 'agent-shell-mode
                      'agent-shell-viewport-view-mode
                      'agent-shell-viewport-edit-mode)))

  (defun my/persp-skip-saved-agent-shell-buffer (savelist)
    "Skip Agent Shell session buffers in existing perspective save files."
    (when (and (eq (car-safe savelist) 'def-buffer)
               (memq (nth 3 savelist)
                     '(agent-shell-mode
                       agent-shell-viewport-view-mode
                       agent-shell-viewport-edit-mode)))
      'skip))

  (add-hook 'persp-filter-save-buffers-functions
            #'my/persp-agent-shell-buffer-p)
  (add-hook 'persp-load-buffer-functions
            #'my/persp-skip-saved-agent-shell-buffer))


(use-package agent-shell-hq
  :vc (:url "https://github.com/sreenivasvrao/agent-shell-hq" :rev :newest :lisp-dir ".")
  :bind (
	 ("C-c C-;" . agent-shell-hq-toggle)
	 ("C-c C-:" . agent-shell-hq-peek)
	 )
  )

agent-shell 将 input 历史以 ASCII 编码的方式保存到 ~/.emacs.d/agent/history 文件, 这会导致后续 M-n/M-p 修改历史输入时, 历史输入中的中文会被以转移序列的方式显示,不可读。这里将中文以 UTF8 格式存入历史文件并解码为中文显示:

(defun my/agent-shell-byte-in-range-p (string index minimum maximum)
  (and (< index (length string))
       (<= minimum (aref string index) maximum)))

(defun my/agent-shell-valid-utf-8-bytes-p (string)
  (let ((index 0)
        (length (length string)))
    (catch 'invalid
      (while (< index length)
        (let ((byte (aref string index)))
          (cond
           ((<= byte #x7f)
            (setq index (1+ index)))
           ((and (<= #xc2 byte #xdf)
                 (my/agent-shell-byte-in-range-p string (1+ index) #x80 #xbf))
            (setq index (+ index 2)))
           ((and (= byte #xe0)
                 (my/agent-shell-byte-in-range-p string (1+ index) #xa0 #xbf)
                 (my/agent-shell-byte-in-range-p string (+ index 2) #x80 #xbf))
            (setq index (+ index 3)))
           ((and (or (<= #xe1 byte #xec) (<= #xee byte #xef))
                 (my/agent-shell-byte-in-range-p string (1+ index) #x80 #xbf)
                 (my/agent-shell-byte-in-range-p string (+ index 2) #x80 #xbf))
            (setq index (+ index 3)))
           ((and (= byte #xed)
                 (my/agent-shell-byte-in-range-p string (1+ index) #x80 #x9f)
                 (my/agent-shell-byte-in-range-p string (+ index 2) #x80 #xbf))
            (setq index (+ index 3)))
           ((and (= byte #xf0)
                 (my/agent-shell-byte-in-range-p string (1+ index) #x90 #xbf)
                 (my/agent-shell-byte-in-range-p string (+ index 2) #x80 #xbf)
                 (my/agent-shell-byte-in-range-p string (+ index 3) #x80 #xbf))
            (setq index (+ index 4)))
           ((and (<= #xf1 byte #xf3)
                 (my/agent-shell-byte-in-range-p string (1+ index) #x80 #xbf)
                 (my/agent-shell-byte-in-range-p string (+ index 2) #x80 #xbf)
                 (my/agent-shell-byte-in-range-p string (+ index 3) #x80 #xbf))
            (setq index (+ index 4)))
           ((and (= byte #xf4)
                 (my/agent-shell-byte-in-range-p string (1+ index) #x80 #x8f)
                 (my/agent-shell-byte-in-range-p string (+ index 2) #x80 #xbf)
                 (my/agent-shell-byte-in-range-p string (+ index 3) #x80 #xbf))
            (setq index (+ index 4)))
           (t (throw 'invalid nil)))))
      t)))

(defun my/agent-shell-history-entry (entry)
  (if (and (stringp entry)
           (not (multibyte-string-p entry))
           (string-match-p "[\200-\377]" entry)
           (my/agent-shell-valid-utf-8-bytes-p entry))
      (condition-case nil
          (decode-coding-string entry 'utf-8)
        (error entry))
    entry))

(defun my/agent-shell-normalize-input-ring ()
  (when (ring-p comint-input-ring)
    (let* ((ring comint-input-ring)
           (entries (mapcar #'my/agent-shell-history-entry (ring-elements ring)))
           (normalized (make-ring (ring-size ring))))
      (dolist (entry (reverse entries))
        (ring-insert normalized entry))
      (setq comint-input-ring normalized))))

(with-eval-after-load 'agent-shell
  (add-hook 'agent-shell-mode-hook #'my/agent-shell-normalize-input-ring))

配置 Transcript 和 Screenshots 统一保存到 ~/aiwork/projects/<项目名>/.agent-shell/ ,文件名包含启动时间和会话标题。标题尚未生成时使用 untitled ;收到标题更新(包括 /rename )后重命名同一份文件,保留原时间戳和内容。文件名格式为 20261123T1223-<session name>.md (本地时间,精确到分钟);同一分钟内同名会话追加 -1=、 =-2 等序号,避免覆盖。预占的空文件在首次写入时初始化标准元信息头部;已有正文不会被重新初始化。

(defvar my/agent-shell-transcript-root "~/aiwork/projects/"
  "Root directory for Agent Shell data grouped by project.")
(defvar-local my/agent-shell-transcript-stamp nil)
(defvar-local my/agent-shell-transcript-path nil)

(defun my/agent-shell-transcript-component (text)
  "Turn TEXT into a bounded, safe filename component, preserving Chinese."
  (let ((name (string-trim
               (replace-regexp-in-string
                "[[:cntrl:][:space:]/\\\\:*?\"<>|]+" "-" (or text ""))
               "[ .-]+" "[ .-]+")))
    (if (string-empty-p name) "untitled"
      (truncate-string-to-width name 60))))

(defun my/agent-shell-dot-subdir (subdir)
  "Resolve Agent Shell SUBDIR under the centralized project data directory."
  (expand-file-name
   subdir
   (expand-file-name
    ".agent-shell/"
    (expand-file-name
     (my/agent-shell-transcript-component (agent-shell--project-name))
     (expand-file-name my/agent-shell-transcript-root)))))

(defun my/agent-shell-transcript-title-updated (event)
  "Keep the transcript filename in sync with session title EVENT."
  (when (and (memq (map-elt event :event)
                   '(session-title-changed init-session session-restored))
             my/agent-shell-transcript-path
             (equal agent-shell--transcript-file my/agent-shell-transcript-path))
    (let* ((title (map-nested-elt agent-shell--state '(:session :title)))
           (base (expand-file-name
                  (concat my/agent-shell-transcript-stamp "-"
                          (my/agent-shell-transcript-component title))
                  (file-name-directory my/agent-shell-transcript-path)))
           (target (concat base ".md"))
           (suffix 1))
      ;; Never overwrite a transcript belonging to another shell.
      (while (and (file-exists-p target)
                  (not (equal target my/agent-shell-transcript-path)))
        (setq target (format "%s-%d.md" base suffix)
              suffix (1+ suffix)))
      (unless (equal target my/agent-shell-transcript-path)
        (condition-case err
            (progn
              (when (file-exists-p my/agent-shell-transcript-path)
                (rename-file my/agent-shell-transcript-path target))
              (setq my/agent-shell-transcript-path target
                    agent-shell--transcript-file target))
          (file-error
           (message "Could not rename Agent Shell transcript: %s"
                    (error-message-string err))))))))

(defun my/agent-shell-transcript-file-path ()
  "Allocate a project transcript and follow subsequent session title changes."
  (or my/agent-shell-transcript-path
      (let* ((directory (agent-shell--dot-subdir "transcripts"))
             (title (map-nested-elt agent-shell--state '(:session :title))))
        (setq my/agent-shell-transcript-stamp (format-time-string "%Y%m%dT%H%M"))
        (let* ((base (expand-file-name
                      (concat my/agent-shell-transcript-stamp "-"
                              (my/agent-shell-transcript-component title))
                      directory))
               (target (concat base ".md"))
               (suffix 1))
          (while (file-exists-p target)
            (setq target (format "%s-%d.md" base suffix)
                  suffix (1+ suffix)))
          ;; Reserve the filename before another shell starts in the same minute.
          (write-region "" nil target nil 'silent nil 'excl)
          (setq my/agent-shell-transcript-path target))
        (agent-shell-subscribe-to
         :shell-buffer (current-buffer)
         :on-event #'my/agent-shell-transcript-title-updated)
        my/agent-shell-transcript-path)))

(defun my/agent-shell-initialize-reserved-transcript ()
  "Initialize only this shell's empty reservation before upstream appends.
Defer metadata collection until the first write, when session/model are known.
Never rewrite a nonempty transcript or a file owned by another path provider."
  (when (and (derived-mode-p 'agent-shell-mode)
             my/agent-shell-transcript-path
             (equal agent-shell--transcript-file my/agent-shell-transcript-path)
             (file-regular-p agent-shell--transcript-file)
             (zerop (file-attribute-size
                     (file-attributes agent-shell--transcript-file))))
    (let ((agent-name (or (map-nested-elt agent-shell--state '(:agent-config :mode-line-name))
                          (map-nested-elt agent-shell--state '(:agent-config :buffer-name))
                          "Unknown Agent"))
          (session-id (map-nested-elt agent-shell--state '(:session :id)))
          (model-id (map-nested-elt agent-shell--state '(:session :model-id))))
      ;; Match agent-shell--ensure-transcript-file's standard Markdown header.
      ;; Let write errors propagate: appending a body must not hide a failed header.
      (write-region
       (format "# Agent Shell Transcript\n\n**Agent:** %s\n**Started:** %s\n**Working Directory:** %s%s%s\n\n---\n\n"
               agent-name (format-time-string "%F %T") (agent-shell-cwd)
               (if session-id (format "\n**Session ID:** %s" session-id) "")
               (if model-id (format "\n**Model:** %s" model-id) ""))
       nil agent-shell--transcript-file nil 'silent))))

(with-eval-after-load 'agent-shell
  (advice-add 'agent-shell--ensure-transcript-file :before
              #'my/agent-shell-initialize-reserved-transcript)
  ;; 配置 agent-shell 使用上面自定义的 transcript 文件路径函数。
  (setq agent-shell-dot-subdir-function #'my/agent-shell-dot-subdir
        agent-shell-transcript-file-path-function
        #'my/agent-shell-transcript-file-path))

agent-shell 的项目会话列表来自后端 agent。你当前使用 Codex,因此历史保存在 Codex 的本地存储中;刚配置的 Markdown transcript 目录是另外一份可读记录。

获取流程如下:

  1. agent-shell 用 agent-shell-cwd 获取项目工作目录,通常是项目根目录,例如 /Users/alizj/emacs。
  2. 通过 ACP 发送 session/list,携带 cwd 和分页游标。
  3. 你安装的 codex-acp 1.11.0 调用 Codex App Server 的 thread/list,再按工作目录过滤结果。
  4. 返回 session ID、标题、工作目录和更新时间;agent-shell 按最近更新时间排序,显示选择列表。本地适配器实现

这里匹配的是项目路径,不是 emacs 这样的项目简称。 移动项目目录后,原会话可能不会出现在新路径的列表中(因为用新的目录过滤失败) 。选择历史会话后,agent-shell 会请求后端加载或恢复对应 session。Codex 的 thread/list、thread/resume 等接口负责这类操作。因此,修改 transcript 保存目录不会改变历史会话列表或恢复机制;单独保存这些 .md 文件,也不能替代 Codex 原始会话数据的备份。

agent-rcall 搜索和恢复 transcript:

M-x agent-recall-reindex
索引
M-x agent-recall-search
搜索
M-x agent-recall-browse
浏览
M-x agent-recall-resume
恢复回话
;; transcripts 搜索和恢复。
(use-package agent-recall
  :ensure t
  :config
  ;; 在该目录下递归搜索 .agent-shell/transcripts 目录中的 markdown 文件。
  (setq agent-recall-search-paths '("~/aiwork/projects"))
  (setq agent-recall-search-function 'consult-ripgrep)
  ;;Session tracking:To automatically embed session IDs in new transcripts (enabling instant
  ;;resume):
  (add-hook 'agent-shell-mode-hook #'agent-recall-track-sessions)
  )

10 终端
#

使用性能和功能支持更好的 ghostel

需要先安装编译 ghostty 的 zig 编译器: brew install zig

安装和配置 ghostel 包:

(use-package ghostel
  :vc (:url "https://github.com/dakra/ghostel"
	    :lisp-dir "lisp"
	    :rev :newest)
  :init
  (defun my/ghostel-reject-nested-minibuffer (&rest _)
    "Reject terminal commands while another minibuffer is still active."
    (when (> (minibuffer-depth) 0)
      (user-error "请先退出当前 minibuffer,再选择或创建 Ghostel 终端")))

  (defun my/toggle-ghostel-panel ()
    "Toggle a ghostel terminal in a regular window along the bottom of the frame."
    (interactive)
    (my/ghostel-reject-nested-minibuffer)
    (if-let* ((win (seq-find (lambda (w)
                               (with-current-buffer (window-buffer w)
                                 (derived-mode-p 'ghostel-mode)))
                             (window-list))))
        (delete-window win)
      (let ((display-buffer-overriding-action
             '((display-buffer-at-bottom)
               (window-height . 0.33)
               (dedicated . t)
               (preserve-size . (nil . t)))))
	;; 远程路径跳过项目探测,防止卡住;本地路径兼容不在 project 的情况。
	(if (or (file-remote-p default-directory)
		(not (project-current nil)))
	    (consult-ghostel)
	  (consult-ghostel-project))
	)))

  :bind (
	 ("C-`" . my/toggle-ghostel-panel)
	 ("C-x m" . ghostel)
         :map ghostel-semi-char-mode-map
         ("C-s"  . consult-line)
         ("C-k"  . my/ghostel-send-C-k-and-kill)
         ("M-p" . (lambda () (interactive) (ghostel-send-key "p" "ctrl")))
         ("M-n" . (lambda () (interactive) (ghostel-send-key "n" "ctrl")))
         :map project-prefix-map
         ("m" . ghostel-project)
         ("M" . ghostel-project-list-buffers))
  :config
  (defun my/ghostel-send-C-k-and-kill ()
    "Send `C-k' to ghostel.
Like normal Emacs `C-k'.  Kill to end of line and put content in kill-ring."
    (interactive)
    (kill-ring-save (point) (line-end-position))
    (ghostel-send-key "k" "ctrl"))

  (dolist (command '(ghostel ghostel-project))
    (advice-add command :before #'my/ghostel-reject-nested-minibuffer))
  (add-to-list 'project-switch-commands '(ghostel-project "Ghostel") t)
  (add-to-list 'project-switch-commands '(ghostel-project-list-buffers "Ghostel buffers") t)
  (add-to-list 'ghostel-eval-cmds '("magit-status-setup-buffer" magit-status-setup-buffer)))

;; 下面这些 ghostel-* package 是 ghostel 内置的,不需要额外安装。
(use-package ghostel-eshell
  :ensure nil
  :hook (eshell-load . ghostel-eshell-visual-command-mode))

(use-package ghostel-compile
  :ensure nil
  :hook (after-init . ghostel-compile-global-mode))

(use-package ghostel-comint
  :ensure nil
  :hook (after-init . ghostel-comint-global-mode))

;; ghostel ime 输入法集成。
(use-package ghostel-ime
  :ensure nil
  :hook (ghostel-mode . ghostel-ime-mode))

;; consult-ghostel 是 ghostel 项目自带的 extention
(use-package consult-ghostel
  :vc (:url "https://github.com/dakra/ghostel"
	    :lisp-dir "extensions/consult-ghostel"
	    :rev :newest)
  :after (ghostel consult)
  :demand t
  :config
  (dolist (command '(consult-ghostel consult-ghostel-project))
    (advice-add command :before #'my/ghostel-reject-nested-minibuffer))
  :bind (("C-x m" . consult-ghostel)
         :map project-prefix-map
         ("m" . consult-ghostel-project)
         :map ghostel-semi-char-mode-map
         ("C-c h" . consult-ghostel-history)))

ghostel 提供如下几种输入模式(input mode):

  1. semi-char mode (C-c C-j): 缺省模式,几乎转发所有按键到 terminal(除了 C-c,M-x,C-h 等少数位于 ghostel-keymap-exceptions 中定义的按键)。
  2. char mode (C-c M-d): 所有按键都会发送给 terminal(适用于捕获 C-c,M-x,C-h 等按键的终端程序), 按 M-RET 退出并返回到 semi-char mode。
  3. line mode (C-c C-l): 和 M-x 类似, 处于正常的 emacs buffer 编辑模式, 不会发送给 terminal 和 shell, 直到按了回车才把内容发送给 terminal。
  4. emacs (C-c C-e) and copy mode (C-c C-t): 都将 buffer 置为 readonly 模式, 区别是: emacs buffer 的 terminal 还在运行,有 live 输出。而 copy mode 的 buffer 的 terminal 则被冻结,终端没有新输出。
    • 处于 emacs 模式下,可以使用 emacs 正常 edit buffer mode 的各种快捷键,如 isearch-forward, occur, M-x, C-SPC + M-w。

C-c C-q 自动进入(默认为 copy mode)或退出 readonly mode。

ghostel 自动启用 copy mode 的场景:1. 鼠标选择;2. 超链接导航 (C-c C-n/p); 3. 激活 mark。

ghostel 在启用 copy mode 的场景下,如果 copy 了一些内容,或输入一些内容,则会自动关闭 copy mode。

在 ghostel buffer 中支持鼠标操作:

  1. 单击点击鼠标,然后拖动,会进入 copy mode 并选择一个区域。
  2. 双击鼠标,选择一个 word;
  3. 三击鼠标,选择一行;

semi-char mode 下的快捷键:

Key Action Most keys Sent directly to the terminal C-c C-c Send interrupt (C-c) C-c C-z Send suspend (C-z) C-c C-d Send EOF (C-d) C-c C-\ Send quit (C-\) C-c M-w Copy entire scrollback to kill ring C-y Yank from kill ring (bracketed paste) M-y Yank-pop (cycle through kill ring) C-c C-y Paste from kill ring C-c M-l Clear scrollback C-c C-n Jump to next hyperlink C-c C-p Jump to previous hyperlink C-c M-n Enter read-only mode, jump to next prompt C-c M-p Enter read-only mode, jump to prev. prompt C-x C-q Enter ghostel-readonly-default-mode C-q (also C-c C-q) Send next key literally (escape hatch) Mouse wheel Scroll through scrollback

eshell:

(setq eshell-history-size 300)
(setq explicit-shell-file-name "/bin/bash")
(setq shell-file-name "/bin/bash")
(setq shell-command-prompt-show-cwd t)
(setq explicit-bash-args '("--noediting" "--login" "-i"))
;; 提示符只读
(setq comint-prompt-read-only t)
;; 命令补全
(setq shell-command-completion-mode t)
;; 高亮模式
(autoload 'ansi-color-for-comint-mode-on "ansi-color" nil t)
(add-hook 'shell-mode-hook 'ansi-color-for-comint-mode-on t)
(setenv "SHELL" shell-file-name)
(setenv "ESHELL" "bash")
(add-hook 'comint-output-filter-functions 'comint-strip-ctrl-m)

;; 在当前 frame 下方打开或关闭 eshell buffer。
(defun startup-eshell ()
  "Fire up an eshell buffer or open the previous one"
  (interactive)
  (if (get-buffer-window "*eshell*<42>")
      (delete-window (get-buffer-window "*eshell*<42>"))
    (progn
      (eshell 42))))
(global-set-key (kbd "s-`") 'startup-eshell)

(add-to-list 'display-buffer-alist
	     '("\\*eshell\\*<42>"
	       (display-buffer-below-selected display-buffer-at-bottom)
	       (inhibit-same-window . t)
	       (window-height . 0.33)))

;; eshell history 使用 consult-history。
(load-library "em-hist.el")
(keymap-set eshell-hist-mode-map "C-s" #'consult-history)
(keymap-set eshell-hist-mode-map "C-r" #'consult-history)
;; 重置 M-r/s 快捷键,这样 consult-line 等可用。
(define-key eshell-hist-mode-map (kbd "M-r") nil)
(define-key eshell-hist-mode-map (kbd "M-s") nil)

11 ssh
#

consult-dir 提供 ssh host 自动补全功能。

使用 tramp 成功 ssh 登录远程机器的注意事项:

  • tramp 通过 ssh 登录时,如果需要账号密码,则默认从 ~/.authinfo.gpg 中读取,该文件支持使用 default machine 名称来指定缺省账号密码。
  • ~/.ssh/configHost * 中关闭 host key check 和默认尝试的多个私钥(最多配置 3 个私钥,否则可能会导致认证失败超过 SSH Server 默认的 6 次,从而没有机会使用兜底的 password 认证。)
  • 如果直接在终端中使用 ssh 登录,则可以使用 bash alias fj 来登录目标机器,如 fj 230E ,它使用 sshpass 来提供默认密码。

避免打开远程文件、目录或终端卡住的做法:

  • 远程 VC 排除、project-current 拦截、远程面板改用 consult-ghostel。
  • 普通 Dired 图标仅本地启用。
  • auto-revert-remote-files nil、Eglot 仅本地自动启动。
  • recentf 排除远程文件,自动保存统一写本地目录。
  • Marginalia:本机版本已跳过远程文件详细属性查询,无需全局关闭。
  • ghostel-comint:此前确认没有接管 TRAMP 通信缓冲区,无需因此禁用。

为了为 ssh tramp 提供最佳性能,可以在 ~/.ssh/configHost * 中配置连接回话保持特性。

(use-package consult-dir
  :ensure t
  :bind (("C-x C-d" . consult-dir)
         :map vertico-map
         ("C-x C-d" . consult-dir))
  :config
  (defun my/consult-dir-ssh-hosts ()
    "从多个 SSH 配置文件收集主机,并去重。"
    (delete-dups
     (mapcan #'consult-dir--tramp-parse-config
             '("~/.ssh/config"
               "~/old/backup-pc/work/proxylist/hosts_config"))))

  (defvar my/consult-dir-source-ssh
    `(:name "SSH hosts"
	    :narrow ?s
	    :category file
	    :face consult-file
	    :history file-name-history
	    :items ,#'my/consult-dir-ssh-hosts))

  (add-to-list 'consult-dir-sources
               'my/consult-dir-source-ssh t))

;; 先用 consult-dir 选择 SSH 主机,再纯文本输入路径,避免实时目录补全,防止远程卡住。
;; C-x C-f 仍是内置 find-file;
(defun my/find-remote-file-plain (&optional path)
  "使用 consult-dir 选择 SSH 主机,再纯文本输入远程路径。
非交互调用时,若提供 PATH,则直接打开。"
  (interactive)
  (if path
      (find-file path)
    (require 'consult-dir)
    (let ((default-directory (expand-file-name "~/"))
          ;; 只读取你配置的 SSH 主机源。
          (consult-dir-sources '(my/consult-dir-source-ssh))
          (consult-preview-key nil)
          ;; 本次选择不显示候选附加信息。
          (marginalia-annotators
           '((file none) (multi-category none)))
          (consult-dir-default-command
           (lambda ()
             (interactive)
             (let* ((initial (file-name-as-directory default-directory))
                    ;; 输入期间使用本地目录,减少 hooks 触发远程查询。
                    (default-directory (expand-file-name "~/"))
                    (target (read-string "远程路径: " initial)))
               (find-file target)))))
      (consult-dir))))


(with-eval-after-load 'ghostel
  (setq
   ;; 开启后,Ghostel 会向远端传输临时集成脚本,提供目录跟踪、提示符导航和 ghostel_cmd。
   ghostel-tramp-shell-integration t
   ;; auto 会随之启用所需的 terminfo 安装。
   ;; 注意不能在 ~/.ssh/config 的 Host * 中设置 SetEnv TERM=xterm-256color。
   ghostel-ssh-install-terminfo 'auto))

(with-eval-after-load 'tramp
  (setq tramp-default-method "ssh"
	;;遇到问题可以临时设置为 6 来进行排查。
        tramp-verbose 3)
  ;; VC 忽略所有 TRAMP 远程路径,本地文件继续启用 VC,防止卡住。
  (require 'vc-hooks)
  (setq vc-ignore-dir-regexp
        (concat "\\(?:" vc-ignore-dir-regexp "\\)\\|"
                "\\(?:" tramp-file-name-regexp "\\)"))
  )

(with-eval-after-load 'tramp-sh
  ;; 让 TRAMP 遵循 ~/.ssh/config 中的 Control* / Proxy*。
  (setq tramp-use-connection-share nil))

12 其它
#

使用 GNU 系列替换 MacOS 自带的 BSD 风格的 coreutils 包:

which tac || brew install coreutils
which trash || brew install trash
(use-package emacs
  :init
  ;; 粘贴于光标处, 而不是鼠标指针处。
  (setq mouse-yank-at-point t)
  (setq initial-major-mode 'fundamental-mode)
  ;; 按中文折行。
  (setq word-wrap-by-category t)
  ;; 退出时保留前文的进程确认设置 confirm-kill-processes=t。
  (setq use-short-answers t)
  (setq confirm-kill-emacs #'y-or-n-p)
  (setq ring-bell-function 'ignore)
  ;; 不显示行号, 否则鼠标会飘。
  (add-hook 'artist-mode-hook (lambda () (display-line-numbers-mode -1)))
  ;; bookmark 发生变化时自动保存(默认是 Emacs 正常退出时保存)。
  (setq bookmark-save-flag 1)

  ;; 不创建 lock 文件。
  ;;(setq create-lockfiles nil)

  ;; 启动 Server 。
  (unless (and (fboundp 'server-running-p)
               (server-running-p))
    (server-start)))

(use-package hydra :commands defhydra)

历史记录:

(use-package recentf
  :config
  (setq recentf-save-file "~/.emacs.d/recentf")

  ;; 自动清理 recentf 记录(无效的、重复的、被 exclude 的等),防止已经删除的文件继续
  ;; 出现在 consult-buffer 列表中
  (setq recentf-auto-cleanup 'mode)

  ;; 每 5min 以及 emacs 退出时保存 recentf-list。
  ;;(run-at-time nil (* 5 60) 'recentf-save-list)
  ;;(add-hook 'kill-emacs-hook #'recentf-save-list)

  (setq recentf-max-menu-items 100)
  (setq recentf-max-saved-items 100)

  ;; 排除规则使用正则表达式;家目录路径按 recentf 的文件名格式规范化。
  ;;; emacs-dashboard 不显示这里排除的文件。
  (setq recentf-exclude
        `(
          ,(recentf-expand-file-name "~/.emacs.d/\\(straight\\|ln-cache\\|etc\\|var\\|.cache\\|backup\\|elfeed\\|elpa\\)/.*")
          ,(recentf-expand-file-name "~/.emacs.d/\\(recentf\\|bookmarks\\|archived.org\\)")
          ,(recentf-expand-file-name "~/go/pkg/mod/.*")
	  ;;忽略 agent-shell生成的文件。
          ,(recentf-expand-file-name "~/aiwork/projects/[^/]+/\\.agent-shell/.*")
          ;; 安装的软件包及 Rust 工具链不进入最近文件记录。
          "\\`/opt/homebrew/Cellar/"
          ,(concat "\\`"
                   (regexp-quote
                    (recentf-expand-file-name "~/.rustup/toolchains/")))
          ;; Homebrew Go SDK 源码:兼容版本升级及 opt 符号链接路径。
          "/Cellar/go/[^/]+/libexec/src/"
          "/opt/go/libexec/src/"
          ;; 第三方依赖源码:不进入最近文件记录(包括 xref 访问)。
          ,(concat "\\`"
                   (regexp-quote
                    (file-name-as-directory
                     (recentf-expand-file-name (or (getenv "CARGO_HOME") "~/.cargo"))))
                   "\\(registry/src\\|git/checkouts\\)/")
          "/\\(site-packages\\|dist-packages\\)/"
          "/\\(\\.venv\\|venv\\|\\.virtualenvs\\|\\.tox\\|\\.nox\\)/"
          "/node_modules/"
          ;; 不在 recentf 中记录 tramp 文件,防止 tramp 扫描时卡住。
          ,tramp-file-name-regexp
          "^/tmp"
          "\\.bak\\'"
          "\\.gpg\\'"
          "\\.gz\\'"
          "\\.tgz\\'"
          "\\.xz\\'"
          "\\.zip\\'"
          "^/ssh:"
          "\\.png\\'"
          "\\.jpg\\'"
          "/\\.git/"
          "\\.gitignore\\'"
          "\\.log\\'"
          "COMMIT_EDITMSG"
	  ".DS_Store"
          "\\.pyi\\'"
          "\\.pyc\\'"
          "/private/var/.*"
          "/var/folders/.*"
          "^/usr/local/Cellar/.*"
          ".*/vendor/.*"
          ".*/target/.*"
          "/Applications/.*"
          ,(concat package-user-dir "/.*-autoloads\\.egl\\'")))
  (recentf-mode 1))

搜索 grep/isearch:

(use-package grep
  :config
  (setq grep-highlight-matches t)
  (setq grep-find-ignored-directories
        (append (list ".git" ".cache" "vendor" "node_modules" "target")
                grep-find-ignored-directories))
  (setq grep-find-ignored-files
        (append (list "*.blob" "*.gz" "TAGS" "projectile.cache" "GPATH" "GRTAGS" "GTAGS" "TAGS" ".project" )
                grep-find-ignored-files)))

(global-set-key "\C-cn" 'find-dired)
(global-set-key "\C-cN" 'grep-find)

(setq isearch-allow-scroll 'unlimited)
;; 显示当前和总的数量。
(setq isearch-lazy-count t)
(setq isearch-lazy-highlight t)

diff/ediff:

;; diff
(use-package diff-mode
  :init
  (setq diff-default-read-only t)
  (setq diff-advance-after-apply-hunk t)
  (setq diff-update-on-the-fly t))

(use-package ediff
  :ensure nil

  ;; Emacs 31.1 Ediff 循环加载兼容:
  ;; ediff-diff-options 的 :set setter 会在这两个变量定义前运行。
  ;; 上游修复后可删除。
  :preface
  (defvar ediff-ignore-case nil)
  (defvar ediff-diff3-options "")

  :config
  (setq ediff-keep-variants nil)
  (setq ediff-split-window-function 'split-window-horizontally)
  ;; 不创建新的 frame 来显示 Control-Panel。
  (setq ediff-window-setup-function #'ediff-setup-windows-plain))

剪贴板和字符编码:

;; 使用系统剪贴板,实现与其它程序相互粘贴。
(setq x-select-enable-clipboard t)
(setq select-enable-clipboard t)
(setq x-select-enable-primary t)
(setq select-enable-primary t)

;; UTF8 字符。
(prefer-coding-system 'utf-8)
(setq locale-coding-system 'utf-8
      default-buffer-file-coding-system 'utf-8)
(set-buffer-file-coding-system 'utf-8)
(set-language-environment "UTF-8")
(setq-default buffer-file-coding-system 'utf8)
(set-default-coding-systems 'utf-8)
(setenv "LC_ALL" "zh_CN.UTF-8")

buffer/file:

(use-package ibuffer
  :config
  (setq ibuffer-expert t)
  (setq ibuffer-use-other-window nil)
  (setq ibuffer-movement-cycle nil)
  (setq ibuffer-default-sorting-mode 'recency)
  (setq ibuffer-use-header-line t)
  (add-hook 'ibuffer-mode-hook #'hl-line-mode)
  (global-set-key (kbd "C-x C-b") #'ibuffer))

;; 保存 Buffer 时自动更新 #+LASTMOD: 时间戳。
(setq time-stamp-start "#\\+\\(LASTMOD\\|lastmod\\):[ \t]*")
(setq time-stamp-end "$")
(setq time-stamp-format "%Y-%m-%dT%H:%M:%S%5z")
;; #+LASTMOD: 必须位于文件开头的 line-limit 行内, 否则自动更新不生效。
(setq time-stamp-line-limit 30)
(add-hook 'before-save-hook 'time-stamp t)

;; 以下自定义函数参考自:https://github.com/jiacai2050/dotfiles/blob/master/.config/emacs/i-edit.el
(defun my/json-format ()
  (interactive)
  (save-excursion
    (if mark-active
        (json-pretty-print (mark) (point))
      (json-pretty-print-buffer))))

(defun my/delete-file-and-buffer (buffername)
  "确认后删除 BUFFERNAME 访问的文件,并关闭该 buffer。"
  (interactive "b删除文件及 buffer: ")
  (let ((buffer (get-buffer buffername)))
    (unless (buffer-live-p buffer)
      (user-error "Buffer 不存在: %s" buffername))
    (with-current-buffer buffer
      (unless buffer-file-name
        (user-error "该 buffer 没有访问文件"))
      (when (buffer-modified-p)
        (user-error "请先保存或撤销该 buffer 的修改,再删除文件"))
      (when (yes-or-no-p (format "删除文件 %s 并关闭 buffer? " buffer-file-name))
        (delete-file buffer-file-name t)
        (kill-buffer buffer)))))

(defun my/diff-buffer-with-file ()
  "Compare the current modified buffer with the saved version."
  (interactive)
  (let ((diff-switches "-u")) ;; unified diff
    (diff-buffer-with-file (current-buffer))
    (other-window 1)))

(defun my/copy-current-filename-to-clipboard ()
  "Copy `buffer-file-name' to system clipboard."
  (interactive)
  (let ((filename (if-let* (f buffer-file-name)
                      f
                    default-directory)))
    (if filename
        (progn
          (message (format "Copying %s to clipboard..." filename))
          (kill-new filename))
      (message "Not a file..."))))

;; 普通文件使用内置重命名;VC 文件保留 backend 的重命名行为。
(defun my/rename-this-buffer-and-file (new-location)
  "安全重命名当前文件;VC 文件须先保存,目标文件不得已存在。"
  (interactive (list (read-file-name "重命名为: ")))
  (require 'vc)
  (if (and buffer-file-name (vc-backend buffer-file-name))
      (progn
        (when (buffer-modified-p)
          (user-error "请先保存 VC 文件,再执行重命名"))
        (vc-rename-file buffer-file-name new-location))
    (rename-visited-file new-location)))
(global-set-key (kbd "C-c f r") #'my/rename-this-buffer-and-file)
;; C-x C-r 保留内置 find-file-read-only。

C-a/C-e 移动到行或代码的开头、结尾:

(use-package mwim
  :config
  (define-key global-map [remap move-beginning-of-line] #'mwim-beginning-of-code-or-line)
  (define-key global-map [remap move-end-of-line] #'mwim-end-of-code-or-line))

增量扩展选择的区域:

(use-package expand-region
  :config
  (global-set-key (kbd "C-=") #'er/expand-region))

自动备份:

(defvar backup-dir (expand-file-name "~/.emacs.d/backup/"))
(if (not (file-exists-p backup-dir))
    (make-directory backup-dir t))
;; 文件第一次保存时备份。
(setq make-backup-files t)
(setq backup-by-copying t)
;; 是否备份由 predicate 决定;目录规则中的 nil 并不表示禁用备份。
(defun my/backup-enable-predicate (name)
  "仅备份本地文件,并保留 Emacs 对临时文件的默认排除规则。"
  (and (not (file-remote-p name))
       (normal-backup-enable-predicate name)))
(setq backup-enable-predicate #'my/backup-enable-predicate)
;; 允许备份的文件统一保存到 backup-dir。
(setq backup-directory-alist `((".*" . ,backup-dir)))
;; 备份文件时使用版本号。
(setq version-control t)
;; 删除过多的版本。
(setq delete-old-versions t)
(setq kept-new-versions 6)
(setq kept-old-versions 2)
;; 不备份版本控制的文件.
(setq vc-make-backup-files nil)

(defvar autosave-dir (expand-file-name "~/.emacs.d/autosave/"))
(if (not (file-exists-p autosave-dir))
    (make-directory autosave-dir t))

;; auto-save 访问的文件。
(setq auto-save-default t)
(setq auto-save-list-file-prefix autosave-dir)
(setq auto-save-file-name-transforms `((".*" ,autosave-dir t)))
(setq kill-buffer-delete-auto-save-files t)
(setq auto-save-include-big-deletions t)

Emacs 30 xwidget-webkit 对 Mac 支持不好( Better support for xwidget-webkit), 部分功能只有 GTK/X11才支持, 如: buffer 内搜索 increase-search/webkit-history:

  • 如果要复制 xwidget 的内容,需要选择后右击,从上下文菜单中选择 copy。
  • 如果 window 窗口较小,可以按 a 来自动调整(对应 xwidget-webkit-adjust-size-dispatch 命令)。
(setq url-user-agent
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36")
(setq xwidget-webkit-buffer-name-format "*webkit* [%T] - %U")
(setq xwidget-webkit-enable-plugins t)
(setq browse-url-firefox-program "/Applications/Firefox.app/Contents/MacOS/firefox")
;; browse-url-firefox, browse-url-default-macosx-browser
(setq browse-url-browser-function 'xwidget-webkit-browse-url)
(setq xwidget-webkit-cookie-file "~/.emacs.d/cookie.txt")

(add-hook 'xwidget-webkit-mode-hook
          (lambda ()
            ;;(setq kill-buffer-query-functions nil)
            (setq header-line-format nil)
            (display-line-numbers-mode 0)
            ;;(local-set-key "q" (lambda () (interactive) (kill-this-buffer)))
            (local-set-key (kbd "C-t") (lambda () (interactive) (xwidget-webkit-browse-url "https://google.com" t)))))

(defun my/browser-open-at-point (url)
  (interactive
   (list (let ((url (thing-at-point 'url)))
           (if (equal major-mode 'xwidget-webkit-mode)
               (read-string "url: " (xwidget-webkit-uri (xwidget-webkit-current-session)))
             (read-string "url: " url)))))
  (xwidget-webkit-browse-url url t))

(defun my/browser-search (query)
  (interactive "ssearch: ")
  (xwidget-webkit-browse-url
   (concat "https://duckduckgo.com?q=" (string-replace " " "%20" query)) t))

(define-prefix-command 'my-browser-prefix)
(global-set-key (kbd "C-c o") 'my-browser-prefix)
(define-key my-browser-prefix (kbd "o") 'my/browser-open-at-point)
(define-key my-browser-prefix (kbd "s") 'my/browser-search)

;; https://github.com/syl20bnr/spacemacs/issues/6587#issuecomment-232890021
;; make these keys behave like normal browser
(require 'xwidget)
(define-key xwidget-webkit-mode-map [mouse-4] 'xwidget-webkit-scroll-down)
(define-key xwidget-webkit-mode-map [mouse-5] 'xwidget-webkit-scroll-up)
(define-key xwidget-webkit-mode-map (kbd "<up>") 'xwidget-webkit-scroll-down)
(define-key xwidget-webkit-mode-map (kbd "<down>") 'xwidget-webkit-scroll-up)
(define-key xwidget-webkit-mode-map (kbd "M-w") 'xwidget-webkit-copy-selection-as-kill)
(define-key xwidget-webkit-mode-map (kbd "C-c") 'xwidget-webkit-copy-selection-as-kill)

;; 自动调整 xwidget-webkit 窗口大小(也可以手动按 a 来调整)。
(defun my-xwidget-webkit-performance-setup ()
  (add-hook 'window-configuration-change-hook
            #'xwidget-webkit-adjust-size-dispatch
            nil
            t))

(add-hook 'xwidget-webkit-mode-hook
          #'my-xwidget-webkit-performance-setup)

;; make xwidget default browser
(setq browse-url-browser-function
      (lambda (url session)
	(other-window 1)
	(xwidget-webkit-browse-url url)))

在线文档和翻译:

;;在线搜索, 先选中 region 再执行搜索。
(use-package engine-mode
  :config
  (engine/set-keymap-prefix (kbd "C-c s"))
  (engine-mode t)
  ;;(setq engine/browser-function 'eww-browse-url)
  (setq engine/browser-function 'xwidget-webkit-browse-url)
  (defengine github "https://github.com/search?ref=simplesearch&q=%s" :keybinding "h")
  (defengine google "https://google.com/search?q=%s" :keybinding "g"))

;; Google 翻译
(use-package google-translate
  :demand nil
  :defer t
  :bind ("C-c d t" . google-translate-smooth-translate)
  :init
  ;; C-n/p 切换翻译类型。
  (setq google-translate-translation-directions-alist
        '(("en" . "zh-CN") ("zh-CN" . "en"))))

MacOS 互操作:

  • 本机 Emacs 已内置系统回收站支持;远程文件的回收站行为由 TRAMP 后端决定。
  • 在 finder 中打开当前文件或目录:M-! 后执行命令: open .
;; 删除本地文件时,使用 Emacs 内置的系统回收站支持。
(setq-default delete-by-moving-to-trash t)

;; 在 Finder 中打开当前文件。
(use-package reveal-in-osx-finder
  :commands (reveal-in-osx-finder))

帮助增强:

;; 在帮助文档底部显示 lisp demo.
(use-package elisp-demos
  :config
  (advice-add 'describe-function-1 :after #'elisp-demos-advice-describe-function-1)
  (advice-add 'helpful-update :after #'elisp-demos-advice-helpful-update))

;; 相比 Emacs 内置 Help, 提供更多上下文信息。
(use-package helpful
  :config
  (global-set-key (kbd "C-h f") #'helpful-callable)
  (global-set-key (kbd "C-h v") #'helpful-variable)
  (global-set-key (kbd "C-h k") #'helpful-key)
  (global-set-key (kbd "C-c d h") #'helpful-at-point)
  (global-set-key (kbd "C-h F") #'helpful-function)
  (global-set-key (kbd "C-h C") #'helpful-command))

13 pdf
#

安装依赖:

brew install poppler automake mupdf

配置 pdf-tools:

(use-package pdf-tools
  :demand nil
  :defer t
  :commands pdf-tools-install
  ;; :ensure-system-package
  ;; ((pdfinfo . poppler)
  ;;  (automake . automake)
  ;;  (mutool . mupdf)
  ;;  ("/usr/local/opt/zlib" . zlib))
  :init
  ;; 使用包自带的轻量 loader,支持 magic/大小写后缀及异步 epdfinfo 初始化。
  (require 'pdf-loader)
  (pdf-loader-install)
  ;; 使用 scaling 确保中文字体不模糊
  (setq pdf-view-use-scaling t)
  (setq pdf-view-use-imagemagick nil)
  (setq pdf-annot-activate-created-annotations t)
  (setq pdf-view-resize-factor 1.1)
  (setq-default pdf-view-display-size 'fit-width)
  :hook
  ((pdf-view-mode . pdf-view-themed-minor-mode)
   (pdf-view-mode . pdf-view-auto-slice-minor-mode)
   (pdf-view-mode . pdf-isearch-minor-mode))
  :config
  (defun my/pdf-proof-view ()
    "使用原始颜色、完整页边距和整页缩放检查 PDF 排版。"
    (interactive)
    (pdf-view-themed-minor-mode -1)
    (pdf-view-midnight-minor-mode -1)
    (pdf-view-auto-slice-minor-mode -1)
    (pdf-view-reset-slice)
    (pdf-view-fit-page-to-window))

  (defun my/pdf-reading-view ()
    "恢复主题配色、自动裁边和按页宽阅读。"
    (interactive)
    (pdf-view-midnight-minor-mode -1)
    (pdf-view-themed-minor-mode 1)
    (pdf-view-auto-slice-minor-mode 1)
    (pdf-view-fit-width-to-window))

  (define-key pdf-view-mode-map (kbd "C-s") 'isearch-forward)
  ;;(add-hook 'pdf-view-mode-hook (lambda() (linum-mode -1)))
  (setq pdf-info-epdfinfo-program "/opt/homebrew/bin/epdfinfo")
  (setenv "PKG_CONFIG_PATH" "/opt/homebrew/opt/zlib/lib/pkgconfig:/opt/homebrew/opt/pkgconfig:/opt/homebrew/lib/pkgconfig")
)

;; pdf 转为 png 时使用更高分辨率(默认 90)。
(setq doc-view-resolution 144)

;;(use-package org-noter)
  • pdf-tools 默认是白底黑字,可以:
    • 深色模式: M-x pdf-view-midnight-minor-mode
    • 主题模式: M-x pdf-view-themed-minor-mode
  • 搜索中文时,需要使用系统中文输入法和 isearch 模式, 或者使用 M-s o(occur)

14 refs
#

本配置参考了以下仓库代码:

  1. seagle0128/.emacs.d
  2. protesilaos/dotfiles
  3. bbatsov/prelude
  4. MatthewZMD/.emacs.d
  5. condy0919/.emacs.d
  6. manateelazycat/lazycat-emacs
  7. jiacai2050/dotfiles
  8. natecox/dotfiles
  9. daviwil
  10. skybert/my-little-friends
  11. casouri/lunarymacs
  12. tec/emacs-config
  13. minimal-emacs.d

相关文章

My Emacs Reference
·
好记性比如烂笔头, 这是我个人总结的 Emacs 手册。
eBPF 介绍
·
本文档介绍 Linux 内核的各种追踪技术,然后介绍 eBPF 的发展历程、开发和执行流程、开发框架选择和 Demo 示例。
Linux 函数调用栈展开
·
介绍 Linux 函数调用栈生成和展开(stack unwinding)机制。
ELF 符号表和 DWARF 调试符号表
·
介绍 Linux ELF 二进制文件的符号表和调试符号表(.debug_XX)生成、管理机制。