LmCast :: Stay tuned in

My I3-Emacs Integration

Recorded: May 23, 2026, 11:59 p.m.

Original Summarized

my i3-emacs integration

..
/

my i3-emacs integration
"it'll be too slow," they told me
march 2026

tiling window managers are wonderful. ultra-flexible text editors are also
wonderful. for a spell, i thought i'd found the ideal solution in exwm… and i
think it would have been, save for the fact that i use ordinary, graphical
windows as much, if not more than, text buffers, and sometimes those windows are
from dodgy programs (e.g., steam) that have trouble with EXWM's fancy input
methods.

but i still like emacs a lot. hell, it switches light and dark mode on my
machine (still)! so, inspired by such posts as \(\sqrt{-1}\)'s, i set out to get
a common set of keybindings between emacs and i3, along with some sane defaults
around opening terminals, splitting windows, etc.

i first tried a script with xdotool and emacsclient, as in the above-referenced
article, and that worked… but proved to be too slow: i saw lags of up to a
second
timing the script gave a latency of 30 to 100 ms from invocation to exit,
which is still pretty slow but not a dealbreaker. i still don't know where the
rest of the latency came from.
between sending input to emacs and it actually registering. i don't know
if this is because of my emacs version, other packages, emacsclient weirdness,
whatever, but that wasn't going to cut it. plus, it seems wasteful to launch a
whole shell-plus just to register a keypress, especially for some of the most
commonly pressed key combinations i use. so i did the only rational thing: i
patched i3.

my objective was: instead of unilaterally handling commands bound via i3's
bindsym, add an option to check the currently focused window to see if it's
emacs, and if it is, pass the keypress event through to it.
note that this feature has been requested in the past, and the i3
maintainers have deemed it to be out of scope. i would make this a more
fully-fledged patch if that were not the case.
if emacs
decides "no, i3 should actually handle this," it can use i3-msg to route the
action back.

i succeeded in that, though it might not be the most elegant thing in the world.
if you know about xcb and want to give me advice, please! send me an email at
web@khz.ac.

contents

relevant i3 code
the patch

Binding struct changes
modifying the parser

the emacs side

window movement
terminals

results

relevant i3 code

i3 uses xcb_grab_key() with owner_events = 0 on the root x window to intercept
keys. the relevant code in src/bindings.c looks like
all unpatched code snippets refer to i3 4.25.1, if you want to follow
along.

172struct Binding_Keycode *binding_keycode;
173TAILQ_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}

this code isn't super relevant, except that i3 entirely steals its bindings from
anyone else by intercepting on the root window. if you're thinking that setting
owner_events = 1 to allow event passthrough so we don't have to re-emit… that
would be great, but that appears to instruct x to pass the event through to only
the root window. which is not what we want.

in i3's handle_event() in src/handlers.c, if it gets an xcb event, it sends it
off to a specialized handler based on its type:

1481switch (type) {
1482case XCB_KEY_PRESS:
1483case XCB_KEY_RELEASE:
1484 handle_key_press((xcb_key_press_event_t *)event);
1485 break;
1486 // ...
1487}

handle_key_press() (src/key_press.c) looks like this — it receives a keypress
event, looks up a binding based on that event, and, if it finds one, runs the
associated command:
yes, i do know one of the lines is too long. i opted to
leave it that way, as that's how it is in the i3 source. i should note, though:
i3 has really nice source code! i found it very readable and pleasant to work
inside.

12/*
13 * There was a KeyPress or KeyRelease (both events have the same fields). We
14 * compare this key code with our bindings table and pass the bound action to
15 * parse_command().
16 *
17 */
18void 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 /* if we couldn't find a binding, we are done */
28 if (bind == NULL) {
29 return;
30 }
31
32 CommandResult *result = run_binding(bind, NULL);
33 command_result_free(result);
34}

