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
153 changes: 153 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabBarDropDestinationNSView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import AppKit

/// AppKit bridge that keeps one authoritative pointer-to-index calculation per strip.
@MainActor
final class TabBarDropDestinationNSView: NSView {
weak var pane: PaneState?
weak var bonsplitController: BonsplitController?
weak var splitViewController: SplitViewController?
weak var geometryRegistry: TabBarItemGeometryRegistry?
var onIndicatorIndexChanged: ((Int?) -> Void)?

private let dropIndexResolver: TabDropIndexResolver
private var presentedIndicatorIndex: Int?

override init(frame frameRect: NSRect) {
dropIndexResolver = TabDropIndexResolver()
super.init(frame: frameRect)
registerForDraggedTypes([
TabBarDropHandler.tabTransferPasteboardType,
.fileURL,
])
}

@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}

override func hitTest(_ point: NSPoint) -> NSView? {
guard super.hitTest(point) === self else { return nil }
let dragPasteboard = NSPasteboard(name: .drag)
let handler = makeHandler()
guard Self.shouldCaptureHitTest(
eventType: NSApp.currentEvent?.type,
pasteboardTypes: dragPasteboard.types,
hasLocalTabDrag: handler?.hasLiveTabDrag == true
), handler?.operation(for: dragPasteboard) != [] else {
return nil
}
return self
}

override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDrag(sender)
}

override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
updateDrag(sender)
}

override func draggingExited(_ sender: (any NSDraggingInfo)?) {
clearDropState()
}

override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
updateDrag(sender) != []
}

override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
defer { clearDropState() }
guard let handler = makeHandler(),
let targetIndex = insertionIndex(for: sender) else {
return false
}
return handler.performDrop(from: sender.draggingPasteboard, at: targetIndex)
}

override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
clearDropState()
}

override func draggingEnded(_ sender: any NSDraggingInfo) {
clearDropState()
}

static func shouldCaptureHitTest(
eventType: NSEvent.EventType?,
pasteboardTypes: [NSPasteboard.PasteboardType]?,
hasLocalTabDrag: Bool = false
) -> Bool {
let hasSupportedPasteboardType = pasteboardTypes?.contains(
TabBarDropHandler.tabTransferPasteboardType
) == true || pasteboardTypes?.contains(.fileURL) == true
guard hasLocalTabDrag || hasSupportedPasteboardType else {
return false
}
guard let eventType else { return true }
switch eventType {
case .leftMouseUp:
// The drag pasteboard retains types after a drag ends. A normal click's
// mouse-up may only be captured while the controller owns a live session.
return hasLocalTabDrag
case .leftMouseDragged,
.mouseMoved,
.cursorUpdate,
.periodic,
.appKitDefined,
.applicationDefined,
.systemDefined:
return true
default:
return false
}
}

private func updateDrag(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard let handler = makeHandler(),
let targetIndex = insertionIndex(for: sender) else {
clearDropState()
return []
}
let operation = handler.operation(for: sender.draggingPasteboard)
guard operation != [] else {
clearDropState()
return []
}
presentIndicator(at: handler.indicatorIndex(for: targetIndex))
return operation
}

private func insertionIndex(for sender: any NSDraggingInfo) -> Int? {
guard let pane, let geometryRegistry else { return nil }
let indexedFrames = pane.tabs.enumerated().compactMap { index, tab -> TabDropIndexResolver.IndexedFrame? in
guard let frame = geometryRegistry.frame(for: tab.id, in: self) else { return nil }
return TabDropIndexResolver.IndexedFrame(index: index, frame: frame)
}
return dropIndexResolver.insertionIndex(
at: convert(sender.draggingLocation, from: nil),
in: bounds,
indexedTabFrames: indexedFrames,
tabCount: pane.tabs.count
)
}

private func makeHandler() -> TabBarDropHandler? {
guard let pane, let bonsplitController, let splitViewController else { return nil }
return TabBarDropHandler(
pane: pane,
bonsplitController: bonsplitController,
splitViewController: splitViewController
)
}

