Floating windows: fix the scale crash, the click offset, and placement

- KWin's nested backend makes an output the size it's configured to times its
  scale, so after Meta+scroll every size ft-floatd sent was multiplied again,
  and an odd result disconnected KWin (buffer not divisible by its scale).
  ft-floatd now asks for sizes in the output's scaled terms (kwin_size), and
  asks again after each scale change.
- SteamVR reports mouse positions on a panel with texture bounds in the whole
  texture, not the crop, so clicks on a floating window landed up to ~200 px
  off. The mouse scale is now the buffer's size, as on a screen.
- A floated window starts 30 cm in front of its screen (was 5 cm), so it's
  easy to point at apart from the screen behind it.
- No 1 s wait before a spare turns on (a disabled output never commits), and
  the login splash on the spares isn't taken for floating windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-30 10:57:36 -06:00
1 parent 2a9fbdebb4
commit c8fc6351bb
3 files changed
+78 -43

No files matched your search

+64 -26
View File
@@ -47,17 +47,32 @@ 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)
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 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 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):
@@ -126,6 +141,8 @@ class Slot:
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
@@ -257,12 +274,14 @@ class Daemon:
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 = output_scales().get(slot.output, 1.0)
log(f"{wid[:9]} already floats on {slot.output}")
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
@@ -288,6 +307,15 @@ class Daemon:
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:
@@ -323,21 +351,24 @@ class Daemon:
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)
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)
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)
# 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)
@@ -378,11 +409,7 @@ class Daemon:
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]}")
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:
@@ -403,16 +430,26 @@ class Daemon:
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)
# 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}),
@@ -427,6 +464,7 @@ class Daemon:
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)
+4 -2
View File
@@ -434,8 +434,10 @@ static int control_readable(int fd, uint32_t mask, void *data) {
int value, index, w, h;
double scale;
if (sscanf(buf, "size %d %d %d", &index, &w, &h) == 3) {
// A new resolution for a screen, live: KWin resizes the screen to match.
if (index < 1 || index > MAX_SCREENS || !s->screens[index - 1] || w < 320 || h < 200 || w > 16384 ||
// A new resolution for a screen, live: KWin resizes the screen to match. (KWin makes
// it this size times its scale; ft-floatd sends spares' sizes divided by theirs.)
const int min_w = index - 1 < s->n_config ? 320 : 64, min_h = index - 1 < s->n_config ? 200 : 64;
if (index < 1 || index > MAX_SCREENS || !s->screens[index - 1] || w < min_w || h < min_h || w > 16384 ||
h > 16384) {
snprintf(reply, sizeof reply, "error bad screen or size");
} else {
+10 -15
View File
@@ -276,10 +276,8 @@ struct Screen {
return width > 0 ? metres * height / width : metres * 9 / 16;
}
// Buffer pixels from OpenVR's mouse position on the panel (its origin is bottom left).
void ToBuffer(double mx, double my, double *x, double *y) const {
if (floating && cropW > 0) *x = cropX + mx, *y = cropY + cropH - my;
else *x = mx, *y = height - my;
}
// A cropped panel too: SteamVR gives the position in the whole texture, not the crop.
void ToBuffer(double mx, double my, double *x, double *y) const { *x = mx, *y = height - my; }
std::array<vr::VROverlayHandle_t, 6> Controls() const {
return {bar, curveButton, rollButton, handle, dockButton, closeButton};
}
@@ -1150,14 +1148,15 @@ void Push(Screen &s, double notches) {
// ---------------------------------------------------------------- floating windows
// Show the window's rectangle of the buffer, and map the mouse to it (see ToBuffer).
// Texture bounds are fractions of the buffer, v from the top.
// Show the window's rectangle of the buffer. Texture bounds are fractions of the buffer, v
// from the top. SteamVR reports mouse positions in the whole texture (the bounds applied), so
// the mouse scale is the buffer's size, as on a screen (see ToBuffer).
void CropOverlay(vr::VROverlayHandle_t o, const Screen &s, int x, int y, int w, int h) {
if (s.width <= 0 || s.height <= 0 || w <= 0 || h <= 0) return;
vr::VRTextureBounds_t b = {float(x) / s.width, float(y) / s.height, float(x + w) / s.width,
float(y + h) / s.height};
vr::VROverlay()->SetOverlayTextureBounds(o, &b);
vr::HmdVector2_t scale = {float(w), float(h)};
vr::HmdVector2_t scale = {float(s.width), float(s.height)};
vr::VROverlay()->SetOverlayMouseScale(o, &scale);
}
@@ -1571,15 +1570,11 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
RefreshPoses();
for (auto &[index, s] : g_screens) {
vr::VREvent_t ev;
// The screen itself: input for KWin.
// The screen itself (and a floating window's popups): input for KWin.
auto panelEvent = [&](const vr::VREvent_t &ev, bool sub, const Sub *from) {
auto panelEvent = [&](const vr::VREvent_t &ev, bool sub) {
ft_event e{};
e.screen = index;
auto at = [&] {
if (from) e.x = from->x + ev.data.mouse.x, e.y = from->y + from->h - ev.data.mouse.y;
else s.ToBuffer(ev.data.mouse.x, ev.data.mouse.y, &e.x, &e.y);
};
auto at = [&] { s.ToBuffer(ev.data.mouse.x, ev.data.mouse.y, &e.x, &e.y); };
switch (ev.eventType) {
case vr::VREvent_MouseMove:
if (s.titleCarry) return; // KWin's pointer stays where the title bar was pressed
@@ -1624,9 +1619,9 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
}
handle(&e, data);
};
while (vr::VROverlay()->PollNextOverlayEvent(s.overlay, &ev, sizeof ev)) panelEvent(ev, false, nullptr);
while (vr::VROverlay()->PollNextOverlayEvent(s.overlay, &ev, sizeof ev)) panelEvent(ev, false);
for (const auto &[k, sub] : s.subs)
while (vr::VROverlay()->PollNextOverlayEvent(sub.overlay, &ev, sizeof ev)) panelEvent(ev, true, &sub);
while (vr::VROverlay()->PollNextOverlayEvent(sub.overlay, &ev, sizeof ev)) panelEvent(ev, true);
// The controls light up under a laser.
auto hover = [&](int k) {
const bool on = ev.eventType == vr::VREvent_MouseMove || ev.eventType == vr::VREvent_FocusEnter;