cmux NEXT: a Command column-edge drag keeps the edge under the pointer; the strip camera no longer springs back after a change it cannot keep

Merged
LA
15h ago
+510-3

A Command drag of a column edge keeps the edge under the pointer, also for a column that is not the first. The strip camera no longer takes a shift it cannot keep, so it no longer springs back after a layout change.

pixel-perfect's decision in the #35775 review, "your proposal (1 + 2)": (a) the slide was refused (the edge slid 26 pt under a still pointer, which breaks rule D), and so was (b) (a lag, then a catch-up).

Why

  • Command (all columns), second column, strip that fits: on feat (step a73b39aa5294dcc2a520401b), one frame after a 30 pt move of c1's edge, the edge shows 31 pt from its start. Then it slides on under the still pointer: 33, 36, 39 ... 56 pt by frame 11.
    • Command gives every column the dragged column's width, measured from the dragged column's start. The columns before it grow too and move its left edge.
    • The camera keeps the dragged column in place (ScreenDividers.dragAnchorColumn): the anchor step adds the shift to the camera's value and target. In a strip that fits, the target is then clamped back to 0 while the value stays shifted, so the camera springs back.
  • Command, second column, strip that scrolls: the drag measured the pointer in strip space at the camera that the last move's anchor shift had already moved. So each move after the first added that shift to the width (in the test: 45 pt for a 30 pt move).
  • The same spring-back, for any anchored sync: when the new strip leaves the camera no room (a strip that fits, its ends), the value takes the whole anchor shift, the target is clamped, and the camera springs back. That covers a key trade in a fitting strip, a width preset and a window resize.

Fix

  1. Reducer (Swift ColumnScrollReducer and its 1:1 Rust port cmux-layout-reducer scroll/state.rs): the anchor step gives the camera only the shift its target can take in the new strip (anchorShift / anchor_shift). That is all of the shift while the shifted target stays in the strip, up to the strip's end otherwise, and never a shift the other way. The value and the target move together, so nothing springs back. A live trackpad gesture owns its target and still takes the whole shift.
  2. Drag (DividerDrag.columnTargets, new UniformEdge): with Command, the width that every column takes is solved so that the edge rests where the pointer puts it, with the camera the anchored sync will give it (free, at 0, or at the strip's end).
    • The pointer is measured on screen: strip space less the camera now.
    • Only a solution within the pointer's travel of the current width is taken. A further one is another branch, and taking it would jump every width.
    • In a strip with room, the width changes exactly as before.

Known limit

  • At a strip's end, Command-shrinking a column that is not the last moves its edge right, and the last column's edge stays pinned: the content holds it to the viewport's edge. No width can move it toward the pointer there, so the drag keeps the plain extent (Command's widths follow the pointer distance). UniformEdgeTests.atTheStripsEndTheLastColumnsEdgeCannotFollowAShrink covers it.

Changelog

  • Fixed: Command-dragging a column edge (every column takes its width) keeps the edge under the pointer, for every column, and the strip no longer slides back after the drag.

Frames

  • Frames: column edges and panes. Before, in a Command drag of a column that is not the first, the dragged edge showed 1 pt off the pointer and then slid on 26 pt under a still pointer, in a strip that fits. In a strip that scrolls, it ran ahead of the pointer by the camera's last shift on every move. After, the edge shows where the pointer puts it in every frame and stays still with the pointer.
  • Frames: the camera in any anchored layout change (a key trade, a width preset, a window resize) when the strip leaves it no room. Before, the strip showed the anchored column held and then slid it to its place. After, the column takes its place in the change's own frame, and the panes' own springs carry the motion (no extra camera spring).
  • No content stops following its pane.

Tests

Red first (9eec9acc691, tests only on feat), then the fix (667ef1ebb0c).

