Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
austinywang marked this conversation as resolved.
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
}
}
9 changes: 9 additions & 0 deletions Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,9 @@ struct SidebarEmptyArea: View {
@ViewBuilder
Comment thread
kenifxyz marked this conversation as resolved.
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())
Expand Down
12 changes: 12 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -5093,6 +5096,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef
D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = "<group>"; };
EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = "<group>"; };
B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = "<group>"; };
9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = "<group>"; };
9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragOutcome.swift; sourceTree = "<group>"; };
9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift; sourceTree = "<group>"; };
A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = "<group>"; };
B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = "<group>"; };
A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Loading