Switching to the i3 window manager
Vincent Bernat
I have been using the awesome window manager for 10 years. It is a tiling window manager, configurable and extendable with the Lua language. Using a general-purpose programming language to configure every aspect is a double-edged sword. Due to laziness and the apparent difficulty of adapting my configuration—about 3000 lines—to newer releases, I was stuck with the 3.4 version, whose last release is from 2013.
It was time for a rewrite. Instead, I have switched to the i3 window manager, lured by the possibility to migrate to Wayland and Sway later with minimal pain. Using an embedded interpreter for configuration is not as important to me as it was in the past: it brings both complexity and brittleness.
The window manager is only one part of a desktop environment. There are several options for the other components. I am also introducing them in this post.
i3: the window manager#
i3 aims to be a minimal tiling window manager. Its documentation can be read from top to bottom in less than an hour. i3 organize windows in a tree. Each non-leaf node contains one or several windows and has an orientation and a layout. This information arbitrates the window positions. i3 features three layouts: split, stacking, and tabbed. They are demonstrated in the below screenshot:
Most of the other tiling window managers, including the awesome window manager, use predefined layouts. They usually feature a large area for the main window and another area divided among the remaining windows. These layouts can be tuned a bit, but you mostly stick to a couple of them. When a new window is added, the behavior is quite predictable. Moreover, you can cycle through the various windows without thinking too much as they are ordered.
i3 is more flexible with its ability to build any layout on the fly, it can feel quite overwhelming as you need to visualize the tree in your head. At first, it is not unusual to find yourself with a complex tree with many useless nested containers. Moreover, you have to navigate windows using directions. It takes some time to get used to.
I set up a split layout for Emacs and a few terminals, but most of the other workspaces are using a tabbed layout. I don’t use the stacking layout. You can find many scripts trying to emulate other tiling window managers but I did try to get my setup pristine of these tentatives and get a chance to familiarize myself. i3 can also save and restore layouts, which is quite a powerful feature.
My configuration is quite similar to the default one and has less than 200 lines.
i3 companion: the missing bits#
i3 philosophy is to keep a minimal core and let the user implements missing features using the IPC protocol:
Do not add further complexity when it can be avoided. We are generally happy with the feature set of i3 and instead focus on fixing bugs and maintaining it for stability. New features will therefore only be considered if the benefit outweighs the additional complexity, and we encourage users to implement features using the IPC whenever possible.
While this is not as powerful as an embedded language, it is enough for many cases. Moreover, as high-level features may be opinionated, delegating them to small, loosely coupled pieces of code keeps them more maintainable. Libraries exist for this purpose in several languages. Users have published many scripts to extend i3: automatic layout and window promotion to mimic the behavior of other tiling window managers, window swallowing to put a new app on top of the terminal launching it, and cycling between windows with Alt+Tab.
Instead of maintaining a script for each feature, I have centralized
everything into a single Python process,
i3-companion
using asyncio and the
i3ipc-python library. Each feature is self-contained into a
function. It implements the following components:
- Make a workspace exclusive to an application
- When a workspace contains Emacs or Firefox, I would like other
applications to move to another workspace, except for the terminal
which is allowed to “intrude” into any workspace. The
workspace_exclusive()
function monitors new windows and moves them if needed to an empty workspace or to one with the same application already running. - Implement a Quake console
- The
quake_console()
function implements a drop-down console available from any workspace. It can be toggled with Mod+`. This is implemented as a scratchpad window. - Back and forth workspace switching on the same output
- With the
workspace back_and_forth
command, we can ask i3 to switch to the previous workspace. However, this feature is not restricted to the current output. I prefer to have one keybinding to switch to the workspace on the next output and one keybinding to switch to the previous workspace on the same output. This behavior is implemented in theprevious_workspace()
function by keeping a per-output history of the focused workspaces. - Create a new empty workspace or move a window to an empty workspace
- To create a new empty workspace or move a window to an empty
workspace, you have to locate a free slot and use
workspace number 4
ormove container to workspace number 4
. Thenew_workspace()
function finds a free number and use it as the target workspace. - Restart some services on output change
- When adding or removing an output, some actions need to be executed:
refresh the wallpaper, restart some components unable to adapt their
configuration on their own, etc. i3 triggers an event for this
purpose. The
output_update()
function also takes an extra step to coalesce multiple consecutive events and to check if there is a real change with the low-level library xcffib.
I will detail the other features as this post goes on. On the technical side, each function is decorated with the events it should react to:
@on(CommandEvent("previous-workspace"), I3Event.WORKSPACE_FOCUS) async def previous_workspace(i3, event): """Go to previous workspace on the same output."""
The CommandEvent()
event class is my way to send a command to the
companion, using either i3-msg -t send_tick
or binding a key to a
nop
command. The latter is used to avoid spawning a shell and a
i3-msg
process just to send a message. The companion listens to
binding events and checks if this is a nop
command.
bindsym $mod+Tab nop "previous-workspace"
There are other decorators to avoid code duplication: @debounce()
to
coalesce multiple consecutive calls, @static()
to define a static
variable, and @retry()
to retry a function on failure. The whole
script is a bit more than 1000 lines. I think this is
worth a read as I am quite happy with the result. 🦚
Update (2022-07)
Daniel Pereira wrote wmcompanion, a desktop event listener for minimal window manager users, inspired by i3-companion. It is a generic engine to build your own companion. It does not have a builtin support for i3 events, but its modular design allows a motivated user to add it with a few lines of code.
dunst: the notification daemon#
Unlike the awesome window manager, i3 does not come with a built-in
notification system. Dunst is a lightweight notification daemon. I
am running a modified version with HiDPI support for X11 and
recursive icon lookup. The i3 companion has a helper function,
notify()
, to send notifications using DBus. container_info()
and
workspace_info()
uses it to display information about the container
or the tree for a workspace.
polybar: the status bar#
i3 bundles i3bar, a versatile status bar, but I have opted for Polybar. A wrapper script runs one instance for each monitor.
The first module is the built-in support for i3 workspaces. To not
have to remember which application is running in a workspace, the i3
companion renames workspaces to include an icon for each application.
This is done in the workspace_rename()
function. The icons are from
the Font Awesome project. I maintain a mapping between applications
and icons. This is a bit cumbersome but it looks great.
For CPU, memory, brightness, battery, disk, and audio volume, I am relying on the built-in modules. Polybar’s wrapper script generates the list of filesystems to monitor and they get only displayed when available space is low. The battery widget turns red and blinks slowly when running out of power. Check my Polybar configuration for more details.
For Bluetooh, network, and notification statuses, I am using Polybar’s
ipc
module: the next version of Polybar can receive
an arbitrary text on an IPC socket. The module is defined with a
single hook to be executed at the start to restore the latest status.
[module/network] type = custom/ipc hook-0 = cat $XDG_RUNTIME_DIR/i3/network.txt 2> /dev/null initial = 1
It can be updated with polybar-msg action "#network.send.XXXX"
. In
the i3 companion, the @polybar()
decorator takes the string
returned by a function and pushes the update through the IPC socket.
The i3 companion reacts to DBus signals to update the Bluetooth and
network icons. The @on()
decorator accepts a DBusSignal()
object:
@on( StartEvent, DBusSignal( path="/org/bluez", interface="org.freedesktop.DBus.Properties", member="PropertiesChanged", signature="sa{sv}as", onlyif=lambda args: ( args[0] == "org.bluez.Device1" and "Connected" in args[1] or args[0] == "org.bluez.Adapter1" and "Powered" in args[1] ), ), ) @retry(2) @debounce(0.2) @polybar("bluetooth") async def bluetooth_status(i3, event, *args): """Update bluetooth status for Polybar."""
The middle of the bar is occupied by the date and a weather forecast. The latest also uses the IPC mechanism, but the source is a Python script triggered by a timer.
I don’t use the system tray integrated with Polybar. The embedded icons usually look horrible and they all behave differently. A few years back, Gnome has removed the system tray. Most of the problems are fixed by the DBus-based Status Notifier Item protocol—also known as Application Indicators or Ayatana Indicators for GNOME. However, Polybar does not support this protocol. In the i3 companion, The implementation of Bluetooth and network icons, including the notifications on change, takes about 200 lines. I got to learn a bit about how DBus works and I get exactly the info I want.
picom: the compositor#
I like having slightly transparent backgrounds for terminals and to reduce the opacity of unfocused windows. This requires a compositor.1 picom is a lightweight compositor. It works well for me, but it may need some tweaking depending on your graphic card.2 Unlike the awesome window manager, i3 does not handle transparency, so the compositor needs to decide by itself the opacity of each window. Check my configuration for details.
systemd: the service manager#
I use systemd to start i3 and the various services around it. My
xsession script only sets some environment variables and lets
systemd handles everything else. Have a look at this article from
Michał Góral for the rationale. Notably, each component can be
easily restarted and their logs are not mangled inside the
~/.xsession-errors
file.3
I am using a two-stage setup: i3.service
depends on
xsession.target
to start services before
i3:
[Unit] Description=X session BindsTo=graphical-session.target Wants=autorandr.service Wants=dunst.socket Wants=inputplug.service Wants=picom.service Wants=pulseaudio.socket Wants=policykit-agent.service Wants=redshift.service Wants=spotify-clean.timer Wants=ssh-agent.service Wants=weather.service Wants=weather.timer Wants=xiccd.service Wants=xsettingsd.service Wants=xss-lock.service
Then, i3 executes the second stage by invoking the
i3-session.target
:
[Unit] Description=i3 session BindsTo=graphical-session.target Wants=wallpaper.service Wants=wallpaper.timer Wants=polybar.service Wants=i3-companion.service Wants=misc-x.service
Have a look on my configuration files for more details.
rofi: the application launcher#
Rofi is an application launcher. Its appearance can be customized through a CSS-like language and it comes with several themes. Have a look at my configuration for mine.
It can also act as a generic menu application. I have a script to control a media player and another one to select the wifi network. It is quite a flexible application.
xss-lock and i3lock: the screen locker#
i3lock is a simple screen locker. xss-lock invokes it reliably
on inactivity or before a system suspend. For inactivity, it uses the
XScreenSaver events. The delay is configured using the xset s
command. The locker can be invoked immediately with xset s activate
.
X11 applications know how to prevent the screen saver from running. I
have also developed a small dimmer application that is executed 20
seconds before the locker to give me a chance to move the mouse if I
am not away.4 Have a look at my configuration
script.
Update (2021-12)
I am now using XSecureLock with a Python script to customize the screen saver. See “Customer screen saver with XSecureLock” for more details.
The remaining components#
-
autorandr is a tool to detect the connected display, match them against a set of profiles, and configure them with
xrandr
. -
inputplug executes a script for each new mouse and keyboard plugged. This is quite useful to load the appropriate the keyboard map. See my configuration.
-
xsettingsd provides settings to X11 applications, not unlike xrdb but it notifies applications for changes. The main use is to configure the Gtk and DPI settings. See my article on HiDPI support on Linux with X11.
-
Redshift adjusts the color temperature of the screen according to the time of day.
-
maim is a utility to take screenshots. I use Prt Scn to trigger a screenshot of a window or a specific area and Mod+Prt Scn to capture the whole desktop to a file. Check the helper script for details.
-
I have a collection of wallpapers I rotate every hour. A script selects them using advanced machine learning algorithms and stitches them together on multi-screen setups. The selected wallpaper is reused by i3lock.
-
Apart from the eye candy, a compositor also helps to get tear-free video playbacks. ↩︎
-
My configuration works with both Haswell (2014) and Whiskey Lake (2018) Intel GPUs. It also works with AMD GPU based on the Polaris chipset (2017). ↩︎
-
You cannot manage two different displays this way—e.g.
:0
and:1
. In the first implementation, I did try to parametrize each service with the associated display, but this is useless: there is only one DBus user session and many services rely on it. For example, you cannot run two notification daemons. ↩︎ -
I have only discovered later that XSecureLock ships such a dimmer with a similar implementation. But mine has a cool countdown! Optionally, it can also fade to a background image. ↩︎