RunRefStepResult
red, Swift9eec9acc691(fleet, key cmd-fix-red-swift)ColumnScrollAnchorClampTests: a fitting strip, value 30 against target 0; the camera at the start, value -20 against target 0; the camera-with-room guard passes. CommandColumnEdgeFollowsPointerTests: fitting, "still frame 0: c1's right edge moved 31.0 pt, the pointer 30.0 pt" (and -31 for -30); scrolling, "after 30.0 pt frame 0: c1's right edge moved 45.0 pt, the pointer 30.0 pt". DividerModifierDragTests and the scroll suites pass.
red, Rust9eec9acc69137b4256e29766931e27c6e2d (cmux-tui-rust-check.sh test, Linux)anchor_a_strip_that_fits_gives_the_camera_no_shift_it_cannot_keep and anchor_a_camera_at_the_start_takes_only_the_shift_it_has_room_for FAIL; anchor_a_camera_with_room_keeps_the_anchor_in_place passes
green, Swift (first fix)667ef1ebb0ccc7fcb9e9e0f1a144afd802c35 suites pass
green, Rust (first fix)667ef1ebb0c1bfa270a3f214ee68c23b86dthe 3 anchor tests pass
red: the mid-spring camera (Codex round 1), test only24605bfbe2e2795b0253b63b97f64a9d98dcommandDraggingWhileTheCameraSpringsMeasuresThePointerWhereItShows: c0 grew 130 pt for the pointer's 30 pt (abs(width - (start + 30)) → 100.0)
red: a target that leaves the new strip (found on the fleet)307045f249eead43b60f386b89a3c322c02atTheStripsEndTheLastColumnsEdgeCannotFollowAShrink got 570 pt, not nil (the camera would spring back 30 pt)
final head, Swift25f458ccd4c96de797d60e0934791d8393535 suites pass: UniformEdgeTests 5/5, ColumnScrollAnchorClampTests 3/3, CommandColumnEdgeFollowsPointerTests 4/4, DividerModifierDragTests, the scroll suites, the SharedEdge suites (18/18 cases, 0 misses), DividerDragFollowsPointerTests and the 26 S2 suites; PaneMotionWorkTests skips (a bench)
final head, Rust25f458ccd4c88f43ef9c05e41b29f67e871 (cmux-tui-rust-check.sh test scroll::, Linux)62 tests run: 62 passed
final head, 32 more layout suites25f458ccd4cc084224bd9b40755a58aa8cd31 pass; OutOfSightFramesTests skips (it needs a GUI)
display fleet run (gui-session-v1 --display)25f458ccd4ceb85ccfb73f2e8ca7ab286e2ci-step cmd-fix3-display-25f458ccd4c-v1 exited 0: 16/16 suites, 70 tests, 0 skipped
red: the first Command frame with uneven widths at the strip's end (Codex round 3), drag level2e4b1d2c5d9 (scratch fix/command-column-edge-red5-nomerge = 054294ef137 + test d5966937dd2)125eb48192c39b039632d6e0the columns went from [960, 480, 960] to [450, 450, 450] pt; the edge moved -22 then slid to +260 pt under the still pointer (282 pt range)
green: keep every width (fix ad7d245d24e)d5966937dd22bc97ee116950c8519da8d43UniformEdgeTests, ColumnScrollAnchorClampTests, CommandColumnEdgeFollowsPointerTests, DividerModifierDragTests: 27 tests pass
red: Command-Shift skipped .keep while the pointer is down (Codex round 4)a4aee0b376a588818e5a6933342c43b8e6bthe shift: true case fails (columns changed, 282 pt slide); shift: false passes
final head, 10 suites94dd258abe253be22307ec0b96ddd5c2e2610 suites, 48 tests pass
final head, display fleet run94dd258abe2130fe632407fe8ecf48f4e3eexit 0: 16 suites, 75 tests
feat merged (ghostty-next on Zig 0.17, so the fleet takes the ref), 10 suites1b120756a698f24dcc551e12b3e07df99c410 suites, 48 tests pass, 0 failures
app build1b120756a69ace9844aca0a4d4e60c05023done (cmux DEV cmd-edge-v1)
launch smoke (fresh-home)build ace9844af14339eb3d1b5db1c55372f8the app launched: fresh-home launch smoke OK: up 30s, Home at 7.4s, no crash (validator at feat 4822eb5d7ea); the client's verdict is a base finding (below)
clippy fix e438f7fc97e (CI clippy, job 127690420744: unused_qualifications at scroll/state.rs:268; the PR's new use super::{swift_max, ...} made the older super::swift_max redundant; no behavior change)e438f7fc97e61a9c28a2becc1daeb9186e5 (cmux-tui-rust-check.sh clippy, Linux)exit 0
Rust scroll tests at the heade438f7fc97ea9873ee7cb083df0380e14de (cmux-tui-rust-check.sh test scroll::, Linux)62 tests run: 62 passed

Films

This PR changes every anchored camera sync, key trades too. pixel-perfect films the head against its base b5e96a15cd37 on cmux22 with the rule NO NEW RED (STATUS 13607), at 307045f249e (build eec4cd7c); only UniformEdge changed after it:

  • PASS, no new red. The Command film with real input (Command on every mouse-down): the base drifts -28.5 pt at the mouse-up, then slides 30.5 pt (it rests 59 pt off) at x1, and is 30 pt off under Reduce Motion. The head drifts 0.00 pt, does not move after the up, and rests at 0.00 pt, at both speeds.
  • KEYS and S2B journeys (single-3k, held-2, rk-3t, drag-3t, split-drag-2st, clamp-2x, clamp-chase-2x): equal or better. The one m14 "worse" film is a judge artifact (E5 against a ring hidden at rest), sent to R-HARNESS.
  • A real m14 failure in BOTH arms of the Command film, not from this PR: about 165-170 frames of shell beside a terminal after the mouse-up (the terminal drag hold's landing). It goes with the drags-ON PR (m14, owner frame-owner-s2).

Observed / Not run

  • Base finding, not this PR (the launch passed): the hq cmux-ci fresh-home client posts cmux-ci/fresh-home failure, "Home did not render in a visible window", on every fresh-home run since at least 00:09Z (receipts for #36906, #36901, #36962, #36017 and this PR, five Macs, four validator commits). The validator (scripts/ci/fresh_home_launch_smoke.py) has counted the first-run Bring your history page over Home as a healthy launch since #34475 and passes these runs, but the client re-checks report.home_tab, which is set only when Home's tab is selected, and the first-run page leaves it unselected. The screenshot shows the window with the sidebar, Home, the first-run page and the Command Palette tip.
  • Observed: the fleet runs above, the prepush gate, check-no-godfiles, check-motion and the package conventions lint.
  • Codex rounds (no tools; the code in the prompt), each FIX fixed red-first:
    1. 667ef1ebb0c: the solver used the camera target, not its value; a Rust doc comment and lint attribute moved off their function.
    2. 25f458ccd4c: a refused width still went through the plain-extent fallback (60 pt slide); the spring-back test refused a clamp that stops a camera mid-spring.
    3. 054294ef137: the first Command frame with uneven widths at the strip's end applied the plain extent (202 pt slide). Fix ad7d245d24e (UniformEdge.Target.keep: every width stays).
    4. d5966937dd2: with Shift held, the drag skipped .keep while the pointer was down. Fix 94dd258abe2: Shift snaps only at the release.
  • Disclosed: commit 7670cea515e holds the fix with its drag test (a staging error, stated in 054294ef137). The unit test in ad7d245d24e is no red proof: its scratch red branch mapped the old nil to .keep, and it passed on the old code (step 5f9c6f2b016696318b5a9141). The drag-level test d5966937dd2 is the red proof (step 125eb48192c39b039632d6e0).
  • Scratch red branches (never merged): fix/command-column-edge-red3-nomerge, fix/command-column-edge-red4-nomerge, fix/command-column-edge-red5-nomerge.
  • Films: the Command film at ad7d245d24e (round ab-cmdh4): PASS at x1 and Reduce Motion (drift 0.00 pt at the mouse-up, no move after it, rests 0.00 pt from the pointer). Its m14 terminal fails (226 frames, shell beside a terminal after the mouse-up) are the drag hold's landing, in both arms; #36328 removes the hold.
  • Rust: at the head e438f7fc97e, clippy passes and the 62 scroll tests pass (above). The head commit only drops a super:: path; the Swift suites, build and launch smoke ran at 1b120756a69, which has the same Swift.
  • Merge rule: feat-cmux-next m26/m30 (it compiles, the app launches, its focused red/green tests pass). No review subagent ran; the four Codex rounds above are the reviews it had.
  • Not run: a film at the final head e438f7fc97e (only the Command-Shift keep rule changed after ad7d245d24e, then the feat merge).