private func presentIndicator(at index: Int?) {
guard presentedIndicatorIndex != index else { return }
presentedIndicatorIndex = index
onIndicatorIndexChanged?(index)
}

private func clearDropState() {
presentIndicator(at: nil)
}
}
29 changes: 29 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabBarDropDestinationView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import SwiftUI

/// A single native drop destination spanning one pane's horizontal tab strip.
@MainActor
struct TabBarDropDestinationView: NSViewRepresentable {
let pane: PaneState
let bonsplitController: BonsplitController
let splitViewController: SplitViewController
let geometryRegistry: TabBarItemGeometryRegistry
let onIndicatorIndexChanged: (Int?) -> Void

func makeNSView(context: Context) -> TabBarDropDestinationNSView {
let view = TabBarDropDestinationNSView()
configure(view)
return view
}

func updateNSView(_ nsView: TabBarDropDestinationNSView, context: Context) {
configure(nsView)
}

private func configure(_ view: TabBarDropDestinationNSView) {
view.pane = pane
view.bonsplitController = bonsplitController
view.splitViewController = splitViewController
view.geometryRegistry = geometryRegistry
view.onIndicatorIndexChanged = onIndicatorIndexChanged
}
}
186 changes: 186 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabBarDropHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers

/// Applies tab-strip drops through the controller's shared tab mutation APIs.
@MainActor
struct TabBarDropHandler {
static let tabTransferPasteboardType = NSPasteboard.PasteboardType(UTType.tabTransfer.identifier)

let pane: PaneState
let bonsplitController: BonsplitController
let splitViewController: SplitViewController

var hasLiveTabDrag: Bool {
splitViewController.tabDragSession != nil
}

func operation(for pasteboard: NSPasteboard) -> NSDragOperation {
guard splitViewController.isInteractive else { return [] }

let types = pasteboard.types ?? []
let hasTabTransfer = types.contains(Self.tabTransferPasteboardType)
let hasFileURL = types.contains(.fileURL)

// A file-only pasteboard is authoritative even if a cancelled local tab
// drag has not finished clearing its in-memory state.
if hasFileURL, !hasTabTransfer {
guard bonsplitController.onExternalFileDrop != nil || splitViewController.onFileDrop != nil,
UnifiedPaneDropDelegate.hasReadableFileURLs(from: pasteboard) else {
return []
}
return .copy
}

// Item-provider publication can trail the start of a local SwiftUI drag.
// The controller's synchronous drag state is authoritative in that window.
if let sourcePaneId = liveDragSourcePaneId {
return permitsTabMove(from: sourcePaneId) ? .move : []
}

if hasTabTransfer {
guard let transfer = Self.decodeTransfer(from: pasteboard), transfer.isFromCurrentProcess else {
return []
}
let sourcePaneId = PaneID(id: transfer.sourcePaneId)
guard sourcePaneId != pane.id,
bonsplitController.configuration.allowCrossPaneTabMove,
bonsplitController.onExternalTabDrop != nil else {
return []
}
return .move
}

return []
}

func indicatorIndex(for targetIndex: Int) -> Int? {
guard let sourceIndex = liveSamePaneSourceIndex else { return targetIndex }
return Self.isNoopSamePaneTarget(sourceIndex: sourceIndex, targetIndex: targetIndex)
? nil
: targetIndex
}

func performDrop(from pasteboard: NSPasteboard, at targetIndex: Int) -> Bool {
guard operation(for: pasteboard) != [] else { return false }
let hasTabTransfer = pasteboard.types?.contains(Self.tabTransferPasteboardType) == true
let hasFileURL = pasteboard.types?.contains(.fileURL) == true

if hasFileURL, !hasTabTransfer {
return performFileDrop(from: pasteboard, at: targetIndex)
}
if hasLiveTabDrag || hasTabTransfer {
return performTabDrop(from: pasteboard, at: targetIndex)
}
return false
}

static func isNoopSamePaneTarget(sourceIndex: Int, targetIndex: Int) -> Bool {
// Insertion indices use the original array's coordinates. Removing the source
// makes its own slot and the slot immediately after it equivalent no-ops.
targetIndex == sourceIndex || targetIndex == sourceIndex + 1
}

static func sameProcessFallbackRequest(
transfer: TabTransferData,
targetPane: PaneID,
targetIndex: Int,
allowCrossPaneTabMove: Bool
) -> BonsplitController.ExternalTabDropRequest? {
guard transfer.isFromCurrentProcess else { return nil }
let sourcePaneId = PaneID(id: transfer.sourcePaneId)
guard sourcePaneId != targetPane, allowCrossPaneTabMove else { return nil }
return BonsplitController.ExternalTabDropRequest(
tabId: TabID(id: transfer.tab.id),
sourcePaneId: sourcePaneId,
destination: .insert(targetPane: targetPane, targetIndex: targetIndex)
)
}

private var liveDragSourcePaneId: PaneID? {
splitViewController.tabDragSession?.sourcePaneId
}

private var liveSamePaneSourceIndex: Int? {
guard let dragSession = splitViewController.tabDragSession,
dragSession.sourcePaneId == pane.id else {
return nil
}
return pane.tabs.firstIndex(where: { $0.id == dragSession.tab.id })
}

private func permitsTabMove(from sourcePaneId: PaneID) -> Bool {
sourcePaneId == pane.id
? bonsplitController.configuration.allowTabReordering
: bonsplitController.configuration.allowCrossPaneTabMove
}

private func performTabDrop(from pasteboard: NSPasteboard, at targetIndex: Int) -> Bool {
if let dragSession = splitViewController.tabDragSession {
defer { splitViewController.clearTabDragState() }

let draggedTab = dragSession.tab
let sourcePaneId = dragSession.sourcePaneId
if sourcePaneId == pane.id,
let sourceIndex = pane.tabs.firstIndex(where: { $0.id == draggedTab.id }),
Self.isNoopSamePaneTarget(sourceIndex: sourceIndex, targetIndex: targetIndex) {
return true
}

var handled = false
withTransaction(Transaction(animation: nil)) {
if sourcePaneId == pane.id {
handled = bonsplitController.reorderTab(TabID(id: draggedTab.id), toIndex: targetIndex)
} else {
handled = bonsplitController.moveTab(
TabID(id: draggedTab.id),
toPane: pane.id,
atIndex: targetIndex
)
}
}
return handled
}

guard let transfer = Self.decodeTransfer(from: pasteboard),
let request = Self.sameProcessFallbackRequest(
transfer: transfer,
targetPane: pane.id,
targetIndex: targetIndex,
allowCrossPaneTabMove: bonsplitController.configuration.allowCrossPaneTabMove
) else {
return false
}
let handled = bonsplitController.onExternalTabDrop?(request) ?? false
if handled {
splitViewController.clearTabDragState()
}
return handled
}

private func performFileDrop(from pasteboard: NSPasteboard, at targetIndex: Int) -> Bool {
let urls = UnifiedPaneDropDelegate.fileURLs(from: pasteboard)
guard !urls.isEmpty else { return false }

let destination: BonsplitController.ExternalTabDropRequest.Destination = .insert(
targetPane: pane.id,
targetIndex: targetIndex
)
if let handler = bonsplitController.onExternalFileDrop {
return handler(BonsplitController.ExternalFileDropRequest(urls: urls, destination: destination))
}
return splitViewController.onFileDrop?(urls, pane.id) ?? false
}

private static func decodeTransfer(from pasteboard: NSPasteboard) -> TabTransferData? {
if let data = pasteboard.data(forType: tabTransferPasteboardType),
let transfer = try? JSONDecoder().decode(TabTransferData.self, from: data) {
return transfer
}
guard let rawValue = pasteboard.string(forType: tabTransferPasteboardType),
let data = rawValue.data(using: .utf8) else {
return nil
}
return try? JSONDecoder().decode(TabTransferData.self, from: data)
}
}
Loading
Loading