notably, this function receives the original xcb_key_press_event_t from xcb,
which (after a bit of reading and experimentation) i realized you could just
re-emit diretly via xcb_send_event().
unfortunately, the window receiving
the event will still lose focus, as i3 is intercepting key events globally. i
haven't fixed this; let me know if you know how.

this looks like a reasonable place to make a change!

the patch

Binding struct changes

i decided to modify Binding (include/data.h) with an extra field to indicate a class of window
which should, for that binding, receive events directly:

/**
* 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;
};

i also modified the binding initialization to set up passthrough, if
provided:
there is, of course, associated cleanup code, which i've omitted
for brevity. look at the patch file (linked at the end) if you want to see it.

/*
* Adds a binding from config parameters given as strings and returns a
* pointer to the binding structure. Returns NULL if the input code could not
* be parsed.
*
*/
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) {
// ...

// XXX: should change this to be configurable, but I only care about Emacs, so.
if (passthrough) {
new_binding->passthrough.class = sstrdup("Emacs");
} else {
new_binding->passthrough.class = NULL;
}

return new_binding;
}

handle_key_press() now has to look at that setting and decide whether to pass
the key event through. if bind->passthrough.class is set for that binding, we
get the currently focused window, check its class, and if that class matches, we
re-send the key event to that focused window with interception disabled (else it
would just go straight back to i3):

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);
}

modifying the parser

i3 includes a parser generator, which reads what appears to be an i3-specific
dsl. since i want to specify that only certain bindings should be passed
through, i had to modify i3's parser as well. the dsl is pretty
self-documenting, so don't be afraid of doing this yourself. alternately, if you
want to bug me to do it, email me.

the parser configuration for bindsym / bindcode (parser-specs/config.spec),
after modification, looks like this:

# bindsym/bindcode
state BINDING:
# ...
passthrough = '--passthrough'
->
key = word
-> BINDCOMMAND

state BINDCOMMAND:
# ...
passthrough = '--passthrough'
->
command = string
-> call cfg_binding(..., $passthrough, $command)

