summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMyo <myortv@proton.me>2025-09-05 00:19:51 +0300
committerMyo <myortv@proton.me>2025-09-05 00:19:51 +0300
commit2bfe0ea5d0399700f972962c3a77798caf8269b6 (patch)
treec42617597d1632845dee0b11de8e399a73a4edf5
init
-rw-r--r--.gitignore1
-rwxr-xr-xscript.sh2
-rw-r--r--watcher.py310
-rw-r--r--workspace_watcher.py342
4 files changed, 655 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5ceb386
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+venv
diff --git a/script.sh b/script.sh
new file mode 100755
index 0000000..8fe4b35
--- /dev/null
+++ b/script.sh
@@ -0,0 +1,2 @@
+source ~/watcher/venv/bin/activate
+exec python ~/watcher/main.py
diff --git a/watcher.py b/watcher.py
new file mode 100644
index 0000000..a6af333
--- /dev/null
+++ b/watcher.py
@@ -0,0 +1,310 @@
+from functools import reduce
+from enum import Enum
+import subprocess
+import time
+import i3ipc
+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",
+ # },
+ # ],
+}
+
+
+# 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 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)
+
+
+def cleanup_unneded_instances(
+ tree,
+ scratches=None,
+ keep=None,
+) -> bool:
+ if not scratches:
+ scratches = PRESETS.keys()
+ if not keep:
+ keep = []
+
+ scratches_to_cleanup = [PRESETS[item] for item in scratches if item not in keep]
+
+ if not scratches:
+ return
+
+ all_apps = reduce(
+ lambda x, y: x + y, scratches
+ )
+
+ 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 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 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 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 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())
+ )
+ 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")
+
+
+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):
+ global status, button_trigger
+ #
+ # when workpace focused, button_trigger sets to None
+
+ print('~~~~~~~~~~``')
+ print(event.binding.command)
+ print(status)
+ print(button_trigger)
+ print('~~~~~~~~~~``')
+ match event.binding.command:
+
+ 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
+
+ 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
+
+ # 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
+
+i3.on("window::close", on_window_close)
+
+
+
+def on_workspace_focus(i3conn, event):
+ global LAST_WORKSPACE, status, button_trigger
+ ws = event.current
+ if ws is None:
+ return
+ if button_trigger and LAST_WORKSPACE == ws.name:
+ button_trigger = None
+ LAST_WORKSPACE = ws.name
+ 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
+
+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:
+ 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())
+
+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)
+
+
+# ========================================================================================
+
+spawn(
+ ALL_APPS
+)
+i3.main()
diff --git a/workspace_watcher.py b/workspace_watcher.py
new file mode 100644
index 0000000..03a65ac
--- /dev/null
+++ b/workspace_watcher.py
@@ -0,0 +1,342 @@
+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()