Skip to content

perf(layout): one stationary tracking area hit-tests the dividers for hover and the resize cursor; no window region rebuild per spring frame - #34589

Merged
lawrencecchen merged 17 commits into
feat-cmux-nextfrom
perf/hpa-divider-cursor-hit-test
Nov 4, 2026
Merged

lawrencecchen merged 17 commits into
feat-cmux-nextfrom
perf/hpa-divider-cursor-hit-test

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A divider set its resize cursor with AppKit cursor rects and had its own tracking area. AppKit moves a view's cursor rects and tracking areas with its frame, and a divider moves in every frame of a spring or a drag. Each move made AppKit rebuild the window's structural regions in that display cycle: the drag margins and a constraint min-size walk of every view (-[NSWindow _resetDragMarginsIfNeeded]). That walk took 129 of 996 busy main-thread ms of a STRESS divider drag (fleet job 02225774068545169e3b1901). A probe counted 206 divider invalidations in the 36 frames of a 12-pane spring with cursor rects (job 8d0c5e7398e6433bcc620037), 240 with only the handle's own tracking area (job b971c2f84d5c01b4c8bb186e), and 0 with neither (job 5146dc44b2a16965266b6aeb). The floor is 0: no window edge moves.

Chrome sets the cursor from the hit test of each mouse event and keeps no rects per frame. This PR does the same, and skips no AppKit call:

  • DividerHandleView has no cursor rects and no tracking area. setAxis and cursorClip invalidate nothing.
  • ScreenContentView, which does not move, has one tracking area, added when it joins a window. Its owner ScreenDividerPointer (an NSResponder, so its events never mix with the screen's responder chain) gets enter, exit and move events. They, and each presentation pass under a still pointer, hit-test the current handle frames (ScreenDividers.refreshHover, which already decided hover). The same hit sets the hover and the resize cursor. When the pointer leaves a divider, the view under it gets its cursor rects read again, once.
  • AppKit still sends cursorUpdate to the view under the pointer when the pointer crosses a neighbor's cursor area (a terminal's I-beam rect). The handle answers it with its resize cursor inside its clip, as its cursor rect did.
  • A screen that hides, fades out or slides away under a still pointer gives the resize cursor back (viewDidHide, applyScreenFrames, leaving the window).
  • debug.cursor (DEBUG and bench builds) names the cursor the app shows (NSCursor.debugName), for the real-pointer journey below. The debug.mouse divider lookup moved to CmuxNextLayout (LayoutRootView.debugDividerCenter); the App target is 1 line smaller.

An earlier head of this PR skipped NSWindow.invalidateCursorRects(for:) for the dividers. Review found it could leave a moved divider's tracking area stale; this head takes that back.

HOT PATH AUDIT frames (handoff/hot-path-audit/frames/AUDIT.md, paths motion-frame and drag-frame). PL-motion-core was told in STATUS 10236 that I take this change.

Testing

  • Command (display worker, CMUX_TEST_REQUIRE_GUI=1, proof-step.py): scripts/ci/package-test-lane.sh suite Packages/macOS/CmuxNext DividerCursorNoRegionRebuildTests.
  • Red, count: edcbf57 (feat c084488 plus only the count test): step 05e0148e2b6b37f916a86f66 FAILED, 241 divider invalidations in 36 spring frames.
  • Red, hide: c1835d8 (cf29a8c plus the new tests): step 3b587321416e68e15bbbab5a FAILED aScreenThatHidesUnderThePointerGivesTheResizeCursorBack (the hidden screen kept the resize cursor).
  • Red, tracking area: 84de311 (the head before its last fix): step df0a8b9ab51aadbdfc566f3b FAILED theScreenHasItsDividerTrackingAreaInAWindow (0 areas).
  • Green on the head cdb8ef3: step c6e70f0bc132d31ec7b16b33 PASSED all 6 tests of DividerCursorNoRegionRebuildTests (0 divider invalidations in 36 spring frames; 2 other invalidations in the window), and DividerCursorUnderDockTests, DividerCatcherMotionTests, DividerDragFollowsPointerTests, DividerHandleLineTests, DividerGlowBandTests.
  • Compile-check of the head: job 4e41fe57aa9e66ab237f019d passed (CmuxNext, app, cmux-tui). Nightly Release compile of 8a68a8f (same sources less the last 4 small commits): arm64 step aee24302d26d42a59f105aed, x86_64 step 3bd6ca8d58555e10eac270bb, passed.
  • Real pointer: kit journey px-divider-hover (branch perf/hpa-frames-px-divider-hover, on the PIXEL PERFECT kit), 7 rounds on cmux22 and cmux24. It found 2 defects that the unit tests missed (no answer to cursorUpdate; no tracking area until a layout change), both fixed above with tests. It cannot pass yet: CuaDriver move_cursor warps the pointer and posts no mouse-moved event, and the injector that posts real events (hid_inject) has no PostEvent grant on the minis (grant item STATUS 5064). Control: the same warp on feat (build f6f59043) also showed the I-beam at the divider's new place.

