From db7af21ee3c9f6ea5fc912cee5603bc58fe06eae Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 14:40:08 -0700 Subject: [PATCH 1/8] test: a dropped tab slides to the slot it takes Red: the drop leaves animatesToDestination off, so AppKit drops the drag image under the pointer while the tab appears in its slot at once. Co-Authored-By: Claude Opus 5.5 --- Tests/BonsplitTests/TabDropSettleTests.swift | 154 +++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 Tests/BonsplitTests/TabDropSettleTests.swift diff --git a/Tests/BonsplitTests/TabDropSettleTests.swift b/Tests/BonsplitTests/TabDropSettleTests.swift new file mode 100644 index 00000000..332f6408 --- /dev/null +++ b/Tests/BonsplitTests/TabDropSettleTests.swift @@ -0,0 +1,154 @@ +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] + _ = strip.controller.internalController.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)) + } + + 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 + 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. +@MainActor +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 namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { nil } + func resetSpringLoading() {} + + func enumerateDraggingItems( + options enumOpts: NSDraggingItemEnumerationOptions = [], + for view: NSView?, + classes classArray: [AnyClass], + searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [:], + using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void + ) { + var stop: ObjCBool = false + block(item, 0, &stop) + } +} From 8aee5e07d677aba77523690df9fe0167f0837cc7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 14:43:20 -0700 Subject: [PATCH 2/8] test: the drop fake takes NSObject's resetSpringLoading Co-Authored-By: Claude Opus 5.5 --- Tests/BonsplitTests/TabDropSettleTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/BonsplitTests/TabDropSettleTests.swift b/Tests/BonsplitTests/TabDropSettleTests.swift index 332f6408..0369d382 100644 --- a/Tests/BonsplitTests/TabDropSettleTests.swift +++ b/Tests/BonsplitTests/TabDropSettleTests.swift @@ -139,7 +139,6 @@ private final class DropInfo: NSObject, NSDraggingInfo { func slideDraggedImage(to screenPoint: NSPoint) {} func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { nil } - func resetSpringLoading() {} func enumerateDraggingItems( options enumOpts: NSDraggingItemEnumerationOptions = [], From 85a5b5fc580945ff94242f5933429d08633e5382 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 14:45:21 -0700 Subject: [PATCH 3/8] test: the drop fake conforms as AppKit declares NSDraggingInfo Co-Authored-By: Claude Opus 5.5 --- Tests/BonsplitTests/TabDropSettleTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/BonsplitTests/TabDropSettleTests.swift b/Tests/BonsplitTests/TabDropSettleTests.swift index 0369d382..609d52e6 100644 --- a/Tests/BonsplitTests/TabDropSettleTests.swift +++ b/Tests/BonsplitTests/TabDropSettleTests.swift @@ -110,7 +110,6 @@ final class TabDropSettleTests: XCTestCase { /// The parts of a drop AppKit hands a destination, with the one dragging /// item whose frame the destination may move. -@MainActor private final class DropInfo: NSObject, NSDraggingInfo { let window: NSWindow let location: NSPoint @@ -138,7 +137,7 @@ private final class DropInfo: NSObject, NSDraggingInfo { var springLoadingHighlight: NSSpringLoadingHighlight { .none } func slideDraggedImage(to screenPoint: NSPoint) {} - func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { nil } + func resetSpringLoading() {} func enumerateDraggingItems( options enumOpts: NSDraggingItemEnumerationOptions = [], From 7b9b8ca0ec16e477e004680bb4ac07825704771c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 14:47:39 -0700 Subject: [PATCH 4/8] A dropped tab settles into its slot The drop asks AppKit to slide the drag image to the slot the tab takes (animatesToDestination, the item's frame read from the strip before the move reflows it). The tab draws nothing there until the drag session ends (or 0.5 s), so the gliding image and the tab never show twice. Reduce Motion keeps the drop instant. Only the tab row moves. Co-Authored-By: Claude Opus 5.5 --- .../SplitViewController+TabDrag.swift | 2 + .../SplitViewController+TabSettle.swift | 30 ++++++++++ .../Controllers/SplitViewController.swift | 5 ++ .../Views/TabBarDropDestinationNSView.swift | 58 ++++++++++++++++++- .../Bonsplit/Internal/Views/TabBarView.swift | 3 + Tests/BonsplitTests/TabDropSettleTests.swift | 21 ++++++- 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 Sources/Bonsplit/Internal/Controllers/SplitViewController+TabSettle.swift diff --git a/Sources/Bonsplit/Internal/Controllers/SplitViewController+TabDrag.swift b/Sources/Bonsplit/Internal/Controllers/SplitViewController+TabDrag.swift index 4f2d187f..cfcce866 100644 --- a/Sources/Bonsplit/Internal/Controllers/SplitViewController+TabDrag.swift +++ b/Sources/Bonsplit/Internal/Controllers/SplitViewController+TabDrag.swift @@ -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() } } diff --git a/Sources/Bonsplit/Internal/Controllers/SplitViewController+TabSettle.swift b/Sources/Bonsplit/Internal/Controllers/SplitViewController+TabSettle.swift new file mode 100644 index 00000000..db79b57a --- /dev/null +++ b/Sources/Bonsplit/Internal/Controllers/SplitViewController+TabSettle.swift @@ -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 + } + } +} diff --git a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift index 8f8af7a6..4923fcdb 100644 --- a/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift +++ b/Sources/Bonsplit/Internal/Controllers/SplitViewController.swift @@ -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 diff --git a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift index d0270db7..deb9716c 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift @@ -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? @@ -62,7 +64,61 @@ 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 settle = settleSlot(for: sender, at: targetIndex) + guard handler.performDrop(from: sender.draggingPasteboard, at: targetIndex) else { return false } + if let settle { + // 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 = settle.frame + } + splitViewController?.beginTabSettle(settle.tabId) + } + return true + } + + /// The slot, in this view, that the dragged tab takes when dropped at + /// `index`: nil when the drop lands at once (not a tab drag from this + /// app, Reduce Motion, a slot whose tabs have no frames). + private func settleSlot(for sender: any NSDraggingInfo, at index: Int) -> (tabId: UUID, frame: NSRect)? { + guard !reducesMotion(), let pane, let geometryRegistry, + let session = splitViewController?.tabDragSession else { return nil } + let frames = pane.tabs.map { geometryRegistry.frame(for: $0.id, in: self) } + guard let row = frames.lazy.compactMap({ $0 }).first else { return nil } + let minX: CGFloat + let width: CGFloat + if session.sourcePaneId == pane.id, + let source = pane.tabs.firstIndex(where: { $0.id == session.tab.id }), + let own = frames[source] { + // The tabs between move over by its width. + width = own.width + if index > source { + guard let before = frames[index - 1] else { return nil } + minX = before.maxX - width + } else { + guard let at = frames[index] else { return nil } + minX = at.minX + } + } else { + // From another pane: the slot opens where the indicator showed. + var imageWidth: CGFloat = 0 + sender.enumerateDraggingItems(options: [], for: self, classes: [NSPasteboardItem.self], searchOptions: [:]) { item, _, stop in + imageWidth = item.draggingFrame.width + stop.pointee = true + } + guard imageWidth > 0 else { return nil } + width = imageWidth + if index < frames.count { + guard let at = frames[index] else { return nil } + minX = at.minX + } else { + guard let last = frames.last ?? nil else { return nil } + minX = last.maxX + } + } + return (session.tab.id, NSRect(x: minX, y: row.minY, width: width, height: row.height)) } override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index f5c38df0..bef2f05d 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -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, @@ -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, diff --git a/Tests/BonsplitTests/TabDropSettleTests.swift b/Tests/BonsplitTests/TabDropSettleTests.swift index 609d52e6..001b4f2c 100644 --- a/Tests/BonsplitTests/TabDropSettleTests.swift +++ b/Tests/BonsplitTests/TabDropSettleTests.swift @@ -10,7 +10,8 @@ final class TabDropSettleTests: XCTestCase { func testLaterTabDroppedEarlierSlidesToTheSlotItTakes() throws { let strip = try makeStrip() let moved = strip.pane.tabs[3] - _ = strip.controller.internalController.beginTabDrag(moved, from: strip.pane.id) + 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) @@ -19,6 +20,24 @@ final class TabDropSettleTests: XCTestCase { 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 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 { From 25a1fab6b4dcfdc6e73825b17912886ec13884fc Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 14:51:27 -0700 Subject: [PATCH 5/8] Tab settle: land where the pane keeps the tab; tab drags only; in view only Review and CI: - The slot comes from the tab's index after the drop (pinned tabs stay first), measured on the strip as it was before the drop. - Only a drop carrying a tab transfer settles: a file dropped while a stale tab session lingers no longer hides that tab. - A slot not fully in view (the strip scrolls to reveal it) lands at once. - Tests pin reducesMotion off: the CI runner's setting decided them. Co-Authored-By: Claude Opus 5.5 --- .../Views/TabBarDropDestinationNSView.swift | 97 +++++++++++-------- Tests/BonsplitTests/TabDropSettleTests.swift | 18 ++++ 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift index deb9716c..3cc8f089 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift @@ -65,68 +65,81 @@ final class TabBarDropDestinationNSView: NSView { return false } // Read from the strip before the move reflows it. - let settle = settleSlot(for: sender, at: targetIndex) + let before = settleSnapshot(for: sender) guard handler.performDrop(from: sender.draggingPasteboard, at: targetIndex) else { return false } - if let settle { + 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 = settle.frame + item.draggingFrame = slot } - splitViewController?.beginTabSettle(settle.tabId) + splitViewController?.beginTabSettle(before.tabId) } return true } - /// The slot, in this view, that the dragged tab takes when dropped at - /// `index`: nil when the drop lands at once (not a tab drag from this - /// app, Reduce Motion, a slot whose tabs have no frames). - private func settleSlot(for sender: any NSDraggingInfo, at index: Int) -> (tabId: UUID, frame: NSRect)? { + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + clearDropState() + } + + override func draggingEnded(_ sender: any NSDraggingInfo) { + clearDropState() + } + + /// The strip as a drop found it: the dragged tab, where it was in this + /// strip (nil from another pane), its width and every tab's frame here. + private struct SettleSnapshot { + let tabId: UUID + let source: Int? + let width: CGFloat + let frames: [CGRect?] + } + + /// Nil when the drop lands at once: not a tab dragged from this app, + /// Reduce Motion, 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 else { return nil } let frames = pane.tabs.map { geometryRegistry.frame(for: $0.id, in: self) } - guard let row = frames.lazy.compactMap({ $0 }).first else { return nil } - let minX: CGFloat - let width: CGFloat - if session.sourcePaneId == pane.id, - let source = pane.tabs.firstIndex(where: { $0.id == session.tab.id }), - let own = frames[source] { - // The tabs between move over by its width. - width = own.width - if index > source { - guard let before = frames[index - 1] else { return nil } - minX = before.maxX - width - } else { - guard let at = frames[index] else { return nil } - minX = at.minX - } + let source = session.sourcePaneId == pane.id ? pane.tabs.firstIndex(where: { $0.id == session.tab.id }) : nil + var width: CGFloat = 0 + if let source { + width = frames[source]?.width ?? 0 } else { - // From another pane: the slot opens where the indicator showed. - var imageWidth: CGFloat = 0 sender.enumerateDraggingItems(options: [], for: self, classes: [NSPasteboardItem.self], searchOptions: [:]) { item, _, stop in - imageWidth = item.draggingFrame.width + width = item.draggingFrame.width stop.pointee = true } - guard imageWidth > 0 else { return nil } - width = imageWidth - if index < frames.count { - guard let at = frames[index] else { return nil } - minX = at.minX - } else { - guard let last = frames.last ?? nil else { return nil } - minX = last.maxX - } } - return (session.tab.id, NSRect(x: minX, y: row.minY, width: width, height: row.height)) - } - - override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { - clearDropState() + guard width > 0 else { return nil } + return SettleSnapshot(tabId: session.tab.id, source: source, width: width, frames: frames) } - override func draggingEnded(_ sender: any NSDraggingInfo) { - clearDropState() + /// 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 when it is not fully in view: the strip scrolls to reveal it. + private func settleSlot(after before: SettleSnapshot) -> NSRect? { + guard let pane, let landed = pane.tabs.firstIndex(where: { $0.id == before.tabId }), + let row = before.frames.lazy.compactMap({ $0 }).first else { return nil } + let frames = before.frames + let minX: CGFloat + if let source = before.source, landed > source { + // The tabs between move over by its width. + guard let end = frames[landed] else { return nil } + minX = end.maxX - before.width + } else if landed < frames.count { + guard let at = frames[landed] else { return nil } + minX = at.minX + } else { + guard let last = frames.last ?? nil else { return nil } + minX = last.maxX + } + let slot = NSRect(x: minX, y: row.minY, width: before.width, height: row.height) + let visible = NSRect(x: bounds.minX, y: bounds.minY, + width: bounds.width - (geometryRegistry?.trailingObscuredWidth ?? 0), height: bounds.height) + return visible.contains(slot) ? slot : nil } static func shouldCaptureHitTest( diff --git a/Tests/BonsplitTests/TabDropSettleTests.swift b/Tests/BonsplitTests/TabDropSettleTests.swift index 001b4f2c..903625ef 100644 --- a/Tests/BonsplitTests/TabDropSettleTests.swift +++ b/Tests/BonsplitTests/TabDropSettleTests.swift @@ -26,6 +26,22 @@ final class TabDropSettleTests: XCTestCase { 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 } @@ -103,6 +119,8 @@ final class TabDropSettleTests: XCTestCase { 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, From 632dea3ca1706e993fd8346d46d32f2dbfa2f5f0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 14:56:38 -0700 Subject: [PATCH 6/8] Tab settle: reorders within a strip only, every passed tab on screen Review: a tab from another pane lands in a layout that changes under it (its emptied pane closes and the strip widens; fill-width tabs narrow), and the old visible check let a slot half under the pinned tabs of a scrolled strip settle before the strip scrolled. Settling now needs a reorder within the strip with every tab the slot passes fully on screen. Co-Authored-By: Claude Opus 5.5 --- .../Views/TabBarDropDestinationNSView.swift | 65 ++++++++----------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift index 3cc8f089..ea27557c 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift @@ -87,59 +87,48 @@ final class TabBarDropDestinationNSView: NSView { clearDropState() } - /// The strip as a drop found it: the dragged tab, where it was in this - /// strip (nil from another pane), its width and every tab's frame here. + /// 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 source: Int let width: CGFloat let frames: [CGRect?] + let visible: [CGRect?] } - /// Nil when the drop lands at once: not a tab dragged from this app, - /// Reduce Motion, a strip whose tabs have no frames. + /// 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 else { return nil } - let frames = pane.tabs.map { geometryRegistry.frame(for: $0.id, in: self) } - let source = session.sourcePaneId == pane.id ? pane.tabs.firstIndex(where: { $0.id == session.tab.id }) : nil - var width: CGFloat = 0 - if let source { - width = frames[source]?.width ?? 0 - } else { - sender.enumerateDraggingItems(options: [], for: self, classes: [NSPasteboardItem.self], searchOptions: [:]) { item, _, stop in - width = item.draggingFrame.width - stop.pointee = true - } - } - guard width > 0 else { return nil } - return SettleSnapshot(tabId: session.tab.id, source: source, width: width, frames: frames) + 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] }) } /// 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 when it is not fully in view: the strip scrolls to reveal it. + /// drop. Nil unless every tab it passes is fully on screen: a slot + /// scrolled under the edge or the pinned tabs makes the strip scroll. private func settleSlot(after before: SettleSnapshot) -> NSRect? { - guard let pane, let landed = pane.tabs.firstIndex(where: { $0.id == before.tabId }), - let row = before.frames.lazy.compactMap({ $0 }).first else { return nil } - let frames = before.frames - let minX: CGFloat - if let source = before.source, landed > source { - // The tabs between move over by its width. - guard let end = frames[landed] else { return nil } - minX = end.maxX - before.width - } else if landed < frames.count { - guard let at = frames[landed] else { return nil } - minX = at.minX - } else { - guard let last = frames.last ?? nil else { return nil } - minX = last.maxX + 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 else { return nil } } - let slot = NSRect(x: minX, y: row.minY, width: before.width, height: row.height) - let visible = NSRect(x: bounds.minX, y: bounds.minY, - width: bounds.width - (geometryRegistry?.trailingObscuredWidth ?? 0), height: bounds.height) - return visible.contains(slot) ? slot : 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( From 447e6215335ca748e55a2a98341d1cc7a99cfbf8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 14:58:15 -0700 Subject: [PATCH 7/8] Tab settle: not under the trailing button lane Review: the clip view runs under the split-button lane, so a slot there counted as on screen, then the strip scrolled to reveal the selected tab. Every tab the slot passes must lie in the viewport outside that lane, the width the strip's own reveal uses. Co-Authored-By: Claude Opus 5.5 --- .../Views/TabBarDropDestinationNSView.swift | 13 +++++++++---- .../Internal/Views/TabBarItemGeometryRegistry.swift | 9 +++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift index ea27557c..f6a61964 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift @@ -95,6 +95,8 @@ final class TabBarDropDestinationNSView: NSView { let width: CGFloat let frames: [CGRect?] let visible: [CGRect?] + /// The strip's viewport outside the trailing button lane. + let viewport: CGRect? } /// Nil when the drop lands at once: Reduce Motion, not a tab dragged @@ -111,19 +113,22 @@ final class TabBarDropDestinationNSView: NSView { 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] }) + visible: ids.map { visible[$0] }, + viewport: geometryRegistry.unobscuredViewport(in: self)) } /// 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: a slot - /// scrolled under the edge or the pinned tabs makes the strip scroll. + /// 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 else { return nil } + abs(shown.width - frame.width) < 0.5, + 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. diff --git a/Sources/Bonsplit/Internal/Views/TabBarItemGeometryRegistry.swift b/Sources/Bonsplit/Internal/Views/TabBarItemGeometryRegistry.swift index e107f659..aa556218 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarItemGeometryRegistry.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarItemGeometryRegistry.swift @@ -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, From 4df2b47ad07bf7fdcec7952217a665d1c5421420 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 11 Oct 2026 15:01:18 -0700 Subject: [PATCH 8/8] Tab settle: pinned tabs are always in view Review: pinned tabs sit before the scrolling viewport, so the viewport check refused every pinned reorder. A reorder never crosses the pinned boundary, so pinned tabs skip it. Co-Authored-By: Claude Opus 5.5 --- .../Internal/Views/TabBarDropDestinationNSView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift index f6a61964..0ed260fc 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift @@ -95,8 +95,10 @@ final class TabBarDropDestinationNSView: NSView { let width: CGFloat let frames: [CGRect?] let visible: [CGRect?] - /// The strip's viewport outside the trailing button lane. + /// 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 @@ -114,7 +116,8 @@ final class TabBarDropDestinationNSView: NSView { 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)) + 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 @@ -127,7 +130,8 @@ final class TabBarDropDestinationNSView: NSView { for index in span { guard let frame = before.frames[index], let shown = before.visible[index], abs(shown.width - frame.width) < 0.5, - before.viewport.map({ frame.minX >= $0.minX - 0.5 && frame.maxX <= $0.maxX + 0.5 }) ?? true + 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 }