我的I3-Emacs集成方案

Hacker News Top 工具

摘要

一位开发者描述了如何修补i3窗口管理器,使其在聚焦时将按键事件传递给Emacs,从而实现i3与Emacs共享键盘快捷键。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/24 00:34

# 我的 i3-Emacs 集成 来源:https://khz.ac/software/i3-integration.html 平铺窗口管理器真是太棒了。超灵活的文本编辑器也同样棒。曾经有一段时间,我以为我在 EXWM 中找到了理想方案……而且我认为它*本来*会是的,除了我像使用文本缓冲区一样频繁(甚至更频繁)地使用普通图形窗口,有时这些窗口来自有问题的程序(例如 Steam),它们难以应对 EXWM 花哨的输入方法。但我仍然非常喜欢 Emacs。天哪,它仍然能在我机器上切换亮色和暗色模式!所以,受到 \(\sqrt{-1}\) 的帖子(https://sqrtminusone.xyz/posts/2021-10-04-emacs-i3/)等内容的启发,我开始着手在 Emacs 和 i3 之间建立一套通用的快捷键绑定,并设置一些关于打开终端、分割窗口等方面的合理默认值。 我首先尝试了上面那篇文章中提到的用 `xdotool` 和 `emacsclient` 的脚本,它确实能工作……但速度太慢了:我发现从执行脚本到 Emacs 实际注册按键之间,会有长达一秒的延迟。对脚本进行计时显示,从调用到退出有 30 到 100 毫秒的延迟,这虽然不算慢得无法接受,但我仍然不知道剩下的延迟来自哪里。发送输入到 Emacs 和实际注册之间。我不知道这是因为我使用的 Emacs 版本、其他包、`emacsclient` 的怪异行为还是别的什么原因,但这样肯定不行。而且,只为注册一次按键就启动一个完整的 shell 加 `emacsclient` 似乎很浪费,*尤其*是对于那些我最常用的按键组合。 所以我做了唯一理性的事:我修改了 i3。我的目标是:不要一刀切地处理通过 i3 的 `bindsym` 绑定的命令,而是添加一个选项,检查当前聚焦的窗口是否是 Emacs,如果是,则将按键事件直接传递给它。 请注意,这个功能过去曾被请求过(https://github.com/i3/i3/issues/4768),i3 的维护者认为这超出了范围。如果不是那样,我会把这个补丁做得更完善。如果 Emacs 判断“不,这个应该由 i3 来处理”,它可以使用 `i3-msg` 将动作路由回去。我成功地实现了这一点,尽管它可能不是最优雅的方案。如果你了解 XCB 并想给我建议,请发邮件到 [[email protected]](mailto:[email protected])。 ## 目录 - [相关的 i3 代码](#orgc03b018) - [补丁](#org098ab8e) - `Binding` 结构体修改 - 修改解析器 - [Emacs 侧](#org742ec20) - 窗口移动 - 终端 - [结果](#org92b0bfc) ## 相关的 i3 代码 i3 使用 `xcb_grab_key()` 并在根窗口上设置 `owner_events = 0` 来拦截按键。相关的代码在 `src/bindings.c` 中,看起来像这样(如果愿意,所有未修补的代码片段均指向 i3 4.25.1): ```c 172 struct Binding_Keycode *binding_keycode; 173 TAILQ_FOREACH(binding_keycode, &(bind->keycodes_head), keycodes) { 174 const int keycode = binding_keycode->keycode; 175 const int mods = (binding_keycode->modifiers & 0xFFFF); 176 DLOG("Binding %p Grabbing keycode %d with mods %d\n", bind, keycode, mods); 177 xcb_grab_key(conn, 0, root, mods, keycode, XCB_GRAB_MODE_ASYNC, 178 XCB_GRAB_MODE_ASYNC); 179 } ``` 这段代码并不是特别重要,只是指出 i3 通过在根窗口上拦截来完全夺走其他程序的绑定。如果你在想设置 `owner_events = 1` 来允许事件透传,这样我们就不必重新发送了……那会很棒,但似乎那样会导致 X 仅将事件传递给根窗口。这不是我们想要的。 在 i3 的 `handle_event()`(位于 `src/handlers.c`)中,如果它收到了一个 XCB 事件,就会根据事件类型发送给专门的处理器: ```c 1481 switch (type) { 1482 case XCB_KEY_PRESS: 1483 case XCB_KEY_RELEASE: 1484 handle_key_press((xcb_key_press_event_t *)event); 1485 break; 1486 } ``` `handle_key_press()`(位于 `src/key_press.c`)看起来是这样的——它接收一个按键事件,根据该事件查找绑定,如果找到则执行关联的命令(是的,我知道有一行太长了。我选择保持原样,因为 i3 源代码中就是这样。不过我得说,i3 的源代码真不错!我发现它非常易读,在里面工作很愉快): ```c 18 void handle_key_press(xcb_key_press_event_t *event) { 19 const bool key_release = (event->response_type == XCB_KEY_RELEASE); 20 21 last_timestamp = event->time; 22 23 DLOG("%s %d, state raw = 0x%x\n", (key_release ? "KeyRelease" : "KeyPress"), event->detail, event->state); 24 25 Binding *bind = get_binding_from_xcb_event((xcb_generic_event_t *)event); 26 27 28 if (bind == NULL) { 29 return; 30 } 31 32 CommandResult *result = run_binding(bind, NULL); 33 command_result_free(result); 34 } ``` 值得注意的是,该函数接收来自 XCB 的原始 `xcb_key_press_event_t`,经过一些阅读和实验,我发现你可以直接通过 `xcb_send_event()` 重新发送它。不幸的是,接收事件的窗口仍然会失去焦点,因为 i3 全局拦截了按键事件。我还没有修复这个问题;如果你知道怎么修复,请告诉我。这看起来是一个合理的修改点! ## 补丁 ### `Binding` 结构体修改 我决定修改 `Binding`(在 `include/data.h` 中),添加一个额外字段,用于指示对于该绑定,应该直接将事件发送给哪个窗口类: ```c /** * Holds a keybinding, consisting of a keycode combined with modifiers and the * command which is executed as soon as the key is pressed (see * src/config_parser.c) * */ struct Binding { /** Window class to use for key passthrough. Currently an exact string match. */ struct { char *class; } passthrough; }; ``` 我还修改了绑定初始化函数,如果提供了透传设置则进行设置(当然还有相关的清理代码,为了简洁我略去了。如果想看完整代码,请看末尾链接的补丁文件): ```c Binding *configure_binding(const char *bindtype, const char *modifiers, const char *input_code, const char *release, const char *border, const char *whole_window, const char *exclude_titlebar, const char *command, const char *modename, bool pango_markup, const char *passthrough) { if (passthrough) { new_binding->passthrough.class = sstrdup("Emacs"); } else { new_binding->passthrough.class = NULL; } return new_binding; } ``` 现在 `handle_key_press()` 必须查看该设置并决定是否透传按键事件。如果该绑定的 `bind->passthrough.class` 设置了,我们就获取当前聚焦的窗口,检查其类,如果类匹配,我们重新发送按键事件给那个聚焦窗口,并禁用拦截(否则它会直接回到 i3): ```c void handle_key_press(xcb_key_press_event_t *event) { DLOG("PATCH: checking if we should pass keypress through\n"); if (bind->passthrough.class) { xcb_generic_error_t *focus_error; xcb_get_input_focus_reply_t *input_focus = xcb_get_input_focus_reply( conn, xcb_get_input_focus(conn), &focus_error); if (focus_error != NULL) { DLOG("PATCH: could not get focused window"); free(focus_error); } else { Con *con = con_by_window_id(input_focus->focus); const xcb_window_t focus = input_focus->focus; free(input_focus); const bool should_pass = con && con->window->class_class && strcmp(con->window->class_class, bind->passthrough.class) == 0; if (should_pass) { DLOG("PATCH: forwarding keypress (%d %s %s @ %d %d)\n", focus, con->name, con->window->class_class, event->event_x, event->event_y); event->event = focus; xcb_send_event(conn, false, focus, XCB_EVENT_MASK_NO_EVENT, (const char *)event); return; } } } DLOG("PATCH: handling keypress normally\n"); CommandResult *result = run_binding(bind, NULL); command_result_free(result); } ``` ### 修改解析器 i3 包含一个解析器生成器,它读取一种看起来是 i3 特有的 DSL。由于我想指定只有某些绑定应该被透传,我还必须修改 i3 的解析器。这个 DSL 是相当自文档化的,所以不要害怕自己动手。或者,如果你想*让我*来做,请发邮件给我。修改后的 `bindsym`/`bindcode` 的解析器配置(`parser-specs/config.spec`)如下: ``` state BINDING: passthrough = '--passthrough' -> key = word -> BINDCOMMAND state BINDCOMMAND: passthrough = '--passthrough' -> command = string -> call cfg_binding(..., $passthrough, $command) ``` 解析器配置的这一部分定义了两个解析状态:`BINDING`(解析 `bindsym` 命令,但我们还没有解析键符号)和 `BINDCOMMAND`(相同,但我们已经解析了键符号)。正确的方法(如果我想让语法像 `--passthrough "Emacs"` 那样)是在遇到此标志时转移到新的解析状态,并将下一个标记作为 `passthrough` 消耗掉。也许有一天会这样做。 i3 的解析 DSL 似乎通过 `variable = ` 来累积变量,并将该变量作为 `char*` 传递给 `call` 命令——如果遇到则为非 NULL,否则为 NULL。因此,如果标志 `--passthrough` 在解析时出现,`$passthrough` 会求值为字符串 `"--passthrough"` 而不是 `NULL`。然后 `if (passthrough) { /* ... */ }` 在 `configure_binding()` 中求值,剩下的事就是历史了。 ## Emacs 侧 既然按键透传已经能工作,我们只需要一点 Elisp 就能让一切美好。这部分很大程度上借鉴了上面链接的 \(\sqrt{-1}\) 的帖子。基本上,我想要集成两个动作:窗口移动和打开终端。 ### 窗口移动 首先,我们需要一种方式让 Emacs 在尝试移动到现有窗口之外时,将消息*发回*给 i3: ```elisp (defmacro nausicaa/i3-msg (&rest args) "Call i3-msg with ARGS." `(start-process "emacs-to-i3" nil "i3-msg" ,@args)) ``` 当在窗口之间移动或移动窗口时,Emacs 应尝试选择自身在给定方向上的一个窗口。如果失败,它应指示 i3 来做: ```elisp (defun nausicaa/emacs-i3-windmove (dir) "Select window in DIR, if it exists; if not, i3-select it." (let ((other-window (nausicaa/find-other-window dir))) (if (or (null other-window) (window-minibuffer-p other-window)) (nausicaa/i3-msg "focus" (symbol-name dir)) (nausicaa/do-window-select dir)))) (defun nausicaa/emacs-i3-move-window (dir) "Do some stuff to move window in DIR. I should check out `evil-move-window' at some point." (let ((other-window (windmove-find-other-window dir))) (cond ((and other-window (not (window-minibuffer-p other-window))) (window-swap-states (selected-window) other-window)) (t (nausicaa/i3-msg "move" (symbol-name dir)))))) ``` `nausicaa/find-other-window` 是一个实际上只是调用适当的 windmove 命令的函数。我写它的原因是我现有的 windmove 命令有 advice(我猜是 Doom 添加的),允许它们选择弹出窗口和 minibuffer,我想重用这个行为: ```elisp (defun nausicaa/find-other-window (&rest args) "Pass ARGS through to `windmove-find-other-window'. Exists solely so I can reuse `+popup--ignore-window-parameters-a'." (apply #'windmove-find-other-window args)) (defun nausicaa/do-window-select (&rest args) "Pass ARGS through to `windmove-do-window-select'. Exists solely so I can reuse `+popup--ignore-window-parameters-a'." (apply #'windmove-do-window-select args)) (advice-add 'nausicaa/find-other-window :around #'+popup--ignore-window-parameters-a) (advice-add 'nausicaa/do-window-select :around #'+popup--ignore-window-parameters-a) ``` 可以说正确的方法是将该 advice 添加到 `windmove-find-other-window`,我可能会在某个时候这样做。 ### 终端 我总是在启动终端——有时一天五十次。我通常使用 mistty 作为终端,因为它与 Emacs 的其余部分有很好的集成,但它往往会卡在更困难的文本渲染任务上,而 alacritty 更适合这些任务。在任何时候,在任何目录下,我可能都想启动其中任何一个,所以我写了一些脚本,可以从 Emacs 或 i3 调用 mistty 或 alacritty。i3 配置为根据上下文启动 mistty 和 alacritty,使用两个脚本: ``` bindsym --passthrough $super+Return exec mistty-create bindsym --passthrough $super+Control+Return exec alacritty-create ``` 如果这些按键透传到 Emacs,Emacs 要么启动一个 mistty 会话,要么直接 shell 调用脚本: ```elisp (defun nausicaa/launch-alacritty () (interactive) (async-start-process "alacritty-create" "bash" nil "-c" "exec alacritty-create")) (map! "s-" #'mistty-create "C-s-" #'nausicaa/launch-alacritty) ``` `mistty-create` 是一个 shell 脚本,告诉 Emacs 用 mistty 打开一个新 frame: ```nix pkgs.writeShellApplication { name = "mistty-create"; text = '' ${config.programs.emacs.package}/bin/emacsclient -e "(progn (other-frame-prefix) (mistty-create))" ''; } ``` `alacritty-create` 指示当前的 alacritty 进程在当前工作目录下创建一个新窗口: ```nix pkgs.writeShellApplication { name = "alacritty-create"; text = '' if ! ${pkgs.alacritty}/bin/alacritty msg create-window --working-directory "$PWD"; then env -u INSIDE_EMACS ${pkgs.alacritty}/bin/alacritty "$@" >/dev/null 2>&1 & disown %env fi ''; } ``` 这个脚本有一个很好的特性:如果在 Emacs 内部调用,你会看到一个 alacritty 窗口,它在你当前所在的项目目录中,从而在 mistty 和 alacritty 之间获得大致相等的人体工程学行为。棒极了。 ## 结果 i3 和 Emacs 现在配合得非常好了。如果你想获取 i3 的补丁,可以在[这里](https://khz.ac/software/i3-passthrough.patch)找到。我最终会发布我的完整配置,包括按键码,但上面的内容应该足以让你运行起来。如果你和我一样,想用 Nix 进行 i3 开发,这里是我使用的 `shell.nix`: ```nix { pkgs ? import <nixpkgs> { } }: pkgs.mkShell { nativeBuildInputs = with pkgs; [ pkg-config makeWrapper meson ninja installShellFiles perl asciidoc xmlto docbook_xml_dtd_45 docbook_xsl findXMLCatalogs ]; buildInputs = with pkgs.buildPackages; [ libxcb libxcb-util libxcb-wm libxcb-keysyms libxkbcommon xcbutilxrm libstartup_notification libx11 pcre2 libev yajl xcb-util-cursor perl pango perlPackages.AnyEventI3 perlPackages.X11XCB perlPackages.IPCRun perlPackages.ExtUtilsPkgConfig perlPackages.InlineC ]; } ```

