diff options
| author | myortv <myortv@proton.me> | 2026-01-17 08:37:20 +0300 |
|---|---|---|
| committer | myortv <myortv@proton.me> | 2026-01-17 08:37:20 +0300 |
| commit | f0749b308158456712159c03874a2ade957e610f (patch) | |
| tree | f2b36872245ddadac7dea17632d6513dd5aca716 | |
| parent | 2bfe0ea5d0399700f972962c3a77798caf8269b6 (diff) | |
Multihead support
Rewriten Layout setup
Support for restorable windows
| -rw-r--r-- | README.md | 211 | ||||
| -rw-r--r-- | req.txt | 1 | ||||
| -rwxr-xr-x | script.sh | 4 | ||||
| -rw-r--r-- | todo.md | 2 | ||||
| -rw-r--r-- | watcher.py | 761 | ||||
| -rw-r--r-- | workspace_watcher.py | 342 |
6 files changed, 732 insertions, 589 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..997b0f6 --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# About Summoner + +This is a daemon script, that manages summoning layouts. +By summoning layouts, I mean, showing set of floating windows on top of your workspace windows in floating mode. + + +You don't need to know python and be deeply into programming to configure and use Summoner, but it is still just a script and i don't plan to support any external configs. + +It's features: + +1. Summon layout on empty workspaces on workspace switch +2. Summon layouts with keybindings +3. Spawn processes to use for layouts +4. Or use already running windows as parts of the layout +5. Restore window position after using this window in layout summon +6. Support multihead setups + + +# Running +#### It is not standalone package +For now, it is not a package, and you'll need to manage dependencies on your own. Summoner has only one dependecny - i3ipc. +You can either run +```bash +. ./setup.sh +``` +or +```bash +python -m venv venv +. ./venv/bin/activate +pip install -r req.txt +``` + +To run script, you'll need your envrinment activated. You can either do: +```bash +. ./run.sh +``` +or +```bash +. ./venv/bin/activate +python summoner.py +``` + +Tho, you'll probably want to run it inside of i3 config. After placing summoner into the `.config/i3/summoner`: + +```bash +exec_always --no-startup-id ~/.config/i3/summoner/run.sh +``` + + +# Configuration +## Simply showing windows on empty workspaces +If you want to use Summoner only to summon layout on empty workspace, you only need to setup your initial layout. +Open script and find `LAYOUTS` variable. +Edit it to be something like this: + + +```python +LAYOUTS = { + '############': Layout( + windows=[ + Window( + run=["alacritty", "-T", "cmatrix1", "-e", "cmatrix"], + geometry={"x": 350, "y": 150, "w": 250, "h": 500}, + window_name="cmatrix1", + ), + Window( + run=None, + geometry={"x": 900, "y": 300, "w": 250, "h": 500}, + window_name="cmatrix2", + workspace="1" + skip_spawn=True, + skip_init=True, + steal_focus=True, + ), + ] + ), +``` +|---|---| +|`run`| Either None (explicitly) or list of cli args to run | +| `geometry` | dict in format {"x": 0, "y": 0, "w": 0, "h": 0}, where `x`, `y` is top left of your workspace (output) `w` is window width and `h` is window height. Note that window should be at least 100/100 in size, or window will be counted as too small and not displayed (it still will be spawned) | +| `window_name` | name of the window. Note that it should be unique, and you need to either set it in `run` somewhere, depending on what you run, or by hand, when you run with `skip_spawn` | +| `workspace` | home workspace window will hide itself at. Default as "w_hidden" | +| `skip_spawn` | skipping spawn step of setup. This means you started your process yourself, or plan to start it later | +| `skip_init` | skipping init step of setup. This means, you don't touch window (don't store it's initial values, don't float it, don't resize, don't move it to hidden workspace, etc) | +| `steal_focus` | make this window steal focus from whatever focus was beforehand | +| `restore_to_initial_state` | record initial state of window (init step, don't skip it). When window should be hided, it will try to go to it's initial state. Note that it does not save any layout data, only simple workspace/floated/focused | + + +## Summoning windows with key presses +If you want to bring out full potential of Summoner and make it summon floating windows right in your face, on top of your current tiling layout, you need to do as follows: + +### Edit i3 config +Unfortunately, you need to edit i3 config for it to work. You need to bind some keystrokes for Summoner to catch. Hopefully, it is pretty easy to do. +Just add string like this in your config: +```bash +bindsym --release $mod+Ctrl+y exec --no-startup-id echo "show_system_monitor" +``` + +In the i3, whole exec line, including exec will be caught as binding: +```bash +exec --no-startup-id echo "show_system_monitor" +``` + +### Edit Summoner + +Now, we'll need to edit script file itself. Notice how we had bunch of hashes as the key for `LAYOUTS` in the first example. It was like this because it did not really mattered what value we put there, it will still work. You can even put `ur_mom` in here, but i will be carefull not to cause buffer overflow. +Anyway, now, key for the `LAYOUTS` dictionary will play it's role. + +We need to set key to our binded command, including exec and everything after. Like this: +```python +LAYOUTS = { + 'exec --no-startup-id echo "show_system_monitor"': Layout( + windows=[ + ... + ], + ), +} + +``` + +And, we will need to setup, how our layout is closed: +```python +LAYOUTS = { + 'exec --no-startup-id echo "show_system_monitor"': Layout( + ..., + close_layout_on=[ + 'exec --no-startup-id echo "show_system_monitor"', + ], + ), +} + +``` + +Why this separation? + +So we can have scenario like this then: + +> .configs/i3/config +```bash +bindsym --release $mod+Ctrl+y exec --no-startup-id echo "show_system_monitor" +bindsym --release $mod+Ctrl+u exec --no-startup-id echo "show_newsboat" +bindsym --release $mod+Ctrl+i exec --no-startup-id echo "show_newsboat_and_system_monitor" + +``` + +> Summoner.py +```python + +LAYOUTS = { + 'show_system_monitor': Layout( + windows=[ + Window( + run=["system_monitor.sh"], + ... + ), + ], + close_layout_on=[ + 'exec --no-startup-id echo "show_system_monitor"', + ] + ), + 'exec --no-startup-id echo "show_newsboat"': Layout( + windows=[ + Window( + run=["alacritty", "-T", "w_newsboat", "-e", "newsboat"], + window_name="w_newsboat", + ), + ], + close_layout_on=[ + 'exec --no-startup-id echo "show_system_monitor"', + 'exec --no-startup-id echo "show_newsboat"', + ] + ), + 'exec --no-startup-id echo "show_newsboat_and_system_monitor"': Layout( + windows=[ + Window( + run=["alacritty", "-T", "w_newsboat", "-e", "newsboat"], + window_name="w_newsboat", + ), + ], + close_layout_on=[ + 'exec --no-startup-id echo "show_newsboat_and_system_monitor"', + ] + ), +} + +``` + +In this case, if we hit firts key combination (mod+ctrl+y) will bring out system_monitor, then, if we hit second combination (mod+ctrl+u), we will summon newsboat. Otherwise, we would first close system monitor, and only after this we will be able to bring out system_monitor. +Then, inside of "newsboat" layout, we can close whole layout either hitting system_monitor combination, or newsboat combination +And, after navigating into the show_newsboat_and_system_monitor (mod+ctrl+i), we will be able to close this layout only with show_newsboat_and_system_monitor combination. +Hitting system_monitor or newsboat combinations (mod+ctrl+y/mod+ctrl+u), we will not close whole overlay, but navigate back to each layout respectfully. + +So, this system, gives as ability to do some simple "navigation" between layouts, saving some keystrokes, and sanity. + + +## Restorable windows +As you couldve seen, `Window` have `restore_to_initial_state`. It makes it possible (while still very crude) to bring window back, from where it was taken to show on the layouts. +Initially, this feature was intended to be used shile streaming, to summon chat on screen, from secondary monitor, to the main one, which is captured by obs, and then brought back. +Hope it will be usable with any other usecases. + + +## Multihead +Summoner supports multihead (tho, only tested in 2 monitor setup by me). General behaviour of summoned layouts will be the same as in the single monitor setup, but in case one of the monitor workspaces considered "empty", when you move focus form "empty" monitor, to monitor with windows opened, current layout will "stick" to the empty monitor (otherwise it wouldve just hid itself) + + + +# Contribution +This repo does not accepts contributions outright. If you want to contribute, hit me up on discord (@myortv) or email (myortv@proton.me) (email is worse, i rarely check it) and i'll either merge your patches or give you access to git ssh. +If you found any bugs, hit me up on discord (@myortv) or email (myortv@proton.me) +If you have any feature requests, hit me up on discord (@myortv) or email (myortv@proton.me) +If you are hop femboy in my area, hit me up on discord (@myortv) or email (myortv@proton.me) @@ -0,0 +1 @@ +i3ipc @@ -1,2 +1,2 @@ -source ~/watcher/venv/bin/activate -exec python ~/watcher/main.py +source venv/bin/activate +python watcher.py @@ -0,0 +1,2 @@ +- [ ] refactoring (code is very shit, it's 7:22, i want to go to sleep, i had work to do instead of this...) +- [ ] documentation @@ -1,310 +1,581 @@ -from functools import reduce -from enum import Enum +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 -PRESETS = { - "hidden1": [ - { - "run": ["alacritty", "-T", "thing"], - "name": "thing", - "geometry": {"x": 0, "y": 0, "w": 200, "h": 200}, - "scratch": "hidden1", - }, - ], - "hidden2": [ - { - "run": ["alacritty", "-T", "thing2"], - "name": "thing2", - "geometry": {"x": 250, "y": 250, "w": 200, "h": 200}, - "scratch": "hidden2", - }, - ], - # - # - # { - # "run": ["/usr/bin/bash", "/home/myo/.config/i3/watcher/script.sh"], - # "name": "SystemMonitor", - # "geometry": {"x": 10, "y": 10, "w": 400, "h": 1060}, - # "scratch": "hidden1", - # # "skip_startup": False, - # }, - # "hidden2": [ - # { - # "run": ["alacritty", "-T", "newsboatterm", "-e", "newsboat"], - # "name": "newsboatterm", - # "geometry": {"x": 420, "y": 10, "w": 1200, "h": 353}, - # "scratch": "hidden2", - # # "skip_startup": True, - # }, - # { - # "run": ["alacritty", "-T", "termusicoverlay", "-e", "termusic"], - # "name": "termusicoverlay", - # "geometry": {"x": 420, "y": 373, "w": 1200, "h": 696}, - # "scratch": "hidden2", - # }, - # ], - # "hidden3": [ - # { - # "run": ["alacritty", "-T", "todo", "-e", "helix", "~/Desk/todo_/todo.md"], - # "name": "todo", - # "geometry": {"x": 420, "y": 10, "w": 1200, "h": 1060}, - # "scratch": "hidden3", - # }, - # ], +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 -# TODO: make it enum instead -status = 0 -LAST_WORKSPACE = None -ALL_APPS = reduce( - lambda x, y: x + y, PRESETS.values() -) -ALL_NAMES = [app['name'] for app in ALL_APPS] -button_trigger = 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 -def spawn(targets=None): - if not targets: - targets = PRESETS["hidden1"] - if not cleanup_unneded_instances(i3.get_tree()): - for app in targets: - res = subprocess.Popen(app["run"]) - print(res) + if desired_x >= max_x: + return + max_y = workspace.rect.y + workspace.rect.height + desired_y = geometry['y'] + workspace.rect.y -def cleanup_unneded_instances( - tree, - scratches=None, - keep=None, -) -> bool: - if not scratches: - scratches = PRESETS.keys() - if not keep: - keep = [] + if desired_y >= max_y: + return - scratches_to_cleanup = [PRESETS[item] for item in scratches if item not in keep] + width = min( + geometry['w'], + max_x - desired_x + ) + height = min( + geometry['h'], + max_y - desired_y, + ) - if not scratches: + if width < 100 or height < 100: return - all_apps = reduce( - lambda x, y: x + y, scratches + + 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']}" ) - res = True - for app in all_apps: - matching = list( - filter( - lambda w: w.name - and w.name == app['name'] - and w.workspace().name not in PRESETS.keys(), - tree.leaves(), - ) + 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, ) - if len(matching) > 0: - for win in matching[1:]: - win.command("kill") - # float_and_resize(matching[0], app["geometry"]) - move_to_scratchpad(matching[0], app["scratch"]) - res = False - return -def float_and_resize(win, geometry): - win_id = win.id - i3.command(f"[con_id={win_id}] floating enable") - i3.command(f"[con_id={win_id}] resize set {geometry['w']} {geometry['h']}") - i3.command(f"[con_id={win_id}] move position {geometry['x']} {geometry['y']}") +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 move_to_scratchpad(win, scratch): - if scratch == "scratchpad": - i3.command(f"[con_id={win.id}] move to {scratch}") - else: - i3.command(f"[con_id={win.id}] move to workspace {scratch}") +def close_Layout(layout: Optional[Layout] = None): + global CURRENT_LAYOUT + global HIDE_ON_WORKSPACE + if not layout: + layout = CURRENT_LAYOUT -def showup(tree, ws, show_scratches=None, focus_win=None): - if not show_scratches: - show_scratches = ["hidden1"] - all_apps = PRESETS["hidden1"] - else: - all_apps = reduce( - lambda x, y: x + y, [PRESETS[item] for item in show_scratches] + 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'] ) - if resp_if_needed( - tree, - all_apps, - ): - tree = i3.get_tree() - for app in all_apps: - # if app["skip_startup"] and not app["scratch"] == show_scratch and not FULL_OVERLAY_SPAWN: - # return - matching = list( - filter(lambda w: w.name and w.name == app["name"], tree.leaves()) + 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(), + ) ) - if not matching: - print("ERR: index out of bounds. Check apps health.") - return - win = matching[0] - float_and_resize(win, app["geometry"]) - win.command(f"move container to workspace {ws.name}") - if focus_win == app["name"]: - win.command("focus") + 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 resp_if_needed(tree, targets): - dead = list(filter(lambda app: not list(filter(lambda w: w.name==app["name"], tree.leaves())), targets)) - if dead: - spawn(dead) - sleep(0.5) - return True +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 -def on_binding(i3conn, event): - global status, button_trigger - # - # when workpace focused, button_trigger sets to None + if CURRENT_LAYOUT is None: + close_Layout() + spawn_Layout(layout_to_switch_to) + open_Layout(layout_to_switch_to) + return - print('~~~~~~~~~~``') - print(event.binding.command) - print(status) - print(button_trigger) - print('~~~~~~~~~~``') - match event.binding.command: + if CURRENT_LAYOUT == layout_to_switch_to: + close_Layout() + HIDE_ON_WORKSPACE = tree.find_focused().workspace().name + return - case 'exec --no-startup-id echo "show_partial_overlay"': - if status != 0: - cleanup_unneded_instances(i3.get_tree()) - status = 0 - button_trigger = True - else: - status = 1 - cleanup_unneded_instances(i3conn.get_tree(), None, ["hidden1"]) - ws = i3.get_tree().find_focused().workspace() - showup(i3.get_tree(), ws, ["hidden1"], focus_win="thing") - button_trigger = True + if event.binding.command in CURRENT_LAYOUT.close_layout_on: + close_Layout() + HIDE_ON_WORKSPACE = tree.find_focused().workspace().name + return - case 'exec --no-startup-id echo "show_overlay_1"': - if status == 2: - # cleanup status (current thing opened) - status = 0 - cleanup_unneded_instances(i3.get_tree(), ["hidden1", "hidden2"]) - button_trigger = True - else: - if status != 0: - cleanup_unneded_instances(i3conn.get_tree(), None, ["hidden1", "hidden2"]) - status = 2 - ws = i3.get_tree().find_focused().workspace() - showup(i3.get_tree(), ws, ["hidden1", "hidden2"], "thing2") - button_trigger = True + close_Layout() + open_Layout(layout_to_switch_to) - # case 'exec --no-startup-id echo "show_overlay_2"': - # if status == 3: - # status = 0 - # cleanup_unneded_instances(i3.get_tree(), ["hidden1", "hidden3"]) - # button_trigger = True - # else: - # if status != 0: - # cleanup_unneded_instances(i3conn.get_tree(), None, ["hidden1", "hidden3"]) - # status = 3 - # ws = i3.get_tree().find_focused().workspace() - # showup(i3.get_tree(), ws, ["hidden1", "hidden3"], "todo") - # button_trigger = True i3.on("binding", on_binding) -def on_window_close(i3conn, event): - global status - win = event.container - if not win.name or not win.name in ALL_NAMES: - ws = i3.get_tree().find_focused().workspace() - if status == 0 and not ws.nodes: - showup(i3.get_tree(), ws, ["hidden1"]) - status = 1 - resp_if_needed(i3conn.get_tree(), ALL_APPS) - # if not cleanup_unneded_instances(i3.get_tree()): - # spawn() - # return +def on_workspace_focus(i3conn: i3ipc.Connection, event: i3ipc.events.IpcBaseEvent): + global HIDE_ON_WORKSPACE -i3.on("window::close", on_window_close) + 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 -def on_workspace_focus(i3conn, event): - global LAST_WORKSPACE, status, button_trigger - ws = event.current - if ws is None: + hide_button_pressed_on_current_workspace = focus_workspace.name == HIDE_ON_WORKSPACE + if hide_button_pressed_on_current_workspace: return - if button_trigger and LAST_WORKSPACE == ws.name: - button_trigger = None - LAST_WORKSPACE = ws.name + + if workspace_empty(focus_workspace): + close_Layout() + default_layout = list(LAYOUTS.values())[0] + spawn_Layout(default_layout) + open_Layout(default_layout) return - if ( - not ws.nodes - and LAST_WORKSPACE != ws.name - ): - showup(i3.get_tree(), ws, ["hidden1"]) - status = 1 - else: - cleanup_unneded_instances(i3.get_tree()) - status = 0 - LAST_WORKSPACE = ws.name - + + close_Layout() + i3.on("workspace::focus", on_workspace_focus) -def on_new_windnow(i3conn, event): - global status - win = event.container - if win.name and win.name in ALL_NAMES: - if cleanup_unneded_instances(i3.get_tree()): - return - if status == 0: +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 - ws = win.workspace() - if ws and ws.nodes: - status = 0 - return cleanup_unneded_instances(i3.get_tree()) - if win.floating != "user_on" and win.floating != "auto_on": - status = 0 - return cleanup_unneded_instances(i3.get_tree()) + 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", on_new_windnow) +i3.on("window::new", default_behavior) +i3.on("window::floating", default_behavior) -def on_window_floating(i3conn, event): - win = event.container - ws = i3.get_tree().find_focused().workspace() - if ws and ws.nodes: - cleanup_unneded_instances(i3.get_tree()) - elif win.floating == "user_on": - showup(i3.get_tree(), ws) + +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: - cleanup_unneded_instances(i3.get_tree()) + 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.on("window::floating", on_window_floating) + i3.main() - -# ======================================================================================== -spawn( - ALL_APPS -) -i3.main() +if __name__ == "__main__": + main() diff --git a/workspace_watcher.py b/workspace_watcher.py deleted file mode 100644 index 03a65ac..0000000 --- a/workspace_watcher.py +++ /dev/null @@ -1,342 +0,0 @@ -from functools import reduce -from enum import Enum -import subprocess -import time -import i3ipc -from time import sleep - - -APP_CMD = { - "SystemMonitor": { - "run": ['bash', "/home/myo/script.sh"], - "name": "SystemMonitor", - "geometry": { - "x": 10, "y": 10, "w": 400, "h": 1060 - }, - "scratch": "hidden1", - # "skip_startup": False, - }, - "newsboatterm":{ - "run": ['alacritty', '-T', 'newsboatterm', '-e', 'newsboat'], - "name": "newsboatterm", - "geometry": { - "x": 420, "y": 10, "w": 1200, "h": 353 - }, - "scratch": "hidden2", - # "skip_startup": True, - }, - "termusicoverlay":{ - "run": ['alacritty', '-T', 'termusicoverlay', '-e', 'termusic'], - "name": "termusicoverlay", - "geometry": { - "x": 420, "y": 373, "w": 1200, "h": 696 - }, - "scratch": "hidden2", - # "skip_startup": True, - }, - "todo":{ - "run": ['alacritty', '-T', 'todo', '-e', 'helix', '~/todo/todo'], - "name": "todo", - "geometry": { - "x": 420, "y": 373, "w": 1200, "h": 696 - }, - "scratch": "hidden3", - # "skip_startup": True, - }, -} - -def gen_app_list_by_scratches(): - res = dict() - for item in APP_CMD.values(): - if res.get(item["scratch"]): - res[item["scratch"]].append(item) - else: - res[item["scratch"]] = [item] - return res - - -APP_NAMES = APP_CMD.keys() -APPS_BY_SCRATCHES = gen_app_list_by_scratches() -SCRATCHES = {item["scratch"] for item in APP_CMD.values()} -LAST_WORKSPACE = None - -i3 = i3ipc.Connection() - -is_stashed = True - -class Status(Enum): - stashed=0 - hidden1=1 - hidden2=2 - hidden3=3 - -overlay_status = Status(0) - -toggled = False -# stashed_focus_hist = [True, True] - -def float_and_resize(win, geometry): - win_id = win.id - i3.command(f"[con_id={win_id}] floating enable") - i3.command(f"[con_id={win_id}] resize set {geometry['w']} {geometry['h']}") - i3.command(f"[con_id={win_id}] move position {geometry['x']} {geometry['y']}") - - -def cleanup_unneded_instances( - tree, - scratches=None, -) -> bool: - print("======= cleanup") - print("scratches", scratches) - if not scratches: - scratches = list(SCRATCHES) - - all_apps = reduce(lambda x, y: x + y, [APPS_BY_SCRATCHES[item] for item in scratches]) - print("all apps", all_apps) - - res = True - for name in APP_NAMES: - matching = list(filter(lambda w: w.workspace() and w.workspace().name not in SCRATCHES, search(tree, name))) - if len(matching) > 0: - for win in matching[1:]: - win.command('kill') - float_and_resize(matching[0], APP_CMD[name]["geometry"]) - move_to_scratchpad(matching[0], APP_CMD[name]['scratch']) - res = False - return - - -def search(tree, name): - return filter(lambda w: w.name and w.name == name, tree.leaves()) - -def resp_if_needed(tree, targets): - # dead = gather_dead( - # tree, - # targets - # ) - dead = filter( - lambda app: not all(search(tree, app["name"])), targets - ) - if dead: - spawn(dead) - sleep(0.5) - return True - -def gather_dead(tree, targets): - res = list() - for app in targets: - if not list(search(tree, app["name"])): - res.append(app) - return res - - -def cleanup( - tree, - name, -) -> bool: - matching = list(filter(lambda w: w.workspace() and w.workspace().name not in SCRATCHES, search(tree, name))) - if len(matching) > 0: - for win in matching[1:]: - win.command('kill') - float_and_resize(matching[0], APP_CMD[name]["geometry"]) - move_to_scratchpad(matching[0], APP_CMD[name]['scratch']) - global overlay_status - overlay_status = Status(0) - return True - return False - -def move_to_scratchpad(win, scratch): - if scratch == "scratchpad": - i3.command(f'[con_id={win.id}] move to {scratch}') - else: - i3.command(f'[con_id={win.id}] move to workspace {scratch}') - - -def on_window_close(i3conn, event): - win = event.container - if not win.name or not win.name in APP_NAMES: - ws = i3.get_tree().find_focused().workspace() - if overlay_status == Status.stashed and not ws.nodes: - showup(i3.get_tree(), ws) - # resp_if_needed(i3conn.get_tree()) - # if not cleanup_unneded_instances(i3.get_tree()): - # spawn() - # return - -i3.on("window::close", on_window_close) - -def showup(tree, ws, show_scratches=None, focus_win=None): - print('==== showup') - print('show scratches', show_scratches) - if not show_scratches: - show_scratches = ['hidden1'] - all_apps = APPS_BY_SCRATCHES['hidden1'] - else: - all_apps = reduce(lambda x, y: x + y,[APPS_BY_SCRATCHES[item] for item in show_scratches]) - - # global overlay_status - # overlay_status = Status() - if resp_if_needed( - tree, - all_apps, - ): - tree = i3.get_tree() - for app in all_apps: - # if app["skip_startup"] and not app["scratch"] == show_scratch and not FULL_OVERLAY_SPAWN: - # return - matching = list(filter(lambda w: w.name and w.name == app["name"], tree.leaves())) - win = matching[0] - float_and_resize(win, app['geometry']) - win.command(f'move container to workspace {ws.name}') - if focus_win == app["name"]: - win.command('focus') - - -def on_workspace_focus(i3conn, event): - print('========== focus ') - global LAST_WORKSPACE, overlay_status - ws = event.current - if ws is None: - return - if ( - not ws.nodes - and ws.name != LAST_WORKSPACE - and overlay_status == Status.stashed - ): - showup(i3.get_tree(), ws) - else: - cleanup_unneded_instances(i3.get_tree()) - LAST_WORKSPACE = ws.name - overlay_shown = False - overlay_shown2 = False - - -i3.on("workspace::focus", on_workspace_focus) - - -def on_new_windnow(i3conn, event): - win = event.container - if win.name and win.name in APP_NAMES: - if cleanup_unneded_instances(i3.get_tree()): - return - if overlay_status == Status.stashed: - return - ws = win.workspace() - if ws and ws.nodes: - return cleanup_unneded_instances(i3.get_tree()) - if win.floating != "user_on" and win.floating != "auto_on": - return cleanup_unneded_instances(i3.get_tree()) - - -i3.on("window::new", on_new_windnow) - -def on_window_floating(i3conn, event): - win = event.container - ws = i3.get_tree().find_focused().workspace() - if ws and ws.nodes: - cleanup_unneded_instances(i3.get_tree()) - elif win.floating == "user_on": - showup(i3.get_tree(), ws) - else: - cleanup_unneded_instances(i3.get_tree()) - -i3.on("window::floating", on_window_floating) - -def clean_other_scratches(tree, keep=None): - if not keep: - keep={"hidden1"} - cleanup_unneded_instances( - tree, - list(SCRATCHES - keep) - ) - -def on_binding(i3conn, event): - global overlay_status - match event.binding.command: - case 'exec --no-startup-id echo "show_partial_overlay"': - if overlay_status == Status.hidden1: - cleanup_unneded_instances( - i3.get_tree(), - ["hidden1"] - ) - overlay_status = Status(0) - else: - ws = i3.get_tree().find_focused().workspace() - showup(i3.get_tree(), ws, ['hidden1'], focus_win="SystemMonitor") - case 'exec --no-startup-id echo "show_overlay_1"': - if overlay_status == Status.hidden2: - overlay_status = Status(0) - cleanup_unneded_instances( - i3.get_tree(), - ["hidden1", 'hidden2'] - ) - else: - clean_other_scratches(i3conn.get_tree(), {"hidden1", "hidden2"}) - ws = i3.get_tree().find_focused().workspace() - showup(i3.get_tree(), ws, ['hidden1', 'hidden2'], "SystemMonitor") - case 'exec --no-startup-id echo "show_overlay_2"': - if overlay_status == Status.hidden3: - overlay_status = Status(0) - cleanup_unneded_instances(i3.get_tree(), ["hidden1", "hidden3"]) - else: - clean_other_scratches(i3conn.get_tree(), {"hidden1", "hidden3"}) - ws = i3.get_tree().find_focused().workspace() - showup(i3.get_tree(), ws, ['hidden1', 'hidden3'], "todo") - -i3.on("binding", on_binding) - -def spawn(targets=None): - if not targets: - targets = APP_CMD.values() - if not cleanup_unneded_instances(i3.get_tree()): - for app in targets: - res = subprocess.Popen(app["run"]) - print(res) - - - - - # # Wait and find its window - # for _ in range(50): - # time.sleep(0.1) - # for win in i3.get_tree().leaves(): - # if APP_MARK in win.marks: - # return # already marked - # # Find unmarked new window in focused workspace - # new_win = [ - # w for w in focused_ws.leaves() - # if APP_MARK not in w.marks - # ] - # if new_win: - # float_and_resize(new_win[0].id) - # return - -# def move_to_scratch_if_other_windows(): -# ws = i3.get_tree().find_focused().workspace() -# app_win = next((w for w in ws.leaves() if APP_MARK in w.marks), None) -# if not app_win: -# return -# others = [w for w in ws.leaves() if w.id != app_win.id] -# if others: -# i3.command(f"[con_mark={APP_MARK}] move scratchpad") - -# def kill_duplicate_instances(): -# app_windows = [w for w in i3.get_tree().leaves() if APP_MARK in w.marks] -# if len(app_windows) <= 1: -# return -# # Keep one, kill others -# keep_id = app_windows[0].id -# for w in app_windows[1:]: -# i3.command(f"[con_id={w.id}] kill") - -# def on_window_new(i3conn, e): -# move_to_scratch_if_other_windows() -# kill_duplicate_instances() - -# spawn_and_mark_app() - -# i3.on("window::new", on_window_new) -# i3.on('workspace::focus', on_workspace_focus) - -spawn() -i3.main() |
