feat: drop dragged windows at the gap under the pointer on any display

Closed
QE
1d ago
+460-22

Closes #95

Stacked on #150. Review this diff against feat/window-proxy-ribbon.

Why

#150 already moves single windows by drag. Its overlay previews the drop and commits it on release. That covers extraction from a stack, column moves and stacking. Two gaps remain from the first version of this PR:

  • A drop on another display goes through the native cross-display move. The window lands where that move puts it, not at the gap under the pointer.
  • A pointer resting on a target boundary can flip the target back and forth. Each flip restarts the overlay springs.

What changed

  • MouseDropTarget gains .display(MonitorID, workspaceID:gap:). When the pointer is on another display, mouseDropTarget returns the gap under the pointer in that display's active workspace. The gap counts the columns whose center is left of the pointer.
  • applyMouseDrop handles .display. It revalidates the target first: the window is still tiled, the destination workspace is still active, and the gap is in range. Otherwise it changes nothing. The move reuses moveFocusedSelection, so transient windows and width conversion behave as for keyboard moves. The gap is anchored to the column after it, so transient columns that move along do not shift it.
  • mouseDropTarget takes the previous target. It keeps that target while the pointer is within 24 pt of it.
  • The daemon passes the previewed target and the monitor frames. The cross-display bookkeeping (active monitor, native focus monitor, scroll anchor) now runs after an overlay drop too, not only after a native one.
  • The daemon traces each preview change as mouse-drop-preview window=<id> target=<target>.
  • New verify-defi journey: .agents/skills/verify-defi/drag_check.py and features/window-drag.md. They drive real title-bar drags with cliclick, check column membership before, during and after, and read the previewed target from defi trace.

Scope

  • Only the overlay path changed (animation.window_proxies = true, the default). The live reorder for window_proxies = false is untouched.
  • In-stack row reorders and pointers off every display still go to #150's native drop path.
  • The overlay carries a window only on a single display (from #150). So a cross-display drag has no preview. The drop commits on release.
  • Not in this PR: edge scrolling to offscreen columns (#97).

Tradeoffs

  • The first version of this PR had its own target model and a native insertion bar. Both are gone. #150's overlay shows the whole layout the drop would produce, with its own borders, and #150's target model stays.
  • The hysteresis evaluates the target up to twice more, and only when the target would change. A region narrower than 48 pt can hold its target a little longer.
  • Stacking stays limited to the source display.

Blast Radius

  • Sources/DefiRuntime/MouseReordering.swift: the new target case, the hysteresis and the cross-display drop. Pure and covered by MouseReorderingTests.
  • Sources/DefiDaemon/DaemonDesktopSynchronization.swift: argument changes in the overlay drop branch, one trace line, and the cross-display bookkeeping moved after both drop branches. No new Accessibility writes. No per-frame main-thread work. The preview is still computed only when the target changes.

Verification

  • python3 script/verify.py local on the final head: passed (build, non-desktop Swift tests, workflow tests). One earlier staged run failed in FrameGeometryReadTests (a DefiMacOS timing test this branch does not touch). It passed alone, then the full staged run passed. MouseReorderingTests: 44 tests passed.
  • Desktop E2E on the final head (verify.py desktop, full DesktopE2ETests, Accessibility available, one display): 42 of 50 passed, 5 skipped, 3 failed. The failures were testManagedResizeAnimationConvergesWithAdaptiveSizeWrites, testOverviewNavigationRenderBudget and testPointerTransitionsUseWindowUnderPointerAndWarpDoesNotLoop. Other agents were sharing the desktop at the time. The first two passed when rerun alone. The pointer test skipped twice when rerun alone ("Focused on-screen managed window required"). It passed alone on the previous head of this branch, which had the same drop path. Session restoration passed.
  • Real native drags on the installed final build: drag_check.py passed all four cases. They are a short drag inside its own column, extracting the bottom of a 2-stack to the right, moving a single column left across two, and dragging out and back to the origin. Membership did not change while the button was held. The trace showed the expected previewed target for both real moves. One defi-daemon remained.
  • Cross-display drops: unit tests only. One display was connected.
  • Computer Use: not available in this session. Nobody has judged the overlay motion and borders during a drag by eye.

[!NOTE]

Add cross-display tiled-window drag drop targets and 24-point preview hysteresis

  • Dragging a tiled window onto another monitor now resolves to an insertion gap in that monitor's active workspace, using mouseDisplayDropTarget in MouseReordering.swift
  • The drop moves only the dragged window, preserves the destination column width, tracks an anchor window to keep the requested gap stable, and rejects stale, same-monitor, floating, fullscreen, or invalid targets without state mutation
  • Preview targets now use 24-point hysteresis to stay stable when the pointer rests near a boundary; the daemon records preview transitions and applies drops with physical monitor geometry in DaemonDesktopSynchronization.swift
  • Adds unit tests for two-monitor scenarios (gap selection, transient column shifts, empty workspace gap zero, stale-workspace rejection) and an end-to-end drag verification harness in drag_check.py plus feature docs
  • Behavioral Change: pointers over the source window's own stack now route to native row reordering instead of column targets, and active-monitor/native-focus updates run after all drag reorder paths, not just overlay ones

Macroscope summarized 5b648da.