From 87a402c68df7135cfeeda3654a78839aafe64189 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:20:26 -0600 Subject: [PATCH] Add ft-floatd and the frametop-float KWin script ft-floatd loads the script into the desktop's KWin, which reports windows over D-Bus and takes commands through a long poll. Floating a window (the window menu's Float in VR, Meta+Shift+F, or ft-float) turns on a spare output sized to the window plus a margin, moves the window onto it, and tells ft-screens the panel's crop, density, and place; docking puts it back and turns the spare off. Tested on the headless test desktop; the panel side in ft-screens comes next. Co-Authored-By: Claude Opus 5.5 --- float/frametop-float.js | 166 +++++++++++++ float/ft-float | 25 ++ float/ft-floatd | 3 + float/ft_floatd.py | 523 +++++++++++++++++++++++++++++++++++++++ screens/test/headless.sh | 16 +- 5 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 float/frametop-float.js create mode 100755 float/ft-float create mode 100755 float/ft-floatd create mode 100644 float/ft_floatd.py diff --git a/float/frametop-float.js b/float/frametop-float.js new file mode 100644 index 0000000..8d12c0f --- /dev/null +++ b/float/frametop-float.js @@ -0,0 +1,166 @@ +// frametop-float: the KWin side of floating windows (see docs/floating-windows.md). ft-floatd +// loads it into the desktop's KWin over D-Bus (org.kde.kwin.Scripting) and talks to it: +// - events go to ft-floatd as JSON strings (org.frametop.Float.Event), for the windows it +// cares about: floating windows (the ones on a spare output, WL- and up), their +// popups and dialogs, new windows, and "Float in VR" requests; +// - commands come back through a long poll: the script calls NextCommand, ft-floatd +// answers when it has one (or after a while with nothing), and the script calls again. +// KWin scripts can call D-Bus but can't serve it, hence the poll. Window ids are KWin's +// internalId (a UUID string). + +const SERVICE = "org.frametop.Float", PATH = "/Float", IFACE = "org.frametop.Float"; +let screens = 0; // outputs WL-0 .. WL- are screens; the rest are spares +let polling = false; +const watched = {}; // id -> true once its signals are connected + +function send(ev) { + callDBus(SERVICE, PATH, IFACE, "Event", JSON.stringify(ev)); +} + +function outputIndex(o) { + const m = o ? /^WL-(\d+)$/.exec(o.name) : null; + return m ? parseInt(m[1]) : -1; +} +function isSpare(o) { + return screens > 0 && outputIndex(o) >= screens; +} +function rect(g) { + return {x: g.x, y: g.y, w: g.width, h: g.height}; +} +function byId(id) { + const all = workspace.windowList(); + for (let i = 0; i < all.length; ++i) + if (String(all[i].internalId) === id) return all[i]; + return null; +} +function outputByName(name) { + const all = workspace.screens; + for (let i = 0; i < all.length; ++i) + if (all[i].name === name) return all[i]; + return null; +} +function info(w) { + const o = w.output; + return { + id: String(w.internalId), pid: w.pid, cls: String(w.resourceClass), app: String(w.desktopFileName), + caption: String(w.caption), output: o ? o.name : "", outputRect: o ? rect(o.geometry) : null, + frame: rect(w.frameGeometry), client: rect(w.clientGeometry), popup: w.popupWindow, + transient: w.transient, parent: w.transientFor ? String(w.transientFor.internalId) : "", + normal: w.normalWindow, dialog: w.dialog, fullScreen: w.fullScreen, minimized: w.minimized, + onAllDesktops: w.onAllDesktops + }; +} + +function report(type, w) { + const ev = info(w); + ev.ev = type; + send(ev); +} + +// Floating windows, and popups and dialogs on a spare output: tell ft-floatd about changes. +function watch(w) { + const id = String(w.internalId); + if (watched[id]) return; + watched[id] = true; + const onSpare = () => isSpare(w.output); + w.frameGeometryChanged.connect(() => { if (onSpare()) report("geometry", w); }); + w.outputChanged.connect(() => report("output", w)); + w.interactiveMoveResizeStarted.connect(() => { + if (onSpare()) send({ev: "move-start", id: id, move: w.move, resize: w.resize, frame: rect(w.frameGeometry)}); + }); + w.interactiveMoveResizeFinished.connect(() => { if (onSpare()) report("move-end", w); }); + w.fullScreenChanged.connect(() => { if (onSpare()) report("fullscreen", w); }); + w.minimizedChanged.connect(() => { if (onSpare()) report("minimized", w); }); + w.maximizedChanged.connect(() => { + // A floating window stays an ordinary window: its output is its size plus a margin. + if (onSpare() && w.normalWindow && !w.fullScreen) w.setMaximize(false, false); + }); +} + +workspace.windowAdded.connect(w => { + watch(w); + report("added", w); +}); +workspace.windowRemoved.connect(w => { + send({ev: "removed", id: String(w.internalId)}); + delete watched[String(w.internalId)]; +}); +workspace.windowActivated.connect(w => { + if (w && isSpare(w.output)) send({ev: "activated", id: String(w.internalId)}); +}); +workspace.windowList().forEach(watch); + +function requestFloat(w) { + if (!w || !w.normalWindow || w.popupWindow) return; + report(isSpare(w.output) ? "dock-request" : "float-request", w); +} + +registerUserActionsMenu(w => { + if (!w.normalWindow || w.popupWindow) return null; + const floating = isSpare(w.output); + return { + text: floating ? "Back to Desktop" : "Float in VR", + icon: floating ? "window-restore" : "window-new", + triggered: () => requestFloat(w) + }; +}); +registerShortcut("Frametop Float Window", "Frametop: Float Window in VR (or put it back)", "Meta+Shift+F", + () => requestFloat(workspace.activeWindow)); + +function run(c) { + const w = c.id ? byId(c.id) : null; + switch (c.cmd) { + case "config": + screens = c.screens; + workspace.windowList().forEach(w => report("window", w)); + break; + case "place": { // onto an output, at a frame rectangle (logical, global) + if (!w) break; + const o = outputByName(c.output); + if (!o) break; + if (w.fullScreen && !c.keepFullScreen) w.fullScreen = false; + w.setMaximize(false, false); + workspace.sendClientToScreen(w, o); + w.frameGeometry = {x: c.x, y: c.y, width: c.w, height: c.h}; + if (c.onAllDesktops !== undefined) w.onAllDesktops = c.onAllDesktops; + break; + } + case "geometry": + if (w) w.frameGeometry = {x: c.x, y: c.y, width: c.w, height: c.h}; + break; + case "close": + if (w) w.closeWindow(); + break; + case "activate": + if (w) workspace.activeWindow = w; + break; + case "minimize": + if (w) w.minimized = c.on; + break; + case "info": + if (w) report("window", w); + break; + case "request-active": // ft-float float|dock active: like the shortcut + requestFloat(workspace.activeWindow); + break; + } +} + +function poll() { + if (polling) return; + polling = true; + callDBus(SERVICE, PATH, IFACE, "NextCommand", reply => { + polling = false; + if (reply) { + try { + JSON.parse(reply).forEach(run); + } catch (e) { + print("frametop-float: bad command " + reply + ": " + e); + } + } + poll(); + }); +} + +send({ev: "hello"}); +poll(); diff --git a/float/ft-float b/float/ft-float new file mode 100755 index 0000000..ebd83c9 --- /dev/null +++ b/float/ft-float @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""ft-float: talk to ft-floatd (floating windows in the Frametop desktop). + + ft-float float [ID|active] float a window (default: the active one; for it, a toggle) + ft-float dock [ID|active] put a floating window back on the desktop + ft-float close ID close a window + ft-float list the spare outputs and what floats on them +FT_FLOAT_SOCKET names ft-floatd's socket (default frametop_float). +""" +import os +import socket +import sys + +if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): + sys.exit(__doc__) +s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) +s.bind("") +s.settimeout(5) +try: + s.sendto(" ".join(sys.argv[1:]).encode(), "\0" + os.environ.get("FT_FLOAT_SOCKET", "frametop_float")) + reply = s.recv(8192).decode() +except OSError as e: + sys.exit(f"ft-floatd didn't answer ({e}); is the Frametop desktop running?") +print(reply) +sys.exit(0 if reply.startswith("ok") else 1) diff --git a/float/ft-floatd b/float/ft-floatd new file mode 100755 index 0000000..3b32693 --- /dev/null +++ b/float/ft-floatd @@ -0,0 +1,3 @@ +#!/bin/bash +# ft-floatd on the Frame host, inside the Frametop desktop's session (see ft_floatd.py). +exec python3 "$(dirname "$(readlink -f "$0")")/ft_floatd.py" "$@" diff --git a/float/ft_floatd.py b/float/ft_floatd.py new file mode 100644 index 0000000..caf5c36 --- /dev/null +++ b/float/ft_floatd.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +"""ft-floatd: floating windows for the Frametop desktop (see docs/floating-windows.md). + +Runs inside the desktop's Plasma session (its D-Bus and Wayland). It keeps the table of +which window floats on which spare output and panel, and connects three parts: + - the KWin script frametop-float (float/frametop-float.js), which it loads into KWin. The + script sends events over D-Bus (org.frametop.Float.Event) and fetches commands with a + long poll (NextCommand). + - ft-screens, through its control socket (@ft_screens): the spare output's size, and the + floating panel's crop, density, place, and popups. + - kscreen-doctor, to turn spare outputs on and off and place them in KWin's layout. +Commands and ft-screens' events arrive as datagrams on @frametop_float (ft-float is the +command-line side). Replies go to the sender: + float [ID|active] dock [ID|active] close ID list quit (ft-float) + dock N | close N | resize N W H | carried N (ft-screens, N = its screen) + +Spare outputs are WL- .. WL-. A floating window's output is its +frame plus a margin on each side (FLOAT_MARGIN pixels), so menus have room; the panel shows +only the window. Spares are placed apart from the screens and from each other in KWin's +layout, all within Xwayland's 32767-pixel limit. + +Usage: ft-floatd [--screens N] [--slots N] [--margin PX] [--control NAME] [--socket NAME] +Defaults: FT_SCREEN_COUNT (from the session) or the layout's count, FLOAT_SLOTS and +FLOAT_MARGIN from ~/.config/frametop.conf (8 and 300), @ft_screens, @frametop_float. +""" +import argparse +import json +import math +import os +import re +import socket +import subprocess +import sys +import time + +import dbus +import dbus.mainloop.glib +import dbus.service +from gi.repository import GLib + +HERE = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "layout")) +import ft_layout # noqa: E402 (config and layout) + +SERVICE = IFACE = "org.frametop.Float" +PATH = "/Float" +SCRIPT = "frametop-float" +POLL_SECONDS = 20 # NextCommand answers empty after this (KWin's D-Bus timeout is 25 s) +SPARE_X, SPARE_CELL = 12000, 5000 # spares in KWin's layout: a grid from here, 4 across +PULL_OUT = 0.05 # a floated window starts this far in front of its screen (metres) +DEFAULT_MPP = 1.6 / 1920 # metres per pixel when ft-screens can't say (no SteamVR) + + +def log(*args): + print(time.strftime("%H:%M:%S"), *args, flush=True) + + +class Screens: + """ft-screens' control socket.""" + + def __init__(self, name): + self.address = "\0" + name + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + self.sock.bind("") + + def ask(self, text, quiet=False): + self.sock.settimeout(2.0) + try: + self.sock.sendto(text.encode(), self.address) + reply = self.sock.recv(8192).decode() + except OSError as e: + reply = f"error no answer ({e})" + if not reply.startswith("ok") and not quiet: + log(f"ft-screens: {text}: {reply}") + return reply + + +def kscreen(*args): + try: + r = subprocess.run(["kscreen-doctor", *args], capture_output=True, text=True, timeout=20) + return r.stdout + except (OSError, subprocess.TimeoutExpired) as e: + log(f"kscreen-doctor: {e}") + return "" + + +def output_scales(): + try: + data = json.loads(kscreen("-j") or "{}") + except ValueError: + return {} + return {o["name"]: float(o.get("scale", 1)) for o in data.get("outputs", []) if o.get("name")} + + +class Float: + """A floating window.""" + + def __init__(self, wid, slot, saved): + self.id = wid + self.slot = slot # Slot + self.saved = saved # where it came from: output, frame, onAllDesktops + self.scale = 1.0 # its output's scale (pixels per logical unit) + self.mpp = DEFAULT_MPP # metres per pixel on its panel + self.frame = None # last frame (logical, global) + self.client = None + self.full = False # full screen: no margin + self.move_from = None # frame when a title-bar move started (put back after) + self.subs = {} # popup or dialog id -> number on the panel + + +class Slot: + def __init__(self, k, screens): + self.k = k + self.output = f"WL-{screens + k}" + self.index = screens + k + 1 # ft-screens' number (1-based) + self.pos = (SPARE_X + (k % 4) * SPARE_CELL, (k // 4) * SPARE_CELL) + self.size = None # its output's size in pixels, as last set + self.window = None # Float + + +class Daemon: + def __init__(self, args): + self.screens_n = args.screens + self.margin = args.margin + self.slots = [Slot(k, args.screens) for k in range(args.slots)] + self.floats = {} # window id -> Float + self.windows = {} # window id -> last info from the script + self.pending = [] # commands for the script + self.waiter = None # (reply callback, timeout source) while the script waits + self.screens = Screens(args.control) + self.sub_numbers = {} # popup/dialog id -> (window id, number) + self.next_sub = 1 + + # ------------------------------------------------------------ the script + + def command(self, **cmd): + self.pending.append(cmd) + self.flush() + + def flush(self): + if not self.waiter or not self.pending: + return + reply, source = self.waiter + self.waiter = None + GLib.source_remove(source) + text, self.pending = json.dumps(self.pending), [] + reply(text) + + def wait(self, reply): + if self.waiter: # a stale poll (the script reloaded): let it go + old, source = self.waiter + GLib.source_remove(source) + old("") + + def timeout(): + if self.waiter and self.waiter[0] is reply: + self.waiter = None + reply("") + return False + self.waiter = (reply, GLib.timeout_add_seconds(POLL_SECONDS, timeout)) + self.flush() + + def load_script(self, bus): + kwin = dbus.Interface(bus.get_object("org.kde.KWin", "/Scripting"), "org.kde.kwin.Scripting") + if kwin.isScriptLoaded(SCRIPT): + kwin.unloadScript(SCRIPT) + sid = int(kwin.loadScript(os.path.join(HERE, "frametop-float.js"), SCRIPT, signature="ss")) + if sid < 0: + raise RuntimeError("KWin didn't load the script") + bus.get_object("org.kde.KWin", f"/Scripting/Script{sid}").run(dbus_interface="org.kde.kwin.Script") + log(f"script loaded ({sid})") + + # ------------------------------------------------------------ events from the script + + def on_event(self, ev): + kind = ev.get("ev") + wid = ev.get("id", "") + if kind == "hello": + self.command(cmd="config", screens=self.screens_n) + # The script reports every window after "config"; spares nothing floats on are off. + GLib.timeout_add(1500, self.disable_unused) + return + if kind == "removed": + self.windows.pop(wid, None) + if wid in self.floats: + log(f"{wid[:9]} closed") + self.release(self.floats.pop(wid)) + self.drop_sub(wid) + return + if wid: + self.windows[wid] = ev + f = self.floats.get(wid) + if kind == "float-request": + self.float_window(ev) + elif kind == "dock-request": + if f: + self.dock(f) + elif kind in ("added", "window", "output"): + self.seen(ev) + elif kind == "geometry": + if f: + self.follow(f, ev) + else: + self.sub(ev) + elif kind == "move-start" and f and ev.get("move"): + f.move_from = ev["frame"] + self.screens.ask(f"carry {f.slot.index}", quiet=True) + elif kind == "move-end" and f and f.move_from: + # The panel carried the window; KWin may have slipped it a few pixels first. + m, f.move_from = f.move_from, None + if (m["x"], m["y"]) != (ev["frame"]["x"], ev["frame"]["y"]): + self.command(cmd="geometry", id=f.id, x=m["x"], y=m["y"], w=ev["frame"]["w"], h=ev["frame"]["h"]) + elif kind == "fullscreen" and f: + f.full = bool(ev.get("fullScreen")) + self.follow(f, ev, refit=True) + elif kind == "minimized" and f: + self.screens.ask(f"minimized {f.slot.index} {1 if ev.get('minimized') else 0}", quiet=True) + + def spare_slot(self, output): + for s in self.slots: + if s.output == output: + return s + return None + + def seen(self, ev): + """A window the script told us about: is it somewhere it shouldn't be?""" + wid = ev["id"] + slot = self.spare_slot(ev.get("output", "")) + f = self.floats.get(wid) + if f and f.slot is not slot and ev["ev"] == "output": + # Left its spare (KWin moved it, or docking): it's not floating any more. + if slot is None: + log(f"{wid[:9]} left its floating output") + del self.floats[wid] + self.release(f) + return + if slot is None or f: + return + if ev.get("popup") or (ev.get("transient") and ev.get("parent") in self.floats): + self.sub(ev) + return + if not ev.get("normal"): + return + if slot.window is None: + # Floating when ft-floatd (re)started: take it over where it is. + f = Float(wid, slot, None) + slot.window = f + self.floats[wid] = f + f.scale = output_scales().get(slot.output, 1.0) + log(f"{wid[:9]} already floats on {slot.output}") + self.follow(f, ev, refit=True) + return + # A new window that opened on a floating window's output: windows of floating apps + # float too; anything else goes to the screens. + if ev["ev"] == "added" and any(o.saved is not None and self.windows.get(o.id, {}).get("pid") == ev.get("pid") + for o in self.floats.values()): + self.float_window(ev) + else: + self.command(cmd="place", id=wid, output="WL-0", x=ev["frame"]["x"] % 400 + 100, + y=ev["frame"]["y"] % 300 + 100, w=ev["frame"]["w"], h=ev["frame"]["h"]) + + # ------------------------------------------------------------ floating and docking + + def disable_unused(self): + off = [f"output.{s.output}.disable" for s in self.slots if s.window is None] + if off: + kscreen(*off) + return False + + def free_slot(self): + for s in self.slots: + if s.window is None: + return s + return None + + def screen_mpp(self, output): + """Metres per pixel on the screen showing this output, from ft-screens.""" + m = re.match(r"WL-(\d+)$", output or "") + reply = self.screens.ask("screens", quiet=True) + if m and reply.startswith("ok"): + for part in reply.split()[2:]: + idx, size, metres = part.split(":") + if int(idx) == int(m.group(1)) + 1: + return float(metres) / max(1, int(size.split("x")[0])) + return DEFAULT_MPP + + def float_window(self, ev): + wid = ev["id"] + if wid in self.floats: + return + slot = self.free_slot() + if slot is None: + self.notify(f"All {len(self.slots)} floating windows are in use. Put one back on the desktop " + "to float another.") + return + f = Float(wid, slot, {"output": ev["output"], "frame": ev["frame"], "onAllDesktops": ev.get("onAllDesktops")}) + slot.window = f + self.floats[wid] = f + f.scale = output_scales().get(ev["output"], 1.0) + f.mpp = self.screen_mpp(ev["output"]) + fr, s, m = ev["frame"], f.scale, self.margin + w, h = round(fr["w"] * s), round(fr["h"] * s) + slot.size = (w + 2 * m, h + 2 * m) + log(f"{wid[:9]} ({ev.get('cls')}) floats on {slot.output}: {w}x{h} px, scale {s:g}") + # The spare's size first (while it's off, so its first frame is right), then its panel, + # then turn it on, then the window. + self.screens.ask(f"size {slot.index} {slot.size[0]} {slot.size[1]}") + self.set_panel(f, (m, m, w, h), title=round((ev["client"]["y"] - fr["y"]) * s)) + self.place_panel(f, ev) + kscreen(f"output.{slot.output}.enable", f"output.{slot.output}.scale.{s:g}", + f"output.{slot.output}.position.{slot.pos[0]},{slot.pos[1]}") + self.command(cmd="place", id=wid, output=slot.output, x=slot.pos[0] + m / s, y=slot.pos[1] + m / s, + w=fr["w"], h=fr["h"], onAllDesktops=True) + + def set_panel(self, f, crop, title=0): + x, y, w, h = crop + self.screens.ask(f"float {f.slot.index} {f.mpp:.7f} {x} {y} {w} {h} {title}") + + def place_panel(self, f, ev): + """Put the panel where the window was on its screen, a little in front of it.""" + m = re.match(r"WL-(\d+)$", ev["output"]) + reply = self.screens.ask(f"get {int(m.group(1)) + 1}", quiet=True) if m else "" + if not reply.startswith("ok"): + return + g = ft_layout.parse_get(reply) + c, xa, ya, za = g["center"], g["x"], g["y"], g["z"] + out, fr, s = ev["outputRect"], ev["frame"], f.scale + # The window's centre relative to the screen's, in panel pixels, then metres. + dx = ((fr["x"] - out["x"]) + fr["w"] / 2 - out["w"] / 2) * s * f.mpp + dy = ((fr["y"] - out["y"]) + fr["h"] / 2 - out["h"] / 2) * s * f.mpp + p = [c[k] + xa[k] * dx - ya[k] * dy + za[k] * PULL_OUT for k in range(3)] + rows = [f"{xa[k]:.5f} {ya[k]:.5f} {za[k]:.5f} {p[k]:.4f}" for k in range(3)] + self.screens.ask(f"pose {f.slot.index} {' '.join(rows)}") + + def follow(self, f, ev, refit=False): + """The window moved or resized on its output: crop the panel to it, and keep the + output its size plus the margin.""" + slot, s = f.slot, f.scale + fr, cl, out = ev["frame"], ev["client"], ev["outputRect"] + f.frame, f.client = fr, cl + m = 0 if f.full else self.margin + w, h = round(fr["w"] * s), round(fr["h"] * s) + want = (w + 2 * m, h + 2 * m) + if f.full: + want = slot.size or want + if refit or want != slot.size: + if not f.full and want != slot.size: + slot.size = want + self.screens.ask(f"size {slot.index} {want[0]} {want[1]}") + x0, y0 = slot.pos[0] + m / s, slot.pos[1] + m / s + if not f.full and (abs(fr["x"] - x0) > 0.5 or abs(fr["y"] - y0) > 0.5): + self.command(cmd="geometry", id=f.id, x=x0, y=y0, w=fr["w"], h=fr["h"]) + return # the next geometry event crops the panel + x, y = round((fr["x"] - out["x"]) * s), round((fr["y"] - out["y"]) * s) + self.set_panel(f, (x, y, w, h), title=0 if f.full else round((cl["y"] - fr["y"]) * s)) + + def dock(self, f, frame=None): + """Back where it came from (or onto screen 1 if we don't know).""" + saved = f.saved or {"output": "WL-0", "frame": dict(f.frame or {"x": 100, "y": 100, "w": 800, "h": 600}), + "onAllDesktops": False} + fr = frame or saved["frame"] + log(f"{f.id[:9]} back to {saved['output']}") + self.command(cmd="place", id=f.id, output=saved["output"], x=fr["x"], y=fr["y"], w=fr["w"], h=fr["h"], + onAllDesktops=bool(saved.get("onAllDesktops"))) + + def release(self, f): + """Its window left: hide the panel and turn the spare off.""" + slot = f.slot + if slot.window is f: + slot.window = None + for sub_id in list(f.subs): + self.drop_sub(sub_id) + self.screens.ask(f"unfloat {slot.index}", quiet=True) + kscreen(f"output.{slot.output}.disable") + + # ------------------------------------------------------------ popups and dialogs + + def sub(self, ev): + parent = self.floats.get(ev.get("parent", "")) + if parent is None: + # A popup of a popup: its top-level parent is the floating window. + known = self.sub_numbers.get(ev.get("parent", "")) + parent = self.floats.get(known[0]) if known else None + if parent is None: + return + wid = ev["id"] + if wid not in self.sub_numbers: + self.sub_numbers[wid] = (parent.id, self.next_sub) + parent.subs[wid] = self.next_sub + self.next_sub += 1 + n = self.sub_numbers[wid][1] + fr, out, s = ev["frame"], ev["outputRect"], parent.scale + x, y = round((fr["x"] - out["x"]) * s), round((fr["y"] - out["y"]) * s) + self.screens.ask(f"sub {parent.slot.index} {n} {x} {y} {round(fr['w'] * s)} {round(fr['h'] * s)}", quiet=True) + + def drop_sub(self, wid): + known = self.sub_numbers.pop(wid, None) + if not known: + return + parent = self.floats.get(known[0]) + if parent: + parent.subs.pop(wid, None) + self.screens.ask(f"sub {parent.slot.index} {known[1]} off", quiet=True) + + # ------------------------------------------------------------ requests on @frametop_float + + def by_panel(self, index): + for s in self.slots: + if s.index == index: + return s.window + return None + + def request(self, text): + words = text.split() + if not words: + return "error empty" + cmd, rest = words[0], words[1:] + if cmd == "list": + return "ok " + " ".join(f"{s.output}:{s.window.id if s.window else '-'}" for s in self.slots) + if cmd == "quit": + GLib.idle_add(self.loop.quit) + return "ok" + if cmd in ("float", "dock") and (not rest or rest[0] == "active"): + self.command(cmd="request-active") + return "ok" + if cmd in ("dock", "close", "resize", "carried") and rest and rest[0].isdigit(): + f = self.by_panel(int(rest[0])) + if not f: + return f"error no floating window on screen {rest[0]}" + if cmd == "dock": + self.dock(f) + elif cmd == "close": + self.command(cmd="close", id=f.id) + elif cmd == "resize" and len(rest) == 3 and f.frame: + w, h = max(320, int(rest[1])), max(200, int(rest[2])) + self.command(cmd="geometry", id=f.id, x=f.frame["x"], y=f.frame["y"], w=w / f.scale, h=h / f.scale) + return "ok" + if cmd in ("float", "dock", "close") and rest: + ev = self.windows.get(rest[0]) + if not ev: + return f"error no window {rest[0]}" + if cmd == "float": + self.float_window(ev) + elif cmd == "dock" and rest[0] in self.floats: + self.dock(self.floats[rest[0]]) + elif cmd == "close": + self.command(cmd="close", id=rest[0]) + return "ok" + return "error unknown command" + + def notify(self, text): + log(text) + try: + n = dbus.Interface(dbus.SessionBus().get_object("org.freedesktop.Notifications", + "/org/freedesktop/Notifications"), + "org.freedesktop.Notifications") + n.Notify("Frametop", 0, "window-new", "Floating windows", text, [], {}, 5000) + except dbus.DBusException as e: + log(f"notification: {e.get_dbus_message()}") + + +class Service(dbus.service.Object): + def __init__(self, bus, daemon): + super().__init__(dbus.service.BusName(SERVICE, bus), PATH) + self.daemon = daemon + + @dbus.service.method(IFACE, in_signature="s", out_signature="") + def Event(self, text): + try: + self.daemon.on_event(json.loads(text)) + except (ValueError, KeyError, TypeError) as e: + log(f"bad event {text[:200]}: {e!r}") + + @dbus.service.method(IFACE, in_signature="", out_signature="s", async_callbacks=("reply", "error")) + def NextCommand(self, reply, error): + self.daemon.wait(reply) + + +def main(): + conf = ft_layout.read_conf() + p = argparse.ArgumentParser(description="Floating windows for the Frametop desktop") + p.add_argument("--screens", type=int, default=int(os.environ.get("FT_SCREEN_COUNT") or 0)) + p.add_argument("--slots", type=int, default=int(conf.get("FLOAT_SLOTS") or 8)) + p.add_argument("--margin", type=int, default=int(conf.get("FLOAT_MARGIN") or 300)) + p.add_argument("--control", default="ft_screens") + p.add_argument("--socket", default="frametop_float") + args = p.parse_args() + if args.screens <= 0: + args.screens = ft_layout.screen_count() + args.slots = max(0, min(16, args.slots)) + args.margin = max(0, min(1000, args.margin)) + + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SessionBus() + daemon = Daemon(args) + service = Service(bus, daemon) # noqa: F841 (keeps the name) + daemon.loop = GLib.MainLoop() + + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + sock.bind("\0" + args.socket) + sock.setblocking(False) + + def readable(*_): + while True: + try: + data, sender = sock.recvfrom(4096) + except BlockingIOError: + return True + reply = daemon.request(data.decode(errors="replace").strip()) + if sender: + try: + sock.sendto(reply.encode(), sender) + except OSError: + pass + GLib.io_add_watch(sock.fileno(), GLib.IO_IN, readable) + + log(f"{args.screens} screens, {args.slots} floating slots (WL-{args.screens} and up), margin {args.margin} px") + daemon.load_script(bus) + daemon.loop.run() + + +if __name__ == "__main__": + main() diff --git a/screens/test/headless.sh b/screens/test/headless.sh index a0952c5..d2f5bc1 100755 --- a/screens/test/headless.sh +++ b/screens/test/headless.sh @@ -16,7 +16,9 @@ # headless.sh js '' run a one-off KWin script; its print()s come back # headless.sh shot [OUTPUT] a JPEG of one output (default WL-0); from the PC it # lands in captures/ and its path is printed -# headless.sh log [kwin|screens|apps] [N] the last N lines (default 30) +# headless.sh floatd start ft-floatd in it (socket @frametop_float_test) +# headless.sh float e.g. "float active", list +# headless.sh log [kwin|screens|apps|floatd] [N] the last N lines (default 30) set -euo pipefail here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) . "$here/../../scripts/_env.sh" @@ -83,6 +85,7 @@ case "${1:-}" in rm -rf "$rt" "$cfg" mkdir -p "$rt" "$cfg" chmod 700 "$rt" + echo "$screens $spares" > "$rt/counts" args=(--screen 1920x1080@1.6 --screen 1080x1920@0.9) for ((i = 2; i < screens; i++)); do args+=(--screen 1920x1080@1.6); done args=("${args[@]:0:$((screens * 2))}") @@ -108,6 +111,17 @@ case "${1:-}" in ask toplevels ;; stop) stop ;; + floatd) + pkill -f -- "--socket frametop_float_test" 2>/dev/null || true + read -r screens spares < "$rt/counts" + nohup env XDG_RUNTIME_DIR="$rt" XDG_CONFIG_HOME="$cfg" DBUS_SESSION_BUS_ADDRESS="$bus" \ + WAYLAND_DISPLAY=wayland-0 "$REPO_ROOT/float/ft-floatd" --screens "$screens" --slots "$spares" \ + --control ft_screens_test --socket frametop_float_test \ + > "$logs/floatd.log" 2>&1 & + sleep 2 + tail -3 "$logs/floatd.log" + ;; + float) shift; FT_FLOAT_SOCKET=frametop_float_test "$REPO_ROOT/float/ft-float" "$@" ;; ask) ask "$2" ;; kd) shift; kd "$@" ;; run)