我的I3-Emacs集成方案
摘要
一位开发者描述了如何修补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 吗?
文章推荐使用 Emacs 的 eww 网页浏览器,强调其缺少 JavaScript 的特性改善了许多网站的体验,并指出 Emacs 提供了独特的 UI 创新,如逐张图片调整大小和键盘导航。
我的 Emacs 配置(Dired)
一份关于配置和使用 Emacs 文件管理器 Dired 的详细指南,涵盖键绑定、钩子及高级功能。
推荐一下……理解 Emacs 的模式
文章解释了 Emacs 的架构模式,重点介绍了通用缓冲区数据模型和增量补全读取(ICR),并将其比作玫瑰的根与花瓣。文章强调了 Emacs 如何统一界面并通过 Elisp 实现可扩展性。
还有人用 Emacs 吗?
作者对与 Emacs 数十年关系的个人反思,包括转向 VSCode 和 IntelliJ,最终因其独特功能回归 Emacs。
离开 Magit 后的 Emacs
作者讲述了他们离开 Emacs 的 Magit Git 界面,转而采用 VC-mode 和自定义 Git 脚本等替代方案的经历,重点介绍了其中的调整和所学到的经验教训。