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:
- Making it work with my chromebook thin client.
- 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.
- You need to explicitly enter and exit passthrough mode
- Mouse bindings, particularly with the floating modifier, are still grabbed by the parent
- 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