From 95fc1dd5b960bb6e29b24fa3ea102c5b9a438129 Mon Sep 17 00:00:00 2001 From: myortv Date: Sat, 17 Jan 2026 08:38:19 +0300 Subject: rename to summoner --- summoner.py | 581 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 summoner.py (limited to 'summoner.py') 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() -- cgit v1.3.1