Master-stack layout for i3

One thing which DWM nails is its tiled master-stack layout. Windows automatically spawn on the right, without distracting from a very important work taking place on the left, in master area. Unfortunately, even though I love Suckless software and its philosophy in general, DWM is not my pair of boots. This is why I decided to take a shot and replicate automatic master-stack layout in i3, a manual Window Manager.

master-stack layout in i3

Master-stack layout in i3

Manual master-stack layout

Let’s start by thinking how to create such layout manually. First we need to open two windows side-by-side: the one on the left will be master area and the one on the right will be stack. Now focus the right window and do two simple operations: split it (doesn’t matter if split is vertical or horizontal) and then change its layout to stacking. Voilà, we created a master-stack.

To do that we used the fact that i3 stores windows in a tree. Splitting windows means moving them under a new node which is called a split container (see a diagram below). This container holds a property which tells i3 how its child windows should be arranged. Changing a layout means changing this property and without an intermediate split container we’d be changing it for the whole workspace.

i3 tree transformation

Transformation of i3 tree after splitting the window

Creating stack is only part of story. Now we have to maintain layout when windows open and close. i3 creates new sibling windows next to the currently focused one. It means that we only have to manually move them if master area has a focus. We could exploit this behavior to somehow replicate DWM’s many master windows, but I don’t find it very useful and prefer having only one of it, so we won’t do it.

Automation via IPC

Repeating this over and over again this would be cumbersome for every new window. To bring some automation, we’re going to use Inter Process Communication Protocol (IPC) exposed by i3.

For basic stuff it’s enough to act only when new windows open, whic simplifies the algorithm which we’ll implement:

  1. when new window opens, count all tiled windows on a workspace;
  2. if there are less than 2 windows, stop;
  3. mark the last window as a stack target;
  4. if there are 2 windows, perform split vertical and layout stacking operations on the second one;
  5. if there are more than 2 windows and new window isn’t already opened on a stack, move it to the last position of the stack;
  6. if master window is on the stack, let’s make a new master from any new window.

We could directly write and read to i3 socket, but there are many libraries which already handle this burden. One of them is i3ipc-python. Not only it provides a complete interface to interact with i3, but it also provides asyncio support for asynchronous events handling.

To subscribe for notifications about new windows we have to first create a connection with i3 socket (i3 = Connection().connect()) and provide a callback to i3.on(), which will be triggered for chosen events. Below snippet creates a stack whenever second tiling window appears.

import asyncio
from i3ipc import Connection


def tiled_nodes(tree, wsname):
  for w in leaves_dfs(tree):
        ws = w.workspace()
        if ws and ws.name == wsname and 'off' in w.floating:
          yield w
    return workspaces


async def on_new_window(i3, ev):
  tree = await i3.get_tree()
  cont = tree.find_by_id(e.container.id)
  wsname = cont.workspace().name
  tiled = list(tiled_nodes(tree, wsname))

  master = tiled[0]
  stack = tiled[-1]

  if len(tiled) == 2:
    await i3.command('[con_id="{}"] split vertical'.format(stack.id))
    await i3.command('[con_id="{}"] layout stacking'.format(stack.id))


async def amain():
  i3 = await Connection(auto_reconnect=True).connect()
  i3.on(Event.WINDOW_NEW, on_new_window)
  await i3.main()

asyncio.get_event_loop().run_until_complete(amain())

There are two interesting things about this snippet. First, we learn the hard truth about i3 events: they don’t provide information about the whole i3 tree because i3 doesn’t send these informations in original JSONs. To get it, we must explicitly fetch the tree (i3.get_tree()) and find a created node in it (tree.find_by_id()).

Second thing is that we use a depth-first tree traversal method (leaves_dfs()), which is one of elementary graph algorithms. It is useful, because thanks to it we know the order of windows on a workspace: first leaf node is always a master and the last one – the last stack window.

We have to implement depth-first search oourselves because i3ipc-python only ships with breadth-first method. I like using Python generators for traversing graphs because they trivialize plugging in stop conditions in the middle of tree search. Let’s use them than:

def leaves_dfs(root):
    stack = collections.deque([root])
    while len(stack) > 0:
        n = stack.pop()
        if not n.nodes and n.type == "con" and n.parent.type != "dockarea":
            yield n
        stack.extend(reversed(n.nodes))

We also have to move created windows to the existing stack. To do that we’ll use i3 feature to move windows to marks and move it to the last window on the stack, which happens to be the last tiled window (can you see now why the choice of depth-first search was important?)

stackmark = '"__w_{}_stack"'.format(cont.workspace().name)
if len(tiled) > 2 and cont.parent.layout != 'stacked':
    await i3.command('[con_id="{}"] move window to mark {}'.format(cont.id, stackmark))
    await i3.command('[con_id="{}"] focus'.format(cont.id))  # moving to mark doesn't move focus

There’s one last missing bit: when master window closes, we must choose a new one. We could subscribe for window close events and use one of the stack windows, but I decided to do something simpler: choose any new window as a new master.

Detecting this situation is simple. There’s no real master and we’re only left with a stack, so the situation needs our attention if we detect such “master impostor”:

if master.parent.layout == 'stacked':
    master = cont
    await i3.command('[con_id="{}"] move left'.format(master.id))

All above snippets, when glued together, give us a functional, automated master-stack layout. I called it i3a and made its more robust and feature-full version available to download from my git repository. I’ve been working with it for a few weeks and it already improved my workflow, so please check it out if you’re interested.