cmux NEXT: a drag during a spring takes over where the edge shows; shared edges never open (FRAME OWNER S2B)
FRAME OWNER REFACTOR S2B. A drag that starts while panes still spring (a key resize, a release, a column growing) takes over where its edge shows. Two panes that share an edge show it at one place in every frame, the press moves nothing, the pane follows the pointer 1:1, and no pane shows a negative size. Before, the shell showed between neighbours (up to 92 pt) or they overlapped (up to 80 pt), and a regrabbed divider kept sliding under a still pointer.
Root cause
During a pointer gesture, ScreenContentView.place snapped a whole frame (all four edges) whenever the drag retargeted it, and a drag measured the pointer against the rest layout. If a spring still moved a neighbour, the dragged column's panes jumped to rest while the neighbour stayed on its spring. A release fling wrote velocity to whole frames.
Fix: rule C (lead decision; the motion rule "an animation is interruptible from its current value")
Behind motion.frameOwner.sharedEdges (Debug, default on), latched when a drag starts. With the switch off, the old path is unchanged.
- The press (
DividerDrag.catchShown): the drag's own value (the split's ratio, the column's width) is set to where its edge shows from its anchor. The anchor is the nearest edge among the panes the layout puts on the other side. The value is written in the drag's own transaction, and the window syncs it in one pass in which every frame keeps where it shows and its velocity. Each edge then gets its share of the change of speed that stops the dragged line against its anchor (SharedEdgeMotion.Influence: how far the edge moves when the value moves, from an unrounded probe layout), so shared edges always get the same change. The drag then starts from that layout. - Each move (
SharedEdgeMotion.place): each component of a retargeted frame moves by its target's change and keeps its velocity. A component at rest lands on its target exactly, so a drag with no spring places frames as before. - The release (
SplitMotion.seed): a fling seeds only the edges the release moved. A frame that is new in that pass keeps the motion it rides. - Cancel (a click, Escape): the unconfirmed release the catch replaced is sent again in a new transaction. A modifier mode that stops setting the caught key puts it back at the caught value.
- Fallback to the old drag (whole retargeted frames snap to rest, as shipped today):
- the press finds no anchor frame, or its probes cannot move the line;
- the caught value, laid out on the screen's own pixel grid, does not put the line within 0.5 pt of where it shows (a value the layout clamps);
- any pane's width or height spring, followed along its whole path, could go further under its rest size than a pane's minimum (any drag mode can shrink a rest size only to that minimum).
- Row-edge drags: no press catch. They take the old drag while the screen moves.
Changelog
- Fixed: dragging a divider or a column edge while panes still animate no longer opens a gap or an overlap between neighbouring panes, and the pane follows the pointer from where it shows.
Tests
SharedEdgeDuringSpringTests (new, 10 cases, checked at every 120 Hz frame tick). Run on the fleet:
cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg=Packages/macOS/CmuxNext --env CMUX_SWIFT_TEST_SUITES=SharedEdgeDuringSpringTests
The checks in every case:
- shared pane edges, and the split divider against b1's edge, stay within 0.5 pt of their rest gap;
- no spring shows a negative size;
- the press moves no frame, and a still pointer holds the dragged edge;
- the fling seed is set, and a new pane keeps its neighbour's speed;
- every frame lands at rest on its target.
The red proof is the tests-only branch scratch/s2b-red4 (the feat tip + the test file only), so the commit order on this branch is not red-first.
- Red at 9b2cc36d3aa (step c19136345aa4c31e07759827): 7 of 10 fail.
clickDuringReleaseSpring,releaseSpringThenKeyPressandshrinkColumnDuringInnerSpringpass on feat too; they are guard cases for the rule. - The new-pane case grows A by width, because a key press trades width between A and B and does not move C.
- Probe without the negative-size guard (
scratch/s2b-probe-noguardb6dbb1e, step b62adaeed572bb2da01ba796):shrinkColumnDuringInnerSpringfails (b1 at -105 pt, edges open by about 100 pt). - Green at 3fbd40a34c1:
- step 5aedc96a4d4d1f8a75d8a0c4: 28/28 (SharedEdgeDuringSpringTests 10/10, DividerDragFollowsPointerTests, and the 26 S2 suites);
- step 4d571a35206f3b81c5f57f34: 32/32 extra layout suites.
Review
Codex (gpt-6.1-sol, no tools; the pack held the full diff, the changed files and the model, sync, reconcile and spring code it calls):
- Reviews 1 to 6 said FIX. Their blockers were the shift rule's spring leftovers, the catch edge selection, velocity-only catches, a missing-anchor fallback, stale override restore, negative sizes in other drag modes, and clamped catches. Each is fixed.
- Review 7:
Review (codex): SHIP at 3fbd40a34c1e33c4b40e81d78e8b7e3ac1480fca. - Not done here, from its should-fix list: a fractional-clamp test, vertical and dock-edge catch cases, and a comment that still says a clamped catch springs to the nearest value. These go to the switch-delete PR, so this head and its films stay valid.
Bench (switch OFF vs ON, same host, interleaved OFF/ON/OFF/ON)
No regression (pixel-perfect). cmux22, LayoutDragBench + PaneMotionBench, CMUX_DRAG_BENCH_ROUNDS=5, runs OFF, ON, OFF, ON (OFF: scratch/pixel-perfect-s2b-off-3fbd40a 81642f12177, only the switch default false). Per-frame main-thread ms:
| case | p50 OFF | p50 ON | p95 OFF | p95 ON | max OFF | max ON |
|---|---|---|---|---|---|---|
| drag column | 0.220-0.221 | 0.217 / 0.217 | 0.231-0.232 | 0.234 / 0.227 | 0.52 | 0.88 |
| drag split | 0.212 | 0.212 / 0.213 | 0.220-0.223 | 0.221 / 0.224 | 1.31 | 1.32 |
- Five pane cases (close x2, splitDown x8, zoom x2, zoom x30, zoom x8) were outside the OFF spread in the first ON run only (job 08b285d1). The second ON run (job 7a2839f2) is inside the spread or its 5 % band in each, for example zoom x30 p95 0.463 then 0.355 (OFF 0.332-0.345). All p50s are inside. Outside a drag, S2B adds only a constant guard per place call.
- Every case is under 0.65 ms p95. The only max over 4 ms is unzoom x30, in both arms (OFF 5.22, ON 6.11).
- Table: /work/tmp/pixel-perfect/logs/s2bd-bench-compare.txt.
Films
GREEN for S2B's scope (pixel-perfect). Release build 9453c297 at 3fbd40a34c1, cmux22, terminal-only layouts at x0.125, OFF then ON in one launch, judge_pixel 3d+C f6c2ef7e895. Both presses grabbed the edge (column drag travel 170-171.5 pt, split drag 120 pt; 20-21 pt of the key spring left at the mouse-down).
| OFF | ON | |
|---|---|---|
| p0|p1 divider off the middle of its panes' gap (trace samples, max) | column 84 (15 px), split 85 (15.5 px) | 0, 0 |
| Shell past rest on p0's right and p1's left edges | up to 16 px | none (split drag: 2 px on two edges) |
| p0's right content edge off | 121-123 frames (8 px; rings hidden 25 frames, settle 26) | 3-4 frames (2 px) |
| Clock fit (3d) | split film +1 refresh late; column 0 | 0 |
Frames notes (not blockers):
- Dragged pane (terminal): OFF shows the content at its old size for about half the drag (57 of 111 drag frames: the old drag path's hold); ON keeps the content at the moving edge in every frame (175 of 175), within 10 px (its own size steps), with no shell gap. Measured against p1's own tab strip right edge in the same frame (pixel-perfect's correction: the first count missed the frames where OFF's content was not at the edge at all).
- In both arms, the panes the key moves keep the key-resize content hold through the drag (K3/S3), so their terminal content leads or trails the moving edge by 3-15 px in 290-440 frames. That is m6 1a (S4 changes the key-resize path), not S2B. With ON the hold goes on into the drag instead of the OFF snap.
- The first round with Chromium-page layouts (ab-s2b-sw-3fbd40a) gave no verdict: the page window beside the moving edge took the mouse-down (a PL-motion-cef defect, reported in STATUS), so the films use terminal-only layouts with the same geometry.
Judge reports: /work/tmp/pixel-perfect/judge/s2b/ (s2bt-col-, s2bt-split-).
Observed / Not run
- Observed: the fleet runs above (red, probe and green step ids), the prepush gate, check-no-godfiles (no god type grew) and check-motion.
- Not run or not covered:
- catch tests for a vertical split and docked column edges (the same rule on the other axis or edge);
- pressing during a structural change;
- a minimum smaller than the style minimum (tiny windows).
- Pre-existing and unchanged: the screen's step stops on positions, not on velocities.
No capture: the change is frame timing during motion; the films from the refactor owner are the evidence.