mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
- The decoration's "bottom" property clashed with Item's (KWin refused it). - No On All Desktops button with one virtual desktop, like Breeze. - decoration/apply.sh finds the session's D-Bus through plasmashell (KWin's environment isn't readable), and installs each try under a new name: KWin keeps a decoration's QML by name until it restarts. - ft-floatd starts its script with Scripting.start: after a reload, the new script gets the old one's id while the old one is still being deleted, so run() on /Scripting/Script<id> went to the old script and the new one never ran (a second ft-floatd start left floating windows without their script). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
644 lines
28 KiB
Python
644 lines
28 KiB
Python
#!/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|pointer] dock [ID|active|all] close ID list quit (ft-float)
|
|
("float pointer" is the float key: the window under the pointer, else the active one,
|
|
floated or docked; the input relay sends it for float_toggle, and "dock all" for dock_all)
|
|
dock N | close N | resize N W H | scale N STEPS (ft-screens, N = its screen)
|
|
|
|
Spare outputs are WL-<screens> .. WL-<screens + slots - 1>. 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.3 # a floated window starts this far in front of its screen (metres), clear of it
|
|
DEFAULT_MPP = 1.6 / 1920 # metres per pixel when ft-screens can't say (no SteamVR)
|
|
DEBUG = os.environ.get("FT_FLOAT_DEBUG") == "1" # log every event from the script
|
|
|
|
|
|
def whole(*scales):
|
|
"""The multiple a spare's size in pixels must be at these scales: KWin's nested backend gives
|
|
the buffer a whole buffer scale (1.2 -> 2), and a buffer that isn't a multiple of it is a
|
|
protocol error that disconnects KWin."""
|
|
k = 1
|
|
for s in scales:
|
|
k = math.lcm(k, max(1, math.ceil(s - 1e-6)))
|
|
return k
|
|
|
|
|
|
def kwin_size(px, scale, k):
|
|
"""What to ask ft-screens for so a spare comes out at least px pixels, a multiple of k. KWin
|
|
makes a nested output the size it's configured to times its scale, rounded. Returns (the
|
|
size to ask for, the pixels it comes out); the margin takes the extra pixels."""
|
|
n = max(1, math.floor(px / scale))
|
|
while True:
|
|
exact = n * scale
|
|
p = math.floor(exact + 0.5)
|
|
if p >= px and p % k == 0 and abs(exact - math.floor(exact) - 0.5) > 1e-6:
|
|
return n, p
|
|
n += 1
|
|
|
|
|
|
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.normal = None # its size in pixels when last not full screen
|
|
self.unfull_until = 0.0 # left full screen just now (see follow)
|
|
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.want = None # the size in pixels asked for (size is at least that)
|
|
self.kscale = 1.0 # its output's scale in KWin, 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")
|
|
# Scripting.start runs every loaded script that isn't running. Not /Scripting/Script<id>'s
|
|
# run: a reloaded script gets the old one's id while the old one is still being deleted,
|
|
# so that path is still the old script's, and the new one would never run.
|
|
kwin.start()
|
|
log(f"script loaded ({sid})")
|
|
# Older scripts registered the float key with KWin; the input relay owns it now. Drop
|
|
# that shortcut, so System Settings doesn't list one that does nothing.
|
|
try:
|
|
accel = dbus.Interface(bus.get_object("org.kde.kglobalaccel", "/kglobalaccel"), "org.kde.KGlobalAccel")
|
|
if accel.unregister("kwin", "Frametop Float Window"):
|
|
log("dropped KWin's old float shortcut")
|
|
except dbus.DBusException:
|
|
pass
|
|
|
|
# ------------------------------------------------------------ events from the script
|
|
|
|
def on_event(self, ev):
|
|
kind = ev.get("ev")
|
|
if DEBUG:
|
|
log("event", {k: v for k, v in ev.items() if k in ("ev", "id", "output", "frame", "fullScreen", "popup",
|
|
"parent", "move")})
|
|
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:
|
|
if not ev.get("fullScreen"):
|
|
f.unfull_until = time.monotonic() + 1.0
|
|
self.follow(f, ev)
|
|
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:
|
|
if ev.get("cls") == "ksplashqml": # the login splash, on every output at first
|
|
return
|
|
# 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 = slot.kscale = output_scales().get(slot.output, 1.0)
|
|
log(f"{wid[:9]} ({ev.get('cls')}) already floats on {slot.output}")
|
|
self.follow(f, ev)
|
|
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 sized(self, slot, size, timeout=1.0):
|
|
"""Wait (briefly) until KWin has taken the spare's new size, before a scale that needs it."""
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
for line in self.screens.ask("toplevels", quiet=True).splitlines()[1:]:
|
|
f = line.split()
|
|
if len(f) >= 2 and f[0] == str(slot.index) and f[1] == f"{size[0]}x{size[1]}":
|
|
return True
|
|
time.sleep(0.03)
|
|
log(f"{slot.output} didn't take {size[0]}x{size[1]} in time")
|
|
return False
|
|
|
|
def set_size(self, slot, want, k=None):
|
|
"""Size a spare's output to at least want (pixels) at its current scale in KWin."""
|
|
if want == slot.want:
|
|
return
|
|
k = k or whole(slot.kscale)
|
|
(w, pw), (h, ph) = kwin_size(want[0], slot.kscale, k), kwin_size(want[1], slot.kscale, k)
|
|
slot.want, slot.size = want, (pw, ph)
|
|
self.screens.ask(f"size {slot.index} {w} {h}")
|
|
|
|
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.command(cmd="mark", id=wid) # the title bar button set keep-below for nothing
|
|
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
|
|
scales = output_scales()
|
|
f.scale = scales.get(ev["output"], 1.0)
|
|
slot.kscale = scales.get(slot.output, slot.kscale)
|
|
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)
|
|
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; it's sized at
|
|
# its old scale, for pixels that suit the new one), then its panel, then turn it on,
|
|
# then the window.
|
|
slot.want = None
|
|
self.set_size(slot, (w + 2 * m, h + 2 * m), whole(slot.kscale, s))
|
|
self.screens.ask(f"scale {slot.index} {s:g}") # for pointer positions (KWin's units)
|
|
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.rescaled(slot, s)
|
|
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):
|
|
"""The window moved or resized on its output: crop the panel to it, and keep the
|
|
output its size plus the margin. Full screen: no margin, and the output keeps the
|
|
window's size from before, so the window fills its own panel."""
|
|
slot, s = f.slot, f.scale
|
|
fr, cl, out = ev["frame"], ev["client"], ev["outputRect"]
|
|
f.frame, f.client = fr, cl
|
|
# KWin sizes a window to its output before it reports it full screen; with a margin, a
|
|
# window that fills its output is going full screen. (Not just after it left full
|
|
# screen: then it fills the output until the output grows back.)
|
|
fills = self.margin > 0 and (fr["x"], fr["y"], fr["w"], fr["h"]) == (out["x"], out["y"], out["w"], out["h"])
|
|
full = bool(ev.get("fullScreen")) or (fills and time.monotonic() > f.unfull_until)
|
|
f.full = full
|
|
w, h = round(fr["w"] * s), round(fr["h"] * s)
|
|
if not full:
|
|
f.normal = (w, h)
|
|
m = 0 if full else self.margin
|
|
self.set_size(slot, f.normal if full and f.normal else (w + 2 * m, h + 2 * m))
|
|
if not full:
|
|
x0, y0 = slot.pos[0] + m / s, slot.pos[1] + m / s
|
|
if 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 full else round((cl["y"] - fr["y"]) * s))
|
|
|
|
def rescale(self, f, steps):
|
|
"""Meta+scroll: the window's content bigger or smaller, at the same size in pixels, so its
|
|
panel stays the same size (KWin's output scale, in steps of 10%)."""
|
|
if not f.frame or f.full or steps == 0:
|
|
return
|
|
s = min(3.0, max(0.5, round(f.scale * 1.1 ** steps * 20) / 20))
|
|
if s == f.scale:
|
|
return
|
|
w, h = round(f.frame["w"] * f.scale), round(f.frame["h"] * f.scale)
|
|
slot, m = f.slot, self.margin
|
|
log(f"{f.id[:9]} scale {f.scale:g} -> {s:g}")
|
|
f.scale = s
|
|
# The output's size in pixels must suit the new scale before KWin draws at it (see whole).
|
|
k = whole(slot.kscale, s)
|
|
if slot.size[0] % k or slot.size[1] % k:
|
|
slot.want = None
|
|
self.set_size(slot, slot.size, k)
|
|
self.sized(slot, slot.size)
|
|
kscreen(f"output.{slot.output}.scale.{s:g}")
|
|
self.screens.ask(f"scale {slot.index} {s:g}")
|
|
self.rescaled(slot, s)
|
|
self.command(cmd="geometry", id=f.id, x=slot.pos[0] + m / s, y=slot.pos[1] + m / s, w=w / s, h=h / s)
|
|
|
|
def rescaled(self, slot, s):
|
|
"""KWin has the spare at scale s now: ask for its size again in the new scale's terms,
|
|
or the next configure (any size, or KWin's own) would make it the old size times s."""
|
|
if s == slot.kscale:
|
|
return
|
|
slot.kscale = s
|
|
want, slot.want = slot.size, None
|
|
self.set_size(slot, want)
|
|
|
|
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
|
|
slot.want = 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 == "float" and rest == ["pointer"]:
|
|
self.command(cmd="request-pointer")
|
|
return "ok"
|
|
if cmd == "dock" and rest == ["all"]:
|
|
for f in list(self.floats.values()):
|
|
self.dock(f)
|
|
return "ok"
|
|
if cmd in ("dock", "close", "resize", "scale") 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 == "scale" and len(rest) == 2:
|
|
self.rescale(f, int(rest[1]))
|
|
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()
|