sway new autotiling

This commit is contained in:
2026-08-14 12:14:31 +02:00
parent ae6fc6b755
commit e3a2072bf6
2 changed files with 167 additions and 1 deletions

View File

@@ -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

View File

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