diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift index f4698171ebf..bb35ff843e2 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift @@ -4,8 +4,19 @@ import AppKit @MainActor final class SidebarWorkspaceTableClipView: NSClipView { weak var workspaceController: SidebarWorkspaceTableController? + private let emptyAreaWindowDragController = SidebarEmptyAreaWindowDragController() + /// Lets an inactive window begin dragging from empty viewport space. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + /// Preserves empty-space clicks while promoting threshold-crossing presses to window drags. override func mouseDown(with event: NSEvent) { + // Presses only reach the clip view when they miss the table's frame + // entirely, so anything landing here is empty space by construction. + if event.clickCount == 1, + emptyAreaWindowDragController.perform(with: event, in: self) != .passThrough { + return + } super.mouseDown(with: event) if event.clickCount == 2 { workspaceController?.doubleClickEmptyArea() diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift index 5fa7f686c37..140571f6fda 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift @@ -8,6 +8,7 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { /// dismantles its representable. Keep the controller alive until the /// terminal source callback arrives. var activeWorkspaceDragController: SidebarWorkspaceTableController? + private let emptyAreaWindowDragController = SidebarEmptyAreaWindowDragController() private var pointerTrackingArea: NSTrackingArea? private(set) var lastPointerWindowLocation: NSPoint? @@ -51,10 +52,17 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { workspaceController?.middleClick(row: row) } + /// Preserves row clicks while promoting threshold-crossing empty-area presses to window drags. override func mouseDown(with event: NSEvent) { workspaceController?.prepareForMouseDown() let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) + // Below the last row the press is window-drag territory. Single clicks + // only: a double-click still belongs to doubleClickEmptyArea(). + if clickedRow < 0, event.clickCount == 1, + emptyAreaWindowDragController.perform(with: event, in: self) != .passThrough { + return + } // No selection paint on press: the highlight applies on down-then-up // (owner ruling). The action fires on mouse-up and paints the // optimistic treatment there, so a press that becomes a drag or a diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift new file mode 100644 index 00000000000..b7a8a125880 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -0,0 +1,93 @@ +import AppKit + +/// Coordinates window dragging from empty sidebar space. +/// +/// Tracking runs synchronously from `mouseDown` with `nextEvent(matching:)`, +/// the same shape as ``SidebarDividerTrackingView``, rather than through an +/// `NSGestureRecognizer`. `NSWindow.performDrag(with:)` runs its own modal +/// tracking loop and consumes the terminating mouse-up, so a recognizer that +/// calls it never receives the event that would drive it back to `.possible`: +/// it stays parked in a terminal state, `reset()` never runs, and it silently +/// stops recognizing for the rest of the window's life. +@MainActor +struct SidebarEmptyAreaWindowDragController { + /// Pointer travel, in window points, before a press becomes a window drag. + private static let dragThreshold: CGFloat = 4 + private let nextEvent: @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? + + /// Creates a controller with an injected event source. + /// + /// The default event source blocks in `.eventTracking` until the press + /// resolves into either movement or a mouse-up. + init( + nextEvent: @escaping @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? = { window in + var eventMask: NSEvent.EventTypeMask = [.leftMouseDragged, .leftMouseUp] + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + eventMask.insert(.mouseCancelled) + } + #endif + guard let event = window.nextEvent( + matching: eventMask, + until: .distantFuture, + inMode: .eventTracking, + dequeue: true + ) else { return nil } + + #if compiler(>=6.2) + if #available(macOS 26.0, *), event.type == .mouseCancelled { + return .cancelled + } + #endif + if event.type == .leftMouseUp { + return .mouseUp(event) + } + return .dragged(location: event.locationInWindow) + } + ) { + self.nextEvent = nextEvent + } + + /// Consumes `event` as a window drag when the press turns into one. + /// + /// Returns `.dragged` when AppKit moved the window and `.cancelled` when + /// the system ended the sequence without a mouse-up; callers consume both. + /// Returns `.passThrough` for a press that never passed the configured drag + /// threshold, having pushed the terminating mouse-up back onto the queue + /// first — `NSTableView`'s own `mouseDown` tracking loop waits for that + /// event, and swallowing it would hang the click. + func perform( + with event: NSEvent, + in view: NSView + ) -> SidebarEmptyAreaWindowDragOutcome { + guard let window = view.window else { return .passThrough } + guard !isWindowDragSuppressed(window: window) else { return .passThrough } + + let start = event.locationInWindow + + while let next = nextEvent(window) { + switch next { + case let .mouseUp(mouseUp): + window.postEvent(mouseUp, atStart: true) + return .passThrough + case .cancelled: + return .cancelled + case let .dragged(location): + let distance = hypot(location.x - start.x, location.y - start.y) + guard distance >= Self.dragThreshold else { continue } + + withTemporaryWindowMovableEnabled(window: window) { + // AppKit requires the original mouse-down event; the + // tracked location only decides when to hand off. + window.performDrag(with: event) + } + return .dragged + } + } + + // A source that ends without mouse-up cannot safely fall through to + // AppKit's tracking loop, which would wait for an event that may never + // arrive. Treat it as a consumed cancellation. + return .cancelled + } +} diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift new file mode 100644 index 00000000000..68029468768 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift @@ -0,0 +1,9 @@ +/// Result of tracking one empty-area mouse-down sequence. +enum SidebarEmptyAreaWindowDragOutcome: Equatable { + /// The caller should continue its normal `mouseDown` handling. + case passThrough + /// AppKit took ownership of the sequence to move the window. + case dragged + /// AppKit ended the sequence without a mouse-up to replay. + case cancelled +} diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift new file mode 100644 index 00000000000..5db90b89b09 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift @@ -0,0 +1,11 @@ +import AppKit + +/// Event-source value consumed by the synchronous empty-area tracking loop. +enum SidebarEmptyAreaWindowDragTrackingEvent { + /// Pointer movement that may cross the drag threshold. + case dragged(location: NSPoint) + /// The terminating event that normal click handling still needs. + case mouseUp(NSEvent) + /// A system cancellation that terminates the sequence without mouse-up. + case cancelled +} diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 405e0544fb6..fe146c79e02 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -629,6 +629,9 @@ struct SidebarEmptyArea: View { @ViewBuilder private var hitTarget: some View { if expandsVertically { + // This full-height background extends behind the rows. Keep it + // SwiftUI-only so native hit testing cannot steal row presses; + // the AppKit table and clip view own native-sidebar window drags. Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3b0afe17ded..4a9c414303d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2120,6 +2120,9 @@ D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; }; + 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */; }; + 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */; }; + 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */; }; A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; @@ -5093,6 +5096,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = ""; }; + 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = ""; }; + 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragOutcome.swift; sourceTree = ""; }; + 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift; sourceTree = ""; }; A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; @@ -6572,6 +6578,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */, EA1F00000000000000000002 /* SidebarPathFormatter.swift */, EA1F00000000000000000004 /* SidebarDirectoryText.swift */, + 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */, + 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */, + 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, C0DE1A020000000000000002 /* SavedLayoutStore.swift */, @@ -10867,6 +10876,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */, + 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, + 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */, + 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */, A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */, F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */, B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */, diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 97a2d5d3325..24865e41b21 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -246,3 +246,200 @@ struct TitlebarInteractiveControlTests { ) } } + +@MainActor +@Suite("Sidebar empty-area window drag") +struct SidebarEmptyAreaWindowDragTests { + private final class RecordingDragWindow: NSWindow { + var performDragCallCount = 0 + var isMovableDuringPerformDrag: Bool? + + /// Records drag invocation and the movability state visible to AppKit. + override func performDrag(with event: NSEvent) { + performDragCallCount += 1 + isMovableDuringPerformDrag = isMovable + } + } + + /// Creates a synthetic pointer event associated with the supplied window. + private static func makeMouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Expected to create sidebar empty-area mouse event") + } + return event + } + + /// Creates a window whose explicit drag calls can be inspected. + private static func makeWindow() -> RecordingDragWindow { + _ = NSApplication.shared + let window = RecordingDragWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 320), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.contentView = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 320)) + return window + } + + /// Feeds a fixed script in place of the real tracking pump so the loop is + /// exercised without a run loop. + private static func pump( + _ events: [SidebarEmptyAreaWindowDragTrackingEvent] + ) -> @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? { + var remaining = events + return { _ in remaining.isEmpty ? nil : remaining.removeFirst() } + } + + /// Verifies that AppKit replays the same mouse-up payload after queueing it. + private static func expectReplayedMouseUp(in window: NSWindow, matches original: NSEvent) throws { + let replayed = try #require( + window.nextEvent( + matching: [.leftMouseUp], + until: .now, + inMode: .eventTracking, + dequeue: true + ) + ) + + // postEvent may reconstitute the NSEvent, so compare the event identity + // fields instead of requiring the dequeued object to be pointer-identical. + #expect(replayed.type == .leftMouseUp) + #expect(replayed.windowNumber == original.windowNumber) + #expect(replayed.eventNumber == original.eventNumber) + // AppKit can round the event timestamp while rebuilding the queued + // NSEvent, so compare at microsecond precision instead of bit-for-bit. + #expect(abs(replayed.timestamp - original.timestamp) < 0.000_001) + // AppKit may reconstruct the queued point relative to the window's + // current screen origin; that display geometry is not part of replay + // identity and varies across macOS versions and window placement. + #expect(replayed.clickCount == original.clickCount) + #expect(replayed.modifierFlags == original.modifierFlags) + } + + /// A threshold-crossing press invokes AppKit exactly once and restores window state. + @Test func dragPastThresholdMovesWindowAndRestoresMovability() throws { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + window.isMovable = false + let view = try #require(window.contentView) + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 100), window: window) + + let controller = SidebarEmptyAreaWindowDragController( + nextEvent: Self.pump([.dragged(location: dragged.locationInWindow)]) + ) + let outcome = controller.perform(with: down, in: view) + + #expect(outcome == .dragged) + #expect(window.performDragCallCount == 1) + #expect(window.isMovableDuringPerformDrag == true) + #expect(!window.isMovable) + } + + /// A stationary click remains unhandled and replays its terminating mouse-up. + @Test func pressWithoutMovementIsNotADrag() throws { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + let view = try #require(window.contentView) + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 40, y: 80), window: window) + + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([.mouseUp(up)])) + let outcome = controller.perform(with: down, in: view) + + #expect(outcome == .passThrough) + #expect(window.performDragCallCount == 0) + try Self.expectReplayedMouseUp(in: window, matches: up) + } + + /// Sub-threshold pointer jitter remains a click and replays its mouse-up. + @Test func movementBelowThresholdStaysAClick() throws { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + let view = try #require(window.contentView) + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let jitter = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 41, y: 81), window: window) + let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 41, y: 81), window: window) + + let controller = SidebarEmptyAreaWindowDragController( + nextEvent: Self.pump([ + .dragged(location: jitter.locationInWindow), + .mouseUp(up), + ]) + ) + let outcome = controller.perform(with: down, in: view) + + #expect(outcome == .passThrough) + #expect(window.performDragCallCount == 0) + try Self.expectReplayedMouseUp(in: window, matches: up) + } + + /// A detached view declines the drag without consulting the injected event source. + @Test func viewWithoutWindowIsNotADrag() { + _ = NSApplication.shared + let window = Self.makeWindow() + defer { window.orderOut(nil) } + let detached = NSView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 4, y: 4), window: window) + + var nextEventCallCount = 0 + let controller = SidebarEmptyAreaWindowDragController( + nextEvent: { _ in + nextEventCallCount += 1 + return nil + } + ) + let outcome = controller.perform(with: down, in: detached) + + #expect(outcome == .passThrough) + #expect(window.performDragCallCount == 0) + #expect(nextEventCallCount == 0) + } + + /// A system cancellation consumes the sequence without starting a drag. + @Test func cancelledSequenceDoesNotFallThrough() throws { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + let view = try #require(window.contentView) + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([.cancelled])) + + let outcome = controller.perform(with: down, in: view) + + #expect(outcome == .cancelled) + #expect(window.performDragCallCount == 0) + } + + /// Both native empty-area owners receive the initial press in an inactive window. + @Test func nativeEmptyAreaOwnersAcceptFirstMouse() { + _ = NSApplication.shared + + let clipView = SidebarWorkspaceTableClipView() + let tableView = SidebarWorkspaceTableViewImpl() + + #expect(clipView.acceptsFirstMouse(for: nil)) + // NSTableView already accepts click-through via NSControl; retain that + // policy for rows while the clip view opts in for empty viewport space. + #expect(tableView.acceptsFirstMouse(for: nil)) + } +}