Floating windows: full screen, per-window scale, and drags across panels

Full screen fills the window's own panel (the margin drops to zero).
Meta+scroll over a floating window changes its scale at the same size in
pixels. Spare outputs are sized to a multiple of KWin's buffer scale, and
screens to even sizes: an odd buffer at a fractional scale is a protocol
error that disconnected KWin. The 3D mouse's drag lock now crosses onto
other Frametop panels unless the pressed one is being carried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-29 23:31:23 -06:00
1 parent c2e5e861c8
commit 6122b7eb15
4 files changed
+153 -19

No files matched your search

+78 -18
View File
@@ -12,7 +12,7 @@ which window floats on which spare output and panel, and connects three parts:
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)
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
@@ -49,6 +49,15 @@ POLL_SECONDS = 20 # NextCommand answers empty after this (KWin's D-Bus ti
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)
DEBUG = os.environ.get("FT_FLOAT_DEBUG") == "1" # log every event from the script
def fit(px, scale):
"""An output size KWin can draw at this scale: its 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. So round up to a multiple; the margin takes the extra pixels."""
k = max(1, math.ceil(scale - 1e-6))
return (px + k - 1) // k * k
def log(*args):
@@ -104,6 +113,8 @@ class Float:
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
@@ -174,6 +185,9 @@ class Daemon:
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)
@@ -211,8 +225,9 @@ class Daemon:
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)
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)
@@ -248,7 +263,7 @@ class Daemon:
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)
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.
@@ -261,6 +276,18 @@ class Daemon:
# ------------------------------------------------------------ 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 disable_unused(self):
off = [f"output.{s.output}.disable" for s in self.slots if s.window is None]
if off:
@@ -300,11 +327,12 @@ class Daemon:
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)
slot.size = (fit(w + 2 * m, s), fit(h + 2 * m, 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), then its panel,
# then turn it on, then the window.
self.screens.ask(f"size {slot.index} {slot.size[0]} {slot.size[1]}")
self.sized(slot, slot.size)
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)
@@ -333,27 +361,57 @@ class Daemon:
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):
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."""
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
m = 0 if f.full else self.margin
# 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)
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]}")
if not full:
f.normal = (w, h)
m = 0 if full else self.margin
want = f.normal if full and f.normal else (w + 2 * m, h + 2 * m)
want = (fit(want[0], s), fit(want[1], s))
if want != slot.size:
slot.size = want
self.screens.ask(f"size {slot.index} {want[0]} {want[1]}")
if not full:
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):
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 f.full else round((cl["y"] - fr["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 must suit the new scale before KWin draws at it (see fit).
size = (fit(slot.size[0], s), fit(slot.size[1], s))
if size != slot.size:
slot.size = size
self.screens.ask(f"size {slot.index} {size[0]} {size[1]}")
self.sized(slot, size)
kscreen(f"output.{slot.output}.scale.{s:g}")
self.screens.ask(f"scale {slot.index} {s:g}")
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 dock(self, f, frame=None):
"""Back where it came from (or onto screen 1 if we don't know)."""
@@ -424,7 +482,7 @@ class Daemon:
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():
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]}"
@@ -432,6 +490,8 @@ class Daemon:
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)
+58
View File
@@ -105,6 +105,11 @@
// SteamVR's resize snap back.
// A left release also goes to ft-screens ("up"), which releases a button held on its
// screens in KWin if SteamVR gave the release to some other overlay.
// Across Frametop's panels (a drag and drop, or a window moved from one screen or floating
// window to another), the lock gives way: while the left button is held on one of ft-screens'
// panels, the ray is still tested against ft-screens' panels, and the cursor goes onto
// whichever it meets first. Not while that panel is being carried (its title bar or its bar):
// then the ray would find what's behind it.
//
// Head follow (experimental, off by default; POINTER_FOLLOW=1, or the relay's "follow toggle"): the cursor
// is carried by a reference direction, where the head faced when it last settled, and turns
@@ -285,6 +290,19 @@ std::string ExeDir() {
return p.substr(0, p.rfind('/'));
}
// One of ft-screens' panels showing a desktop: a screen (frametop.screen.N), a floating
// window (frametop.float.N), or a floating window's popup (frametop.float.N.sub.K), not a
// control of theirs.
bool FramePanel(const std::string &key) {
for (const char *prefix : {"frametop.screen.", "frametop.float."}) {
if (key.rfind(prefix, 0) != 0) continue;
const std::string rest = key.substr(std::strlen(prefix));
const size_t dot = rest.find('.');
return dot == std::string::npos || rest.compare(dot, 5, ".sub.") == 0;
}
return false;
}
void SendTo(int fd, const char *name, const std::string &msg) {
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
@@ -621,6 +639,9 @@ int main() {
Clock::time_point dropHoldUntil{}; // after a left release: keep the drag pose this long
bool debug = false;
std::string lastHit;
// The ft-screens panel the left button was pressed on, and where it was then (see the top).
std::string pressKey;
vr::HmdMatrix34_t pressPose{};
auto lastDebug = Clock::now();
vr::VROverlayHandle_t systemPointer = vr::k_ulOverlayHandleInvalid;
overlay->FindOverlay("system.pointer", &systemPointer);
@@ -683,6 +704,14 @@ int main() {
nudging = false;
leftHeld = true;
dragDistance = lastDistance;
pressKey.clear();
if (FramePanel(lastHit)) {
vr::ETrackingUniverseOrigin uo;
auto it = handles.find(lastHit);
if (it != handles.end() &&
overlay->GetOverlayTransformAbsolute(it->second, &uo, &pressPose) == vr::VROverlayError_None)
pressKey = lastHit;
}
tiltYaw = tiltPitch = 0; // a new drag starts untilted
dropHoldUntil = {};
pulseAt = Clock::now();
@@ -1366,6 +1395,35 @@ int main() {
}
}
}
// Held on one of ft-screens' panels that stays where it is: onto whichever of them
// the ray meets (see the top).
if (leftHeld && !pressKey.empty()) {
vr::ETrackingUniverseOrigin uo;
vr::HmdMatrix34_t now{};
auto it = handles.find(pressKey);
bool still = it != handles.end() &&
overlay->GetOverlayTransformAbsolute(it->second, &uo, &now) == vr::VROverlayError_None;
for (int i = 0; still && i < 3; ++i)
for (int j = 0; j < 4; ++j)
if (std::fabs(now.m[i][j] - pressPose.m[i][j]) > 0.001f) still = false;
if (still) {
Hit h;
for (const auto &[key, handle] : handles) {
if (!visible[key] || !FramePanel(key)) continue;
vr::VROverlayIntersectionParams_t params{};
params.vSource = {float(anchor.x), float(anchor.y), float(anchor.z)};
params.vDirection = {float(dir.x), float(dir.y), float(dir.z)};
params.eOrigin = vr::TrackingUniverseStanding;
vr::VROverlayIntersectionResults_t r{};
if (overlay->ComputeOverlayIntersection(handle, &params, &r) && r.fDistance > 0.05f &&
r.fDistance < h.along)
h.along = r.fDistance, h.key = key;
}
if (h.along < 1e8) dragDistance = h.along, lastHit = h.key;
} else {
pressKey.clear(); // carried: the lock holds for the rest of this press
}
}
// While dragging: keep the press-time distance and show the non-interactive marker.
// On a scene-graph plane or a panel's edge: the laser-catching dot goes 5 cm behind it.
double distance = dragging ? dragDistance : (best < 1e8 ? best : freeDistance);
+16
View File
@@ -305,6 +305,18 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
break;
case FT_SCROLL:
if (s->pointer_focus != sc) break;
if (sc->index >= s->n_config && e->dy != 0 &&
(wlr_keyboard_get_modifiers(&s->keyboard) & WLR_MODIFIER_LOGO)) {
// Meta+scroll on a floating window: its scale, bigger or smaller (ft-floatd).
char msg[48];
snprintf(msg, sizeof msg, "scale %d %d", sc->index + 1, e->dy < 0 ? 1 : -1);
struct sockaddr_un addr = {.sun_family = AF_UNIX};
const char name[] = "frametop_float";
memcpy(addr.sun_path + 1, name, sizeof name - 1);
sendto(s->relay_fd, msg, strlen(msg), MSG_DONTWAIT, (struct sockaddr *)&addr,
offsetof(struct sockaddr_un, sun_path) + 1 + sizeof name - 1);
break;
}
if (e->dy != 0)
wlr_seat_pointer_notify_axis(s->seat, t, WL_POINTER_AXIS_VERTICAL_SCROLL, e->dy * 15,
(int32_t)(e->dy * 120), WL_POINTER_AXIS_SOURCE_WHEEL,
@@ -427,6 +439,10 @@ static int control_readable(int fd, uint32_t mask, void *data) {
h > 16384) {
snprintf(reply, sizeof reply, "error bad screen or size");
} else {
// A screen's size is even: KWin's nested backend gives a scaled screen a whole
// buffer scale (1.5 -> 2), and a buffer that isn't a multiple of it is a protocol
// error that disconnects KWin. (ft-floatd rounds spares' sizes for their scale.)
if (index - 1 < s->n_config) w += w & 1, h += h & 1;
if (index - 1 < s->n_config) s->config[index - 1].width = w, s->config[index - 1].height = h;
wlr_xdg_toplevel_set_size(s->screens[index - 1]->toplevel, w, h);
snprintf(reply, sizeof reply, "ok");
+1 -1
View File
@@ -115,7 +115,7 @@ case "${1:-}" in
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" \
WAYLAND_DISPLAY=wayland-0 FT_FLOAT_DEBUG=1 "$REPO_ROOT/float/ft-floatd" --screens "$screens" --slots "$spares" \
--control ft_screens_test --socket frametop_float_test \
> "$logs/floatd.log" 2>&1 &
sleep 2