mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
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:
1 parent
c2e5e861c8
commit
6122b7eb15
4 files changed
+153
-19
No files matched your search
+78
-18
@@ -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)
|
||||
|
||||
@@ -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, ¶ms, &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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user