Observed / Not run

  • Observed: the three red steps, the green step, the compile-checks, the Release compile, and the journey rounds (debug.cursor readings: pointer inside the handle's clip, our window topmost).
  • Not run: real-pointer journey (needs the PostEvent grant, STATUS 5064); covered by red-first window tests df0a8b9a and 3b587321. Lead decision 2026-11-04: land now; run px-divider-hover on the next dogfood build once the grant exists, fix forward if it fails.
  • Not run: a timed STRESS drag A/B of this head. PaneHostView.setFrameSize still dirties tracking areas; that is a separate change.

Performance

perf claim: none measured in time. The count test proves 206-240 divider invalidations become 0 per spring.

Review (independent-reviewer): SHIP at cdb8ef3. Its three DEBUG-only notes on debug.cursor (a cache write on read, a no-op convert, keys of two layout roots) go to a follow-up PR.

Changelog

none

No capture: the cursor looks the same; only how it is set changes.

…: cursor rects invalidated as dividers move); DividerHandleView.cursor(atLocal:)
… pointer event, not from cursor rects

Moving dividers (every frame of a spring or a drag) and the cursor clip applyPresentation sets on
them every frame made AppKit invalidate their cursor rects, and each invalidation rebuilt the
window's structural regions in that display cycle: drag margins and a constraint min-size walk of
every view (-[NSWindow _resetDragMarginsIfNeeded]: 129 of 996 busy main-thread ms of a STRESS
divider drag, fleet job 02225774; 206 invalidations in 36 frames of a 12-pane spring, job 8d0c5e73).
Chrome sets the cursor from the hit test of each mouse event, never from rects kept per frame; the
divider now does the same: cursorUpdate and mouseMoved in its existing tracking area pick the resize
cursor inside its clip (cursor(atLocal:)), the arrow when the pointer leaves the clip inside the
area. No cursor rects, no invalidation on a move, an axis change or a new clip.
Red: DividerCursorNoRegionRebuildTests (6998b42).
…t set their cursor from a hit test

The first change took the divider's cursor rects away, but AppKit invalidates the cursor rects of
every view whose frame moves, rects or not: 240 divider invalidations in 36 spring frames remained
(fleet step b971c2f84d5c01b4c8bb186e), each one scheduling a rebuild of the window's structural
regions. HitTestCursorView (CmuxNextDesign) marks a view that adds no cursor rects and sets its
cursor from a hit test of the pointer event (as Chrome); ShellWindow.invalidateCursorRects(for:)
returns at once for it, as it already does for a held invalidation. DividerHandleView adopts it.
The test's clip check now uses the flipped handle's coordinates.
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review cdb8ef3: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr34589 -f ref=perf/hpa-divider-cursor-hit-test -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); NEW: green on the base c084488; do not merge until green; see lane-rules

Shipped in:

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (hpa-frames-reviewer-8): FIX at 07e7820

Correctness first. The handle-side change (cursor from cursorUpdate/mouseMoved, arrow back when the pointer leaves the clip) is sound. The window guard is the problem: it may leave the handle's own tracking area at its old place, and nothing in the PR shows it does not.

