summaryrefslogtreecommitdiff
path: root/summoner.py
diff options
context:
space:
mode:
authormyortv <myortv@proton.me>2026-01-17 08:38:19 +0300
committermyortv <myortv@proton.me>2026-01-17 08:38:19 +0300
commit95fc1dd5b960bb6e29b24fa3ea102c5b9a438129 (patch)
tree812a8b8ac531f0d0749c6990368494a8909736e7 /summoner.py
parentf0749b308158456712159c03874a2ade957e610f (diff)
rename to summoner
Diffstat (limited to 'summoner.py')
-rw-r--r--summoner.py581
1 files changed, 581 insertions, 0 deletions
diff --git a/summoner.py b/summoner.py
new file mode 100644
index 0000000..6a8e889
--- /dev/null
+++ b/summoner.py
@@ -0,0 +1,581 @@
+import logging
+import subprocess
+import time
+import i3ipc
+import argparse
+
+
+from typing import Dict, Optional, List, Any
+from functools import reduce
+from itertools import accumulate
+from enum import Enum
+from time import sleep
+
+
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Window:
+ run: list | None # command to run (list of arguments) or None
+ geometry: Dict[str, int] # dict like {"x": 0, "y": 0, "w": 200, "h": 200}
+ window_name: str # name of the window spawned from run command
+ workspace: Optional[str | int] = "w_hidden" # home workspace
+ skip_spawn: Optional[bool] = False # do not spawn this window. Skips spawn step
+ skip_init: Optional[bool] = False # do not init this window (initial state snapshot, position, set mode, etc). Skips init step
+ steal_focus: Optional[bool] = False # should this window steal focus. If multiple set in one layout, last one will steal focus
+ restore_to_initial_state: Optional[bool] = False # if set, window will catch it current state and try to restore to this state instead of hiding
+
+ _initial_state_snapshot: Optional[Any] = None # internal variable for initial window state to return to
+
+ def __hash__(self):
+ return hash(self.window_name)
+
+ def __eq__(self, o):
+ if not isinstance(o, Window):
+ raise ValueError("compare only with window")
+ return self.window_name == o.window_name
+
+
+@dataclass
+class Layout:
+ windows: List[Window]
+ close_layout_on: Optional[List[str]] = field(default_factory=list())
+ # layout_command: str
+
+
+# Ideally, first layout, which used to showup on empty workspaces
+# should not contain any focus stealing windows
+# because default layout is shown on workspace with floating windows as well
+# and stealing focus in this case can be annoying
+# on the other hand, sub-layouts should have focus stealing on apps, that
+# actually interactable, because it is usually expected behaviour
+
+LAYOUTS = {
+ 'show_partial_overlay': Layout(
+ windows=[
+ Window(
+ run=["alacritty", "-T", "cmatrix2", "-e", "cmatrix"],
+ geometry={"x": 350, "y": 150, "w": 250, "h": 500},
+ window_name="cmatrix2",
+ ),
+ Window(
+ run=["alacritty", "-T", "cmatrix3", "-e", "cmatrix"],
+ geometry={"x": 900, "y": 300, "w": 250, "h": 500},
+ window_name="cmatrix3",
+ ),
+ ],
+ close_layout_on=[
+ 'exec --no-startup-id echo "show_partial_overlay"',
+ ]
+ ),
+ 'exec --no-startup-id echo "show_overlay_1"': Layout(
+ windows=[
+ Window(
+ run=["alacritty", "-T", "cmatrix", "-e", "cmatrix"],
+ geometry={"x": 50, "y": 50, "w": 250, "h": 500},
+ window_name="cmatrix",
+ ),
+ # Window(
+ # run=["alacritty", "-T", "bigtime", "-e", "bigtime"],
+ # geometry={"x": 350, "y": 50, "w": 1800, "h": 250},
+ # window_name="bigtime",
+ # steal_focus=True,
+ # ),
+ Window(
+ run=None,
+ geometry={"x": 350, "y": 50, "w": 1800, "h": 250},
+ window_name="special",
+ skip_spawn=True,
+ restore_to_initial_state=True,
+ workspace="21"
+ ),
+ ],
+ close_layout_on=[
+ 'exec --no-startup-id echo "show_partial_overlay"',
+ 'exec --no-startup-id echo "show_overlay_1"',
+ ]
+ ),
+}
+
+CURRENT_LAYOUT: None | Layout = None
+HIDE_ON_WORKSPACE: None | str = None
+
+
+i3 = i3ipc.Connection()
+
+
+def move_window_to_workspace(
+ i3_container: i3ipc.Con,
+ workspace: Optional[str] = None,
+):
+ if workspace == "scratchpad":
+ i3.command(f"[con_id={i3_container.id}] move to scratchpad")
+ else:
+ i3.command(f"[con_id={i3_container.id}] move to workspace {workspace}")
+
+
+def get_dimensions_on_workspace(
+ geometry: Dict[str, int],
+ workspace: i3ipc.Con,
+):
+ max_x = workspace.rect.x + workspace.rect.width
+ desired_x = geometry['x'] + workspace.rect.x
+
+ if desired_x >= max_x:
+ return
+
+ max_y = workspace.rect.y + workspace.rect.height
+ desired_y = geometry['y'] + workspace.rect.y
+
+ if desired_y >= max_y:
+ return
+
+ width = min(
+ geometry['w'],
+ max_x - desired_x
+ )
+ height = min(
+ geometry['h'],
+ max_y - desired_y,
+ )
+
+ if width < 100 or height < 100:
+ return
+
+
+ return {
+ "x": desired_x,
+ "y": desired_y,
+ "w": width,
+ "h": height,
+ }
+
+
+def place_window(
+ i3_container: i3ipc.Con,
+ x,
+ y,
+):
+ win_id = i3_container.id
+ i3.command(f"[con_id={win_id}] move position {x} {y}")
+
+def resize_window(
+ i3_container: i3ipc.Con,
+ w,
+ h,
+):
+ win_id = i3_container.id
+ i3.command(f"[con_id={win_id}] resize set {w} {h}")
+
+
+def float_window(
+ i3_container: i3ipc.Con,
+):
+ i3.command(f"[con_id={i3_container.id}] floating enable")
+
+
+def focus_window(
+ i3_container: i3ipc.Con,
+):
+ i3.command(f"[con_id={i3_container.id}] focus")
+
+
+def snapshot_Window(
+ i3_container: i3ipc.Con,
+):
+ rect = i3_container.rect
+
+ return {
+ "con_id": i3_container.id,
+ "workspace": i3_container.workspace().name,
+ "floating": i3_container.floating,
+ "fullscreen": i3_container.fullscreen_mode,
+ "rect": {
+ "x": rect.x,
+ "y": rect.y,
+ "w": rect.width,
+ "h": rect.height,
+ },
+ }
+
+
+def restore_Window(
+ window: Window,
+):
+ if not window.restore_to_initial_state:
+ return
+
+ state = window._initial_state_snapshot
+
+ i3.command(
+ f"[con_id={state['con_id']}] move container to workspace {state['workspace']}"
+ )
+
+ if state["floating"] not in {"user_off", "auto_off"}:
+ r = state["rect"]
+ i3.command(f"[con_id={state['con_id']}] floating enable")
+ i3.command(f"[con_id={state['con_id']}] move position {r['x']} px {r['y']} px")
+ i3.command(f"[con_id={state['con_id']}] resize set {r['w']} px {r['h']} px")
+ else:
+ i3.command(f"[con_id={state['con_id']}] floating disable")
+
+ if state["fullscreen"]:
+ i3.command(f"[con_id={state['con_id']}] fullscreen enable")
+
+
+def init_Layout(layout: Layout):
+ """set windows initial positions and sizes"""
+ tree = i3.get_tree()
+ for win in layout.windows:
+ if win.skip_init:
+ continue
+ i3_container = find_window_container(tree, win)
+ if not i3_container:
+ logging.debug(f'Container for window (window_name={win.window_name}) not found, trying to spawn it')
+ continue
+ if win.restore_to_initial_state:
+ win._initial_state_snapshot = snapshot_Window(i3_container)
+ restore_Window(win)
+ continue
+ resize_window(i3_container, win.geometry['w'], win.geometry['h'])
+ move_window_to_workspace(
+ i3_container,
+ win.workspace,
+ )
+
+
+def spawn_Layout(layout: Layout):
+ tree = i3.get_tree()
+ for win in layout.windows:
+ if win.skip_spawn:
+ continue
+ i3_container = find_window_container(tree, win)
+ if i3_container:
+ continue
+ spawn([win])
+ i3_container = find_window_container(tree, win)
+ if not i3_container:
+ logging.debug(f'Container for window (window_name={win.window_name}) either not spawned properly or disabled for spawning. Either way, it is not moved to desired workspace')
+
+
+def close_Layout(layout: Optional[Layout] = None):
+ global CURRENT_LAYOUT
+ global HIDE_ON_WORKSPACE
+
+ if not layout:
+ layout = CURRENT_LAYOUT
+
+ if not layout:
+ return
+
+ tree = i3.get_tree()
+ for win in layout.windows:
+ i3_container = find_window_container(tree, win)
+ if not i3_container:
+ logging.debug(f'Container for window (window_name={win.window_name}) not found')
+ continue
+ if win.restore_to_initial_state:
+ restore_Window(win)
+ continue
+ move_window_to_workspace(
+ i3_container,
+ win.workspace,
+ )
+
+ CURRENT_LAYOUT = None
+
+
+def open_Layout(
+ layout: Layout,
+):
+ global CURRENT_LAYOUT
+ global HIDE_ON_WORKSPACE
+
+ tree = i3.get_tree()
+ ws = tree.find_focused().workspace()
+ windows_data = list()
+ for win in layout.windows:
+ i3_container = find_window_container(tree, win)
+ if not i3_container:
+ logging.debug(f'Container for window (window_name={win.window_name}) not found')
+ continue
+ float_window(i3_container)
+ move_window_to_workspace(
+ i3_container,
+ ws.name,
+ )
+ dimensions = get_dimensions_on_workspace(
+ win.geometry,
+ ws,
+ )
+ if not dimensions:
+ logging.debug(f'Container for window (window_name={win.window_name}) can not be fitted in workspace, hiding it')
+ move_window_to_workspace(
+ i3_container,
+ win.workspace,
+ )
+ continue
+ place_window(i3_container, dimensions["x"], dimensions["y"])
+ if win.steal_focus:
+ focus_window(i3_container)
+ windows_data.append(
+ {
+ "win": win,
+ "i3_container": i3_container,
+ "x": dimensions['x'],
+ "y": dimensions["y"],
+ "w": dimensions["w"],
+ "h": dimensions["h"],
+ }
+ )
+
+ sleep(0.005)
+ for win in windows_data:
+ resize_window(
+ win["i3_container"],
+ win['w'],
+ win['h']
+ )
+
+ CURRENT_LAYOUT = layout
+ HIDE_ON_WORKSPACE = None
+
+
+def workspace_empty(ws: i3ipc.Con):
+ """checks if all workspace empty, or have only 'floating' windows in it"""
+ for w in ws.leaves():
+ if w.floating in {"auto_off", "user_off",}:
+ return False
+ return True
+
+
+def spawn(targets: List[Window]):
+ tree = i3.get_tree()
+ for win in filter(lambda target: not find_window_container(tree, target), targets):
+ res = subprocess.Popen(win.run)
+ logging.debug(f"window spawned: {res}")
+
+
+def find_window_container(tree: i3ipc.Con, win: Window) -> i3ipc.Con | None:
+ try:
+ return next(
+ filter(
+ lambda i3_con: i3_con.name
+ and i3_con.name == win.window_name,
+ tree.leaves(),
+ )
+ )
+ except StopIteration:
+ return None
+
+
+def get_layouts_window_titles() -> List[str]:
+ all_titles = set()
+ for layout in LAYOUTS.values():
+ for win in layout.windows:
+ all_titles.add(win.window_name)
+
+ return list(all_titles)
+
+
+
+def get_spawn_targets(
+ layouts: List[Layout]
+) -> List[Window]:
+ """returns filtered out unique spawn targets"""
+ all_windows = reduce(
+ lambda a, b: a.windows + b.windows,
+ layouts,
+ )
+ return list(set(all_windows))
+
+
+def on_binding(i3conn, event: i3ipc.events.BindingEvent):
+ """check binding sended and try to showup windows"""
+ global CURRENT_LAYOUT
+ global HIDE_ON_WORKSPACE
+
+ tree = i3conn.get_tree()
+
+ layout_to_switch_to = LAYOUTS.get(event.binding.command)
+ if not layout_to_switch_to:
+ return
+
+
+ if CURRENT_LAYOUT is None:
+ close_Layout()
+ spawn_Layout(layout_to_switch_to)
+ open_Layout(layout_to_switch_to)
+ return
+
+ if CURRENT_LAYOUT == layout_to_switch_to:
+ close_Layout()
+ HIDE_ON_WORKSPACE = tree.find_focused().workspace().name
+ return
+
+ if event.binding.command in CURRENT_LAYOUT.close_layout_on:
+ close_Layout()
+ HIDE_ON_WORKSPACE = tree.find_focused().workspace().name
+ return
+
+ close_Layout()
+ open_Layout(layout_to_switch_to)
+
+
+i3.on("binding", on_binding)
+
+
+def on_workspace_focus(i3conn: i3ipc.Connection, event: i3ipc.events.IpcBaseEvent):
+ global HIDE_ON_WORKSPACE
+
+ tree = i3conn.get_tree()
+ focus_workspace = tree.find_focused().workspace()
+
+ current_layout_workspace_is_empty = (
+ CURRENT_LAYOUT
+ and next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None)
+ and workspace_empty(next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None).workspace())
+ )
+ if current_layout_workspace_is_empty and not workspace_empty(focus_workspace):
+ return
+
+ focus_on_same_workspace_as_current_layout = (
+ CURRENT_LAYOUT
+ and next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None)
+ and focus_workspace.name == next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None).workspace().name
+ )
+ if focus_on_same_workspace_as_current_layout:
+ # skip re-focus on the current layout
+ return
+
+ hide_button_pressed_on_current_workspace = focus_workspace.name == HIDE_ON_WORKSPACE
+ if hide_button_pressed_on_current_workspace:
+ return
+
+ if workspace_empty(focus_workspace):
+ close_Layout()
+ default_layout = list(LAYOUTS.values())[0]
+ spawn_Layout(default_layout)
+ open_Layout(default_layout)
+ return
+
+ close_Layout()
+
+i3.on("workspace::focus", on_workspace_focus)
+
+
+def default_behavior(i3conn, event: i3ipc.events.IpcBaseEvent):
+ tree = i3conn.get_tree()
+ focus_workspace = tree.find_focused().workspace()
+
+ if event.container.window_title in get_layouts_window_titles():
+ return
+ focus_on_same_workspace_as_current_layout = (
+ CURRENT_LAYOUT
+ and next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None)
+ and focus_workspace.name == next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None).workspace().name
+ )
+ if focus_on_same_workspace_as_current_layout and workspace_empty(focus_workspace):
+ return
+ elif focus_on_same_workspace_as_current_layout and not workspace_empty(focus_workspace):
+ close_Layout()
+ return
+
+ if workspace_empty(focus_workspace):
+ default_layout = list(LAYOUTS.values())[0]
+ spawn_Layout(default_layout)
+ open_Layout(default_layout)
+ else:
+ close_Layout()
+
+
+i3.on("window::new", default_behavior)
+i3.on("window::floating", default_behavior)
+
+
+def on_close(i3conn, event: i3ipc.events.WindowEvent):
+ closed_window = event.container
+ tree = i3conn.get_tree()
+ focus_workspace = tree.find_focused().workspace()
+
+ focus_on_same_workspace_as_current_layout = (
+ CURRENT_LAYOUT
+ and next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None)
+ and focus_workspace.name == next((find_window_container(tree, win) for win in CURRENT_LAYOUT.windows), None).workspace().name
+ )
+ if focus_on_same_workspace_as_current_layout and workspace_empty(focus_workspace):
+ return
+
+ if closed_window.window_title in get_layouts_window_titles():
+ return
+ if workspace_empty(focus_workspace):
+ default_layout = list(LAYOUTS.values())[0]
+ spawn_Layout(default_layout)
+ open_Layout(default_layout)
+ else:
+ close_Layout()
+
+i3.on("window::close", on_close)
+
+
+
+def parse_loglevel(loglevel: str):
+ map = {
+ "debug": logging.DEBUG,
+ "info": logging.INFO,
+ "warning": logging.WARNING,
+ "error": logging.ERROR,
+ }
+ return map.get(loglevel, logging.INFO)
+
+
+def get_cli_args():
+ parser = argparse.ArgumentParser(
+ prog="Manage and use floating sub-layouts with i3.",
+ description=(
+ "Manage floating overlay layouts in i3wm. "
+ "The script listens to i3 IPC events and keybindings to automatically "
+ "spawn, position, float, hide, and restore predefined windows. "
+ "Layouts appear on empty workspaces or when triggered, and disappear "
+ "when focus or workspace state changes."
+ )
+ )
+
+ parser.add_argument(
+ "--log-level",
+ dest="log_level",
+ choices=[
+ "debug",
+ "info",
+ "warning",
+ "error",
+ ],
+ default="warning",
+ )
+
+ return parser.parse_args()
+
+
+def main():
+ args = get_cli_args()
+
+ logging.basicConfig(level=parse_loglevel(args.log_level))
+
+ spawn(
+ get_spawn_targets(
+ LAYOUTS.values(),
+ )
+ )
+
+ sleep(1)
+
+ for layout in LAYOUTS.values():
+ spawn_Layout(layout)
+ init_Layout(layout)
+
+ i3.main()
+
+
+if __name__ == "__main__":
+ main()