Emacs finally folds to my liking
本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。
Emacs finally folds to my liking
Emacs finally folds to my liking 23/10/2026 · 11 minutes I’ve been slowly turning my Emacs config into something I actually enjoy using, not just something that works, and folding was the last place where that wasn’t true. Every mode had its own idea of what folding is: treesit-fold in Tree-sitter modes, Hideshow in the Lisp-ish ones, Outline in everything that resembles text, Org with its own invisible text that no one else’s commands can undo. So every buffer had different keys, a different look, and worst of all different performance characteristics. Folding a big file sometimes felt like holding my breath. I’m running Emacs 32.0.50 from master (built from source, as one does), and it’s finally one system.
我一直在慢慢地将我的 Emacs 配置打造成我真正喜欢使用的工具,而不仅仅是能用就行,而代码折叠是最后一个尚未实现这一点的领域。每种模式对“折叠”都有自己的定义:Tree-sitter 模式下的 treesit-fold,Lisp 类模式下的 Hideshow,所有类似文本的模式下的 Outline,以及拥有自己不可见文本且无法被他人命令撤销的 Org。因此,每个缓冲区都有不同的按键、不同的外观,最糟糕的是还有不同的性能表现。折叠一个大文件有时感觉就像屏住呼吸一样。我正在运行从 master 分支构建的 Emacs 32.0.50(像往常一样从源码构建),现在它终于成为了一个统一的系统。
kirigami is the piece I didn’t know I needed
kirigami doesn’t fold anything itself. It dispatches: it keeps a list of folding engines, checks which one is actually active in the buffer, and forwards to it. Tree-sitter, Hideshow, Outline, Org, Vdiff — all behind one interface. So on top of it I put one set of keys, the same in a Kotlin buffer, YAML, a Scheme or ELisp buffer and an Org file:
kirigami 是我之前没意识到自己需要的组件
kirigami 本身并不折叠任何东西。它负责调度:它维护一个折叠引擎列表,检查当前缓冲区中哪个引擎处于活动状态,然后将请求转发给它。Tree-sitter、Hideshow、Outline、Org、Vdiff——所有这些都统一在一个接口之下。因此,我在它之上设置了一套按键,无论是在 Kotlin 缓冲区、YAML、Scheme 或 ELisp 缓冲区,还是在 Org 文件中,它们都是一样的:
Key Does C-c v c close this fold C-c v C close every fold C-c v o open this fold C-c v O open this fold and everything under it C-c v r reveal everything C-c v t toggle the fold at point
按键 功能 C-c v c 关闭当前折叠 C-c v C 关闭所有折叠 C-c v o 打开当前折叠 C-c v O 打开当前折叠及其下所有内容 C-c v r 显示所有内容 C-c v t 切换当前位置的折叠状态
That sounds small. It is not small. The number of times a day I used to think “wait, what does TAB do in this mode again” is now zero, and the muscle memory transfers across an entire polyglot config. One wrinkle worth mentioning: kirigami answers with the first engine in its list whose mode is on, and outline-minor-mode is on in nearly every buffer I have. In the buffers where I deliberately turned on Hideshow — Scheme, for instance — that choice has to win, otherwise C-c v c folds “headings” a Scheme file doesn’t have. So I reorder kirigami-fold-list to put Hideshow first instead of rewriting it; engines kirigami adds later still get used wherever Hideshow is off.
这听起来微不足道,但其实不然。我每天思考“等等,这个模式下 TAB 键是做什么的”的次数现在变成了零,肌肉记忆可以跨越整个多语言配置。值得一提的一个小问题是:kirigami 会返回列表中第一个处于开启状态的引擎,而我的几乎每个缓冲区都开启了 outline-minor-mode。在那些我特意开启了 Hideshow 的缓冲区(例如 Scheme)中,必须优先使用 Hideshow,否则 C-c v c 会折叠 Scheme 文件中并不存在的“标题”。因此,我重新排列了 kirigami-fold-list,将 Hideshow 放在首位,而不是重写它;kirigami 稍后添加的引擎仍然会在 Hideshow 关闭的地方被使用。
Tree-sitter folding, with guides that are actually lines
Most of my programming buffers are *-ts-mode, so treesit-fold does the work there — bash, C, C++, CMake, CSS, Dockerfile, Elixir, Go, HTML, Java, JS/TS/TSX, JSON, Kotlin, Lua, Nix, PHP, Python, Ruby, Rust, Scala, TOML, YAML. Line counts are shown inline, which I find genuinely useful when deciding whether to open a fold. The indicators took the most fiddling. treesit-fold-indicators draws a block’s guide with a bitmap 23 rows tall, centered in the line beside it. A line in my config is 29 pixels — the font’s 23 plus six of line-spacing — so every row of the guide was a 23-pixel dash with three pixels of nothing above and below it. A folded block looked like a dotted line running down the fringe. Not subtle, just wrong.
Tree-sitter 折叠,带有真正的线条引导
我大部分编程缓冲区都是 *-ts-mode,所以 treesit-fold 在那里工作——包括 bash、C、C++、CMake、CSS、Dockerfile、Elixir、Go、HTML、Java、JS/TS/TSX、JSON、Kotlin、Lua、Nix、PHP、Python、Ruby、Rust、Scala、TOML、YAML。行数会内联显示,我觉得这在决定是否打开折叠时非常有用。指示器花了我最多的时间来调整。treesit-fold-indicators 使用一个 23 行高的位图来绘制块引导,并将其居中显示在旁边的行中。我的配置中一行是 29 像素——字体 23 像素加上 6 像素的行间距——所以引导的每一行都是一个 23 像素的短横线,上下各有 3 像素的空白。一个折叠块看起来就像一条沿着边缘延伸的虚线。这并不微妙,而是完全错了。
There’s no setting for that, so I replaced the bitmaps: a single-row bitmap that define-fringe-bitmap repeats down however tall the line is (no spacing can introduce a gap), plus a +=/-= box and an end-elbow cut to the exact height of a line, so the guide runs from the marker into the row below and turns toward the text where the block ends. It’s an advice on the function that builds the string for each line. It looks like a bracket now, not a perforation. And the colour: the indicators inherited shadow, which most themes make as bright as a comment. Fine for one marker on one line, wrong for a marker on every line of a block — the grey became a rule drawn through the code. They’re now shadow blended 50% toward the theme’s background, recomputed on every theme and scale change, so it fades correctly on light themes too and no theme needs a special entry.
没有相关的设置,所以我替换了位图:使用一个单行位图,通过 define-fringe-bitmap 在行高范围内重复(没有间距可以引入空隙),再加上一个 +=/-= 框和一个精确到行高的末端弯头,这样引导线就会从标记延伸到下一行,并在块结束的地方转向文本。这是对构建每一行字符串的函数进行的建议(advice)。它现在看起来像一个括号,而不是穿孔。关于颜色:指示器继承了阴影色,大多数主题将其设置得和注释一样亮。对于单行上的一个标记来说还可以,但对于块中每一行的标记来说就错了——灰色变成了一条贯穿代码的线。现在它们与主题背景进行了 50% 的阴影混合,并在每次主题和缩放更改时重新计算,因此在浅色主题上也能正确淡化,无需为任何主题进行特殊配置。
Hideshow earns its keep
Not everything has a grammar, and I’m not going to pretend otherwise: Scheme — my Emacs build has no Scheme grammar, so Scheme buffers use Hideshow with hs-block-start-regexp set to ”^(” and hs-forward-sexp-function set to forward-sexp. A Scheme file is a sequence of top-level forms, so each definition is a fold, and collapsing the one you’re inside works from anywhere within it. That’s the behaviour I wanted from the start and Hideshow doesn’t guess it by default. Emacs Lisp — the prog-mode fallback, same keys. XML — folds from start tag to end tag using nxml-scan-element-forward, skipping , comments, DOCTYPE and self-closing elements. YAML (the non-Tree-sitter mode) — indentation-based blocks with a small forward-sexp of my own that walks to the end of the indented block. Hideshow’s own fringe indicators (hs-show-indicators + hs-indicator-type ‘fringe) are on in all of these, so it doesn’t feel like a downgrade from the Tree-sitter buffers. The rule I enforce per buffer is exactly one set of indicators: Tree-sitter buffers get treesit-fold and Hideshow is turned off; Hideshow buffers get Hideshow and Outline’s clickable buttons are turned off so the two don’t double up; outline-only buffers (Markdown, diff) keep the buttons. Nobody ends up with two markers per block.
Hideshow 物有所值
并非所有东西都有语法,我不会假装如此:Scheme——我的 Emacs 构建没有 Scheme 语法,所以 Scheme 缓冲区使用 Hideshow,并将 hs-block-start-regexp 设置为 ”^(“,将 hs-forward-sexp-function 设置为 forward-sexp。Scheme 文件是一系列顶层形式的序列,因此每个定义都是一个折叠,并且从内部任何位置都可以折叠当前所在的块。这就是我从一开始就想要的行径,而 Hideshow 默认不会这样猜测。Emacs Lisp——prog-mode 的后备方案,按键相同。XML——使用 nxml-scan-element-forward 从开始标签折叠到结束标签,跳过 、注释、DOCTYPE 和自闭合元素。YAML(非 Tree-sitter 模式)——基于缩进的块,使用我自己编写的一个小型 forward-sexp 函数,可以走到缩进块的末尾。Hideshow 自己的边缘指示器(hs-show-indicators + hs-indicator-type ‘fringe)在所有这些模式中都是开启的,所以感觉并不比 Tree-sitter 缓冲区差。我为每个缓冲区强制执行的规则是:只保留一套指示器:Tree-sitter 缓冲区使用 treesit-fold 并关闭 Hideshow;Hideshow 缓冲区使用 Hideshow 并关闭 Outline 的可点击按钮,以免两者重复;仅 Outline 的缓冲区(Markdown、diff)保留按钮。这样就不会出现每个块有两个标记的情况。
Org, which needed its own everything
Org folds with text properties, not overlays, so neither Tree-sitter nor Hideshow nor Outline’s buttons can draw a marker for it. So I wrote its own indicators: a fringe triangle pointing down for an open heading, right for a folded one, clickable with mouse-1 to toggle exactly like TAB. Folding itself is untouched — TAB, S-TAB and the C-c v keys all still work. The performance part is where the real win is. A naive “redraw indicators after every command” implementation is a disaster in a large file, because it re-searches the buffer and rebuilds every overlay on every pause. Mine is driven by the text instead: if buffer-chars-modified-tick didn’t change, only the indicators that are on screen are redrawn — off-screen headings can wait until they’re scrolled to; if it changed and the change was recorded, only the dirty region is rebuilt; if it changed with no recorded change, only then is the whole buffer rebuilt. Folding changes text properties rather than characters, so folding alone t
Org,它需要自己的一切