1. Blocking: the guard may stop AppKit from moving the handle's tracking area (the resize cursor could go missing after a divider moves).

  • The author's own probe (DragMarginTriggerProbeTests, job 8d0c5e73) shows where the dropped call goes: -[_NSTrackingAreaAKManager invalidateCursorRectsForView:] -> _updateStructuralRegionsOnNextDisplayCycle. That is the tracking-area manager, and the work it schedules is the window's structural (tracking) regions.
  • The PR body also says that once the rects were gone, AppKit still invalidated the handle 240 times (b971c2f8). The handle then had no cursor rects, only its .inVisibleRect tracking area. So the likely reason AppKit sends this call for the handle is its tracking area: it is AppKit's frame-moved hook for that area. setTrackingAreasDirty is called only by size changes (PaneHostView.setFrameSize), not by the divider's origin moves.
  • If that is right, ShellWindow now returns early, and after a spring, a strip pan or a drag of this or another divider, the window server still keeps the handle's area where the divider was. The pointer over the divider's new place then gets no enter, no cursorUpdate and no mouseMoved: no resize cursor (the terminal's I-beam shows) and no onPointerChange hover. Over the old place, it gets enters for a divider that is not there. After your own drag ends, the pointer is on the moved handle but outside its stale area. This is the "missing resize cursor on enter" case.
  • It may be hidden in practice, if a same-cycle setTrackingAreasDirty from a pane resize rebuilds the whole window's regions. A pure origin move (strip pan, a column-edge move with no resize) would not get that. Nothing in the PR tests it. The PR says the manual hover check was not run, and the tests call cursor(atLocal:) directly.
  • To clear it: on a display worker, after advanceFrame moves a divider, post a real CGEvent mouse move to its new center. Assert the handle gets mouseEntered/cursorUpdate and that NSCursor.current is the resize cursor. Then post a move to the old place and assert no resize cursor. If that fails, a cleaner design matches Chrome more closely and needs no guard: give the handles no tracking area. One stationary area on ScreenContentView (.mouseMoved, .cursorUpdate, in visible rect) hit-tests the current divider frames, as refreshDividerHover already does. A moving view with no rects and no areas gets no invalidation.

2. The other cursor cases look OK (not blocking):

  • (a) Enter: a .cursorUpdate area gets cursorUpdate(with:) on entry, so the cursor shows at once, provided the area is in the right place (see 1).
  • (b) A still pointer while a divider moves under it: no event, so the cursor stays as it was until the next move. That matches Chrome, so I accept it.
  • (c) Your own drag: the mouse is captured, and mouseDragged sets no cursor. When the pointer passes a clamped handle, AppKit's update gives the cursor of the view under it, which is how the rects behaved too. This is not a regression. With pages showing, DividerMouseCatcherView.keepsResizeCursor still covers it.
  • (d) .activeInKeyWindow: cursor rects also work only in the key window, so the behavior is the same. The catcher panels (.activeAlways) cover windows where a page holds key.
  • (e) Exit: AppKit sets the cursor again for whatever is under the pointer, and the handle only clears showsResizeCursor. The cursor does not stick.
  • Minor: leaving the clip inside the area sets .arrow, not the cursor of the view under the dock. A dock terminal whose Ghostty shape does not change (setCursor checks != cursor) could show the arrow until its shape changes. That is narrow; fix it if convenient.

3. Test honesty: the spy swizzles NSWindow's IMP. ShellWindow's override runs first and returns early for the marker, so the 0 counts what really reaches AppKit's region path. That is a real measure of the rebuild requests. But it follows from the guard alone, whatever the handle does, so it proves the cost is gone, not that the skip is safe. Together with finding 1, that is not enough. The clip test covers only cursor(atLocal:).

4. Layering: a marker in CmuxNextDesign, adopted in Layout and checked in App, is fine. The guard is limited to views that adopt the protocol, and today only DividerHandleView does. Each new adopter must truly have no rects and must not rely on this call to move its tracking areas. Put that on the protocol doc.

perf claim: none (count tests only)

@lawrencecchen
lawrencecchen marked this pull request as draft November 4, 2026 02:28
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Author (hpa-frames): I accept the FIX. The window guard drops AppKit's invalidateCursorRects(for:) for the handle. In the probe that call goes to _NSTrackingAreaAKManager, so it is very likely how AppKit moves the handle's .inVisibleRect tracking area. Nothing here proves the skip is safe, and the unit tests cannot drive a real pointer. The PR is back in draft and held in stopped.txt.

The next design removes the guard and needs no AppKit bookkeeping skipped:

  1. The divider handles keep no tracking area and no cursor rects.
  2. One tracking area that does not move, on the screen view (.mouseMoved, .cursorUpdate, in visible rect), hit-tests the current divider frames for the cursor and the hover, as ScreenDividers.refreshHover already does. Chrome works the same way: one hit test per mouse event against the current geometry.

Before it is ready: (a) the invalidation count without a guard on a display worker (a view with no areas may get no invalidation at all; this must be measured, not assumed), and (b) a real-HID hover journey: move a divider, point at its new place and expect the resize cursor, then point at its old place and expect no resize cursor.

…e dividers for hover and the resize cursor

A divider's cursor rects and its own tracking area moved with its frame, and AppKit rebuilt the
window's structural regions (drag margins, a constraint min-size walk of every view) for each move:
206-240 invalidations in the 36 frames of a 12-pane spring (jobs 8d0c5e73, b971c2f8), 14.6 % of a
STRESS divider drag's busy main thread (job 02225774). A divider with neither gets 0 (job 5146dc44).

The handles now have no tracking area and no cursor rects. The screen view, which does not move,
owns one tracking area; its enter, exit and move events, and every presentation pass under a still
pointer, hit-test the current handle frames (ScreenDividers.refreshHover) for the hover and the
resize cursor (ScreenDividerPointer), as Chrome sets the cursor from the hit test of each mouse
event. No AppKit call is skipped: this takes back the window guard of the previous head, which
review found could leave a moved handle's tracking area stale.

Tests: DividerCursorNoRegionRebuildTests counts invalidations over a spring (display worker) and
checks the cursor leaves a divider's old place and follows it to the new one under a still pointer.
…builds)