this section of the parser config defines two parser states: BINDING (parsing a
bindsym command, but we haven't parsed a keysym yet) and BINDCOMMAND (the same,
but after we've parsed the keysym).
the right way to do this, should i have
wanted to have syntax like --passthrough "Emacs", would be to move to a new
parsing state upon encountering this flag and eating the next token as
passthrough. perhaps someday.
i3's parsing dsl appears to accumulate variables
via variable = <stuff> and passing that variable to a call command as a char*
— non-null if encountered and null if not. hence, if the flag --passthrough
appears while parsing, $passthrough evaluates to the string "--passthrough"
rather than NULL. then if (passthrough) { /* ... */ } gets evaluated in
configure_binding(), and the rest is history.

the emacs side

now that key passthrough works, all we need is a bit of elisp and life is good.
a lot of this is heavily pulled from the \(\sqrt{-1}\) post linked above.
basically, i want to integrate two actions: window movement and opening
terminals.

window movement

to start, we need a way for emacs to send messages back to i3 if we try to move beyond an existing window:

(defmacro nausicaa/i3-msg (&rest args)
"Call i3-msg with ARGS."
`(start-process "emacs-to-i3" nil "i3-msg" ,@args))

when either moving windows or moving between windows, emacs should attempt to
select one of its own windows in the given direction. failing that, it should
instruct i3 to do so:

(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 is a function that really just invokes the
appropriate windmove command. i wrote it because my existing windmove commands
have advice around them (placed there by doom, i expect) that allows them to
select popup windows and the minibuffer, which i wanted to reuse:

(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)

arguably the right way to do this is to add that advice to
windmove-find-other-window, which i might do at some point.

terminals

                      i am always launching terminals —
sometimes
          fifty
       a
          day.

i typically use mistty as a terminal, since it has delightful integration with
the rest of emacs, but it tends to choke on more difficult text rendering tasks,
for which alacritty is better suited. at any given moment, in any given
directory, i might want to launch either of them, so i wrote a few scripts to
invoke either mistty or alacritty from either emacs or i3.

i3 is configured to launch both mistty and alacritty, depending on context,
using two scripts:

# start a terminal
bindsym --passthrough $super+Return exec mistty-create
bindsym --passthrough $super+Control+Return exec alacritty-create

if those keys pass through to emacs, emacs either launches a mistty session or
just shells out to the script:

(defun nausicaa/launch-alacritty ()
(interactive)
(async-start-process "alacritty-create" "bash" nil "-c" "exec alacritty-create"))

(map!
"s-<return>" #'mistty-create
"C-s-<return>" #'nausicaa/launch-alacritty)

mistty-create is a shell script that tells emacs to open a new frame with mistty in it:

pkgs.writeShellApplication {
name = "mistty-create";
text = ''
${config.programs.emacs.package}/bin/emacsclient -e "(progn (other-frame-prefix) (mistty-create))"
'';
}

alacritty-create instructs the current alacritty process to create a new window
in the current working directory:

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
'';
}

this script has the wonderful property that, if invoked inside emacs, you get an
alacritty window in whatever project directory you're currently in, yielding
roughly equally ergonomic behavior between mistty and alacritty. it's great.

results

i3 and emacs play really nicely together now. if you want the patch for i3, it's
here. i'll eventually post my full configuration with keycodes, but the above
should be enough to get something working.

and if you're like me, and want to do i3 development on nix, here's what i used
for a shell.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
];
}

email

RSS

released under CC0

The author details the process of integrating the $i3$ tiling window manager with Emacs, addressing initial difficulties encountered with slow scripting methods and focusing on a more integrated solution. The motivation stemmed from a desire to establish common keybindings and sensible defaults between the two applications, inspired by existing concepts. Initial attempts using external tools like xdotool and emacsclient proved inefficient, introducing significant latency, suggesting a need for a deeper, system-level modification.

To resolve this, the author patched the $i3$ source code to allow Emacs to intercept keypress events directly, bypassing slow external scripting. The modification involved altering how $i3$ handles input interception, specifically within the key handling routines. $i3$ typically uses xcb_grab_key() on the root window to capture key events. The patch modifies the key press handling function to check the currently focused window. If the window is identified as Emacs, the function forwards the key event directly to Emacs using xcb_send_event, effectively routing the input to the application rather than executing an external command immediately. This required changes to the binding structure to include a passthrough field, allowing bindings to specify which window class should receive the event.

Furthermore, the author modified $i3$'s parser to accommodate this new functionality. They adjusted the domain-specific language (dsl) used by $i3$ to allow binding configurations to include a passthrough directive, enabling the mechanism described above. The parser configuration was updated to include a state for binding and a subsequent state where the command execution can be conditionally passed based on the specified passthrough flag.

The integration then extended to the Emacs side, involving the development of Elisp code to define the necessary communication protocols between Emacs and $i3$. For window manipulation, the author defined macros and functions designed to instruct $i3$ to focus or move windows when Emacs attempts such actions. These functions leverage the $i3$-msg command system to communicate intent to the window manager. Specifically, functions were created to attempt window selection or swapping, falling back to $i3$-msg commands if the desired window could not be immediately selected.

The setup also addressed launching external terminals. Since the author utilized both mistty and alacritty for terminal emulation, the system was configured so that specific key combinations could trigger the opening of either terminal. This involved defining shell application entries that instruct Emacs to execute commands that launch the respective terminal, which in turn manages context, such as launching terminals within the current working directory. This approach successfully provides an ergonomic experience for launching preferred terminal emulators in context with the Emacs environment. The documentation also outlines the necessary compilation environment, specifying the use of packages such as libxcb, libxkbcommon, pango, and various Perl modules, emphasizing the technical dependencies required for this level of system integration.