aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 20:41:29 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 20:41:29 +0100
commit6465ffceeef5c3df404972bf4eaa1b24e451a7c4 (patch)
tree4b1c769fb39809a9b2ca7570190baed3ecc1bde0
parent853f1ed1928aaeca9ea956c24bee12cd56a7cfdd (diff)
downloaddeck-it-6465ffceeef5c3df404972bf4eaa1b24e451a7c4.tar.gz
deck-it-6465ffceeef5c3df404972bf4eaa1b24e451a7c4.zip
feat: window rows get app icons and a close/move menu
-rw-r--r--deckit/modules/windows.py64
-rw-r--r--static/deck.css23
-rw-r--r--static/modules/windows.js102
-rw-r--r--tests/test_windows.py30
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),