相似文章

我可以推荐 Emacs 的创新 UI——eww 吗?

Lobsters Hottest

文章推荐使用 Emacs 的 eww 网页浏览器,强调其缺少 JavaScript 的特性改善了许多网站的体验,并指出 Emacs 提供了独特的 UI 创新,如逐张图片调整大小和键盘导航。

我的 Emacs 配置(Dired)

Hacker News Top

一份关于配置和使用 Emacs 文件管理器 Dired 的详细指南,涵盖键绑定、钩子及高级功能。

推荐一下……理解 Emacs 的模式

Lobsters Hottest

文章解释了 Emacs 的架构模式,重点介绍了通用缓冲区数据模型和增量补全读取(ICR),并将其比作玫瑰的根与花瓣。文章强调了 Emacs 如何统一界面并通过 Elisp 实现可扩展性。

还有人用 Emacs 吗?

Lobsters Hottest

作者对与 Emacs 数十年关系的个人反思,包括转向 VSCode 和 IntelliJ,最终因其独特功能回归 Emacs。

离开 Magit 后的 Emacs

Lobsters Hottest

作者讲述了他们离开 Emacs 的 Magit Git 界面,转而采用 VC-mode 和自定义 Git 脚本等替代方案的经历,重点介绍了其中的调整和所学到的经验教训。