{"slug": "my-i3-emacs-integration", "title": "My I3-Emacs Integration", "summary": "The article describes the author's attempt to integrate the i3 tiling window manager with the Emacs text editor by creating shared keybindings. After finding that using a script with xdotool and emacsclient was too slow (with latency up to a second), the author patched i3's source code to check if the focused window is Emacs and pass keypress events through to it when appropriate. The author notes that while this feature has been requested before, i3 maintainers considered it out of scope, so the patch remains a personal modification rather than an official addition.", "body_md": "tiling window managers are wonderful. ultra-flexible text editors are also\nwonderful. for a spell, i thought i'd found the ideal solution in *would* have been, save for the fact that i use ordinary, graphical\nwindows as much, if not more than, text buffers, and sometimes those windows are\nfrom dodgy programs (e.g., steam) that have trouble with EXWM's fancy input\nmethods.\n\nbut i still like emacs a lot. hell, it switches light and dark mode on my\nmachine (still)! so, inspired by such posts as [\\(\\sqrt{-1}\\)'s](https://sqrtminusone.xyz/posts/2021-10-04-emacs-i3/), i set out to get\na common set of keybindings between emacs and i3, along with some sane defaults\naround opening terminals, splitting windows, etc.\n\ni first tried a script with `xdotool`\n\nand `emacsclient`\n\n, as in the above-referenced\narticle, and that worked… but proved to be too slow: i saw lags of up to a\nsecond\ntiming the script gave a latency of 30 to 100 ms from invocation to exit,\nwhich is still pretty slow but not a dealbreaker. i still don't know where the\nrest of the latency came from.\nbetween sending input to emacs and it actually registering. i don't know\nif this is because of my emacs version, other packages, `emacsclient`\n\nweirdness,\nwhatever, but that wasn't going to cut it. plus, it seems wasteful to launch a\nwhole shell-plus just to register a keypress, *especially* for some of the most\ncommonly pressed key combinations i use. so i did the only rational thing: i\npatched i3.\n\nmy objective was: instead of unilaterally handling commands bound via i3's\n`bindsym`\n\n, add an option to check the currently focused window to see if it's\nemacs, and if it is, pass the keypress event through to it.\nnote that this feature [has been requested in the past](https://github.com/i3/i3/issues/4768), and the i3\nmaintainers have deemed it to be out of scope. i would make this a more\nfully-fledged patch if that were not the case.\nif emacs\ndecides \"no, i3 should actually handle this,\" it can use `i3-msg`\n\nto route the\naction back.\n\ni succeeded in that, though it might not be the most elegant thing in the world.\nif you know about [web@khz.ac](mailto:web@khz.ac).\n\n## relevant i3 code\n\ni3 uses `xcb_grab_key()`\n\nwith `owner_events = 0`\n\non the root `src/bindings.c`\n\nlooks like\nall unpatched code snippets refer to i3 4.25.1, if you want to follow\nalong.\n\n```\n172struct Binding_Keycode *binding_keycode;\n173TAILQ_FOREACH(binding_keycode, &(bind->keycodes_head), keycodes) {\n174    const int keycode = binding_keycode->keycode;\n175    const int mods = (binding_keycode->modifiers & 0xFFFF);\n176    DLOG(\"Binding %p Grabbing keycode %d with mods %d\\n\", bind, keycode, mods);\n177    xcb_grab_key(conn, 0, root, mods, keycode, XCB_GRAB_MODE_ASYNC,\n178                 XCB_GRAB_MODE_ASYNC);\n179}\n```\n\nthis code isn't super relevant, except that i3 entirely steals its bindings from\nanyone else by intercepting on the *root* window. if you're thinking that setting\n`owner_events = 1`\n\nto allow event passthrough so we don't have to re-emit… that\nwould be great, but that appears to instruct *only*\nthe root window. which is not what we want.\n\nin i3's `handle_event()`\n\nin `src/handlers.c`\n\n, if it gets an\n\n```\n1481switch (type) {\n1482case XCB_KEY_PRESS:\n1483case XCB_KEY_RELEASE:\n1484    handle_key_press((xcb_key_press_event_t *)event);\n1485    break;\n1486    // ...\n1487}\n```\n\n`handle_key_press()`\n\n(`src/key_press.c`\n\n) looks like this — it receives a keypress\nevent, looks up a binding based on that event, and, if it finds one, runs the\nassociated command:\nyes, i do know one of the lines is too long. i opted to\nleave it that way, as that's how it is in the i3 source. i should note, though:\ni3 has really nice source code! i found it very readable and pleasant to work\ninside.\n\n```\n12/*\n13 * There was a KeyPress or KeyRelease (both events have the same fields). We\n14 * compare this key code with our bindings table and pass the bound action to\n15 * parse_command().\n16 *\n17 */\n18void handle_key_press(xcb_key_press_event_t *event) {\n19    const bool key_release = (event->response_type == XCB_KEY_RELEASE);\n20\n21    last_timestamp = event->time;\n22\n23    DLOG(\"%s %d, state raw = 0x%x\\n\", (key_release ? \"KeyRelease\" : \"KeyPress\"), event->detail, event->state);\n24\n25    Binding *bind = get_binding_from_xcb_event((xcb_generic_event_t *)event);\n26\n27    /* if we couldn't find a binding, we are done */\n28    if (bind == NULL) {\n29        return;\n30    }\n31\n32    CommandResult *result = run_binding(bind, NULL);\n33    command_result_free(result);\n34}\n```\n\nnotably, this function receives the original `xcb_key_press_event_t`\n\nfrom `xcb_send_event()`\n\n.\nunfortunately, the window receiving\nthe event will still lose focus, as i3 is intercepting key events globally. i\nhaven't fixed this; let me know if you know how.\n\nthis looks like a reasonable place to make a change!\n\n## the patch\n\n`Binding`\n\nstruct changes\n\ni decided to modify `Binding`\n\n(`include/data.h`\n\n) with an extra field to indicate a class of window\nwhich should, for that binding, receive events directly:\n\n```\n/**\n * Holds a keybinding, consisting of a keycode combined with modifiers and the\n * command which is executed as soon as the key is pressed (see\n * src/config_parser.c)\n *\n */\nstruct Binding {\n    // ...\n\n    /** Window class to use for key passthrough. Currently an exact string match. */\n    struct {\n        char *class;\n    } passthrough;\n};\n```\n\ni 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.\n\n```\n/*\n * Adds a binding from config parameters given as strings and returns a\n * pointer to the binding structure. Returns NULL if the input code could not\n * be parsed.\n *\n */\nBinding *configure_binding(const char *bindtype, const char *modifiers, const char *input_code,\n                           const char *release, const char *border, const char *whole_window,\n                           const char *exclude_titlebar, const char *command, const char *modename,\n                           bool pango_markup, const char *passthrough) {\n    // ...\n\n    // XXX: should change this to be configurable, but I only care about Emacs, so.\n    if (passthrough) {\n        new_binding->passthrough.class = sstrdup(\"Emacs\");\n    } else {\n        new_binding->passthrough.class = NULL;\n    }\n\n    return new_binding;\n}\n```\n\n`handle_key_press()`\n\nnow has to look at that setting and decide whether to pass\nthe key event through. if `bind->passthrough.class`\n\nis set for that binding, we\nget the currently focused window, check its class, and if that class matches, we\nre-send the key event to that focused window with interception disabled (else it\nwould just go straight back to i3):\n\n```\nvoid handle_key_press(xcb_key_press_event_t *event) {\n    // ...\n\n    DLOG(\"PATCH: checking if we should pass keypress through\\n\");\n    if (bind->passthrough.class) {\n        xcb_generic_error_t *focus_error;\n        xcb_get_input_focus_reply_t *input_focus = xcb_get_input_focus_reply(\n            conn, xcb_get_input_focus(conn), &focus_error);\n\n        if (focus_error != NULL) {\n            DLOG(\"PATCH: could not get focused window\");\n            free(focus_error);\n        } else {\n            Con *con = con_by_window_id(input_focus->focus);\n            const xcb_window_t focus = input_focus->focus;\n            free(input_focus);\n\n            const bool should_pass =\n                con && con->window->class_class &&\n                strcmp(con->window->class_class, bind->passthrough.class) == 0;\n            if (should_pass) {\n                DLOG(\"PATCH: forwarding keypress (%d %s %s @ %d %d)\\n\", focus,\n                     con->name, con->window->class_class, event->event_x,\n                     event->event_y);\n                event->event = focus;\n                xcb_send_event(conn, false, focus, XCB_EVENT_MASK_NO_EVENT,\n                               (const char *)event);\n                return;\n            }\n        }\n    }\n\n    DLOG(\"PATCH: handling keypress normally\\n\");\n    CommandResult *result = run_binding(bind, NULL);\n    command_result_free(result);\n}\n```\n\n### modifying the parser\n\ni3 includes a parser generator, which reads what appears to be an i3-specific\n*me* to do it, email me.\n\nthe parser configuration for `bindsym`\n\n/ `bindcode`\n\n(`parser-specs/config.spec`\n\n),\nafter modification, looks like this:\n\n```\n# bindsym/bindcode\nstate BINDING:\n  # ...\n  passthrough = '--passthrough'\n      ->\n  key = word\n      -> BINDCOMMAND\n\nstate BINDCOMMAND:\n  # ...\n  passthrough = '--passthrough'\n      ->\n  command = string\n      -> call cfg_binding(..., $passthrough, $command)\n```\n\nthis section of the parser config defines two parser states: `BINDING`\n\n(parsing a\n`bindsym`\n\ncommand, but we haven't parsed a keysym yet) and `BINDCOMMAND`\n\n(the same,\nbut after we've parsed the keysym).\nthe *right* way to do this, should i have\nwanted to have syntax like `--passthrough \"Emacs\"`\n\n, would be to move to a new\nparsing state upon encountering this flag and eating the next token as\n`passthrough`\n\n. perhaps someday.\ni3's parsing `variable = <stuff>`\n\nand passing that variable to a `call`\n\ncommand as a `char*`\n\n— non-null if encountered and null if not. hence, if the flag `--passthrough`\n\nappears while parsing, `$passthrough`\n\nevaluates to the string `\"--passthrough\"`\n\nrather than `NULL`\n\n. then `if (passthrough) { /* ... */ }`\n\ngets evaluated in\n`configure_binding()`\n\n, and the rest is history.\n\n## the emacs side\n\nnow 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.\n\n### window movement\n\nto start, we need a way for emacs to send messages *back* to i3 if we try to move beyond an existing window:\n\n```\n(defmacro nausicaa/i3-msg (&rest args)\n  \"Call i3-msg with ARGS.\"\n  `(start-process \"emacs-to-i3\" nil \"i3-msg\" ,@args))\n```\n\nwhen 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:\n\n```\n(defun nausicaa/emacs-i3-windmove (dir)\n  \"Select window in DIR, if it exists; if not, i3-select it.\"\n  (let ((other-window (nausicaa/find-other-window dir)))\n    (if (or (null other-window) (window-minibuffer-p other-window))\n        (nausicaa/i3-msg \"focus\" (symbol-name dir))\n      (nausicaa/do-window-select dir))))\n\n(defun nausicaa/emacs-i3-move-window (dir)\n  \"Do some stuff to move window in DIR.\n\nI should check out `evil-move-window' at some point.\"\n  (let ((other-window (windmove-find-other-window dir)))\n    (cond\n     ((and other-window (not (window-minibuffer-p other-window)))\n      (window-swap-states (selected-window) other-window))\n     (t (nausicaa/i3-msg \"move\" (symbol-name dir))))))\n```\n\n`nausicaa/find-other-window`\n\nis a function that really just invokes the\nappropriate windmove command. i wrote it because my existing windmove commands\nhave advice around them (placed there by doom, i expect) that allows them to\nselect popup windows and the minibuffer, which i wanted to reuse:\n\n```\n(defun nausicaa/find-other-window (&rest args)\n  \"Pass ARGS through to `windmove-find-other-window'.\n\nExists solely so I can reuse `+popup--ignore-window-parameters-a'.\"\n  (apply #'windmove-find-other-window args))\n\n(defun nausicaa/do-window-select (&rest args)\n  \"Pass ARGS through to `windmove-do-window-select'.\n\nExists solely so I can reuse `+popup--ignore-window-parameters-a'.\"\n  (apply #'windmove-do-window-select args))\n\n(advice-add 'nausicaa/find-other-window\n            :around #'+popup--ignore-window-parameters-a)\n(advice-add 'nausicaa/do-window-select\n            :around #'+popup--ignore-window-parameters-a)\n```\n\narguably the right way to do this is to add that advice to\n`windmove-find-other-window`\n\n, which i might do at some point.\n\n### terminals\n\ni am always launching terminals —\n\nsometimes\n\nfifty\n\na\n\nday.\n\ni 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.\n\ni3 is configured to launch both mistty and alacritty, depending on context, using two scripts:\n\n```\n# start a terminal\nbindsym --passthrough $super+Return exec mistty-create\nbindsym --passthrough $super+Control+Return exec alacritty-create\n```\n\nif those keys pass through to emacs, emacs either launches a mistty session or just shells out to the script:\n\n```\n(defun nausicaa/launch-alacritty ()\n  (interactive)\n  (async-start-process \"alacritty-create\" \"bash\" nil \"-c\" \"exec alacritty-create\"))\n\n(map!\n \"s-<return>\" #'mistty-create\n \"C-s-<return>\" #'nausicaa/launch-alacritty)\n```\n\n`mistty-create`\n\nis a shell script that tells emacs to open a new frame with mistty in it:\n\n```\npkgs.writeShellApplication {\n  name = \"mistty-create\";\n  text = ''\n    ${config.programs.emacs.package}/bin/emacsclient -e \"(progn (other-frame-prefix) (mistty-create))\"\n  '';\n}\n```\n\n`alacritty-create`\n\ninstructs the current alacritty process to create a new window\nin the current working directory:\n\n```\npkgs.writeShellApplication {\n  name = \"alacritty-create\";\n  text = ''\n    if ! ${pkgs.alacritty}/bin/alacritty msg create-window --working-directory \"$PWD\"; then\n       env -u INSIDE_EMACS ${pkgs.alacritty}/bin/alacritty \"$@\" >/dev/null 2>&1 &\n       disown %env\n    fi\n  '';\n}\n```\n\nthis 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.\n\n## results\n\ni3 and emacs play really nicely together now. if you want the patch for i3, it's\n[here](./i3-passthrough.patch). i'll eventually post my full configuration with keycodes, but the above\nshould be enough to get something working.\n\nand if you're like me, and want to do i3 development on nix, here's what i used\nfor a `shell.nix`\n\n:\n\n```\n{ pkgs ? import <nixpkgs> { } }:\n\npkgs.mkShell {\n  nativeBuildInputs = with pkgs; [\n    pkg-config\n    makeWrapper\n    meson\n    ninja\n    installShellFiles\n    perl\n    asciidoc\n    xmlto\n    docbook_xml_dtd_45\n    docbook_xsl\n    findXMLCatalogs\n  ];\n\n  buildInputs = with pkgs.buildPackages; [\n    libxcb\n    libxcb-util\n    libxcb-wm\n    libxcb-keysyms\n    libxkbcommon\n    xcbutilxrm\n    libstartup_notification\n    libx11\n    pcre2\n    libev\n    yajl\n    xcb-util-cursor\n    perl\n    pango\n    perlPackages.AnyEventI3\n    perlPackages.X11XCB\n    perlPackages.IPCRun\n    perlPackages.ExtUtilsPkgConfig\n    perlPackages.InlineC\n  ];\n}\n```\n\n", "url": "https://wpnews.pro/news/my-i3-emacs-integration", "canonical_source": "https://khz.ac/software/i3-integration.html", "published_at": "2026-05-23 23:13:42+00:00", "updated_at": "2026-05-24 00:03:22.154660+00:00", "lang": "en", "topics": ["developer-tools", "open-source"], "entities": ["Emacs", "i3", "xdotool", "emacsclient"], "alternates": {"html": "https://wpnews.pro/news/my-i3-emacs-integration", "markdown": "https://wpnews.pro/news/my-i3-emacs-integration.md", "text": "https://wpnews.pro/news/my-i3-emacs-integration.txt", "jsonld": "https://wpnews.pro/news/my-i3-emacs-integration.jsonld"}}