diff options
| -rw-r--r-- | deckit/modules/windows.py | 64 | ||||
| -rw-r--r-- | static/deck.css | 23 | ||||
| -rw-r--r-- | static/modules/windows.js | 102 | ||||
| -rw-r--r-- | tests/test_windows.py | 30 |
4 files changed, 198 insertions, 21 deletions
diff --git a/deckit/modules/windows.py b/deckit/modules/windows.py index 8642b1c..69b1210 100644 --- a/deckit/modules/windows.py +++ b/deckit/modules/windows.py @@ -8,6 +8,7 @@ from pathlib import Path from ..module import Module, ModuleError ADDR = re.compile(r"0x[0-9a-f]{1,16}") +DESKTOP_BASES = (Path("/usr/share/applications"), Path.home() / ".local/share/applications") def _run(cmd, timeout=2): @@ -21,6 +22,51 @@ def _popen(cmd): subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +def _dispatch(cmd): + """Run a hyprctl dispatch and wait: the two-step move depends on the order.""" + try: + subprocess.run(cmd, capture_output=True, timeout=2) + except (OSError, subprocess.TimeoutExpired): + pass + + +def _desktop_icons(bases=DESKTOP_BASES) -> dict: + """Window class -> candidate icon names, from the installed .desktop files.""" + mapping = {} + for base in bases: + if not base.is_dir(): + continue + for entry in base.glob("*.desktop"): + try: + text = entry.read_text(errors="replace") + except OSError: + continue + section = None + icon = wmclass = None + for line in text.splitlines(): + line = line.strip() + if line.startswith("[") and line.endswith("]"): + section = line + if section != "[Desktop Entry]": + break + continue + if section != "[Desktop Entry]": + continue + if line.startswith("Icon="): + icon = line.split("=", 1)[1].strip() + elif line.startswith("StartupWMClass="): + wmclass = line.split("=", 1)[1].strip() + if not icon: + continue + candidates = [icon] + if wmclass and wmclass != icon: + candidates.append(wmclass) + for key in (entry.stem, wmclass): + if key: + mapping.setdefault(key.lower(), candidates) + return mapping + + def hypr_lua(cfg: dict) -> bool: mode = cfg.get("hyprland", "auto") if mode == "auto": @@ -64,6 +110,7 @@ def make(cfg: dict) -> Module: lua = hypr_lua(cfg) launch = cfg.get("launch", {}) workspaces = cfg.get("windows", {}).get("workspaces", list(range(1, 11))) + icons = _desktop_icons() def launch_spec(spec): if isinstance(spec, dict): @@ -88,6 +135,18 @@ def make(cfg: dict) -> Module: raise ModuleError("bad window address") _popen(window_cmd("close", addr, lua)) + def move(body): + addr = str(body.get("address", "")) + n = body.get("n") + if not ADDR.fullmatch(addr) or not isinstance(n, int) or not 1 <= n <= 99: + raise ModuleError("bad window or workspace") + if lua: + # window.move acts on the focused window, so focus it first. + _dispatch(["hyprctl", "eval", f'hl.dispatch(hl.dsp.focus({{window = "address:{addr}"}}))']) + _dispatch(["hyprctl", "eval", f'hl.dispatch(hl.dsp.window.move({{workspace = "{n}"}}))']) + else: + _dispatch(["hyprctl", "dispatch", "movetoworkspacesilent", f"{n},address:{addr}"]) + def launch_key(body): key = str(body.get("key", "")) command, _, _ = launch_spec(launch.get(key)) @@ -97,6 +156,8 @@ def make(cfg: dict) -> Module: def state(): st = _state(run) + for win in st["windows"]: + win["icons"] = icons.get(win["class"].lower(), [win["class"].lower()]) known = sorted(set(int(n) for n in workspaces) | set(st["workspaces"])) launches = [] for key, spec in launch.items(): @@ -105,4 +166,5 @@ def make(cfg: dict) -> Module: return {**st, "workspaces": known, "launches": launches} return Module(name="windows", title="Windows", state=state, - actions={"workspace": workspace, "focus": focus, "close": close, "launch": launch_key}) + actions={"workspace": workspace, "focus": focus, "close": close, + "move": move, "launch": launch_key}) diff --git a/static/deck.css b/static/deck.css index c56a7e5..9aaeaec 100644 --- a/static/deck.css +++ b/static/deck.css @@ -263,9 +263,30 @@ select { } .wrow.on { color: var(--udt-accent); } .wt { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } -.wclose { flex: none; width: 36px; height: 36px; padding: 0; } +.wicon { width: 24px; height: 24px; object-fit: contain; flex: none; } +.wmenu { flex: none; width: 40px; height: 40px; padding: 0; font-size: 18px; line-height: 1; } .windows .launches { display: flex; flex-wrap: wrap; gap: 8px; } +.wpop { + position: fixed; + z-index: 50; + min-width: 220px; + max-width: 80vw; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + background: var(--udt-mantle); + border: 1px solid var(--udt-surface1); + border-radius: 12px; + box-shadow: 0 12px 30px rgba(0, 0, 0, .35); +} +.wpop-title { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wpop-label { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--udt-overlay0); } +.wpop-close { text-align: left; } +.wpop-ws { display: flex; flex-wrap: wrap; gap: 6px; } +.wpop-ws button { min-width: 44px; min-height: 44px; padding: 0; font-size: 16px; font-weight: 600; } + .llm { display: flex; flex-direction: column; gap: 10px; } .llm-sec { padding: 10px 12px; diff --git a/static/modules/windows.js b/static/modules/windows.js index 002bb5b..c981846 100644 --- a/static/modules/windows.js +++ b/static/modules/windows.js @@ -18,6 +18,68 @@ function tile(label, onclick) { return b; } +let pop = null; +function closePop() { + if (pop) { + pop.remove(); + pop = null; + } +} + +function openMenu(anchor, win, workspaces) { + closePop(); + pop = document.createElement("div"); + pop.className = "wpop"; + pop.innerHTML = ` + <div class="wpop-title"></div> + <button class="wpop-close">Close window</button> + <div class="wpop-label">Move to desktop</div> + <div class="wpop-ws"></div>`; + pop.querySelector(".wpop-title").textContent = win.title || win.class; + pop.querySelector(".wpop-close").onclick = () => { + closePop(); + post("close", { address: win.address }); + }; + const targets = pop.querySelector(".wpop-ws"); + for (const n of workspaces ?? []) { + const b = document.createElement("button"); + b.textContent = String(n); + b.onclick = () => { + closePop(); + post("move", { address: win.address, n }); + }; + targets.append(b); + } + document.body.append(pop); + const r = anchor.getBoundingClientRect(); + const width = pop.offsetWidth; + const height = pop.offsetHeight; + pop.style.top = `${Math.min(r.bottom + 6, window.innerHeight - height - 8)}px`; + pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - width - 8))}px`; +} + +document.addEventListener("pointerdown", (e) => { + if (pop && !pop.contains(e.target) && !e.target.closest(".wmenu")) closePop(); +}); + +function appIcon(w) { + if (!w.icons?.length) return null; + const img = document.createElement("img"); + img.className = "wicon"; + img.alt = ""; + let i = 0; + const next = () => { + if (i >= w.icons.length) { + img.remove(); + return; + } + img.src = `/icons/${w.icons[i++]}.svg`; + }; + img.addEventListener("error", next); + next(); + return img; +} + export default { title: "Windows", tab: "Windows", @@ -40,25 +102,6 @@ export default { ws.append(b); } - const list = el.querySelector(".winlist"); - list.textContent = ""; - for (const w of (st.windows ?? []).slice(0, 8)) { - const row = document.createElement("div"); - row.className = "wrow"; - row.classList.toggle("on", Boolean(w.active)); - const title = document.createElement("span"); - title.className = "wt"; - title.textContent = w.title || w.class; - title.onclick = () => post("focus", { address: w.address }); - const close = document.createElement("button"); - close.className = "wclose"; - close.textContent = "×"; - close.setAttribute("aria-label", "Close window"); - close.onclick = () => post("close", { address: w.address }); - row.append(title, close); - list.append(row); - } - const launches = el.querySelector(".launches"); launches.textContent = ""; for (const item of st.launches ?? []) { @@ -78,5 +121,26 @@ export default { b.onclick = () => post("launch", { key: item.key }); launches.append(b); } + + const list = el.querySelector(".winlist"); + list.textContent = ""; + for (const w of (st.windows ?? []).slice(0, 12)) { + const row = document.createElement("div"); + row.className = "wrow"; + row.classList.toggle("on", Boolean(w.active)); + const icon = appIcon(w); + if (icon) row.append(icon); + const title = document.createElement("span"); + title.className = "wt"; + title.textContent = w.title || w.class; + title.onclick = () => post("focus", { address: w.address }); + const menu = document.createElement("button"); + menu.className = "wmenu"; + menu.textContent = "⋮"; + menu.setAttribute("aria-label", "Window actions"); + menu.onclick = () => openMenu(menu, w, st.workspaces); + row.append(title, menu); + list.append(row); + } }, }; diff --git a/tests/test_windows.py b/tests/test_windows.py index ca9be71..8e27d26 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -4,10 +4,40 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import unittest +from unittest import mock import deckit.modules.windows as windows +class DesktopIcons(unittest.TestCase): + def test_maps_class_and_wmclass(self): + import tempfile + from pathlib import Path + base = Path(tempfile.mkdtemp()) + (base / "com.obsproject.Studio.desktop").write_text( + "[Desktop Entry]\nIcon=com.obsproject.Studio\nStartupWMClass=obs\n") + mapping = windows._desktop_icons([base]) + self.assertEqual(mapping["com.obsproject.studio"], ["com.obsproject.Studio", "obs"]) + self.assertEqual(mapping["obs"], ["com.obsproject.Studio", "obs"]) + + +class Move(unittest.TestCase): + def test_lua_focuses_then_moves(self): + calls = [] + m = windows.make({"hyprland": "lua", "run": lambda cmd: "[]"}) + with mock.patch.object(windows, "_dispatch", lambda cmd: calls.append(cmd)): + m.actions["move"]({"address": "0x1", "n": 3}) + self.assertIn("focus", calls[0][2]) + self.assertIn("window.move", calls[1][2]) + + def test_classic_uses_movetoworkspacesilent(self): + calls = [] + m = windows.make({"hyprland": "classic", "run": lambda cmd: "[]"}) + with mock.patch.object(windows, "_dispatch", lambda cmd: calls.append(cmd)): + m.actions["move"]({"address": "0x1", "n": 3}) + self.assertEqual(calls[0], ["hyprctl", "dispatch", "movetoworkspacesilent", "3,address:0x1"]) + + class Commands(unittest.TestCase): def test_lua_and_classic(self): self.assertEqual(windows.workspace_cmd(3, lua=False), |
