mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 05:00:08 +02:00
Floating windows: keep KWin's placement memory out
KWin's PlacementTracker keeps each window's geometry, full screen and maximized state per layout of the outputs, and puts windows back when a layout it has seen comes back. A spare output resizes after its window, so resizing a floating window back to an earlier size (or changing its scale, or full screen) made the window and its output flip forever, and floating or docking one window could move others onto or off a spare. The KWin script now keeps where each window belongs, reports nothing while KWin changes the outputs, and on screensChanged puts floating windows back (and the screens' windows when only spares changed), cancelling KWin's requests before the app sees them. A size asked for is held for a second against late answers. With that: a launched app and a profile get their remembered scale back, scale steps keep the size in pixels, ft-floatd waits for the end of an edge resize before resizing the output (KWin cancels the resize on any output change), a window taken over after a restart keeps its app and panel density, and `ft-float float ID` asks the script for the window's current place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81b64302aa
commit
c3785bb46e
4 files changed
+242
-33
No files matched your search
+196
-8
@@ -13,6 +13,10 @@ let screens = 0; // outputs WL-0 .. WL-<screens - 1> are screens; the rest
|
||||
let polling = false;
|
||||
const watched = {}; // id -> true once its signals are connected
|
||||
let marking = false; // the script itself is setting keep-below (see mark)
|
||||
const settled = {}; // id -> {output, frame, fullScreen, maximized}: where a window belongs (see putBack)
|
||||
const held = {}; // id -> {w, h, until, asked}: a size asked for, for a second (see hold)
|
||||
const moved = {}; // id -> true, or "back" once put back: it moved while KWin changed the outputs
|
||||
let layout = "", layoutOutputs = {}, layoutSince = 0; // the outputs at the last screensChanged
|
||||
|
||||
function send(ev) {
|
||||
callDBus(SERVICE, PATH, IFACE, "Event", JSON.stringify(ev));
|
||||
@@ -67,20 +71,184 @@ function report(type, w) {
|
||||
send(ev);
|
||||
}
|
||||
|
||||
// KWin's placement memory (its PlacementTracker) keeps each window's geometry for each layout of
|
||||
// the outputs (every enabled output's name and geometry), and when the outputs come back to a
|
||||
// layout it has seen, it puts the windows back where they were in it. That's for plugging monitors
|
||||
// in and out, and it does harm here. A spare output changes size after its window does, so what
|
||||
// KWin keeps for a spare's size is the window's next size: resizing a floating window back to a
|
||||
// size it had set off an endless flip between two sizes. And floating or docking one window could
|
||||
// move others, even onto a spare or off one. So the script keeps where each window belongs
|
||||
// (settled), tells ft-floatd nothing while KWin changes the outputs, and once KWin is done
|
||||
// (screensChanged comes after its restore) puts the floating windows back, and the screens' windows
|
||||
// too when only spares changed.
|
||||
function outputsNow() {
|
||||
const all = workspace.screens, out = {};
|
||||
for (let i = 0; i < all.length; ++i) {
|
||||
const g = all[i].geometry;
|
||||
out[all[i].name] = g.x + "," + g.y + " " + g.width + "x" + g.height;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function keyOf(outputs) {
|
||||
return Object.keys(outputs).sort().map(n => n + "=" + outputs[n]).join(" ");
|
||||
}
|
||||
function takeLayout() {
|
||||
layoutOutputs = outputsNow();
|
||||
layout = keyOf(layoutOutputs);
|
||||
layoutSince = 0;
|
||||
}
|
||||
// KWin is changing the outputs: they differ from the last screensChanged.
|
||||
function changingOutputs() {
|
||||
if (keyOf(outputsNow()) === layout) {
|
||||
layoutSince = 0;
|
||||
return false;
|
||||
}
|
||||
if (!layoutSince) {
|
||||
layoutSince = Date.now();
|
||||
} else if (Date.now() - layoutSince > 2000) {
|
||||
takeLayout(); // screensChanged should have come by now: don't stay quiet for good
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function settle(w) {
|
||||
if (w.output) {
|
||||
settled[String(w.internalId)] = {output: w.output.name, frame: rect(w.frameGeometry), fullScreen: w.fullScreen};
|
||||
}
|
||||
}
|
||||
// ft-floatd put the window here: it's where it belongs now.
|
||||
function expect(w, output, c, fullScreen) {
|
||||
settled[String(w.internalId)] = {output: output, frame: {x: c.x, y: c.y, w: c.w, h: c.h}, fullScreen: fullScreen};
|
||||
hold(w, c.w, c.h);
|
||||
}
|
||||
|
||||
// A size asked for (by ft-floatd, or by the script putting a window back) comes in when the app
|
||||
// answers, and until then the app can still answer older requests: one KWin's restore made, or,
|
||||
// just after it opened, its own. For a second, the script asks again instead of taking those;
|
||||
// then it takes the size the window has (an app can refuse a size, below its minimum).
|
||||
const holdTimer = new QTimer();
|
||||
holdTimer.singleShot = true;
|
||||
holdTimer.timeout.connect(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(held).forEach(id => {
|
||||
const h = held[id];
|
||||
if (h.until > now) return;
|
||||
delete held[id];
|
||||
const w = byId(id);
|
||||
if (!h.asked || !w || w.deleted) return; // (nothing held back: nothing to tell)
|
||||
settle(w);
|
||||
if (isSpare(w.output)) report("geometry", w);
|
||||
});
|
||||
if (Object.keys(held).length) holdTimer.start();
|
||||
});
|
||||
function hold(w, width, height) {
|
||||
held[String(w.internalId)] = {w: width, h: height, until: Date.now() + 1000};
|
||||
holdTimer.interval = 1100;
|
||||
holdTimer.start();
|
||||
}
|
||||
// A size change while a size is held: true when it isn't that size (the script asked again).
|
||||
// (ft-floatd's sizes can be fractional, the window's are whole: within a pixel is the same.)
|
||||
function holding(w) {
|
||||
const id = String(w.internalId), h = held[id], s = settled[id];
|
||||
if (!h) return false;
|
||||
const g = w.frameGeometry;
|
||||
if (!s || h.until < Date.now() || w.move || w.resize || (Math.abs(g.width - h.w) < 1 && Math.abs(g.height - h.h) < 1)) {
|
||||
delete held[id];
|
||||
return false;
|
||||
}
|
||||
w.frameGeometry = {x: s.frame.x, y: s.frame.y, width: h.w, height: h.h};
|
||||
h.asked = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Runs while KWin's output change still counts as going on (nothing reported), so the steps on
|
||||
// the way don't reach ft-floatd: told only where the window ends up (see reportMoved).
|
||||
function putBack(w, screensChanged) {
|
||||
const id = String(w.internalId), s = settled[id];
|
||||
if (w.deleted || !s || screens === 0 || !marked(w)) return;
|
||||
const o = outputByName(s.output);
|
||||
// KWin's restore sets full screen (and maximized) as it was in that layout too: with a
|
||||
// floating window that flipped forever, its output changing size with it. Ask for the
|
||||
// state it had: KWin's request hasn't reached the app yet, so it never sees it.
|
||||
w.fullScreen = s.fullScreen;
|
||||
if (o && isSpare(o) && !s.fullScreen) w.setMaximize(false, false);
|
||||
// Not where KWin had to move it: its output went, a screen changed, or it's full screen or
|
||||
// maximized (KWin fits those to their output).
|
||||
const back = o && !s.fullScreen && !w.fullScreen && !w.move && !w.resize && !s.maximized
|
||||
&& (isSpare(o) || (!screensChanged && !isMaximized(w)));
|
||||
if (!back) return;
|
||||
if (!w.output || w.output.name !== s.output) workspace.sendClientToScreen(w, o);
|
||||
w.frameGeometry = {x: s.frame.x, y: s.frame.y, width: s.frame.w, height: s.frame.h};
|
||||
if (isSpare(o)) hold(w, s.frame.w, s.frame.h);
|
||||
// Moved during the change (by KWin, by the lines above, or the size ft-floatd asked for came
|
||||
// in): report where it is, and keep settled as it is.
|
||||
if (moved[id]) moved[id] = "back";
|
||||
}
|
||||
// After an output change: tell ft-floatd where the windows that moved during it are now.
|
||||
function reportMoved(w) {
|
||||
const id = String(w.internalId), s = settled[id], how = moved[id];
|
||||
if (!how) return;
|
||||
delete moved[id];
|
||||
if (w.deleted) return;
|
||||
if (how !== "back") settle(w);
|
||||
if (!w.output || !s || w.output.name !== s.output) {
|
||||
report("output", w);
|
||||
mark(w);
|
||||
} else if (isSpare(w.output)) {
|
||||
report("geometry", w);
|
||||
}
|
||||
}
|
||||
workspace.screensChanged.connect(() => {
|
||||
const before = layoutOutputs, now = outputsNow();
|
||||
if (keyOf(now) === layout) return;
|
||||
let screensChanged = screens === 0;
|
||||
Object.keys(Object.assign({}, before, now)).forEach(name => {
|
||||
const m = /^WL-(\d+)$/.exec(name);
|
||||
if (before[name] !== now[name] && !(m && parseInt(m[1]) >= screens)) screensChanged = true;
|
||||
});
|
||||
const all = workspace.windowList();
|
||||
all.forEach(w => putBack(w, screensChanged));
|
||||
takeLayout();
|
||||
all.forEach(reportMoved);
|
||||
});
|
||||
|
||||
// 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); mark(w); });
|
||||
w.frameGeometryChanged.connect(() => {
|
||||
if (changingOutputs()) {
|
||||
moved[id] = true;
|
||||
return;
|
||||
}
|
||||
if (holding(w)) return;
|
||||
settle(w);
|
||||
if (onSpare()) report("geometry", w);
|
||||
});
|
||||
w.outputChanged.connect(() => {
|
||||
if (changingOutputs()) {
|
||||
moved[id] = true;
|
||||
return;
|
||||
}
|
||||
if (!held[id]) settle(w); // (held: the place asked for is settled already)
|
||||
report("output", w);
|
||||
mark(w);
|
||||
});
|
||||
w.keepBelowChanged.connect(() => keepBelowChanged(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.fullScreenChanged.connect(() => {
|
||||
if (changingOutputs()) {
|
||||
moved[id] = true;
|
||||
return;
|
||||
}
|
||||
if (settled[id]) settled[id].fullScreen = w.fullScreen;
|
||||
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.
|
||||
@@ -90,16 +258,25 @@ function watch(w) {
|
||||
|
||||
workspace.windowAdded.connect(w => {
|
||||
watch(w);
|
||||
settle(w);
|
||||
report("added", w);
|
||||
});
|
||||
workspace.windowRemoved.connect(w => {
|
||||
send({ev: "removed", id: String(w.internalId)});
|
||||
delete watched[String(w.internalId)];
|
||||
const id = String(w.internalId);
|
||||
send({ev: "removed", id: id});
|
||||
delete watched[id];
|
||||
delete settled[id];
|
||||
delete held[id];
|
||||
delete moved[id];
|
||||
});
|
||||
workspace.windowActivated.connect(w => {
|
||||
if (w && isSpare(w.output)) send({ev: "activated", id: String(w.internalId)});
|
||||
});
|
||||
workspace.windowList().forEach(watch);
|
||||
takeLayout();
|
||||
workspace.windowList().forEach(w => {
|
||||
watch(w);
|
||||
settle(w);
|
||||
});
|
||||
|
||||
// Keep-below means "floating" in the Frametop desktop. The title bar's float button (Frametop's
|
||||
// window decoration, decoration/) is the Keep Below button, so setting the flag on a window on
|
||||
@@ -178,14 +355,22 @@ function run(c) {
|
||||
if (!o) break;
|
||||
if (w.fullScreen && !c.keepFullScreen) w.fullScreen = false;
|
||||
w.setMaximize(false, false);
|
||||
expect(w, o.name, c, w.fullScreen && !!c.keepFullScreen);
|
||||
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;
|
||||
if (c.maximized) w.setMaximize(true, true);
|
||||
if (c.maximized) {
|
||||
// Maximized: KWin picks the size, and the place above is only where it goes.
|
||||
delete held[c.id];
|
||||
settled[c.id].maximized = true;
|
||||
w.setMaximize(true, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "geometry":
|
||||
if (w) w.frameGeometry = {x: c.x, y: c.y, width: c.w, height: c.h};
|
||||
if (!w) break;
|
||||
expect(w, settled[c.id] ? settled[c.id].output : (w.output ? w.output.name : ""), c, w.fullScreen);
|
||||
w.frameGeometry = {x: c.x, y: c.y, width: c.w, height: c.h};
|
||||
break;
|
||||
case "close":
|
||||
if (w) w.closeWindow();
|
||||
@@ -203,6 +388,9 @@ function run(c) {
|
||||
workspace.windowList().forEach(w => report("window", w));
|
||||
send({ev: "reported", token: c.token});
|
||||
break;
|
||||
case "request-float": // ft-float float ID: float it, if it isn't floating
|
||||
if (w && !isSpare(w.output)) requestFloat(w);
|
||||
break;
|
||||
case "request-active": // ft-float float|dock active
|
||||
requestFloat(workspace.activeWindow);
|
||||
break;
|
||||
|
||||
+39
-18
@@ -257,6 +257,7 @@ class Float:
|
||||
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.resizing = False # KWin's resize by the window's edge is going on (see follow)
|
||||
self.subs = {} # popup or dialog id -> number on the panel
|
||||
self.app = "" # its desktop file name (for its remembered place)
|
||||
|
||||
@@ -401,6 +402,11 @@ class Daemon:
|
||||
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-start" and f and ev.get("resize"):
|
||||
f.resizing = True
|
||||
elif kind == "move-end" and f and f.resizing:
|
||||
f.resizing = False
|
||||
self.follow(f, ev)
|
||||
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
|
||||
@@ -443,9 +449,14 @@ class Daemon:
|
||||
return
|
||||
# Floating when ft-floatd (re)started: take it over where it is.
|
||||
f = Float(wid, slot, None)
|
||||
f.app = ev.get("app", "")
|
||||
slot.window = f
|
||||
self.floats[wid] = f
|
||||
f.scale = slot.kscale = output_scales().get(slot.output, 1.0)
|
||||
# Its panel's density as it is (follow below sets the panel again).
|
||||
g, px = self.panel_get(slot.index), round(ev["frame"]["w"] * f.scale)
|
||||
if g and g["metres"] > 0 and px > 0:
|
||||
f.mpp = g["metres"] / px
|
||||
log(f"{wid[:9]} ({ev.get('cls')}) already floats on {slot.output}")
|
||||
self.follow(f, ev)
|
||||
return
|
||||
@@ -512,9 +523,10 @@ class Daemon:
|
||||
return float(metres) / max(1, int(size.split("x")[0]))
|
||||
return DEFAULT_MPP
|
||||
|
||||
def float_window(self, ev, place=None, mpp=None):
|
||||
def float_window(self, ev, place=None, mpp=None, scale=None):
|
||||
"""Float a window: its panel in front of where it was on its screen, or at place (centre
|
||||
and axes in the world), at the density of its screen, or mpp. Returns its Float."""
|
||||
and axes in the world), at the density of its screen, or mpp, and at its screen's scale,
|
||||
or scale (then ev's frame is the window's size at that scale). Returns its Float."""
|
||||
wid = ev["id"]
|
||||
if wid in self.floats:
|
||||
return self.floats[wid]
|
||||
@@ -529,7 +541,7 @@ class Daemon:
|
||||
slot.window = f
|
||||
self.floats[wid] = f
|
||||
scales = output_scales()
|
||||
f.scale = scales.get(ev["output"], 1.0)
|
||||
f.scale = scale or scales.get(ev["output"], 1.0)
|
||||
slot.kscale = scales.get(slot.output, slot.kscale)
|
||||
f.mpp = mpp or self.screen_mpp(ev["output"])
|
||||
fr, s, m = ev["frame"], f.scale, self.margin
|
||||
@@ -590,9 +602,17 @@ class Daemon:
|
||||
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:
|
||||
if not full and not (f.normal and abs(f.normal[0] - w) <= math.ceil(s) and abs(f.normal[1] - h) <= math.ceil(s)):
|
||||
# (Within a logical pixel it's the same size: a size asked for in pixels comes out
|
||||
# rounded to whole logical pixels. Keeping it stops scale changes from creeping.)
|
||||
f.normal = (w, h)
|
||||
m = 0 if full else self.margin
|
||||
if f.resizing:
|
||||
# KWin ends a resize by the window's edge whenever an output changes: the output
|
||||
# follows when it's done (the margin is room to grow until then).
|
||||
x, y = round((fr["x"] - out["x"]) * s), round((fr["y"] - out["y"]) * s)
|
||||
self.set_panel(f, (x, y, w, h), title=round((cl["y"] - fr["y"]) * s))
|
||||
return
|
||||
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
|
||||
@@ -614,7 +634,7 @@ class Daemon:
|
||||
s = min(3.0, max(0.5, s))
|
||||
if not f.frame or f.full or s == f.scale or self.floats.get(f.id) is not f:
|
||||
return
|
||||
w, h = round(f.frame["w"] * f.scale), round(f.frame["h"] * f.scale)
|
||||
w, h = f.normal or (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
|
||||
@@ -785,15 +805,11 @@ class Daemon:
|
||||
ref = self.reference()
|
||||
place = to_world(ref, known["rel"]) if known and ref and len(known.get("rel", [])) == 12 else self.in_front()
|
||||
mpp = (known or {}).get("mpp") or self.screen_mpp(f"WL-{self.primary()}")
|
||||
scale = None
|
||||
if known and known.get("pixels"):
|
||||
ev = dict(ev, frame=dict(ev["frame"]))
|
||||
s = output_scales().get(ev["output"], 1.0)
|
||||
ev["frame"]["w"], ev["frame"]["h"] = known["pixels"][0] / s, known["pixels"][1] / s
|
||||
# The remembered scale isn't applied yet: changing a floating window's scale can set
|
||||
# off a loop between its size and its output's (KWin keeps the window's margins when
|
||||
# the output resizes, and follow resizes the output after the window), which needs
|
||||
# fixing first (docs/floating-windows.md, Known problems).
|
||||
self.float_window(dict(ev, app=app), place=place, mpp=mpp)
|
||||
scale = known.get("scale") or output_scales().get(ev["output"], 1.0)
|
||||
ev = dict(ev, frame=dict(ev["frame"], w=known["pixels"][0] / scale, h=known["pixels"][1] / scale))
|
||||
self.float_window(dict(ev, app=app), place=place, mpp=mpp, scale=scale)
|
||||
|
||||
# ------------------------------------------------------------ profiles (docs/profiles.md)
|
||||
|
||||
@@ -920,18 +936,22 @@ class Daemon:
|
||||
if not ref or len(fl.get("rel", [])) != 12:
|
||||
return
|
||||
place = to_world(ref, fl["rel"])
|
||||
pixels = fl.get("pixels")
|
||||
pixels, scale = fl.get("pixels"), fl.get("scale")
|
||||
if f:
|
||||
self.pose(f, *place)
|
||||
if scale:
|
||||
self.set_scale(f, scale)
|
||||
if pixels and f.frame:
|
||||
m = self.margin
|
||||
self.command(cmd="geometry", id=f.id, x=f.slot.pos[0] + m / f.scale,
|
||||
y=f.slot.pos[1] + m / f.scale, w=pixels[0] / f.scale, h=pixels[1] / f.scale)
|
||||
return
|
||||
if pixels:
|
||||
s = output_scales().get(ev["output"], 1.0)
|
||||
ev = dict(ev, frame=dict(ev["frame"], w=pixels[0] / s, h=pixels[1] / s))
|
||||
self.float_window(ev, place=place, mpp=fl.get("mpp"))
|
||||
scale = scale or output_scales().get(ev["output"], 1.0)
|
||||
ev = dict(ev, frame=dict(ev["frame"], w=pixels[0] / scale, h=pixels[1] / scale))
|
||||
else:
|
||||
scale = None
|
||||
self.float_window(ev, place=place, mpp=fl.get("mpp"), scale=scale)
|
||||
return
|
||||
n = int(e.get("screen", 1)) - 1
|
||||
if not 0 <= n < self.screens_n:
|
||||
@@ -1039,7 +1059,8 @@ class Daemon:
|
||||
if not ev:
|
||||
return f"error no window {rest[0]}"
|
||||
if cmd == "float":
|
||||
self.float_window(ev)
|
||||
# The script's word for where it is now: a window on a screen isn't followed here.
|
||||
self.command(cmd="request-float", id=rest[0])
|
||||
elif cmd == "dock" and rest[0] in self.floats:
|
||||
self.dock(self.floats[rest[0]])
|
||||
elif cmd == "close":
|
||||
|
||||
Reference in new issue
Block a user