내 i3‑Emacs 연동

발행: (2026년 5월 24일 AM 08:13 GMT+9)
8 분 소요

출처: Hacker News

타일링 윈도우 매니저는 정말 멋집니다. 초유연 텍스트 편집기도 역시 멋지죠. 한 번은 EXWM이 이상적인 해결책이라고 생각했는데… 텍스트 버퍼보다 일반적인 그래픽 윈도우를 더 많이, 혹은 똑같이 사용하고, 때때로 그 윈도우가 EXWM의 화려한 입력 방식과 충돌하는 프로그램(예: Steam)이라서요.

하지만 저는 여전히 Emacs를 아주 좋아합니다. 심지어 제 머신에서는 밝은 모드와 어두운 모드를 전환해 주기도 하죠(아직도!). 그래서 (\sqrt{-1})‘s 같은 글에 영감을 받아 Emacs와 i3 사이에 공통된 키 바인딩을 만들고, 터미널 열기, 창 분할 등에 대한 합리적인 기본값을 정해 보기로 했습니다.

우선 위에서 언급한 글처럼 xdotoolemacsclient를 이용한 스크립트를 시도했는데, 동작은 했지만 너무 느렸습니다. 최대 1초 정도 지연이 있었고, 스크립트 자체는 호출부터 종료까지 30~100 ms 정도 걸렸습니다. 아직도 그 지연이 어디서 오는지 정확히 알지 못합니다. Emacs에 입력을 보내고 실제로 인식되기까지의 시간 때문인지, Emacs 버전이나 다른 패키지, emacsclient의 특이점 때문인지 모르겠지만, 이대로는 부족했습니다. 게다가 가장 자주 사용하는 키 조합을 위해 전체 쉘을 띄우는 것은 낭비라고 생각했습니다. 그래서 합리적인 선택을 했습니다: i3를 직접 패치했습니다.

목표는 간단했습니다. i3의 bindsym으로 바인딩된 명령을 일방적으로 처리하는 대신, 현재 포커스된 창이 Emacs인지 확인하고, 맞다면 키 이벤트를 Emacs에 전달하도록 옵션을 추가하는 것이었습니다. 이 기능은 과거에 요청된 적도 있지만, i3 유지관리자는 범위 밖이라고 판단했습니다. 상황이 달라진다면 더 완전한 패치를 만들 수도 있겠죠. Emacs가 “아니, 이건 i3가 처리해야 한다”고 판단하면 i3-msg를 사용해 다시 i3로 라우팅할 수 있습니다.

결국 성공했지만, 가장 우아한 방법은 아닐 수도 있습니다. xcb에 대해 조언해 주실 분은 언제든지 환영합니다! 아래 이메일로 연락 주세요: web@khz.ac.

목차

관련 i3 코드

i3는 xcb_grab_key()owner_events = 0으로 루트 X 윈도우에 적용해 키를 가로챕니다. src/bindings.c에 있는 관련 코드는 다음과 같습니다. 모든 코드 조각은 i3 4.25.1 기준이며, 따라가시려면 참고하세요.

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}

이 코드는 크게 중요하지는 않지만, i3가 루트 윈도우에서 모든 바인딩을 가로채는 방식을 보여줍니다. owner_events = 1로 설정해 이벤트를 통과시키면 루트 윈도우에만 전달된다고 하니, 우리가 원하는 동작은 아닙니다.

i3의 handle_event() (src/handlers.c)에서는 xcb 이벤트를 받으면 타입에 따라 특화된 핸들러로 넘깁니다:

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

handle_key_press() (src/key_press.c)는 키 이벤트를 받아 바인딩을 찾아 해당 명령을 실행합니다:

12
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    
28    if (bind == NULL) {
29        return;
30    }
31
32    CommandResult *result = run_binding(bind, NULL);
33    command_result_free(result);
34}

특히 이 함수는 원본 xcb_key_press_event_t를 받으며, 이를 xcb_send_event()로 바로 재전송할 수 있다는 것을 깨달았습니다. 다만 이벤트를 받는 창이 포커스를 잃게 되는 문제가 남아 있습니다. 해결 방법을 아시는 분은 알려 주세요.

여기가 변경을 가하기에 적절한 위치처럼 보입니다!

패치

Binding 구조체 변경

include/data.hBinding에 새로운 필드를 추가해, 해당 바인딩이 직접 이벤트를 받아야 할 창 클래스를 지정하도록 했습니다:

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

바인딩 초기화 시 passthrough 값을 설정하도록 수정했습니다(정리 코드는 생략). 패치 파일을 보시면 전체 내용을 확인할 수 있습니다.

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

이제 handle_key_press()bind->passthrough.class가 설정돼 있는지 확인하고, 현재 포커스된 창의 클래스가 일치하면 키 이벤트를 해당 창에 재전송합니다. 인터셉션을 비활성화해야 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;
0 조회
Back to Blog

관련 글

더 보기 »