From e3a2072bf67c545b282f5ea88f5763608ecb1fc6 Mon Sep 17 00:00:00 2001 From: Jonas Haugesen Date: Fri, 14 Aug 2026 12:14:31 +0200 Subject: [PATCH] sway new autotiling --- sway/.config/sway/config | 2 +- sway/.config/sway/scripts/autotiling-new.py | 166 ++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100755 sway/.config/sway/scripts/autotiling-new.py diff --git a/sway/.config/sway/config b/sway/.config/sway/config index f683eb6..66473eb 100644 --- a/sway/.config/sway/config +++ b/sway/.config/sway/config @@ -1,5 +1,5 @@ exec dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=sway -exec_always autotiling -l 2 +exec_always ~/.config/sway/scripts/autotiling-new.py exec_always ~/.config/sway/scripts/workspace-colors.py exec_always ~/.config/sway/scripts/eww-bars.sh exec_always mako diff --git a/sway/.config/sway/scripts/autotiling-new.py b/sway/.config/sway/scripts/autotiling-new.py new file mode 100755 index 0000000..54ac2e7 --- /dev/null +++ b/sway/.config/sway/scripts/autotiling-new.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Give newly opened sway/i3 windows the split direction matching the focused +window's aspect ratio: + + focused window wider than tall -> new window lands side by side (splith) + focused window taller than wide -> new window lands stacked (splitv) + +Mechanism (same as i3-alternating-layout): when a window gains focus, if its +aspect disagrees with its parent's orientation, the window is wrapped in a +fresh container with the matching orientation. The next window opened while +this one is focused is inserted INTO that wrapper, so it lands in the right +direction. Wrapping a single window is invisible until the next window opens; +existing multi-window layouts are never re-flowed. + +wezterm-nudge collision: the nudge script floats a window when its resize +fails (the window is alone in its wrapper). Sway destroys the empty wrapper +on float and re-tiles the window into the workspace on un-float, undoing the +wrap. This script therefore also listens for the "floating" change event and +re-creates the wrapper after the window is re-tiled. The whole dance is +invisible because the nudge keeps the window at opacity 0 until it finishes. + +Why not upstream `autotiling`: it reacts to every WINDOW/MODE event and flips +the focused window's parent layout on each one, so merely focusing one of two +side-by-side windows flips the pair to stacked. + +Single-instance guard: a second copy exits immediately. This makes +`exec_always` safe to re-run on config reloads (watch-outputs.sh reloads). +""" +import argparse +import os +import sys +import time +from functools import partial + +from i3ipc import Connection, Event + +PIDFILE = "/tmp/autotiling-new.pid" + + +def already_running(): + try: + with open(PIDFILE) as f: + pid = int(f.read().strip()) + except (FileNotFoundError, ValueError): + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def wrap_if_needed(i3, con, debug): + """Wrap `con` in a container whose orientation matches its aspect ratio, + but only if the current parent orientation disagrees.""" + if con.type != "con": + return + if con.fullscreen_mode == 1: + return + parent = con.parent + if parent is None or parent.type == "floating_con": + return + if parent.layout not in ("splith", "splitv"): + return + if con.rect.width <= 0 or con.rect.height <= 0: + return + + parent_horizontal = parent.layout == "splith" + tall = con.rect.height > con.rect.width + if tall and parent_horizontal: + split_dir = "vertical" # tall window -> stacked next time + elif not tall and not parent_horizontal: + split_dir = "horizontal" # wide window -> side by side next time + else: + return # aspect and parent orientation already agree + + result = i3.command(f"[con_id={con.id}] split {split_dir}") + if debug: + if result and result[0].success: + print(f"autotiling-new: split {split_dir} on con {con.id} " + f"({con.rect.width}x{con.rect.height}, parent {parent.layout})", + file=sys.stderr) + else: + print(f"autotiling-new: failed: {result}", file=sys.stderr) + + +def wait_committed(i3, con_id, timeout=0.8): + """Wait until the window is tiled with stable, non-zero geometry. Sway + emits window events before the arrange transaction commits, so rects can + be stale or zero at event time; this polls until they settle.""" + deadline = time.monotonic() + timeout + prev = None + con = None + while time.monotonic() < deadline: + con = i3.get_tree().find_by_id(con_id) + if con is not None and con.parent is not None \ + and con.parent.type != "floating_con": + r = (con.rect.width, con.rect.height) + if prev == r and r[0] > 0 and r[1] > 0: + return con + prev = r + time.sleep(0.03) + return con + + +def handle(i3, e, debug): + try: + if e.container is None: + return + con_id = e.container.id + + if e.change == "focus": + # Usually the arrange has already committed; use the rect as-is. + con = i3.get_tree().find_by_id(con_id) + if con is not None and con.parent is not None \ + and con.parent.type != "floating_con" \ + and con.rect.width > 0 and con.rect.height > 0: + wrap_if_needed(i3, con, debug) + else: + con = wait_committed(i3, con_id) + if con is not None: + wrap_if_needed(i3, con, debug) + + elif e.change == "floating": + # The wezterm-nudge float-toggle destroyed the wrapper; re-tile is + # in progress. Skip if the window stayed floating, otherwise wait + # for the tiled geometry to commit and re-wrap. + con = i3.get_tree().find_by_id(con_id) + if con is None: + return + if con.parent is not None and con.parent.type == "floating_con": + return + con = wait_committed(i3, con_id) + if con is not None: + wrap_if_needed(i3, con, debug) + except Exception as ex: + print(f"autotiling-new: {ex}", file=sys.stderr) + + +def main(): + if already_running(): + print("autotiling-new: already running, exiting", file=sys.stderr) + return 0 + with open(PIDFILE, "w") as f: + f.write(str(os.getpid())) + + parser = argparse.ArgumentParser( + prog="autotiling-new", + description="Split direction for new windows follows the focused window's aspect ratio", + ) + parser.add_argument("-d", "--debug", action="store_true", + help="print debug messages to stderr") + args = parser.parse_args() + + i3 = Connection() + handler = partial(handle, debug=args.debug) + i3.on(Event.WINDOW, handler) + i3.main() + return 0 + + +if __name__ == "__main__": + sys.exit(main())