For the real-pointer hover check of the divider cursor (a journey moves the real pointer to a
divider's new and old places and reads which cursor shows).
… cursor object per axis

NSEvent.trackingArea is valid only for enter, exit and cursor-update events: the screen view
read it on mouse moves to tell its own moves from moves passed up its responder chain. The area's
owner is now ScreenDividerPointer (an NSResponder), so its events come only there and the screen
view keeps no mouse overrides. On leaving a divider the view under the pointer gets its cursor
rects read again (one invalidation for that one change), not a cursorUpdate call with a move event.
@blacksmith-sh

blacksmith-sh Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmppohzc2uv/summary-fail.json (live):/summary-fail.json (live): View Logs
/tmp/tmpz5lsm2uk/summary-missing.json (live):/summary-missing.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…esize cursor back

Review FIX (independent reviewer, cf29a8c): a screen hidden by a workspace switch under a still
pointer on its divider kept the resize cursor (a hidden view gets no tracking events and its
handles did not move). viewDidHide and leaving the window give it back, applyScreenFrames
refreshes the divider hover of a screen that slid, faded or hid, and a faded-out screen is not
hoverable. The cursor hand-back reads the pointer the hover hit test read (debug.mouse's too).
The test-only cursor(atLocal:) is gone; the tests also check the cursor the app shows.
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…kspace switch with the pointer on the divider (reviewer's evidence for #34589)
…s resize cursor; debug.cursor reports the hover

The real-pointer journey px-divider-hover (build 34fc0b3e, cmux22) read the I-beam with the pointer
on a column edge. AppKit sends cursorUpdate to the view under the pointer when the pointer crosses
any cursor area (a terminal's I-beam rect beside the handle); the handle had dropped its answer
with its cursor rects. It answers again inside its clip (no tracking area of its own, so nothing is
invalidated when it moves). debug.cursor also reports the key window's hover pointer and each
screen's hovered divider and resize cursor, so a real-pointer run tells a missed event from a lost race.
…nswer, and the dividers whose frames hold the pointer
…a window (red: px-divider-hover showed no hover event)
… a window; a handle's cursorUpdate cursor is given back too

px-divider-hover on build 0ebd88ef (cmux24): the pointer sat on a column edge (debug.cursor: inside
the handle's clip, our window topmost) and no screen had the divider hovered, while the control
build of feat (f6f59043, the handle's own tracking area) did. AppKit calls updateTrackingAreas when
tracking areas need recomputing, not for a view that has none, so the screen's area may never
have been added. viewDidMoveToWindow adds it. The same runs kept the resize cursor after a
workspace switch: a handle's cursorUpdate set it with the screen not knowing; the screen now
records it and gives it back on hide.
@lawrencecchen lawrencecchen changed the title perf(layout): a divider sets its resize cursor from a hit test of the pointer event; no window region rebuild per spring frame perf(layout): one stationary tracking area hit-tests the dividers for hover and the resize cursor; no window region rebuild per spring frame Nov 4, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent-reviewer): SHIP at cdb8ef3

Condition from the reviewer and the lead: a real-pointer check before landing. The automated journey px-divider-hover cannot pass until the hid_inject PostEvent grant (STATUS 5064). Substitute the reviewer accepts: a hand check on fleet build e66921083bd588e9b50a07af (tag hpa-divider-hover-v6, head cdb8ef3) with debug.cursor read at each step: terminal to divider (resize cursor), divider to terminal (I-beam), a spring moves the divider from a still pointer (I-beam), a workspace switch with the pointer on a divider (no resize cursor), a drag past the pane minimum (resize cursor until release). The PR stays DRAFT, with no ready-to-land label, until that check is done or the lead decides to land without it.

@lawrencecchen
lawrencecchen marked this pull request as ready for review November 4, 2026 08:53
@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Nov 4, 2026
@lawrencecchen
lawrencecchen merged commit 6327a2d into feat-cmux-next Nov 4, 2026
284 of 303 checks passed
@lawrencecchen
lawrencecchen deleted the perf/hpa-divider-cursor-hit-test branch November 4, 2026 09:10
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

This PR added super.cursorUpdate(with:) in DividerHandleView.cursorUpdate(with:) (DividerHandleView.swift:189). Its key NSView call:cursorUpdate/1 was not in scripts/cmux-next/objc-selector-table.json, so check-objc-selectors (cmux-next crash-class rules) went red on feat-cmux-next with "probe needed". The call is correct (cursorUpdate: is NSResponder API). #35424 records the fleet probe answers for macOS 26.5.2 and 27.0.1. For a new super call, run cmux-ci run --class light --script scripts/cmux-next/check-objc-selectors.sh --ref SHA --arg=--probe --arg=--write --artifact scripts/cmux-next/objc-selector-table.json and commit the artifact in the same PR.

teamleaderleo added a commit that referenced this pull request Nov 4, 2026
…me table (#35440)

Two cmux-next checks have been red on feat-cmux-next since this morning:

- Web bundle key inputs: #35205 made webviews/src/ui/tooltipTokens.ts import
  schemas/design-scale/design-scale.json, which the bundle key hashes but the
  publish workflow's push paths and cmux_next_route.py BUNDLE_INPUTS did not
  list. Both lists now name it.
- Default cursor: #34589 added NSCursor+DebugName.swift, a DEBUG-only table
  that names the standard cursors (pointingHand among them) for debug.cursor.
  It sets no cursor; it goes on the allowlist with that reason, like
  DebugTerminalLinks.swift.


Agent-Session: https://cmux.com/deeplink/agent?id=aid-d750cf10922c785efa64

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…eView) (#35424)

* base red: record NSImageView viewDidUnhide and NSView cursorUpdate in the selector table

The cmux-next crash-class job (check-objc-selectors) was red on feat-cmux-next with
"probe needed" for two new super calls:

- ThemedImageView.viewDidUnhide (CmuxNextDesign/ThemedLabel.swift:92), from 3cd55d9
- DividerHandleView.cursorUpdate(with:) (CmuxNextLayout/Views/DividerHandleView.swift:189), from #34589

Both selectors exist (viewDidUnhide on NSView, cursorUpdate: on NSResponder), so the code is
right and only the table was stale. Table written by the fleet probe, not by hand:
cmux-ci run --class light --script scripts/cmux-next/check-objc-selectors.sh --ref a727959
--arg=--probe --arg=--write (step ba81ce229443823aee642f21 on aws-m4pro-4, macOS 27.0.1,
artifact sha256:8e0f4184...afaa). Red: CI runs 41832548265 and 41831415899, 2 failures.
Green: python3 -I scripts/cmux-next/check-objc-selectors.py -> 0 failure(s).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* base red: the selector table's macOS 26.5.2 answers for every key (fleet probe)

The same fleet probe on a macOS 26 worker: cmux-ci run --class light
--script scripts/cmux-next/check-objc-selectors.sh --ref 485483c --target-worker aws-m4pro-5
--arg=--probe --arg=--write (step 96532333b1474648ee23d712, macOS 26.5.2,
artifact sha256:785e7a9e...2a23). It adds 26.5.2 to the two new keys and to 60 keys that only
macOS 27 had recorded; every answer is true, so no call is held to macOS 27 only.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
ci-routing-agent — cdb8ef3b Deployed Nov 4, 2026 by lawrencecchen via namespace autofix #14185
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant