#!/usr/bin/env python3 #Based on https://github.com/9ary/dotfiles/blob/master/i3/ws-1.py #Generalized and extended by Nolan Leake # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. #Example layout: # { # "layout": "splith", # "nodes": [ # { # "layout": "splitv", # "width": 60, # "nodes": [ # { # "swallows": {"class": "Audacious"} # } # ] # }, # { # "layout": "splitv", # "width": 40, # "nodes": [ # { # "swallows": {"app_id": "^Alacritty$"} # }, # { # "swallows": {"cmd": "exec alacritty"} # } # ] # } # ] # } #This layout will match an externally started audacious (an Xwayland app), by # its X11 class, then match an externally started alacritty by its Wayland # app_id, and then internally start another alacritty, matching it because its # window PID is the cmd's PID, or a child of it. # #NOTE: A "cmd" will match on PID unless there are other matches in the swallow, # in which case the PID match will be ignored. This is useful for things like # windows spawned via emacsclient, where the PID of the window will end up # being from the emacs daemon, not the emacsclient instance. import asyncio, re, argparse, json, subprocess, sys, os from i3ipc.aio import Connection from i3ipc import Event def pid_is_descendent(pid, parent_pid): if str(pid) == str(parent_pid): return True try: with open(f'/proc/{pid}/stat', 'rb') as f: l = f.read() ppid = int(l[l.rfind(b')') + 2:].split()[1]) except FileNotFoundError: #No Linux compatible procfs, try psutil import psutil ppid = psutil.Process(pid).ppid() if ppid == 0: return False return pid_is_descendent(ppid, parent_pid) def iter_leaves(subtree): for node in subtree["nodes"]: node["parent"] = subtree if node.get("swallows"): yield node if node.get("nodes") is not None: yield from iter_leaves(node) def try_match(con, leaves): if not getattr(try_match, 'already_ids', None): try_match.already_ids = set() if con.id in try_match.already_ids: return #Something already matched this container. for leaf in leaves: if leaf.get("con"): continue #Something already matched this leaf. if 'pid' in leaf['swallows']: pid = getattr(con, 'pid', None) if pid and pid_is_descendent(con.pid, leaf['swallows']['pid']): leaf['con'] = con try_match.already_ids.add(con.id) return else: for key, pattern in leaf["swallows"].items(): if key in ('class', 'instance', 'title'): key = 'window_' + key if (value := getattr(con, key, None)) is None: break if re.search(pattern, value) is None: break else: leaf["con"] = con try_match.already_ids.add(con.id) return def check_all_leaves_matched(leaves): for leaf in leaves: if leaf.get("con") is None: return False return True async def apply_layout(ws, subtree, to_split=None): for node in subtree["nodes"]: if con := node.get("con"): if ws: await con.command(f"move workspace {ws}") else: await con.command('scratchpad show') await con.command("floating disable") if to_split: if to_split == 'splitv': await con.command("split v") else: await con.command("split h") await con.command(f"layout {subtree['layout']}") to_split = None await con.command("focus") elif node.get("nodes") is not None: await apply_layout(ws, node, subtree['layout']) if con := subtree["nodes"][0].get("con"): await con.command("focus parent") async def main(): parser = argparse.ArgumentParser(description='Setup a workspace layout based on a layout config file.') parser.add_argument('--bg', default=False, action='store_true', help='daemonize after listening to new windows starts') parser.add_argument('--ws', default=None, help='workspace number to setup') parser.add_argument('--match-existing', action='store_true', help='match windows that already exist') parser.add_argument('layout_file', help='json file containing the workspace layout') args = parser.parse_args() with open(args.layout_file, 'r') as f: layout = json.load(f) sway = await Connection().connect() leaves = list(iter_leaves(layout)) #Subscribe to events first to make sure we don't miss anything. watched_ids = set() def on_window(self, event): if event.change == Event.WINDOW_NEW: watched_ids.add(event.container.id) if event.change == Event.WINDOW_TITLE: if not (event.container.id in watched_ids or args.match_existing): return try_match(event.container, leaves) if check_all_leaves_matched(leaves): sway.main_quit() sway.on(Event.WINDOW_NEW, on_window) sway.on(Event.WINDOW_TITLE, on_window) #Fork into bg if requested, now that we're listening to window events. if args.bg: pid = os.fork() if pid > 0: sys.exit(0) #Run any cmd directives. for leaf in leaves: swallows = leaf['swallows'] cmd = swallows.get('cmd', None) if cmd: proc = subprocess.Popen(cmd, close_fds=True, shell=True) if len(swallows) == 1: #Since we're not already matching on any other criteria, # match on the PID. swallows['pid'] = proc.pid #If requested, try to match any existing windows. if args.match_existing: for con in await sway.get_tree(): try_match(con, leaves) if not check_all_leaves_matched(leaves): # Wait until all windows have appeared await sway.main() #This really shouldn't be necessary, but we get a warning otherwise # (this could be a bug in i3ipc). sway.off(on_window) scratchpad_windows = [] try: #Move all our windows to the scratchpad temporarily. for leaf in leaves: scratchpad_windows.append(leaf["con"]) await leaf["con"].command("move scratchpad") #Recusrively apply layout to our discovered leaves. await apply_layout(args.ws, layout) #Resize containers for windows that have sizes specified. for leaf in leaves: con = leaf["con"] await con.command("focus") await con.command(f"resize set {leaf.get('width', 0)} " f"{leaf.get('height', 0)}") while leaf := leaf.get("parent"): await sway.command("focus parent") await sway.command(f"resize set {leaf.get('width', 0)} " f"{leaf.get('height', 0)}") await leaves[0]["con"].command("focus") except: #Let's at least not leave hidden windows in the scratchpad... while len(scratchpad_windows): await scratchpad_windows.pop().command('scratchpad show') raise asyncio.run(main())