From c2e5e861c893443cbed0920308752489dfe0dfe9 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:26:20 -0600 Subject: [PATCH] Show floating windows as panels of their own ft-screens makes a panel for each spare output (frametop.float.N), hidden until ft-floatd floats a window on it. The panel shows only the window's rectangle of the buffer at the density of the screen it came from, its popups and dialogs get small panels over it, pressing its title bar carries it while KWin's pointer stays put, the corner tab resizes the window in pixels, and two more buttons close it and put it back on the desktop. The session adds FLOAT_SLOTS spare outputs to KWin and starts ft-floatd; ft-layout arranges only the screens' outputs, and the pointer helper treats the new panels like screens. Not yet tried in the headset. Co-Authored-By: Claude Opus 5.5 --- float/ft_floatd.py | 1 + layout/ft_layout.py | 7 +- pointer/helper/ft-pointer.cpp | 8 +- screens/compositor.c | 34 ++- screens/test/headless.sh | 4 +- screens/vr.cpp | 463 ++++++++++++++++++++++++++++++---- screens/vr.h | 4 + session/frametop-session.sh | 32 ++- session/frametop.conf.example | 2 + 9 files changed, 480 insertions(+), 75 deletions(-) diff --git a/float/ft_floatd.py b/float/ft_floatd.py index caf5c36..3137822 100644 --- a/float/ft_floatd.py +++ b/float/ft_floatd.py @@ -305,6 +305,7 @@ class Daemon: # 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.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}", diff --git a/layout/ft_layout.py b/layout/ft_layout.py index 601a817..50b42d1 100755 --- a/layout/ft_layout.py +++ b/layout/ft_layout.py @@ -684,13 +684,18 @@ def nested_env(): def outputs(env): - """KWin's outputs, in screen order (WL-0, WL-1, ...).""" + """KWin's outputs for the screens, in screen order (WL-0, WL-1, ...).""" try: data = json.loads(subprocess.run(["kscreen-doctor", "-j"], capture_output=True, text=True, env=env, timeout=10).stdout) except (OSError, ValueError, subprocess.TimeoutExpired): return [] outs = [o for o in data.get("outputs", []) if o.get("connected")] + if backend() == "screens": + # Outputs after the screens are spares for floating windows (ft-floatd places them). + count = screen_count() + outs = [o for o in outs if not re.fullmatch(r"WL-(\d+)", o.get("name", "")) or + int(o["name"][3:]) < count] return sorted(outs, key=lambda o: [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", o.get("name", ""))]) diff --git a/pointer/helper/ft-pointer.cpp b/pointer/helper/ft-pointer.cpp index bba6729..a41b5e7 100644 --- a/pointer/helper/ft-pointer.cpp +++ b/pointer/helper/ft-pointer.cpp @@ -371,6 +371,7 @@ private: if (rest.find("Thumbnail") != std::string::npos || rest.find("Subview") != std::string::npos) continue; if (key.rfind("system.pointer", 0) == 0 || key.rfind("system.cursor", 0) == 0 || key.rfind("frametop.pointer", 0) == 0 || key.rfind("frametop.guide", 0) == 0 || + key == "frametop.catcher" || // ft-screens' release catcher: only on a laser mid-drag key == "system.HeadsetView" || key == "system.toast") continue; // The name can hold quotes; it ends at the last "', " (the size and state follow). @@ -1255,10 +1256,11 @@ int main() { overlay->GetOverlayTextureSize(h, &tw, &th); vr::VROverlayTransformType tt = vr::VROverlayTransform_Invalid; overlay->GetOverlayTransformType(h, &tt); - // ft-screens' panels (frametop.screen.N) are 0x0 and absolute too (a shared - // texture), but they're real panels of any size. + // ft-screens' panels (frametop.screen.N, and floating windows with their + // popups, frametop.float.N...) are 0x0 and absolute too (a shared texture), + // but they're real panels of any size. sceneGraph[key] = (tw == 0 || th == 0) && tt == vr::VROverlayTransform_Absolute && - key.rfind("frametop.screen.", 0) != 0; + key.rfind("frametop.screen.", 0) != 0 && key.rfind("frametop.float.", 0) != 0; } ours = vr::k_unTrackedDeviceIndexInvalid; for (vr::TrackedDeviceIndex_t i = 0; i < vr::k_unMaxTrackedDeviceCount; ++i) { diff --git a/screens/compositor.c b/screens/compositor.c index ac48cfc..10affcc 100644 --- a/screens/compositor.c +++ b/screens/compositor.c @@ -13,13 +13,15 @@ // scale first ("scale ", from ft-layout). // // Usage: ft-screens [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]... -// [-- COMMAND ARGS...] +// [--spares N] [-- COMMAND ARGS...] // --socket Wayland socket name in $XDG_RUNTIME_DIR (default ft-screens-0) // --control the control socket's abstract name (default ft_screens) // --no-vr run without SteamVR, for tests next to the running desktop: no panels, no // input, and nothing sent to the input relay. Commands still work, and // "toplevels" shows what KWin opened. // --screen one per screen, in KWin's order (default: 3440x1440@2.4) +// --spares KWin's outputs after the screens: spares for floating windows (ft-floatd +// turns them on and sizes them; see docs/floating-windows.md) // COMMAND run with WAYLAND_DISPLAY set to our socket (e.g. the Frametop session) // Runs in the dev container (wlroots 0.20); KWin connects from the host. #define _GNU_SOURCE @@ -57,7 +59,7 @@ #include "vr.h" -#define MAX_SCREENS 8 +#define MAX_SCREENS 24 // screens and spare outputs struct config { int width, height; @@ -97,7 +99,8 @@ struct server { struct screen *screens[MAX_SCREENS]; struct config config[MAX_SCREENS]; double scale[MAX_SCREENS]; // KWin's scale for each screen (panel pixels per logical unit) - int n_config, n_screens; + int n_config, n_screens; // n_config: the screens; the outputs after them are spares + int spares; struct wl_list buffers; // tracked_buffer struct wl_event_source *tick; struct screen *pointer_focus; @@ -145,7 +148,8 @@ static void screen_commit(struct wl_listener *l, void *data) { struct wlr_xdg_surface *xdg = sc->toplevel->base; if (xdg->initial_commit) { // First commit: tell KWin the size of this screen. - const struct config *c = &sc->server->config[sc->index < sc->server->n_config ? sc->index : 0]; + const struct config spare = {640, 480, 0.5}; // until ft-floatd sizes it + const struct config *c = sc->index < sc->server->n_config ? &sc->server->config[sc->index] : &spare; wlr_xdg_toplevel_set_size(sc->toplevel, c->width, c->height); wlr_xdg_toplevel_set_activated(sc->toplevel, true); if (sc->decoration) @@ -205,7 +209,9 @@ static void screen_destroy(struct wl_listener *l, void *data) { // after it while that output is off. static void screen_title(struct wl_listener *l, void *data) { struct screen *sc = wl_container_of(l, sc, set_title); - wlr_log(WLR_INFO, "screen %d: \"%s\"", sc->index + 1, sc->toplevel->title ? sc->toplevel->title : ""); + const char *title = sc->toplevel->title ? sc->toplevel->title : ""; + wlr_log(WLR_INFO, "screen %d: \"%s\"", sc->index + 1, title); + if (sc->index >= sc->server->n_config) ft_vr_float_output(sc->index, !strstr(title, "Output disabled")); } static void new_toplevel(struct wl_listener *l, void *data) { @@ -222,9 +228,15 @@ static void new_toplevel(struct wl_listener *l, void *data) { sc->index = index; sc->toplevel = toplevel; s->screens[index] = sc; - const struct config *c = &s->config[index < s->n_config ? index : 0]; - wlr_log(WLR_INFO, "screen %d: KWin window, %dx%d, %.2f m wide", index + 1, c->width, c->height, c->metres); - ft_vr_screen_create(index, c->metres, s->n_config > index + 1 ? s->n_config : index + 1); + if (index >= s->n_config) { + wlr_log(WLR_INFO, "screen %d: KWin window, a spare output (floating window %d)", index + 1, + index - s->n_config + 1); + ft_vr_float_create(index, index - s->n_config + 1); + } else { + const struct config *c = &s->config[index]; + wlr_log(WLR_INFO, "screen %d: KWin window, %dx%d, %.2f m wide", index + 1, c->width, c->height, c->metres); + ft_vr_screen_create(index, c->metres, s->n_config); + } sc->commit.notify = screen_commit; wl_signal_add(&toplevel->base->surface->events.commit, &sc->commit); sc->destroy.notify = screen_destroy; @@ -537,6 +549,8 @@ int main(int argc, char **argv) { socket_name = argv[++i]; } else if (strcmp(argv[i], "--control") == 0 && i + 1 < argc) { control_name = argv[++i]; + } else if (strcmp(argv[i], "--spares") == 0 && i + 1 < argc) { + s.spares = atoi(argv[++i]); } else if (strcmp(argv[i], "--no-vr") == 0) { s.vr = false; } else if (strcmp(argv[i], "--screen") == 0 && i + 1 < argc && s.n_config < MAX_SCREENS) { @@ -554,7 +568,7 @@ int main(int argc, char **argv) { } else { fprintf(stderr, "usage: %s [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]... " - "[-- COMMAND ARGS...]\n", + "[--spares N] [-- COMMAND ARGS...]\n", argv[0]); return 2; } @@ -599,7 +613,7 @@ int main(int argc, char **argv) { } char path[256]; snprintf(path, sizeof path, "%s/%s", getenv("XDG_RUNTIME_DIR") ? getenv("XDG_RUNTIME_DIR") : "/tmp", socket_name); - wlr_log(WLR_INFO, "listening on %s; %d screen(s) configured", path, s.n_config); + wlr_log(WLR_INFO, "listening on %s; %d screen(s) configured, %d spare(s)", path, s.n_config, s.spares); const int control = open_control_socket(control_name); if (control < 0) return 1; diff --git a/screens/test/headless.sh b/screens/test/headless.sh index d2f5bc1..7870fb5 100755 --- a/screens/test/headless.sh +++ b/screens/test/headless.sh @@ -6,7 +6,7 @@ # or on the Frame. Build ft-screens first (screens/build.sh). # # headless.sh start [SCREENS] [SPARES] (default 2 screens, 1920x1080 and 1080x1920, and -# 3 spare 800x600 outputs, disabled once KWin is up) +# 3 spare outputs, disabled once KWin is up) # headless.sh stop # headless.sh ask '' e.g. toplevels, "input 1 down 400 20" # headless.sh kd e.g. -o, output.WL-2.enable @@ -89,7 +89,7 @@ case "${1:-}" in args=(--screen 1920x1080@1.6 --screen 1080x1920@0.9) for ((i = 2; i < screens; i++)); do args+=(--screen 1920x1080@1.6); done args=("${args[@]:0:$((screens * 2))}") - for ((i = 0; i < spares; i++)); do args+=(--screen 800x600); done + args+=(--spares "$spares") cd "$REPO_ROOT" nohup "$HOME/.local/bin/distrobox" enter "$FRAME_BOX" -- env XDG_RUNTIME_DIR="$rt" \ ./screens/build/ft-screens --no-vr --socket ft-test-0 --control ft_screens_test "${args[@]}" \ diff --git a/screens/vr.cpp b/screens/vr.cpp index 04c4af9..755f058 100644 --- a/screens/vr.cpp +++ b/screens/vr.cpp @@ -42,6 +42,15 @@ // or stay visible over it; the hotkey still shows them. // - the catcher: a button pressed on a screen is released in KWin even when the laser // lets go between panels (UpdateCatcher). +// - floating windows (docs/floating-windows.md): KWin's spare outputs, after the screens, +// are panels too, for one window each. ft-floatd sizes the output to the window plus a +// margin and tells us the window's rectangle ("float"): the panel shows only that crop of +// the buffer (SetOverlayTextureBounds), at the density of the screen it came from, and +// each popup or dialog gets a small panel of its own over it, cut from the same buffer +// ("sub"). Pressing its title bar carries the panel like the bar does, while KWin's +// pointer stays put, so the window doesn't move on its output. The corner tab resizes the +// window (in pixels, at the same density) instead of scaling the panel, and two more +// buttons close it and put it back on the desktop (both through ft-floatd). // OpenVR has no overlay-relative transforms here (openvr v2.15.6), so the bar, button, // and handle are placed whenever their screen moves. #include "vr.h" @@ -49,6 +58,8 @@ #include #include +#include +#include #include #include #include @@ -59,6 +70,7 @@ extern char **environ; // for posix_spawn #include #include #include +#include #include #include #include @@ -209,10 +221,17 @@ long g_tick = 0; // ft_vr_poll calls bool g_vr = false; // connected to SteamVR (ft-screens --no-vr runs without it) constexpr vr::TrackedDeviceIndex_t kNone = vr::k_unTrackedDeviceIndexInvalid; +// A popup or dialog of a floating window: a small panel over it, cut from the same buffer. +struct Sub { + vr::VROverlayHandle_t overlay = vr::k_ulOverlayHandleInvalid; + int x = 0, y = 0, w = 0, h = 0; // in the output's buffer, pixels +}; + struct Screen { vr::VROverlayHandle_t overlay = vr::k_ulOverlayHandleInvalid, bar = vr::k_ulOverlayHandleInvalid, handle = vr::k_ulOverlayHandleInvalid, curveButton = vr::k_ulOverlayHandleInvalid, - rollButton = vr::k_ulOverlayHandleInvalid; + rollButton = vr::k_ulOverlayHandleInvalid, dockButton = vr::k_ulOverlayHandleInvalid, + closeButton = vr::k_ulOverlayHandleInvalid; // the last two: floating windows int width = 0, height = 0; // current buffer size (mouse scale) double metres = 1; double curve = 0; // cylinder radius in metres; 0 = flat @@ -228,7 +247,7 @@ struct Screen { double grabX = 0, grabY = 0; // resize: the grab point relative to the corner Mat rollFrom = Identity(); // roll: the pose at the press (pinRel when pinned) double rollAngle = 0; // roll: the laser's angle around the centre then - bool hover[4] = {}; // a laser is on the bar, curve, roll, resize control + bool hover[6] = {}; // a laser is on the bar, curve, roll, resize, dock, close control bool lasers = true; // MakeOverlaysInteractiveIfVisible is set float controls = 0; // the controls' fade, 0 (hidden) .. 1 bool controlsUp = false; // the controls' overlays are shown @@ -238,9 +257,35 @@ struct Screen { bool barLit = false; double chrome = 0.3; // the bar's width; the other controls follow it (ChromeSize) double grip = 0.04; // the corner tab's and the round buttons' size - double heightMetres() const { return width > 0 ? metres * height / width : metres * 9 / 16; } - std::array Controls() const { return {bar, curveButton, rollButton, handle}; } - std::array All() const { return {overlay, bar, curveButton, rollButton, handle}; } + // A floating window's panel (see the top): the window's rectangle in the buffer, its + // title bar's height there, and the density. + bool floating = false; // a spare output's panel + bool floatOn = false; // ft-floatd has a window on it ("float" .. "unfloat") + bool outputOn = false; // KWin has the spare output turned on + bool minimized = false; + int cropX = 0, cropY = 0, cropW = 0, cropH = 0; + int titleH = 0; + double mpp = 0; // metres per buffer pixel + bool titleCarry = false; // carried by its title bar: KWin's pointer stays at carryX, carryY + double carryX = 0, carryY = 0; + long resizeSent = 0; // g_tick of the last resize request (they're throttled) + int resizeW = 0, resizeH = 0; // ...and its size + std::map subs; + double heightMetres() const { + if (floating && cropW > 0) return metres * cropH / cropW; + 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; + } + std::array Controls() const { + return {bar, curveButton, rollButton, handle, dockButton, closeButton}; + } + std::array All() const { + return {overlay, bar, curveButton, rollButton, handle, dockButton, closeButton}; + } }; std::map g_screens; std::map g_imports; @@ -348,6 +393,29 @@ std::vector RollTexture(int n) { DiscRim); } +std::vector CloseTexture(int n) { + // A cross: "close this window". + return ControlTexture( + n, InDisc, + [](double u, double v) { + return std::max(std::fabs(u), std::fabs(v)) < 0.42 && + (std::fabs(u - v) < 0.12 || std::fabs(u + v) < 0.12); + }, + DiscRim); +} + +std::vector DockTexture(int n) { + // An arrow down onto a line: "back to the desktop". + return ControlTexture( + n, InDisc, + [](double u, double v) { + if (std::fabs(u) < 0.5 && v > -0.52 && v < -0.38) return true; // the line + if (std::fabs(u) < 0.08 && v > -0.1 && v < 0.5) return true; // the shaft + return v >= -0.3 && v <= -0.05 && std::fabs(u) <= (v + 0.3) * 1.2; // the head, point down + }, + DiscRim); +} + vr::VROverlayHandle_t MakeChrome(const char *key, const char *name, const std::vector &px, int w, int h) { vr::VROverlayHandle_t o = vr::k_ulOverlayHandleInvalid; if (vr::VROverlay()->CreateOverlay(key, name, &o) != vr::VROverlayError_None) return o; @@ -500,13 +568,37 @@ Mat BarOffset(const Screen &s) { return OnSurface(s, 0, BarY(s), 0.003); } // Put the bar, the curve button, and the corner tab under the screen (same parent: the // room or the controller), sized for the screen and its distance, and on its surface. -// Where each control sits, relative to the screen: bar, curve, roll, resize tab. -std::array ControlOffsets(const Screen &s) { +// Where each control sits, relative to the screen: bar, curve, roll, resize tab, and a +// floating window's dock and close buttons (left of the bar). +std::array ControlOffsets(const Screen &s) { const double h = s.heightMetres(), bar = s.chrome, button = s.grip, gap = bar * 0.06; return {BarOffset(s), OnSurface(s, bar / 2 + gap + button / 2, BarY(s), 0.003), OnSurface(s, bar / 2 + gap * 2 + button * 1.5, BarY(s), 0.003), // The tab's top left corner is the screen's bottom right corner. - OnSurface(s, s.metres / 2 + s.grip / 2, -(h / 2 + s.grip / 2), 0.003)}; + OnSurface(s, s.metres / 2 + s.grip / 2, -(h / 2 + s.grip / 2), 0.003), + OnSurface(s, -(bar / 2 + gap + button / 2), BarY(s), 0.003), + OnSurface(s, -(bar / 2 + gap * 2 + button * 1.5), BarY(s), 0.003)}; +} + +// A floating window's popups and dialogs, a few millimetres in front of it, where they are +// in the buffer relative to the window. +void PlaceSubs(const Screen &s) { + if (s.subs.empty() || s.cropW <= 0) return; + Mat p; + if (s.pinned == kNone && !ScreenPose(s, &p)) return; + for (const auto &[k, sub] : s.subs) { + const double u = (sub.x + sub.w / 2.0 - (s.cropX + s.cropW / 2.0)) * s.mpp; + const double v = -(sub.y + sub.h / 2.0 - (s.cropY + s.cropH / 2.0)) * s.mpp; + const Mat off = OnSurface(s, u, v, 0.005); + vr::VROverlay()->SetOverlayWidthInMeters(sub.overlay, float(std::max(0.01, sub.w * s.mpp))); + if (s.pinned != kNone) { + const Mat m = Mul(s.pinRel, off); + vr::VROverlay()->SetOverlayTransformTrackedDeviceRelative(sub.overlay, s.pinned, &m); + } else { + const Mat m = Mul(p, off); + vr::VROverlay()->SetOverlayTransformAbsolute(sub.overlay, vr::TrackingUniverseStanding, &m); + } + } } void PlaceChrome(Screen &s) { @@ -517,12 +609,19 @@ void PlaceChrome(Screen &s) { vr::VROverlay()->SetOverlayWidthInMeters(s.curveButton, float(button)); vr::VROverlay()->SetOverlayWidthInMeters(s.rollButton, float(button)); vr::VROverlay()->SetOverlayWidthInMeters(s.handle, float(s.grip)); + if (s.floating) { + vr::VROverlay()->SetOverlayWidthInMeters(s.dockButton, float(button)); + vr::VROverlay()->SetOverlayWidthInMeters(s.closeButton, float(button)); + } // Curved, the bar bends with the screen's bottom edge. vr::VROverlay()->SetOverlayCurvature(s.bar, s.curve > 0 ? float(std::min(1.0, bar / (2 * M_PI * s.curve))) : 0.f); const std::pair parts[] = { - {s.bar, offsets[0]}, {s.curveButton, offsets[1]}, {s.rollButton, offsets[2]}, {s.handle, offsets[3]}}; + {s.bar, offsets[0]}, {s.curveButton, offsets[1]}, {s.rollButton, offsets[2]}, + {s.handle, offsets[3]}, {s.dockButton, offsets[4]}, {s.closeButton, offsets[5]}}; + PlaceSubs(s); if (s.pinned != kNone) { for (const auto &[o, off] : parts) { + if (o == vr::k_ulOverlayHandleInvalid) continue; const Mat m = Mul(s.pinRel, off); vr::VROverlay()->SetOverlayTransformTrackedDeviceRelative(o, s.pinned, &m); } @@ -531,6 +630,7 @@ void PlaceChrome(Screen &s) { Mat p; if (!ScreenPose(s, &p)) return; for (const auto &[o, off] : parts) { + if (o == vr::k_ulOverlayHandleInvalid) continue; const Mat m = Mul(p, off); vr::VROverlay()->SetOverlayTransformAbsolute(o, vr::TrackingUniverseStanding, &m); } @@ -637,11 +737,13 @@ bool ModeVisible() { // it's being dragged. void ApplyAlpha(const Screen &s) { vr::VROverlay()->SetOverlayAlpha(s.overlay, s.alpha); - const bool active[4] = {s.hover[0] || s.drag == Drag::Move, s.hover[1], s.hover[2] || s.drag == Drag::Roll, - s.hover[3] || s.drag == Drag::Resize}; + for (const auto &[k, sub] : s.subs) vr::VROverlay()->SetOverlayAlpha(sub.overlay, s.alpha); + const bool active[6] = {s.hover[0] || s.drag == Drag::Move, s.hover[1], s.hover[2] || s.drag == Drag::Roll, + s.hover[3] || s.drag == Drag::Resize, s.hover[4], s.hover[5]}; const auto controls = s.Controls(); - for (int k = 0; k < 4; ++k) - vr::VROverlay()->SetOverlayAlpha(controls[k], s.alpha * s.controls * (active[k] ? 1.f : kChromeIdle)); + for (int k = 0; k < 6; ++k) + if (controls[k] != vr::k_ulOverlayHandleInvalid) + vr::VROverlay()->SetOverlayAlpha(controls[k], s.alpha * s.controls * (active[k] ? 1.f : kChromeIdle)); } void SetVisible(Screen &s, bool visible, float alpha) { @@ -653,11 +755,14 @@ void SetVisible(Screen &s, bool visible, float alpha) { s.visible = visible; if (visible) { vr::VROverlay()->ShowOverlay(s.overlay); + for (const auto &[k, sub] : s.subs) vr::VROverlay()->ShowOverlay(sub.overlay); return; } // Hidden: the controls go at once (UpdateControls brings them back). vr::VROverlay()->HideOverlay(s.overlay); - for (auto o : s.Controls()) vr::VROverlay()->HideOverlay(o); + for (const auto &[k, sub] : s.subs) vr::VROverlay()->HideOverlay(sub.overlay); + for (auto o : s.Controls()) + if (o != vr::k_ulOverlayHandleInvalid) vr::VROverlay()->HideOverlay(o); s.controls = 0, s.controlsUp = false; } @@ -667,6 +772,9 @@ void UpdateVisibility() { const bool haveHead = DevicePose(vr::k_unTrackedDeviceIndex_Hmd, &head); for (auto &[i, s] : g_screens) { bool visible = s.shown && (shared || s.drag != Drag::None); + // A floating window's panel: while a window floats on it, its output is on, and the + // window isn't minimized (and once it has a crop). + if (s.floating) visible = visible && s.floatOn && s.outputOn && !s.minimized && s.cropW > 0; float alpha = 1; Mat p; if (visible && s.pinned != kNone && s.pinned != vr::k_unTrackedDeviceIndex_Hmd && s.drag == Drag::None && @@ -712,7 +820,9 @@ void UpdateControls() { const auto offsets = ControlOffsets(s); for (double f : {-0.5, -0.25, 0.0, 0.25, 0.5}) spots.push_back(Mul(p, Mul(offsets[0], Translation(f * s.chrome, 0, 0)))); - for (int k = 1; k < 4; ++k) spots.push_back(Mul(p, offsets[k])); + const auto controls = s.Controls(); + for (int k = 1; k < 6; ++k) + if (controls[k] != vr::k_ulOverlayHandleInvalid) spots.push_back(Mul(p, offsets[k])); const double reach = std::max(s.grip * 1.5, s.chrome * 0.12); for (const Mat &d : lasers) { const double o[3] = {d.m[0][3], d.m[1][3], d.m[2][3]}, dir[3] = {-d.m[0][2], -d.m[1][2], -d.m[2][2]}; @@ -730,13 +840,14 @@ void UpdateControls() { } } } - const bool inUse = s.drag != Drag::None || s.hover[0] || s.hover[1] || s.hover[2] || s.hover[3]; + const bool inUse = s.drag != Drag::None || std::any_of(std::begin(s.hover), std::end(s.hover), [](bool h) { return h; }); const bool want = s.visible && (inUse || g_tick < s.nearUntil); // The controls stay shown while their screen is, just fully transparent when not // wanted: SteamVR's laser still hits them, and the hover event brings them in, for // any device's laser, whatever its shape. if (s.visible && !s.controlsUp) { - for (auto o : s.Controls()) vr::VROverlay()->ShowOverlay(o); + for (auto o : s.Controls()) + if (o != vr::k_ulOverlayHandleInvalid) vr::VROverlay()->ShowOverlay(o); s.controlsUp = true; ApplyAlpha(s); } @@ -748,6 +859,19 @@ void UpdateControls() { // ---------------------------------------------------------------- moving, resizing, pinning +// To ft-floatd (@frametop_float), for floating windows: dock, close, resize. From an unbound +// socket, so its replies go nowhere. +void SendFloat(const std::string &msg) { + static const int fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + const char name[] = "frametop_float"; + std::memcpy(addr.sun_path + 1, name, sizeof name - 1); + sendto(fd, msg.data(), msg.size(), MSG_DONTWAIT, reinterpret_cast(&addr), + socklen_t(offsetof(sockaddr_un, sun_path) + 1 + sizeof name - 1)); + std::printf("to ft-floatd: %s\n", msg.c_str()); +} + // Where a device's ray meets the screen's plane, in the screen's x (right) and y (up), // metres from its centre. bool RayOnPlane(const Mat &p, const Mat &d, double *x, double *y) { @@ -905,7 +1029,7 @@ void FinishDrag(Screen &s, int index) { EndDrag(s); Mat c, p; if (!moved) return; - ArrangeDesktopSoon(); + if (!s.floating) ArrangeDesktopSoon(); if (target != kNone && DevicePose(target, &c) && ScreenPose(s, &p)) { Pin(s, target, Mul(Inverse(c), p)); if (target == vr::k_unTrackedDeviceIndex_Hmd) std::printf("screen %d: pinned to the head\n", index + 1); @@ -992,6 +1116,19 @@ void UpdateDrag(Screen &s, int index) { double hx, hy; Mat l; if (!LaserPose(s.dragDevice, &l) || !RayOnScreen(s, l, &hx, &hy)) return; + if (s.floating) { + // A floating window: the corner goes where the laser is, in both directions, and the + // window gets that many pixels at the same density (ft-floatd resizes it, and the + // new crop comes back as "float", with the top left corner kept where it is). + if (s.mpp <= 0 || g_tick - s.resizeSent < 4) return; // about 20 a second + const double left = -s.metres / 2, top = s.heightMetres() / 2; + const int w = std::max(320, int(std::lround((hx - s.grabX - left) / s.mpp))); + const int h = std::max(200, int(std::lround((top - (hy - s.grabY)) / s.mpp))); + if (w == s.resizeW && h == s.resizeH) return; + s.resizeW = w, s.resizeH = h, s.resizeSent = g_tick; + SendFloat("resize " + std::to_string(index + 1) + " " + std::to_string(w) + " " + std::to_string(h)); + return; + } const double a = s.width > 0 ? double(s.height) / s.width : 9.0 / 16; const double cx = hx - s.grabX, cy = hy - s.grabY; // where the corner should be SetWidth(s, 2 * (cx - a * cy) / (1 + a * a)); @@ -1011,6 +1148,92 @@ void Push(Screen &s, double notches) { s.dragRel = Mul(Inverse(d), p); } +// ---------------------------------------------------------------- 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. +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::VROverlay()->SetOverlayMouseScale(o, &scale); +} + +void ApplyCrop(Screen &s) { + CropOverlay(s.overlay, s, s.cropX, s.cropY, s.cropW, s.cropH); + for (const auto &[k, sub] : s.subs) CropOverlay(sub.overlay, s, sub.x, sub.y, sub.w, sub.h); +} + +// ft-floatd's "float": the window's rectangle, its title bar, and the density. The panel's +// top left corner stays where it is when the window changes size. +void SetFloat(Screen &s, double mpp, int x, int y, int w, int h, int title) { + const bool first = !s.floatOn || s.cropW <= 0; + const double oldW = s.metres, oldH = s.heightMetres(); + s.floatOn = true; + s.mpp = mpp; + s.cropX = x, s.cropY = y, s.cropW = w, s.cropH = h, s.titleH = title; + s.metres = w * mpp; + vr::VROverlay()->SetOverlayWidthInMeters(s.overlay, float(s.metres)); + ApplyCurve(s); + ApplyCrop(s); + const double dx = (s.metres - oldW) / 2, dy = -(s.heightMetres() - oldH) / 2; + if (!first && (std::fabs(dx) > 1e-6 || std::fabs(dy) > 1e-6)) { + if (s.pinned != kNone) Pin(s, s.pinned, Mul(s.pinRel, Translation(dx, dy, 0))); + else SetAbsolute(s, Mul(s.pose, Translation(dx, dy, 0))); + } else { + PlaceChrome(s); + } +} + +void Unfloat(Screen &s) { + if (s.drag != Drag::None) EndDrag(s); + for (auto &[k, sub] : s.subs) vr::VROverlay()->DestroyOverlay(sub.overlay); + s.subs.clear(); + s.floatOn = s.minimized = s.titleCarry = false; + s.cropW = s.cropH = 0; + s.resizeW = s.resizeH = 0; +} + +// A popup or dialog (number k) at x, y, w, h in the buffer; w = 0 takes it away. +void SetSub(Screen &s, int index, int k, int x, int y, int w, int h) { + auto it = s.subs.find(k); + if (w <= 0 || h <= 0) { + if (it != s.subs.end()) { + vr::VROverlay()->DestroyOverlay(it->second.overlay); + s.subs.erase(it); + } + return; + } + if (it == s.subs.end()) { + Sub sub; + char key[80], name[64]; + std::snprintf(key, sizeof key, "frametop.float.%d.sub.%d", index + 1, k); + std::snprintf(name, sizeof name, "Floating window menu %d", k); + if (vr::VROverlay()->CreateOverlay(key, name, &sub.overlay) != vr::VROverlayError_None) return; + vr::VROverlay()->SetOverlayInputMethod(sub.overlay, vr::VROverlayInputMethod_Mouse); + vr::VROverlay()->SetOverlayFlag(sub.overlay, vr::VROverlayFlags_IgnoreTextureAlpha, true); + vr::VROverlay()->SetOverlayFlag(sub.overlay, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true); + vr::VROverlay()->SetOverlayFlag(sub.overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, s.lasers); + vr::VROverlay()->SetOverlaySortOrder(sub.overlay, 5); + it = s.subs.emplace(k, sub).first; + if (s.shown) { + auto imp = g_imports.find(s.shown); + if (imp != g_imports.end()) { + vr::SharedTextureHandle_t handle = imp->second; + vr::Texture_t tex = {&handle, vr::TextureType_SharedTextureHandle, vr::ColorSpace_Gamma}; + vr::VROverlay()->SetOverlayTexture(sub.overlay, &tex); + } + } + vr::VROverlay()->SetOverlayAlpha(sub.overlay, s.alpha); + if (s.visible) vr::VROverlay()->ShowOverlay(sub.overlay); + } + it->second.x = x, it->second.y = y, it->second.w = w, it->second.h = h; + CropOverlay(it->second.overlay, s, x, y, w, h); + PlaceSubs(s); +} + // ---------------------------------------------------------------- the catcher // A button pressed on a screen belongs to KWin until it comes up, wherever the laser is by @@ -1083,9 +1306,11 @@ void UpdateCatcher() { for (int k = 0; k < 3; ++k) params.vSource.v[k] = l.m[k][3], params.vDirection.v[k] = -l.m[k][2]; for (auto &[i, s] : g_screens) { if (!s.visible) continue; - for (auto o : s.All()) { + std::vector parts(s.All().begin(), s.All().end()); + for (const auto &[k, sub] : s.subs) parts.push_back(sub.overlay); + for (auto o : parts) { vr::VROverlayIntersectionResults_t hit; - if (vr::VROverlay()->ComputeOverlayIntersection(o, ¶ms, &hit)) { + if (o != vr::k_ulOverlayHandleInvalid && vr::VROverlay()->ComputeOverlayIntersection(o, ¶ms, &hit)) { g_press.distance = std::max(0.05, double(hit.fDistance)); ShowCatcher(false); return; @@ -1194,18 +1419,21 @@ int ft_vr_modifiers(uint32_t format, uint64_t *out, int max) { bool ft_vr_screens_shown(void) { return g_vr && ModeVisible(); } -void ft_vr_screen_create(int index, double metres, int count) { - if (!g_vr) return; - Screen &s = g_screens[index]; - s.metres = metres; +} // extern "C" + +namespace { + +// A panel and its controls. `prefix` names the overlays (frametop.screen.N, +// frametop.float.N), `label` is what SteamVR shows ("Screen 2", "Floating window 1"). +bool MakePanel(Screen &s, const char *prefix, const char *label) { char key[64], name[64]; - std::snprintf(key, sizeof key, "frametop.screen.%d", index + 1); - std::snprintf(name, sizeof name, "Screen %d", index + 1); + std::snprintf(key, sizeof key, "%s", prefix); + std::snprintf(name, sizeof name, "%s", label); if (vr::VROverlay()->CreateOverlay(key, name, &s.overlay) != vr::VROverlayError_None) { std::fprintf(stderr, "openvr: can't create overlay %s\n", key); - return; + return false; } - vr::VROverlay()->SetOverlayWidthInMeters(s.overlay, float(metres)); + vr::VROverlay()->SetOverlayWidthInMeters(s.overlay, float(s.metres)); vr::VROverlay()->SetOverlayInputMethod(s.overlay, vr::VROverlayInputMethod_Mouse); vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_IgnoreTextureAlpha, true); vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true); @@ -1213,21 +1441,39 @@ void ft_vr_screen_create(int index, double metres, int count) { static const auto corner = CornerTexture(64); static const auto curve = CurveTexture(64); static const auto roll = RollTexture(64); - std::snprintf(key, sizeof key, "frametop.screen.%d.bar", index + 1); - std::snprintf(name, sizeof name, "Screen %d: move", index + 1); - s.bar = MakeChrome(key, name, BarTexture(false), 256, 24); + auto chrome = [&](const char *part, const char *what, const std::vector &px, int w, int h) { + std::snprintf(key, sizeof key, "%s.%s", prefix, part); + std::snprintf(name, sizeof name, "%s: %s", label, what); + return MakeChrome(key, name, px, w, h); + }; + s.bar = chrome("bar", "move", BarTexture(false), 256, 24); vr::VROverlay()->SetOverlayFlag(s.bar, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true); - std::snprintf(key, sizeof key, "frametop.screen.%d.curve", index + 1); - std::snprintf(name, sizeof name, "Screen %d: curve", index + 1); - s.curveButton = MakeChrome(key, name, curve, 64, 64); - std::snprintf(key, sizeof key, "frametop.screen.%d.roll", index + 1); - std::snprintf(name, sizeof name, "Screen %d: roll", index + 1); - s.rollButton = MakeChrome(key, name, roll, 64, 64); + s.curveButton = chrome("curve", "curve", curve, 64, 64); + s.rollButton = chrome("roll", "roll", roll, 64, 64); vr::VROverlay()->SetOverlayFlag(s.rollButton, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true); - std::snprintf(key, sizeof key, "frametop.screen.%d.resize", index + 1); - std::snprintf(name, sizeof name, "Screen %d: resize", index + 1); - s.handle = MakeChrome(key, name, corner, 64, 64); + s.handle = chrome("resize", "resize", corner, 64, 64); + if (s.floating) { + static const auto dock = DockTexture(64); + static const auto close = CloseTexture(64); + s.dockButton = chrome("dock", "back to the desktop", dock, 64, 64); + s.closeButton = chrome("close", "close", close, 64, 64); + } ApplyAlpha(s); + return true; +} + +} // namespace + +extern "C" { + +void ft_vr_screen_create(int index, double metres, int count) { + if (!g_vr) return; + Screen &s = g_screens[index]; + s.metres = metres; + char prefix[64], label[64]; + std::snprintf(prefix, sizeof prefix, "frametop.screen.%d", index + 1); + std::snprintf(label, sizeof label, "Screen %d", index + 1); + if (!MakePanel(s, prefix, label)) return; // Until the layout places it: 2 m ahead of the head, in a row, screen 1 on the left. RefreshPoses(); Mat head; @@ -1238,10 +1484,28 @@ void ft_vr_screen_create(int index, double metres, int count) { SetAbsolute(s, PanelPose(head.m[0][3] + dx * 2, head.m[1][3], head.m[2][3] + dz * 2, yaw, 0, 0)); } +// A spare output's panel (number `slot` from 1): hidden until a window floats on it. +void ft_vr_float_create(int index, int slot) { + if (!g_vr) return; + Screen &s = g_screens[index]; + s.floating = true; + char prefix[64], label[64]; + std::snprintf(prefix, sizeof prefix, "frametop.float.%d", slot); + std::snprintf(label, sizeof label, "Floating window %d", slot); + MakePanel(s, prefix, label); +} + +void ft_vr_float_output(int index, bool on) { + auto it = g_screens.find(index); + if (it != g_screens.end()) it->second.outputOn = on; +} + void ft_vr_screen_destroy(int index) { auto it = g_screens.find(index); if (it == g_screens.end()) return; - for (auto o : it->second.All()) vr::VROverlay()->DestroyOverlay(o); + for (auto o : it->second.All()) + if (o != vr::k_ulOverlayHandleInvalid) vr::VROverlay()->DestroyOverlay(o); + for (auto &[k, sub] : it->second.subs) vr::VROverlay()->DestroyOverlay(sub.overlay); g_screens.erase(it); } @@ -1274,14 +1538,19 @@ bool ft_vr_screen_present(int index, const void *key, const struct ft_dmabuf *b) } if (b->width != s.width || b->height != s.height) { s.width = b->width, s.height = b->height; - vr::HmdVector2_t scale = {float(s.width), float(s.height)}; - vr::VROverlay()->SetOverlayMouseScale(s.overlay, &scale); + if (s.floating) { + ApplyCrop(s); + } else { + vr::HmdVector2_t scale = {float(s.width), float(s.height)}; + vr::VROverlay()->SetOverlayMouseScale(s.overlay, &scale); + } PlaceChrome(s); // the height changed std::printf("screen %d: %dx%d\n", index + 1, s.width, s.height); } vr::SharedTextureHandle_t handle = it->second; vr::Texture_t tex = {&handle, vr::TextureType_SharedTextureHandle, vr::ColorSpace_Gamma}; vr::VROverlay()->SetOverlayTexture(s.overlay, &tex); + for (const auto &[k, sub] : s.subs) vr::VROverlay()->SetOverlayTexture(sub.overlay, &tex); s.shown = key; // UpdateVisibility shows it on the next tick return true; } @@ -1299,14 +1568,19 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { for (auto &[index, s] : g_screens) { vr::VREvent_t ev; // The screen itself: input for KWin. - while (vr::VROverlay()->PollNextOverlayEvent(s.overlay, &ev, sizeof ev)) { + // The screen itself (and a floating window's popups): input for KWin. + auto panelEvent = [&](const vr::VREvent_t &ev, bool sub, const Sub *from) { 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); + }; switch (ev.eventType) { case vr::VREvent_MouseMove: + if (s.titleCarry) return; // KWin's pointer stays where the title bar was pressed e.type = FT_MOTION; - e.x = ev.data.mouse.x; - e.y = s.height - ev.data.mouse.y; // OpenVR's mouse origin is bottom left + at(); if (g_press.buttons) g_press.screen = index, g_press.x = e.x, g_press.y = e.y; break; case vr::VREvent_MouseButtonDown: @@ -1315,10 +1589,17 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { e.type = FT_BUTTON; e.button = LinuxButton(ev.data.mouse.button); e.pressed = ev.eventType == vr::VREvent_MouseButtonDown; - e.x = ev.data.mouse.x; - e.y = s.height - ev.data.mouse.y; + at(); + if (!e.pressed && s.titleCarry) e.x = s.carryX, e.y = s.carryY, s.titleCarry = false; if (e.pressed) { PressDown(ev.trackedDeviceIndex, e.button, index, e.x, e.y); + // A floating window's title bar: carry the panel, and KWin (which starts + // moving the window on the press) sees no motion until the release. + if (!sub && s.floating && e.button == BTN_LEFT && s.titleH > 0 && e.y >= s.cropY && + e.y < s.cropY + s.titleH && s.drag == Drag::None) { + s.titleCarry = true, s.carryX = e.x, s.carryY = e.y; + StartDrag(s, Drag::Move, ev.trackedDeviceIndex); + } } else { g_press.buttons &= ~ButtonBit(e.button); if (!g_press.buttons) g_press.upAt = -1; @@ -1330,14 +1611,18 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { e.dy = -ev.data.scroll.ydelta; break; case vr::VREvent_FocusLeave: - if (g_press.buttons) continue; // KWin keeps the pointer while a button is held + if (g_press.buttons) return; // KWin keeps the pointer while a button is held + if (sub) return; // off a popup is usually onto its window e.type = FT_LEAVE; break; default: - continue; + return; } handle(&e, data); - } + }; + while (vr::VROverlay()->PollNextOverlayEvent(s.overlay, &ev, sizeof ev)) panelEvent(ev, false, nullptr); + for (const auto &[k, sub] : s.subs) + while (vr::VROverlay()->PollNextOverlayEvent(sub.overlay, &ev, sizeof ev)) panelEvent(ev, true, &sub); // The controls light up under a laser. auto hover = [&](int k) { const bool on = ev.eventType == vr::VREvent_MouseMove || ev.eventType == vr::VREvent_FocusEnter; @@ -1376,6 +1661,20 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { ReleaseAwayBy(ev.trackedDeviceIndex, ev.data.mouse.button, handle, data); } } + // A floating window's buttons: back to the desktop, and close (ft-floatd does both). + for (int k : {4, 5}) { + const vr::VROverlayHandle_t o = s.Controls()[k]; + if (o == vr::k_ulOverlayHandleInvalid) continue; + while (vr::VROverlay()->PollNextOverlayEvent(o, &ev, sizeof ev)) { + hover(k); + if (ev.eventType == vr::VREvent_MouseButtonDown && ev.data.mouse.button == vr::VRMouseButton_Left) { + SendFloat((k == 4 ? "dock " : "close ") + std::to_string(index + 1)); + } else if (ev.eventType == vr::VREvent_MouseButtonUp) { + EndDragsBy(ev.trackedDeviceIndex); + ReleaseAwayBy(ev.trackedDeviceIndex, ev.data.mouse.button, handle, data); + } + } + } // The roll button: drag around like a knob, or scroll. while (vr::VROverlay()->PollNextOverlayEvent(s.rollButton, &ev, sizeof ev)) { hover(2); @@ -1446,8 +1745,18 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { // ~100 ms (it landed on something else), KWin gets it anyway // state -> "ok // " +// Floating windows (from ft-floatd; is the spare output's number, after the screens): +// float the window's rectangle in the +// buffer and its title bar's height (pixels); shows the panel +// unfloat <screen> hides it +// pose <screen> <12 numbers> its place in the room (rows of a 3x4, standing universe) +// sub <screen> <k> <x> <y> <w> <h> | sub <screen> <k> off popup or dialog k over it +// minimized <screen> 0|1 +// carry <screen> the window's own title bar was pressed (an app that draws its +// own): carry the panel with the pressing laser until the release // (size <screen> <w> <h> and key <code> <value> are handled in compositor.c.) Screens are -// numbered from 1 here, like everywhere the user sees them. +// numbered from 1 here, like everywhere the user sees them. "screens" and "all" leave out +// floating windows. void ft_vr_command(const char *cmd, char *reply, int size) { if (!g_vr) return (void)std::snprintf(reply, size, "error no SteamVR (--no-vr)"); RefreshPoses(); @@ -1457,7 +1766,8 @@ void ft_vr_command(const char *cmd, char *reply, int size) { float r[12]; auto each = [&](const char *which, auto fn) -> bool { // "all" or a screen number if (std::strcmp(which, "all") == 0) { - for (auto &[i, s] : g_screens) fn(s); + for (auto &[i, s] : g_screens) + if (!s.floating) fn(s); return true; } Screen *s = Find(std::atoi(which)); @@ -1528,9 +1838,10 @@ void ft_vr_command(const char *cmd, char *reply, int size) { for (int k = 0; k < 12 && len < size; ++k) len += std::snprintf(reply + len, size - len, " %.5f", s->pinRel.m[k / 4][k % 4]); } else if (std::strncmp(cmd, "screens", 7) == 0) { - int len = std::snprintf(reply, size, "ok %zu", g_screens.size()); + const size_t count = std::count_if(g_screens.begin(), g_screens.end(), [](auto &e) { return !e.second.floating; }); + int len = std::snprintf(reply, size, "ok %zu", count); for (auto &[i, s] : g_screens) - if (len < size) + if (len < size && !s.floating) len += std::snprintf(reply + len, size - len, " %d:%dx%d:%.3f", i + 1, s.width, s.height, s.metres); } else if (std::strncmp(cmd, "head", 4) == 0) { Mat m; @@ -1576,6 +1887,48 @@ void ft_vr_command(const char *cmd, char *reply, int size) { else return (void)std::snprintf(reply, size, "error modes: always outside_games dashboard"); UpdateLasers(); std::snprintf(reply, size, "ok %s", LasersName()); + } else if (int x0, y0, w0, h0, t0; std::sscanf(cmd, "float %d %lf %d %d %d %d %d", &n, &w, &x0, &y0, &w0, &h0, &t0) == 7) { + Screen *s = Find(n); + if (!s || !s->floating) return (void)std::snprintf(reply, size, "error no floating window panel %d", n); + if (!(w > 1e-5 && w < 0.01) || w0 < 1 || h0 < 1) return (void)std::snprintf(reply, size, "error bad float"); + SetFloat(*s, w, x0, y0, w0, h0, std::max(0, t0)); + std::snprintf(reply, size, "ok"); + } else if (std::sscanf(cmd, "unfloat %d", &n) == 1) { + Screen *s = Find(n); + if (!s || !s->floating) return (void)std::snprintf(reply, size, "error no floating window panel %d", n); + Unfloat(*s); + UpdateVisibility(); + std::snprintf(reply, size, "ok"); + } else if (std::sscanf(cmd, "pose %d %f %f %f %f %f %f %f %f %f %f %f %f", &n, &r[0], &r[1], &r[2], &r[3], &r[4], + &r[5], &r[6], &r[7], &r[8], &r[9], &r[10], &r[11]) == 13) { + Screen *s = Find(n); + if (!s) return (void)std::snprintf(reply, size, "error no screen %d", n); + Mat m{}; + for (int k = 0; k < 12; ++k) m.m[k / 4][k % 4] = r[k]; + EndDrag(*s); + SetAbsolute(*s, m); + std::snprintf(reply, size, "ok"); + } else if (int k0; std::sscanf(cmd, "sub %d %d %d %d %d %d", &n, &k0, &x0, &y0, &w0, &h0) == 6 || + (std::sscanf(cmd, "sub %d %d %15s", &n, &k0, word) == 3 && !std::strcmp(word, "off"))) { + Screen *s = Find(n); + if (!s || !s->floating) return (void)std::snprintf(reply, size, "error no floating window panel %d", n); + if (std::strstr(cmd, " off")) w0 = h0 = 0; + SetSub(*s, n - 1, k0, x0, y0, w0, h0); + std::snprintf(reply, size, "ok"); + } else if (int on; std::sscanf(cmd, "minimized %d %d", &n, &on) == 2) { + Screen *s = Find(n); + if (!s || !s->floating) return (void)std::snprintf(reply, size, "error no floating window panel %d", n); + s->minimized = on != 0; + UpdateVisibility(); + std::snprintf(reply, size, "ok"); + } else if (std::sscanf(cmd, "carry %d", &n) == 1) { + Screen *s = Find(n); + if (!s || !s->floating) return (void)std::snprintf(reply, size, "error no floating window panel %d", n); + if (!(g_press.buttons & ButtonBit(BTN_LEFT)) || g_press.screen != n - 1 || s->drag != Drag::None) + return (void)std::snprintf(reply, size, "error not pressed there"); + s->titleCarry = true, s->carryX = g_press.x, s->carryY = g_press.y; + StartDrag(*s, Drag::Move, g_press.device); + std::snprintf(reply, size, "ok"); } else if (std::strcmp(cmd, "up") == 0) { if ((g_press.buttons & ButtonBit(BTN_LEFT)) && g_press.device != kNone && !IsHandController(g_press.device)) g_press.upAt = g_tick + 9; diff --git a/screens/vr.h b/screens/vr.h index 876fc1e..7b32186 100644 --- a/screens/vr.h +++ b/screens/vr.h @@ -37,6 +37,10 @@ bool ft_vr_screens_shown(void); // A panel for screen `index`, width in metres, placed in a row in front of the head. void ft_vr_screen_create(int index, double metres, int count); void ft_vr_screen_destroy(int index); +// A spare output's panel, for floating windows (slot numbers from 1): hidden until +// ft-floatd floats a window on it and KWin has the output turned on. +void ft_vr_float_create(int index, int slot); +void ft_vr_float_output(int index, bool on); // Show a client buffer (identified by `key`) on the screen's panel. False if SteamVR // can't import it. bool ft_vr_screen_present(int index, const void *key, const struct ft_dmabuf *buf); diff --git a/session/frametop-session.sh b/session/frametop-session.sh index 068cbee..813771c 100755 --- a/session/frametop-session.sh +++ b/session/frametop-session.sh @@ -30,7 +30,7 @@ for var in $(compgen -e); do done conf=$HOME/.config/frametop.conf -BACKEND=screens SCREENS=2 WIDTH=1920 HEIGHT=1080 PHYS_WIDTH=1.6 REMOTE=0 +BACKEND=screens SCREENS=2 WIDTH=1920 HEIGHT=1080 PHYS_WIDTH=1.6 REMOTE=0 FLOAT_SLOTS=8 FLOAT_MARGIN=300 # shellcheck disable=SC1090 [ -f "$conf" ] && . "$conf" backend=${FT_BACKEND:-$BACKEND} @@ -39,6 +39,13 @@ width=${FT_WIDTH:-$WIDTH} height=${FT_HEIGHT:-$HEIGHT} phys_width=${FT_PHYS_WIDTH:-$PHYS_WIDTH} remote=${FT_REMOTE:-$REMOTE} +# Floating windows (screens backend): KWin gets this many spare outputs after the screens, +# and ft-floatd floats a window on each (docs/floating-windows.md). Changing it takes a +# desktop restart. +float_slots=${FT_FLOAT_SLOTS:-$FLOAT_SLOTS} +[[ $float_slots =~ ^[0-9]+$ ]] || float_slots=8 +[ "$float_slots" -le 16 ] || float_slots=16 +[ "$backend" = screens ] || float_slots=0 if [ "$backend" = gamescope ] && [ $((width * height)) -gt $((1920 * 1080)) ]; then # gamescope's VR backend aborts above 1920x1080 worth of pixels (its upload buffer; # see docs/design.md). Shrink a bigger size to fit, keeping its shape. @@ -80,10 +87,10 @@ if [ "${1:-}" != --inner ]; then # Plasma stay on the host and connect to its socket. socket=ft-screens-0 read -ra screen_args <<< "$("$here/../layout/ft-layout" screen-args)" - export FT_SCREEN_COUNT=$(( ${#screen_args[@]} / 2 )) + export FT_SCREEN_COUNT=$(( ${#screen_args[@]} / 2 )) FT_FLOAT_SLOTS=$float_slots "$here/../scripts/container-up.sh" # not owned by this desktop, or stopping it would stop the container "$HOME/.local/bin/distrobox" enter dev -- "$here/../screens/build/ft-screens" --socket "$socket" \ - "${screen_args[@]}" > /tmp/frametop-screens.log 2>&1 < /dev/null & + "${screen_args[@]}" --spares "$float_slots" > /tmp/frametop-screens.log 2>&1 < /dev/null & stop_screens() { pkill -x ft-screens 2>/dev/null || true; } trap stop_screens EXIT for _ in $(seq 100); do [ -S "$XDG_RUNTIME_DIR/$socket" ] && break; sleep 0.2; done @@ -135,7 +142,7 @@ fi # With ft-screens the size is only the starting one: ft-screens sets each screen's own. cat > "$runtime/bin/kwin_wayland_wrapper" <<EOF #!/bin/sh -exec /usr/bin/kwin_wayland_wrapper --width $width --height $height --output-count $screens --no-lockscreen "\$@" +exec /usr/bin/kwin_wayland_wrapper --width $width --height $height --output-count $((screens + float_slots)) --no-lockscreen "\$@" EOF chmod +x "$runtime/bin/kwin_wayland_wrapper" export PATH=$runtime/bin:$PATH @@ -163,4 +170,21 @@ if [ "$remote" = 1 ]; then "$here/vnc-bridge.sh" "$width" "$height" > /tmp/frametop-vnc.log 2>&1 & fi +# ft-floatd (floating windows) runs inside the Plasma session, on its D-Bus: started from +# the session's autostart, which only this desktop reads (XDG_CONFIG_HOME above). +autostart=$XDG_CONFIG_HOME/autostart/frametop-floatd.desktop +if [ "$float_slots" -gt 0 ]; then + mkdir -p "$(dirname "$autostart")" + cat > "$autostart" <<EOF +[Desktop Entry] +Type=Application +Name=Frametop floating windows +Exec=sh -c 'exec "$here/../float/ft-floatd" --screens $screens --slots $float_slots > /tmp/frametop-floatd.log 2>&1' +X-KDE-autostart-phase=2 +NoDisplay=true +EOF +else + rm -f "$autostart" +fi + dbus-run-session startplasma-wayland diff --git a/session/frametop.conf.example b/session/frametop.conf.example index 3ea46cb..194d4eb 100644 --- a/session/frametop.conf.example +++ b/session/frametop.conf.example @@ -7,6 +7,8 @@ WIDTH=1920 # gamescope: pixels per screen (at most 1920x1080 worth) HEIGHT=1080 PHYS_WIDTH=1.6 # gamescope: panel width in metres, docked REMOTE=0 # 1 = serve the desktop over VNC on the tailnet (port 5900; see README) +FLOAT_SLOTS=8 # screens backend: how many windows can float in VR at once (spare outputs; 0 turns it off; restart the desktop after a change) +FLOAT_MARGIN=300 # floating windows: pixels around each window on its output, so menus have room past its edges POINTER=0 # 1 = the mouse drives the universal 3D pointer (ft_pointer driver) instead of a plain mouse POINTER_SENSITIVITY=0.03 # degrees per mouse count POINTER_IDLE=30 # seconds without mouse use before the controllers get their laser back