Add a contrib script to partially replicate i3's append_layout

layout restoration scheme.

Implemented entirely with existing IPC commands.

Based on @9ary's ws-1.py

Signed-off-by: Nolan Leake <nolan@sigbus.net>
This commit is contained in:
Nolan Leake 2021-08-13 10:27:55 -07:00
parent 4cdc4ac63a
commit 6fd5ff109d

221
contrib/sway-layout Executable file
View file

@ -0,0 +1,221 @@
#!/usr/bin/env python3
#Based on https://github.com/9ary/dotfiles/blob/master/i3/ws-1.py
#Generalized and extended by Nolan Leake <nolan@sigbus.net>
# 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())