diff --git a/docs/README/FT_FLOATD.md b/docs/README/FT_FLOATD.md index dd6794e..d1f0880 100644 --- a/docs/README/FT_FLOATD.md +++ b/docs/README/FT_FLOATD.md @@ -10,9 +10,10 @@ Daemon for Frametop floating windows. Runs inside the nested Plasma session and - `screens/compositor.c`: `--spares N`, spare toplevels call `ft_vr_float_create` / `ft_vr_float_output`; Meta+scroll on a spare sends `scale` to `@frametop_float`. Cross-panel drag/drop: on a focus change, enter is at `(x+1,y)` so the following motion at `(x,y)` is not dropped by wlroots (KWin nested otherwise keeps the old output's pointer). - `screens/vr.cpp`: overlays `frametop.float.N` (+ `.bar` / `.dock` / `.close` / `.sub.K`); control commands `float`, `unfloat`, `pose`, `sub`, `minimized`, `carry`. FocusLeave while a button is held does not clear KWin pointer; the catcher covers gaps; `up` is the helper's release backstop. -- `pointer/helper/ft-pointer.cpp`: `FramePanel()` + held-drag retarget across `frametop.screen.*` / `frametop.float.*` (not `.bar`), and `up` on left release. +- `pointer/helper/ft-pointer.cpp`: `FramePanel()` + held-drag retarget across `frametop.screen.*` / `frametop.float.*` (not `.bar`), and `up` on left release. Soft-follow pose noise must not clear `pressKey` (only ~5 cm of origin motion counts as a carry). - `layout/ft_layout.py` `outputs()`: drops `WL-*` at and after the configured screen count so layout never rearranges spares. - Session / Display Settings: `FLOAT_SLOTS`, `FLOAT_MARGIN` in `~/.config/frametop.conf` (see `docs/floating-windows.md`). +- Debug: `FT_DND_DEBUG=1` on ft-screens logs `dnd press|retarget|leave-suppressed|release-away` transitions. ## Related diff --git a/docs/README/FT_POINTER.md b/docs/README/FT_POINTER.md index b098de1..df588ac 100644 --- a/docs/README/FT_POINTER.md +++ b/docs/README/FT_POINTER.md @@ -10,7 +10,7 @@ Script / module: `pointer/helper/ft-pointer.cpp` (user service via `pointer/help `FramePanel()` treats `frametop.screen.N`, `frametop.float.N`, and `frametop.float.N.sub.K` as desktop panels the laser can retarget across while a press is held and the pressed overlay has not moved. Left release sends `up` to `@ft_screens` as a backstop for the catcher. `POINTER_IGNORE` still cannot drop any `frametop.*` key (Phase 2 hard guard). -Cross-panel DnD also needs ft-screens to hand KWin pointer focus to the destination on enter (`x+1` then motion); see [FT_FLOATD.md](FT_FLOATD.md). +Cross-panel DnD also needs ft-screens to hand KWin pointer focus to the destination on enter (`x+1` then motion); see [FT_FLOATD.md](FT_FLOATD.md). Soft-follow must not clear the helper's `pressKey` (carry threshold is ~5 cm of panel origin motion). Set `FT_DND_DEBUG=1` on ft-screens for transition logs (`dnd press|retarget|leave-suppressed|…`). ## Related diff --git a/pointer/helper/ft-pointer.cpp b/pointer/helper/ft-pointer.cpp index 3de5367..98cd4f0 100644 --- a/pointer/helper/ft-pointer.cpp +++ b/pointer/helper/ft-pointer.cpp @@ -1403,21 +1403,28 @@ int main() { } } - // While left is held on a still FramePanel, let the locked ray retarget across other - // FramePanels (floating windows / their popups). Once that panel moves, the lock holds. + // While left is held on a FramePanel that isn't being carried, retarget the locked + // ray onto other FramePanels (screen↔float DnD). Soft-follow / yaw-follow nudge the + // pose every frame; treat only a real carry (~5 cm of origin motion) as "moved", + // not the 0.001 matrix noise that would clear pressKey immediately and freeze the + // drag behind the destination. 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) { + const float dx = now.m[0][3] - pressPose.m[0][3], dy = now.m[1][3] - pressPose.m[1][3], + dz = now.m[2][3] - pressPose.m[2][3]; + still = (dx * dx + dy * dy + dz * dz) < 0.05f * 0.05f; + } if (still) { Hit h; for (const auto &[key, handle] : handles) { - if (!visible[key] || !FramePanel(key)) continue; + // Always test FramePanels: IsOverlayVisible across apps can flicker + // false and would otherwise freeze dragDistance on the source panel. + if (!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)}; diff --git a/screens/compositor.c b/screens/compositor.c index bb44f1b..76fcc29 100644 --- a/screens/compositor.c +++ b/screens/compositor.c @@ -311,6 +311,10 @@ static void handle_vr_event(const struct ft_event *e, void *data) { // pointer until the next move: a drag or press right after crossing onto // another screen or floating panel would stay on the previous output. Entering // one unit off makes the motion below go through (cross-panel DnD). + // Clear first so the destination surface gets a real leave→enter around a + // held button (Wayland DnD focus follows the seat). + if (s->pointer_focus) + wlr_seat_pointer_notify_clear_focus(s->seat); wlr_seat_pointer_notify_enter(s->seat, surface, x + 1, y); s->pointer_focus = sc; } diff --git a/screens/vr.cpp b/screens/vr.cpp index d3ec5b0..c4375bc 100644 --- a/screens/vr.cpp +++ b/screens/vr.cpp @@ -2330,6 +2330,20 @@ struct Press { Press g_press; vr::VROverlayHandle_t g_catcher = vr::k_ulOverlayHandleInvalid; bool g_catcherShown = false; +int g_dndLogScreen = -1; // last screen logged while a button was held (FT_DND_DEBUG) + +static bool DndDebug() { + static int on = -1; + if (on < 0) on = std::getenv("FT_DND_DEBUG") && std::getenv("FT_DND_DEBUG")[0] == '1' ? 1 : 0; + return on == 1; +} + +static void DndLog(const char *msg, int screen = -1) { + if (!DndDebug()) return; + if (screen >= 0) std::printf("dnd %s screen=%d\n", msg, screen + 1); + else std::printf("dnd %s\n", msg); + std::fflush(stdout); +} uint32_t ButtonBit(uint32_t linuxButton) { return 1u << (linuxButton - BTN_LEFT); } @@ -2337,6 +2351,8 @@ void PressDown(vr::TrackedDeviceIndex_t dev, uint32_t button, int screen, double if (!g_press.buttons) g_press.device = dev; g_press.buttons |= ButtonBit(button); g_press.screen = screen, g_press.x = x, g_press.y = y; + DndLog("press", screen); + g_dndLogScreen = screen; } void ReleaseAway(uint32_t button, void (*handle)(const struct ft_event *, void *), void *data) { @@ -2352,6 +2368,7 @@ void ReleaseAway(uint32_t button, void (*handle)(const struct ft_event *, void * e.x = g_press.x, e.y = g_press.y; handle(&e, data); std::printf("caught a release off the screens (button %u)\n", button); + DndLog("release-away", g_press.screen); if (g_press.buttons) return; e = ft_event{}; e.type = FT_LEAVE; @@ -2383,19 +2400,25 @@ void UpdateCatcher() { vr::VROverlayIntersectionParams_t params{}; params.eOrigin = vr::TrackingUniverseStanding; for (int k = 0; k < 3; ++k) params.vSource.v[k] = l.m[k][3], params.vDirection.v[k] = -l.m[k][2]; + // Nearest hit among our panels (map order alone can keep a farther source and leave the + // catcher thinking we're still on it while a nearer destination is under the laser). + double best = 1e9; for (auto &[i, s] : g_screens) { if (!s.visible) continue; std::vector parts = s.All(); for (const auto &[k, sub] : s.subs) parts.push_back(sub.overlay); for (auto o : parts) { vr::VROverlayIntersectionResults_t 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; - } + if (o != vr::k_ulOverlayHandleInvalid && vr::VROverlay()->ComputeOverlayIntersection(o, ¶ms, &hit) && + hit.fDistance > 0.05f && hit.fDistance < best) + best = hit.fDistance; } } + if (best < 1e8) { + g_press.distance = std::max(0.05, best); + ShowCatcher(false); + return; + } const double d = g_press.distance; const double pt[3] = {l.m[0][3] - l.m[0][2] * d, l.m[1][3] - l.m[1][2] * d, l.m[2][3] - l.m[2][2] * d}; const Mat m = FacingPose(pt, l); @@ -4708,7 +4731,13 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) { ft_buffer_to_seat(buf_x, buf_y, s.width, s.height, surf_w, surf_h, s.outputScale, &sx, &sy); if (ev.eventType == vr::VREvent_MouseMove) { e.type = FT_MOTION; - if (g_press.buttons) g_press.screen = index, g_press.x = sx, g_press.y = sy; + if (g_press.buttons) { + if (g_dndLogScreen != index) { + DndLog("retarget", index); + g_dndLogScreen = index; + } + g_press.screen = index, g_press.x = sx, g_press.y = sy; + } } else { if (ev.eventType == vr::VREvent_MouseButtonUp) EndDragsBy(ev.trackedDeviceIndex); e.type = FT_BUTTON; @@ -4738,9 +4767,13 @@ 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) return; // keep KWin pointer while a button is held + if (g_press.buttons) { + DndLog("leave-suppressed", index); + return; // keep KWin pointer while a button is held + } if (sub) return; // off a popup is usually onto its window e.type = FT_LEAVE; + DndLog("leave", index); break; default: return; diff --git a/test/test_float_cross_panel_dnd.py b/test/test_float_cross_panel_dnd.py index c1148a7..3874306 100644 --- a/test/test_float_cross_panel_dnd.py +++ b/test/test_float_cross_panel_dnd.py @@ -61,6 +61,22 @@ class HeldDragRetarget(unittest.TestCase): self.assertIn('SendTo(out, "ft_screens", "up")', p) # Retarget updates locked distance onto another FramePanel self.assertIn("dragDistance = h.along, lastHit = h.key", p) + # Soft-follow must not clear pressKey on 0.001 matrix noise (5 cm carry threshold) + self.assertIn("0.05f * 0.05f", p) + self.assertNotIn("0.001f) still = false", p) + + def test_catcher_uses_nearest_hit(self): + vr = read("screens/vr.cpp") + i = vr.find("void UpdateCatcher(") + body = vr[i : i + 1200] + self.assertIn("best < 1e8", body) + self.assertIn("hit.fDistance < best", body) + + def test_compositor_clears_before_cross_enter(self): + c = read("screens/compositor.c") + block = c[c.find("case FT_MOTION:") : c.find("case FT_SCROLL:")] + self.assertIn("notify_clear_focus", block) + self.assertIn("x + 1", block) def test_pointer_ignore_guard_intact(self): p = read("pointer/helper/ft-pointer.cpp") @@ -70,17 +86,17 @@ class HeldDragRetarget(unittest.TestCase): class FocusHandoff(unittest.TestCase): def test_leave_suppressed_while_held(self): vr = read("screens/vr.cpp") - self.assertIn("case vr::VREvent_FocusLeave:", vr) - # Both comment variants acceptable - self.assertTrue( - "keep KWin pointer while a button is held" in vr - or "KWin keeps the pointer while a button is held" in vr - ) - i = vr.find("case vr::VREvent_FocusLeave:") - body = vr[i : i + 350] - self.assertIn("g_press.buttons", body) - self.assertIn("return", body) - self.assertIn("FT_LEAVE", body) + self.assertIn('DndLog("leave-suppressed"', vr) + self.assertIn("e.type = FT_LEAVE", vr) + # leave-suppressed only runs inside the g_press.buttons branch + i = vr.find('DndLog("leave-suppressed"') + self.assertGreater(i, 0) + self.assertIn("g_press.buttons", vr[max(0, i - 120) : i + 80]) + + def test_motion_updates_press_screen(self): + vr = read("screens/vr.cpp") + self.assertIn("g_press.screen = index, g_press.x = sx, g_press.y = sy", vr) + self.assertIn('DndLog("retarget"', vr) def test_enter_offset_so_motion_not_dropped(self): """wlroots drops motion to the enter position; enter must be one unit off.""" @@ -94,10 +110,6 @@ class FocusHandoff(unittest.TestCase): r"const double x = e->x / s->scale\[e->screen\]", ) - def test_motion_updates_press_screen(self): - vr = read("screens/vr.cpp") - self.assertIn("if (g_press.buttons) g_press.screen = index", vr) - def test_catcher_and_up_backstop(self): vr = read("screens/vr.cpp") self.assertIn("void UpdateCatcher(", vr)