Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,7 @@ extension SplitViewController {
func nativeTabDragSessionDidEnd(generation: Int) {
nativeTabDragSources[generation] = nil
cancelTabDragIfGenerationMatches(generation)
// AppKit ends the session once the drag image has landed.
endTabSettle()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import SwiftUI

extension SplitViewController {
/// The longest a dropped tab stays hidden: a session whose end AppKit
/// never reports shows the tab after this.
static let tabSettleLimit: TimeInterval = 0.5

/// `tabId` was dropped and its drag image slides into its slot: the tab
/// draws nothing there until the session ends or ``tabSettleLimit`` passes.
func beginTabSettle(_ tabId: UUID) {
tabSettleGeneration += 1
let generation = tabSettleGeneration
withTransaction(Transaction(animation: nil)) {
settlingTabId = tabId
}
DispatchQueue.main.asyncAfter(deadline: .now() + Self.tabSettleLimit) { [weak self] in
guard let self, self.tabSettleGeneration == generation else { return }
self.endTabSettle()
}
}

/// The dropped tab shows in its slot, in place of the landed drag image.
func endTabSettle() {
guard settlingTabId != nil else { return }
withTransaction(Transaction(animation: nil)) {
settlingTabId = nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ final class SplitViewController {
/// hit-testing while drop delegates read the same value synchronously.
var tabDragSession: TabDragSession?

/// A dropped tab whose drag image is still sliding into its slot: its
/// item draws nothing there until the image lands (``beginTabSettle(_:)``).
var settlingTabId: UUID?
@ObservationIgnored var tabSettleGeneration = 0

/// Monotonic counter incremented on each drag start. The current value is
/// copied into ``tabDragSession`` to invalidate stale lifecycle callbacks.
var dragGeneration: Int = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ final class TabBarDropDestinationNSView: NSView {
weak var splitViewController: SplitViewController?
weak var geometryRegistry: TabBarItemGeometryRegistry?
var onIndicatorIndexChanged: ((Int?) -> Void)?
/// Whether a drop lands at once instead of settling (tests set it).
var reducesMotion: () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }

private let dropIndexResolver: TabDropIndexResolver
private var presentedIndicatorIndex: Int?
Expand Down Expand Up @@ -62,7 +64,19 @@ final class TabBarDropDestinationNSView: NSView {
let targetIndex = insertionIndex(for: sender) else {
return false
}
return handler.performDrop(from: sender.draggingPasteboard, at: targetIndex)
// Read from the strip before the move reflows it.
let before = settleSnapshot(for: sender)
guard handler.performDrop(from: sender.draggingPasteboard, at: targetIndex) else { return false }
if let before, let slot = settleSlot(after: before) {
// AppKit slides the drag image into the slot; the tab shows there
// when the session ends (SplitViewController.beginTabSettle).
sender.animatesToDestination = true
sender.enumerateDraggingItems(options: [], for: self, classes: [NSPasteboardItem.self], searchOptions: [:]) { item, _, _ in
item.draggingFrame = slot
}
splitViewController?.beginTabSettle(before.tabId)
}
return true
}

override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
Expand All @@ -73,6 +87,59 @@ final class TabBarDropDestinationNSView: NSView {
clearDropState()
}

/// The strip as a drop found it: the dragged tab, where it was, its
/// width, and every tab's frame and on-screen part here.
private struct SettleSnapshot {
let tabId: UUID
let source: Int
let width: CGFloat
let frames: [CGRect?]
let visible: [CGRect?]
/// The strip's viewport outside the trailing button lane, where the
/// unpinned tabs scroll (pinned tabs sit before it, always shown).
let viewport: CGRect?
let pinned: [Bool]
}

/// Nil when the drop lands at once: Reduce Motion, not a tab dragged
/// within this strip (a tab from another pane changes the layout it
/// lands in: its pane may close, fill-width tabs narrow), a strip whose
/// tabs have no frames.
private func settleSnapshot(for sender: any NSDraggingInfo) -> SettleSnapshot? {
guard !reducesMotion(), let pane, let geometryRegistry,
sender.draggingPasteboard.types?.contains(TabBarDropHandler.tabTransferPasteboardType) == true,
let session = splitViewController?.tabDragSession, session.sourcePaneId == pane.id,
let source = pane.tabs.firstIndex(where: { $0.id == session.tab.id }) else { return nil }
let ids = pane.tabs.map(\.id)
let frames = ids.map { geometryRegistry.frame(for: $0, in: self) }
let visible = geometryRegistry.visibleFrames(for: ids, in: self)
guard let width = frames[source]?.width, width > 0 else { return nil }
return SettleSnapshot(tabId: session.tab.id, source: source, width: width, frames: frames,
visible: ids.map { visible[$0] },
viewport: geometryRegistry.unobscuredViewport(in: self),
pinned: pane.tabs.map(\.isPinned))
}

/// The slot, in this view, of the tab's place after the drop (as the
/// pane placed it, pinned tabs first), measured on the strip before the
/// drop. Nil unless every tab it passes is fully on screen, outside the
/// trailing button lane: a slot partly hidden makes the strip scroll.
private func settleSlot(after before: SettleSnapshot) -> NSRect? {
guard let pane, let landed = pane.tabs.firstIndex(where: { $0.id == before.tabId }) else { return nil }
let span = min(landed, before.source)...max(landed, before.source)
for index in span {
guard let frame = before.frames[index], let shown = before.visible[index],
abs(shown.width - frame.width) < 0.5,
before.pinned[index]
|| before.viewport.map({ frame.minX >= $0.minX - 0.5 && frame.maxX <= $0.maxX + 0.5 }) ?? true
else { return nil }
}
guard let row = before.frames[landed] else { return nil }
// Moving right, the tabs between move left by its width.
let minX = landed > before.source ? row.maxX - before.width : row.minX
return NSRect(x: minX, y: row.minY, width: before.width, height: row.height)
}

static func shouldCaptureHitTest(
eventType: NSEvent.EventType?,
pasteboardTypes: [NSPasteboard.PasteboardType]?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,15 @@ final class TabBarItemGeometryRegistry {
self.scrollWheelMonitor = nil
}

/// The part of the strip's scrolling viewport no trailing control lane
/// covers, in `targetView`; nil before the strip has a scroll view.
func unobscuredViewport(in targetView: NSView) -> CGRect? {
guard let clipView = scrollView?.contentView, clipView.window === targetView.window else { return nil }
var rect = clipView.bounds
rect.size.width = max(0, rect.width - trailingObscuredWidth)
return clipView.convert(rect, to: targetView)
}

func frame(for tabId: UUID, in targetView: NSView) -> CGRect? {
guard let itemView = itemViews.object(forKey: tabId as NSUUID),
itemView.window === targetView.window,
Expand Down
3 changes: 3 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,8 @@ struct TabBarView: View {
let showsZoomIndicator = splitViewController.zoomedPaneId == pane.id && pane.selectedTabId == tab.id
let isImmediatelyBeforeSelected = pane.tabs.indices.contains(index + 1)
&& pane.tabs[index + 1].id == pane.selectedTabId
// A dropped tab's drag image is still sliding here: one tab, not two.
let isSettling = splitViewController.settlingTabId == tab.id
TabItemView(
tab: tab,
isSelected: pane.selectedTabId == tab.id,
Expand Down Expand Up @@ -1432,6 +1434,7 @@ struct TabBarView: View {
controller.requestTabMove(toDestination: destinationId, for: TabID(id: tab.id), inPane: pane.id)
}
)
.opacity(isSettling ? 0 : 1)
.background(
TabItemHitRegionView(
tabId: tab.id,
Expand Down
189 changes: 189 additions & 0 deletions Tests/BonsplitTests/TabDropSettleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import AppKit
@testable import Bonsplit
import XCTest

/// A dropped tab settles into its slot: the drop asks AppKit to slide the
/// drag image to where the tab lands instead of letting it vanish under the
/// pointer while the tab appears elsewhere at once.
@MainActor
final class TabDropSettleTests: XCTestCase {
func testLaterTabDroppedEarlierSlidesToTheSlotItTakes() throws {
let strip = try makeStrip()
let moved = strip.pane.tabs[3]
let splits = strip.controller.internalController
let generation = splits.beginTabDrag(moved, from: strip.pane.id)
// Over the second tab's leading half: insertion index 1.
let drop = try makeDrop(for: moved, at: NSPoint(x: 110, y: 15), in: strip)

XCTAssertTrue(strip.destination.performDragOperation(drop))

XCTAssertEqual(strip.pane.tabs.firstIndex(where: { $0.id == moved.id }), 1)
XCTAssertTrue(drop.animatesToDestination)
XCTAssertEqual(drop.item.draggingFrame, NSRect(x: 100, y: 0, width: 100, height: 30))
// The tab shows in its slot once the image has landed, not before.
XCTAssertEqual(splits.settlingTabId, moved.id)
splits.nativeTabDragSessionDidEnd(generation: generation)
XCTAssertNil(splits.settlingTabId)
}

func testUnpinnedTabDroppedAmongPinnedTabsSlidesToWhereThePaneKeepsIt() throws {
let strip = try makeStrip()
strip.pane.tabs[0].isPinned = true
strip.pane.tabs[1].isPinned = true
let moved = strip.pane.tabs[3]
_ = strip.controller.internalController.beginTabDrag(moved, from: strip.pane.id)
// Dropped between the two pinned tabs: the pane keeps it after them.
let drop = try makeDrop(for: moved, at: NSPoint(x: 110, y: 15), in: strip)

XCTAssertTrue(strip.destination.performDragOperation(drop))

XCTAssertEqual(strip.pane.tabs.firstIndex(where: { $0.id == moved.id }), 2)
XCTAssertTrue(drop.animatesToDestination)
XCTAssertEqual(drop.item.draggingFrame, NSRect(x: 200, y: 0, width: 100, height: 30))
}

func testReduceMotionDropLandsAtOnce() throws {
let strip = try makeStrip()
strip.destination.reducesMotion = { true }
let moved = strip.pane.tabs[3]
_ = strip.controller.internalController.beginTabDrag(moved, from: strip.pane.id)
let drop = try makeDrop(for: moved, at: NSPoint(x: 110, y: 15), in: strip)

XCTAssertTrue(strip.destination.performDragOperation(drop))

XCTAssertEqual(strip.pane.tabs.firstIndex(where: { $0.id == moved.id }), 1)
XCTAssertFalse(drop.animatesToDestination)
XCTAssertNil(strip.controller.internalController.settlingTabId)
}

func testEarlierTabDroppedLaterSlidesToTheSlotItTakes() throws {
let strip = try makeStrip()
let moved = strip.pane.tabs[0]
_ = strip.controller.internalController.beginTabDrag(moved, from: strip.pane.id)
// Over the fourth tab's leading half: insertion index 3, so the tab
// lands third, where the third tab ends once the tabs between move up.
let drop = try makeDrop(for: moved, at: NSPoint(x: 310, y: 15), in: strip)

XCTAssertTrue(strip.destination.performDragOperation(drop))

XCTAssertEqual(strip.pane.tabs.firstIndex(where: { $0.id == moved.id }), 2)
XCTAssertTrue(drop.animatesToDestination)
XCTAssertEqual(drop.item.draggingFrame, NSRect(x: 200, y: 0, width: 100, height: 30))
}

// MARK: Harness

private struct Strip {
let window: NSWindow
let controller: BonsplitController
let pane: PaneState
let registry: TabDragTransferRegistry
let geometry: TabBarItemGeometryRegistry
let destination: TabBarDropDestinationNSView
}

/// Four 100-wide tabs in a 400-wide strip, each tab view registered as
/// the strip registers its live tab items.
private func makeStrip() throws -> Strip {
let registry = TabDragTransferRegistry()
let controller = BonsplitController(
configuration: BonsplitConfiguration(
allowTabReordering: true,
allowCrossPaneTabMove: true,
newTabPosition: .end
),
tabDragTransferRegistry: registry
)
let paneId = try XCTUnwrap(controller.focusedPaneId)
_ = controller.createTab(title: "Second")
_ = controller.createTab(title: "Third")
_ = controller.createTab(title: "Fourth")
let pane = try XCTUnwrap(controller.internalController.paneState(for: paneId))
XCTAssertEqual(pane.tabs.count, 4)

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 30),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
let contentView = try XCTUnwrap(window.contentView)
let geometry = TabBarItemGeometryRegistry()
for (index, tab) in pane.tabs.enumerated() {
let item = NSView(frame: NSRect(x: CGFloat(index) * 100, y: 0, width: 100, height: 30))
contentView.addSubview(item)
geometry.register(item, for: tab.id)
}
let destination = TabBarDropDestinationNSView(frame: contentView.bounds)
destination.pane = pane
destination.bonsplitController = controller
destination.splitViewController = controller.internalController
destination.geometryRegistry = geometry
// The runner's own Reduce Motion setting must not decide the test.
destination.reducesMotion = { false }
contentView.addSubview(destination)
return Strip(
window: window,
controller: controller,
pane: pane,
registry: registry,
geometry: geometry,
destination: destination
)
}

private func makeDrop(for tab: TabItem, at location: NSPoint, in strip: Strip) throws -> DropInfo {
let pasteboard = NSPasteboard(name: NSPasteboard.Name("TabDropSettleTests.\(UUID().uuidString)"))
_ = pasteboard.clearContents()
let transfer = TabDragTransfer(tab: Tab(from: tab), sourcePaneId: strip.pane.id)
let registration = try XCTUnwrap(strip.registry.register(transfer))
XCTAssertTrue(pasteboard.writeObjects([registration.pasteboardItem]))
// The drag image as AppKit holds it: tab-sized, under the pointer.
let item = NSDraggingItem(pasteboardWriter: NSPasteboardItem())
item.draggingFrame = NSRect(x: location.x - 50, y: 0, width: 100, height: 30)
return DropInfo(window: strip.window, location: location, pasteboard: pasteboard, item: item)
}
}

/// The parts of a drop AppKit hands a destination, with the one dragging
/// item whose frame the destination may move.
private final class DropInfo: NSObject, NSDraggingInfo {
let window: NSWindow
let location: NSPoint
let pasteboard: NSPasteboard
let item: NSDraggingItem

init(window: NSWindow, location: NSPoint, pasteboard: NSPasteboard, item: NSDraggingItem) {
self.window = window
self.location = location
self.pasteboard = pasteboard
self.item = item
}

var draggingDestinationWindow: NSWindow? { window }
var draggingSourceOperationMask: NSDragOperation { .move }
var draggingLocation: NSPoint { location }
var draggedImageLocation: NSPoint { item.draggingFrame.origin }
var draggedImage: NSImage? { nil }
var draggingPasteboard: NSPasteboard { pasteboard }
var draggingSource: Any? { nil }
var draggingSequenceNumber: Int { 1 }
var draggingFormation: NSDraggingFormation = .default
var animatesToDestination = false
var numberOfValidItemsForDrop = 1
var springLoadingHighlight: NSSpringLoadingHighlight { .none }

func slideDraggedImage(to screenPoint: NSPoint) {}
func resetSpringLoading() {}

func enumerateDraggingItems(
options enumOpts: NSDraggingItemEnumerationOptions = [],
for view: NSView?,
classes classArray: [AnyClass],
searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [:],
using block: (NSDraggingItem, Int, UnsafeMutablePointer<ObjCBool>) -> Void
) {
var stop: ObjCBool = false
block(item, 0, &stop)
}
}
Loading