Nested i3 sessions

Making i3 support Xephyr and other hackery

i3 feels like the true pinnacle of ultra-configurable tiling window managers. There is no abstract form or grotesque incarnation it cannot make manifest with enough patience and hacking. The documentation is my new holy text. I make sure to read a passage each and every day and reflect on its importance to my config file.

It just took some extra effort to solve a couple unique challenges for me:

  1. Making it work with my chromebook thin client.
  2. Making it work in “nested” Xephyr sessions

Managing monitor multitudes

Everyone configures their instance their own way. I like entirely borderless windows that squeeze utility out every last pixel of screen space. It’s a hilarious extravagance on a 5120x1440 super ultrawide monitor I use for “normal” desktop purposes. But it’s a necessity for using my 1366x768 chromebook thin client. I found the “tabbing” alternate mode is also great for making good use of the limited pixels.

When I use my chromebook VNC client I need to essentially resize the window manager itself, and leave the rest as black bars. As it turns out creating a virtual monitor is enough to resize all of i3. I believe I had trouble trying to that with dwm, so I’m glad it worked here.

~/bin/chromebook
#!/bin/bash

DISPLAY=:0
mode="${1:-toggle}"

if [ "${mode}" == "on" ]; then
    xrandr --setmonitor chromebook 1366/1x768/1+0+0 none
elif [ "${mode}" == "off" ]; then
    xrandr --delmonitor chromebook
else
    if xrandr --listmonitors | grep "chromebook" >/dev/null; then
        xrandr --delmonitor chromebook
    else
        xrandr --setmonitor chromebook 1366/1x768/1+0+0 none
    fi
fi

refresh_xephyr() {
    # refresh X (and invoke a window manager resize) by invoking a simple 'xrandr' in Xephyr windows
    for disp in $(ps -e -o command | grep "^Xephyr" | grep -o ":[0-9]*$"); do
        DISPLAY="${disp}" xrandr >/dev/null
    done
}

# refresh_xephyr doesn't always take
sleep 0.1
refresh_xephyr
sleep 0.1
refresh_xephyr
sleep 0.1
refresh_xephyr

There is a weird bug I have seen with Xephyr where resizes don’t take effect with the window manager. The solution I have found is really odd; just invoke xrandr and most of the time it fixes itself. It must end up reminding X to send an event down to its clients somehow.

Mastering matryoshkan matters

I have found it useful to isolate my work tasks onto their own dedicated X server using Xephyr. For awhile I continued to use dwm in my “work window”, but of course I want to “nest” i3 instead. I found this reddit thread with some good tips, but they don’t really solve for the whole enchilada.

The first good tip is how to start another i3 in the same user without hell breaking loose:

~/bin/ww
...
export I3SOCK=/tmp/i3-xephyr-${num}.sock
nohup i3 >/dev/null 2>&1 &

The second mostly good tip is to establish a “passthrough” binding mode so your key bindings go to the nested session rather than the parent:

~/.config/i3/config
bindsym $mod+Shift+p mode "passthrough"
mode passthrough {
    bindsym $mod+Escape mode "default"
}

There are a few problems.

  1. You need to explicitly enter and exit passthrough mode
  2. Mouse bindings, particularly with the floating modifier, are still grabbed by the parent
  3. A resized i3 window doesn’t automagically resize the nested session

I created the following script to help address these problems:

~/i3-xephyr-monitor
#!/bin/bash
set -euo pipefail

in_passthough="false"

refresh_xephyr() {
    # refresh X (and invoke a window manager resize) by invoking a simple 'xrandr' in Xephyr windows    
    for disp in $(ps -e -o command | grep "^Xephyr" | grep -o ":[0-9]*$"); do
        DISPLAY="${disp}" xrandr >/dev/null
    done
}

pidfile="/tmp/i3-xephyr-monitor.pid"
# there can only be one!
if [ -f "${pidfile}" ]; then
    pid=$(cat "${pidfile}")
    if ps -p "${pid}" >/dev/null; then
        echo "monitor already running! exiting..."
        exit 1
    fi
fi
echo $$ >"${pidfile}"

cleanup() {
    rm -f "${pidfile}"
    echo "exiting!"
}

trap cleanup EXIT

while IFS=$'\n' read -r line; do
    #echo "${line}"
    # only listen to focus changes
    if [[ ${line} != *'"change":"focus"'* ]]; then
        continue
    fi

    if [[ ${line} == *'"class":"Xephyr"'* ]]; then
        i3-msg "mode passthrough" >/dev/null
        in_passthough="true"
        echo "switched to passthrough mode"
        refresh_xephyr
    elif [ "${in_passthough}" == "true" ]; then
        i3-msg "mode default" >/dev/null
        in_passthough="false"
        echo "switched to default mode"
        refresh_xephyr
    fi
done < <(i3-msg -t subscribe -m '[ "window" ]')

echo "exiting!"

exit 0

Basically listen on the i3 socket for focus events and change the “passthrough” mode automagically when Xephyr is focused. Also fix up the resizing while we are at it, though not quite automatically. It requires moving the cursor back over the border to fix up a resize. (perhaps a TODO). It does seem like the for_window directive should be able to handle this without a little daemon being involved, but it seems like it only applies to newly mapped windows?

The remaining problem is you can’t use the floating modifier to move and resize windows inside the nested instance using the mouse. Which is unacceptable. That is an absolute killer feature for me. As far as I know i3 is the only tiling window manager with such a feature. And it kicks ass; I love it.

This ain’t dwm where you are highly encouraged to make source changes…but here I am anyway. In click.c you can see most of the mouse clicks are specially handled and are almost always eaten by the parent i3. But whole window click bindings are handled almost right away. I just need a special binding that…isn’t actually a binding.

index abfc3307..324953bf 100644diff --git a/src/click.c b/src/click.c

--- a/src/click.c
+++ b/src/click.c
@@ -186,6 +186,12 @@ static void route_click(Con *con, xcb_button_press_event_t *event, const click_d
     if (bind && ((dest == CLICK_DECORATION && !bind->exclude_titlebar) ||
                  (dest == CLICK_INSIDE && bind->whole_window) ||
                  (dest == CLICK_BORDER && bind->border))) {
+
+        if(strncasecmp(bind->command, "passthrough", strlen("passthrough")) == 0) {
+            allow_replay_pointer(event->time);
+            return;
+        }
+
         CommandResult *result = run_binding(bind, con);

         /* ASYNC_POINTER eats the event */

This is pretty hacky and probably not something for upstream. But with this change I can expand my bindings to:

~/.config/i3/config
# primarily used by i3-xephyr-monitor to auto passthrough
bindsym $mod+Shift+p mode "passthrough"
mode passthrough {
    # for emergencies
    bindsym $mod+Escape mode "default"
    # passthrough move and resize click bindings
    bindsym --whole-window $mod+button1 passthrough
    bindsym --whole-window $mod+button3 passthrough
}
exec_always --no-startup-id i3-xephyr-monitor
bindsym $mod+Shift+d exec --no-startup-id chromebook

And now it just works. With just a touch of jank. It’s fine. I will surely solve it before my use case for Xephyr disappears entirely.

Published
2025-05-01