Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a contrib script to partially replicate i3's append_layout #6435

Closed
wants to merge 1 commit into from
Closed

Add a contrib script to partially replicate i3's append_layout #6435

wants to merge 1 commit into from

Conversation

nolanl
Copy link

@nolanl nolanl commented Aug 13, 2021

layout restoration scheme.

Implemented entirely with existing IPC commands.

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

Signed-off-by: Nolan Leake nolan@sigbus.net

@nolanl
Copy link
Author

nolanl commented Aug 13, 2021

Pinging @9ary since I think the PR message itself might not have detected the @

@nolanl
Copy link
Author

nolanl commented Aug 14, 2021

See closed issue #1005 for more background.

@nolanl
Copy link
Author

nolanl commented Aug 17, 2021

Updated to work with 1.6, as I've just upgraded from 1.5.1. Not entirely sure why it used to work with 1.5.1.

@nolanl
Copy link
Author

nolanl commented Aug 21, 2021

Updated to add a "--bg" option to fork into the background once it is listening for new windows.

Also fixed a bug with multiple swallows that matched the same condition. The first matching window would match all the swallows, leaving subsequent windows unlaidout.

contrib/sway-layout Outdated Show resolved Hide resolved
layout restoration scheme.

Implemented entirely with existing IPC commands.

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

Signed-off-by: Nolan Leake <nolan@sigbus.net>
@hollunder
Copy link

Is there a reason why this has been sitting unmerged for months?

@9ary
Copy link
Contributor

9ary commented Jan 16, 2022

Probably no one's gotten around to properly reviewing it. Even I kinda forgot about it because my own version of this still works fine for me.

@chrhasse
Copy link

This script doesn't work well with some nested layouts. For example:

{
    "layout": "splith",
    "width": 100,
    "nodes": [
        {
            "layout": "splitv",
            "width": 70,
            "nodes": [
                {
                    "layout": "stacking",
                    "height": 70,
                    "nodes": [
                        {
                            "swallows": { "cmd": "exec alacritty" }
                        },
                        {
                            "swallows": { "cmd": "exec alacritty" }
                        },
                        {
                            "swallows": { "cmd": "exec alacritty" }
                        }
                    ]
                },
                {
                    "height": 30,
                    "layout": "splitv",
                    "nodes": [
                        {
                            "swallows": { "cmd": "exec alacritty" }
                        }
                    ]
                }
            ]
        },
        {
            "layout": "stacking",
            "width": 30,
            "nodes": [
                {
                    "swallows": { "cmd": "exec alacritty" }
                },
                {
                    "swallows": { "cmd": "exec alacritty" }
                }
            ]
        }
    ]
}

Through essentially trial and error I was able to fix this by replacing apply_layout with the following

async def apply_layout(sway, ws, subtree):
    to_split = True
    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 and len(subtree["nodes"]) > 1:
                await con.command("split v")
                await con.command(f"layout {subtree['layout']}")
                to_split = False
            await con.command("focus")
        elif node.get("nodes") is not None:
            await apply_layout(sway, ws, node)
    await sway.command("focus parent")
    if not any([True for i in subtree["nodes"] if i.get("con")]):
        await sway.command(f"layout {subtree['layout']}")
        await sway.command("split v")

but I didn't extensively test it outside of the layouts I wanted to use.

@9ary
Copy link
Contributor

9ary commented Jan 26, 2022

It looks like something changed in 1.7 that broke ordering of commands when laying out the tree from the top down. My original script is affected as well. This whole thing remains a hack and without a stable and predictable way to apply a layout I don't think it's worth including such a script.
The problem is that moving things forward requires introducing a new IPC API, which would probably have to be accepted by and implemented in i3 first.

@chrhasse
Copy link

chrhasse commented Feb 5, 2022

1.7 broke my "fix" too it seems. Bad timing for me to start working on this script I guess, since 1.7 was already out for about 12 hours when I finally got it working. I've managed to get something functional for my layout again and figure it might help someone out.

#!/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, Con
from i3ipc import Event
from time import sleep

async def refresh_con(con):
    tree = await con._conn.get_tree()
    return tree.find_by_id(con.id)

Con.__repr__ = lambda self: f'type: {self.type} name: {self.name} id: {self.id} layout: {self.layout} pid: {self.pid}'


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(subtree, ws='current'):
    to_split = len(subtree['nodes']) > 1
    for node in subtree["nodes"]:
        con = None
        if con := node.get("con"):
            await con.command(f"move workspace {ws}")
            await con.command('floating disable')
            await con.command("focus")
            if to_split:
                await con.command('splitt')
                await con.command(f'layout {subtree["layout"]}')
                to_split = False
        elif node.get("nodes") is not None:
            con = await apply_layout(node, ws)
            print("node")
            if con.type != 'workspace':
                await con.command("focus")
                if to_split:
                    new_con = await refresh_con(con)
                    if new_con.parent.type == 'workspace':
                        await con.command('splitt')
                        await con.command(f'layout {subtree["layout"]}')
                        await con.command('focus parent')
                    else:
                        await con.command(f'layout {subtree["layout"]}')
                        
                    to_split = False
            else:
                await con.nodes[0].command('focus parent')
                if to_split:
                    await con._conn.command('splitt')
                    await con._conn.command(f'layout {subtree["layout"]}')

    if con:
        new_con = await refresh_con(con)
        return new_con.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(layout, args.ws)

        #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())

@roselandgoose
Copy link

There's a couple scripts now in the feature MR comments (mentioned above) with similar functionality

#1005 (comment)

@nolanl nolanl closed this by deleting the head repository Nov 10, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

6 participants