Skip to content
Merged
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
43 changes: 34 additions & 9 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1962,7 +1962,7 @@ struct TabBarDragAndHoverView: NSViewRepresentable {
typealias BeginTabDrag = @MainActor (
_ tabId: UUID,
_ sourceView: NSView,
_ mouseDownEvent: NSEvent,
_ dragEvent: NSEvent,
_ draggingFrame: NSRect,
_ dragImage: NSImage
) -> Bool
Expand Down Expand Up @@ -1997,7 +1997,6 @@ struct TabBarDragAndHoverView: NSViewRepresentable {
final class TabBarBackgroundNSView: NSView, BonsplitTabItemHitRegionProviding {
private struct PendingTabDrag {
let tabId: UUID
let mouseDownEvent: NSEvent
let startPoint: NSPoint
let frame: NSRect
}
Expand Down Expand Up @@ -2179,8 +2178,12 @@ struct TabBarDragAndHoverView: NSViewRepresentable {

private func trackTabMouseDown(_ event: NSEvent) {
pendingTabDrag = nil
guard event.clickCount == 1,
!event.modifierFlags.contains(.control),
// Accept any clickCount: AppKit keeps counting presses that land
// near the same point inside the double-click interval, so the
// common "click to select, then drag" flow arrives as clickCount 2.
// A stationary double-click is unaffected because the drag only
// begins once the pointer crosses the movement threshold.
guard !event.modifierFlags.contains(.control),
let window,
event.windowNumber == window.windowNumber else {
return
Expand All @@ -2195,7 +2198,6 @@ struct TabBarDragAndHoverView: NSViewRepresentable {
}
pendingTabDrag = PendingTabDrag(
tabId: tabId,
mouseDownEvent: event,
startPoint: point,
frame: frame
)
Expand All @@ -2216,15 +2218,21 @@ struct TabBarDragAndHoverView: NSViewRepresentable {
}
self.pendingTabDrag = nil

guard let dragImage = dragImage(for: pendingTabDrag.frame),
// Focus/hover can reveal an accessory or scroll the strip between
// the press and the threshold event. Refresh the source frame so
// AppKit's item and the destination's geometry snapshot agree at
// the moment the session starts.
let draggingFrame = geometryRegistry?.frame(for: pendingTabDrag.tabId, in: self)
?? pendingTabDrag.frame
guard let dragImage = dragImage(for: draggingFrame),
let onBeginTabDrag else {
return event
}
_ = onBeginTabDrag(
pendingTabDrag.tabId,
self,
pendingTabDrag.mouseDownEvent,
pendingTabDrag.frame,
event,
draggingFrame,
dragImage
)
// SwiftUI already received the mouse-down. Forward the threshold-
Expand All @@ -2249,6 +2257,11 @@ struct TabBarDragAndHoverView: NSViewRepresentable {
return image
}

/// Returns whether the pointer is over a native control that owns its
/// own gesture. SwiftUI renders static tab titles through AppKit text
/// controls too; treating every ``NSControl`` as interactive therefore
/// made drag arming depend on title width and renderer details. Only
/// controls that can actually consume the press veto the tab source.
private static func isNativeInteraction(
at windowPoint: NSPoint,
in window: NSWindow
Expand All @@ -2257,7 +2270,19 @@ struct TabBarDragAndHoverView: NSViewRepresentable {
let contentPoint = contentView.convert(windowPoint, from: nil)
guard var candidate = contentView.hitTest(contentPoint) else { return false }
while true {
if candidate is NSControl || candidate is NSTextView {
if let button = candidate as? NSButton, button.isEnabled {
return true
}
if let textField = candidate as? NSTextField, textField.isEditable {
return true
}
if let textView = candidate as? NSTextView, textView.isEditable {
return true
}
if let control = candidate as? NSControl,
control.isEnabled,
control.target != nil,
control.action != nil {
return true
}
guard let parent = candidate.superview else { return false }
Expand Down
154 changes: 151 additions & 3 deletions Tests/BonsplitTests/TabBarDragEventRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ import Testing
let tabId = UUID()
let geometryRegistry = TabBarItemGeometryRegistry()
var beganTabId: UUID?
var beganEvent: NSEvent?

contentView.addSubview(sourceView)
sourceView.addSubview(tabView)
geometryRegistry.register(tabView, for: tabId)
sourceView.geometryRegistry = geometryRegistry
sourceView.tabIds = [tabId]
sourceView.onBeginTabDrag = { tabId, _, _, _, _ in
sourceView.onBeginTabDrag = { tabId, _, event, _, _ in
beganTabId = tabId
beganEvent = event
return true
}
window.makeKeyAndOrderFront(nil)
Expand All @@ -45,12 +47,158 @@ import Testing
#expect(sourceView.handleTabDragEvent(mouseDown) === mouseDown)
#expect(sourceView.handleTabDragEvent(mouseDragged) === mouseDragged)
#expect(beganTabId == tabId)
#expect(beganEvent === mouseDragged)
}

@Test func secondClickOfDoubleClickStillArmsTabDrag() throws {
// Regression (issue 10033): selecting a tab and immediately dragging it
// delivers the press with clickCount == 2, because AppKit keeps counting
// clicks that land near the same point inside the double-click interval.
// Drag arming must accept those presses or the drag silently never starts.
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 240, height: 80),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let contentView = try #require(window.contentView)
let sourceView = TabBarDragAndHoverView.TabBarBackgroundNSView(frame: contentView.bounds)
let tabView = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 30))
let tabId = UUID()
let geometryRegistry = TabBarItemGeometryRegistry()
var beganTabId: UUID?

contentView.addSubview(sourceView)
sourceView.addSubview(tabView)
geometryRegistry.register(tabView, for: tabId)
sourceView.geometryRegistry = geometryRegistry
sourceView.tabIds = [tabId]
sourceView.onBeginTabDrag = { tabId, _, _, _, _ in
beganTabId = tabId
return true
}
window.makeKeyAndOrderFront(nil)

let mouseDown = try mouseEvent(
type: .leftMouseDown,
in: sourceView,
at: NSPoint(x: 40, y: 35),
clickCount: 2
)
let mouseDragged = try mouseEvent(
type: .leftMouseDragged,
in: sourceView,
at: NSPoint(x: 50, y: 45),
clickCount: 2
)

_ = sourceView.handleTabDragEvent(mouseDown)
_ = sourceView.handleTabDragEvent(mouseDragged)

#expect(beganTabId == tabId)
}

@Test func staticTitleControlDoesNotBlockTabDragArming() throws {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 240, height: 80),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let contentView = try #require(window.contentView)
let sourceView = TabBarDragAndHoverView.TabBarBackgroundNSView(frame: contentView.bounds)
let tabId = UUID()
let geometryRegistry = TabBarItemGeometryRegistry()
let tabView = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 30))
let title = NSTextField(labelWithString: "A very wide tab title")
title.frame = tabView.bounds.insetBy(dx: 8, dy: 4)
var began = false

contentView.addSubview(sourceView)
sourceView.addSubview(tabView)
tabView.addSubview(title)
geometryRegistry.register(tabView, for: tabId)
sourceView.geometryRegistry = geometryRegistry
sourceView.tabIds = [tabId]
sourceView.onBeginTabDrag = { _, _, _, _, _ in
began = true
return true
}
window.makeKeyAndOrderFront(nil)

let mouseDown = try mouseEvent(
type: .leftMouseDown,
in: sourceView,
at: NSPoint(x: 80, y: 35)
)
let mouseDragged = try mouseEvent(
type: .leftMouseDragged,
in: sourceView,
at: NSPoint(x: 92, y: 35)
)

_ = sourceView.handleTabDragEvent(mouseDown)
_ = sourceView.handleTabDragEvent(mouseDragged)

#expect(began)
}

@Test func actionableButtonKeepsOwnershipOfTabPress() throws {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 240, height: 80),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let contentView = try #require(window.contentView)
let sourceView = TabBarDragAndHoverView.TabBarBackgroundNSView(frame: contentView.bounds)
let tabId = UUID()
let geometryRegistry = TabBarItemGeometryRegistry()
let tabView = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 30))
let button = NSButton(title: "Close", target: nil, action: nil)
button.frame = NSRect(x: 150, y: 4, width: 26, height: 22)
var began = false

contentView.addSubview(sourceView)
sourceView.addSubview(tabView)
tabView.addSubview(button)
geometryRegistry.register(tabView, for: tabId)
sourceView.geometryRegistry = geometryRegistry
sourceView.tabIds = [tabId]
sourceView.onBeginTabDrag = { _, _, _, _, _ in
began = true
return true
}
window.makeKeyAndOrderFront(nil)

let hitPoint = sourceView.convert(NSPoint(x: 183, y: 31), to: nil)
#expect(contentView.hitTest(contentView.convert(hitPoint, from: nil)) === button)

let mouseDown = try mouseEvent(
type: .leftMouseDown,
in: sourceView,
at: NSPoint(x: 183, y: 31)
)
let mouseDragged = try mouseEvent(
type: .leftMouseDragged,
in: sourceView,
at: NSPoint(x: 195, y: 31)
)

_ = sourceView.handleTabDragEvent(mouseDown)
_ = sourceView.handleTabDragEvent(mouseDragged)

#expect(!began)
}

private func mouseEvent(
type: NSEvent.EventType,
in view: NSView,
at point: NSPoint
at point: NSPoint,
clickCount: Int = 1
) throws -> NSEvent {
let window = try #require(view.window)
return try #require(NSEvent.mouseEvent(
Expand All @@ -61,7 +209,7 @@ import Testing
windowNumber: window.windowNumber,
context: nil,
eventNumber: 0,
clickCount: 1,
clickCount: clickCount,
pressure: 1
))
}
Expand Down
Loading