cmux-next: pointing hand over clickable native chrome

Merged
TE
7d ago
+636-34

Leo: "STOP BUTTON, I HOVER OVER IT, MY CURSOR SHOULD ALSO SEE IT AS CLICKABLE.. LOTS OF AREAS LIKE THAT SHOULD BE CHANGING MY CURSOR INDICATOR TO CLICKABLE"

Leo-ask: ask-c4fb23e93c

This is the native half. A separate PR adds cursor: pointer to the webviews, which covers the agent composer's Stop button (web content).

This PR covers the key window and the browser toolbar row. The hand over the other native buttons while a Chromium page (a child window) has the key is in the follow-up, native-pointer-cursor-nonkey, which is stacked on this branch.

What changed:

  • New ClickableButton (CmuxNextDesign/ClickableCursor.swift), an NSButton subclass. While enabled it registers a pointing-hand cursor rect over what shows of it: its visible rect cut to its own bounds (a view that doesn't clip to its bounds, the macOS 14 default, reports its ancestors' visible area). While disabled or hidden it registers none, so the arrow shows. A change to isEnabled invalidates its rects and tells the nearest ClickableCursorHost ancestor. AppKit owns the rects, so nothing is pushed and nothing can leak.
  • These shared buttons now inherit from it instead of NSButton (only the superclass changes):
    • TitlebarBandButton (window toolbar band, pane toolbar), OverflowButton, CmuxToastButton, CmuxDialogButtonView, ThemedBezelButton, FindPillButton.
    • Sidebar: SidebarIconButton, HomeHeaderButton, HomeChatComposeButton. No sidebar view was restructured.
    • AgentQuestionPillButton, TerminalRestartButton, TerminalRetryButton.
    • Browser toolbar: ChromeIconButton, ChromeTextButton, ExtensionActionButton.
  • The tab bar's "+" (NewTabButtonView) and the bookmark star (BookmarkStarButton) register the same rect.
  • The browser toolbar row (ArrowCursorView) sets its cursor itself on every pointer move, so it works while the page has the key. OmnibarPointer.cursor(atWindowPoint:in:) now gives the hand over an enabled clickable view in the row (a ClickableButton or the star) and the arrow over a disabled one. When a button under a still pointer is enabled or disabled, the row reads the pointer again and sets the new cursor, but only while the pointer is inside the row in the top window under it. A hide or a window change forgets the last point.
  • SwiftUI gets View.clickableCursor(). On macOS 15 and later it's pointerStyle(.link). On macOS 14 it pushes the hand on hover and pops it on exit, on disable, and on disappear mid-hover, so every push has exactly one pop. It's applied once in glassButtonStyle(), FeedButtonStyle, SettingsButtonStyle and AccountsButtonStyle.
  • scripts/cmux-next/objc-selector-table.json records the two super calls this adds (NSButton resetCursorRects, NSView addCursorRect:cursor:), probed on macOS 27.0. scripts/cmux-next/omnibar-pointer-live.py now expects the hand over reload.
  • Not changed: terminal cursors, text-field I-beams, and resize and drag cursors. Tab close buttons are drawn inside the tab cell rather than being views, so they're left for later.

Tests:

  • CmuxNextDesignTests/ClickableCursorTests.swift (12): an enabled button registers one hand rect over its bounds, a disabled one none, and re-enabling registers it again. A clipped button claims only what shows, a button clipped out of view or hidden claims none. The macOS 14 push/pop sequences balance. The pointer is read in window coordinates only in the top window. The shared Design buttons inherit ClickableButton. A source guard checks the shared buttons in other modules and the four SwiftUI styles.
  • CmuxNextBrowserTests/OmnibarPointerButtonTests.swift (13): the hand over an enabled toolbar button, the arrow over a disabled one and over the row beside it. A state change under the pointer sets the new cursor. A stale point after a hide, or a pointer no longer over the row, sets none. The extension button's hand and disabled arrow. The star registers its rect over its bounds and none while hidden. currentPointer is nil for a window never ordered in. The inheritance checks.

Observed / Not run

  • Observed: cmux-ci compile-check --ref 3230777c3b87598b2bf3135e66ea91aa72d92311 --app passed (job c33c2c9d923b5887fcdfe4db, 71 s).
  • Observed: at 3230777c3b87598b2bf3135e66ea91aa72d92311, cmux-ci run --class light --script scripts/ci/package-test-lane.sh --ref 3230777c3b87598b2bf3135e66ea91aa72d92311 --key npc-a-3230777c --arg suite --arg Packages/macOS/CmuxNext --arg ClickableCursorTests,OmnibarPointerButtonTests passed both suites, 12 and 13 tests (step c95adffee97a8db28686d981, aws-m4pro-3). At the previous head f80a318 four tests failed, because the rect was the container's visible area; 3230777 cuts it to the button's bounds.
  • Observed: python3 scripts/cmux-next/prepush-gate.py --base 47ae435fcfa7 --head 3230777c3b87 found no new violations.
  • Not run: a live hover check in a running app.
  • Follow-up (native-pointer-cursor-nonkey): outside the browser toolbar row, cursor rects only work in the key window. While a Chromium page has the key, the sidebar's and titlebar's buttons still show the arrow with this PR alone.

No capture: the pointer isn't part of window stills, so a screenshot can't show this change.

Changelog

  • Changed: Clickable buttons in the cmux NEXT toolbar, browser toolbar, pane header, tab bar, sidebar, dialogs and toasts now show the pointing-hand cursor on hover. Disabled buttons keep the arrow.

šŸ¤– Generated with Claude Code

Agent session: cosmic-gray