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
118 changes: 118 additions & 0 deletions Sources/FileDropHintPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import AppKit
import CmuxFoundation

/// Owns only drag feedback. Dismissing a hint must not cancel WebKit's drop delivery.
@MainActor
final class FileDropHintPresentation: NSObject {
private enum Phase: Equatable {
case idle
case tracking(Int)
case dismissed(Int)
}

let badge = FileDropHintBadgeView(frame: .zero)
private weak var hostWindow: NSWindow?
private var phase = Phase.idle
private var eventMonitor: Any?
private let displayDuration: Duration
private var deadline: MainActorCoalescingDeadlineTimer<FileDropHintPresentation>?

init(displayDuration: Duration = .seconds(8)) {
self.displayDuration = displayDuration
super.init()
}

deinit {
if let eventMonitor { NSEvent.removeMonitor(eventMonitor) }
NotificationCenter.default.removeObserver(self)
}

func setHostWindow(_ window: NSWindow?) {
guard hostWindow !== window else { return }
dismiss()
let center = NotificationCenter.default
center.removeObserver(self)
hostWindow = window
guard let window else { return }

// AppKit delivers these lifecycle notifications synchronously on the main thread.
for name in [NSWindow.didResignKeyNotification, NSWindow.willCloseNotification] {
center.addObserver(self, selector: #selector(environmentDidChange(_:)), name: name, object: window)
}
for name in [NSWindow.didBecomeKeyNotification, NSApplication.didResignActiveNotification] {
center.addObserver(self, selector: #selector(environmentDidChange(_:)), name: name, object: nil)
}
}

func begin(sequenceNumber: Int) {
if phase == .tracking(sequenceNumber) || phase == .dismissed(sequenceNumber) {
// AppKit may re-enter a destination after focus changes during the same drag.
// Its feedback stays dismissed until a genuinely new native session arrives.
return
}
dismiss()
phase = .tracking(sequenceNumber)
}

func show(sequenceNumber: Int, text: String, centeredIn target: CGRect, clippedTo bounds: CGRect) {
guard case .tracking(let current) = phase, current == sequenceNumber, hostWindow != nil else { return }
badge.show(text: text, centeredIn: target, clippedTo: bounds)
if deadline == nil {
// A genuine display deadline driven by synchronous AppKit callbacks, not a retry.
deadline = MainActorCoalescingDeadlineTimer(owner: self) { $0.dismiss() }
}
if deadline?.isScheduled == false {
deadline?.schedule(after: displayDuration)
}
installEventMonitorIfNeeded()
}

func hideBadge() {
badge.hideImmediately()
}

func dismiss() {
if case .tracking(let sequenceNumber) = phase {
phase = .dismissed(sequenceNumber)
}
deadline?.cancel()
badge.hideImmediately()
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
self.eventMonitor = nil
}
}

/// Preserve the native end/cancel event so AppKit can also remove the source drag image.
func handleEvent(_ event: NSEvent) -> NSEvent {
switch event.type {
case .leftMouseUp, .rightMouseUp, .otherMouseUp,
.leftMouseDown, .rightMouseDown, .otherMouseDown, .mouseMoved, .keyDown:
dismiss()
case .flagsChanged:
hideBadge()
default:
break
}
return event
}

private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseUp, .rightMouseUp, .otherMouseUp,
.leftMouseDown, .rightMouseDown, .otherMouseDown,
.mouseMoved, .keyDown, .flagsChanged]
) { [weak self] event in
self?.handleEvent(event) ?? event
}
}

@objc private func environmentDidChange(_ notification: Notification) {
if notification.name == NSWindow.didBecomeKeyNotification,
notification.object as? NSWindow === hostWindow {
return
}
dismiss()
}
}
44 changes: 12 additions & 32 deletions Sources/FileDropOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,6 @@ import Bonsplit
import Foundation
import WebKit

@MainActor
protocol FileDropPaneTarget: AnyObject {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?)
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?)
}

extension PaneDropTargetView: FileDropPaneTarget {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
}

extension BrowserPaneDropTargetView: FileDropPaneTarget {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
}

/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to
/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's
/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply
Expand Down Expand Up @@ -61,7 +33,8 @@ final class FileDropOverlayView: NSView {
var didPerformDragAsText = false
weak var performedTextDragWebView: WKWebView?
weak var performedTextPaneDropTarget: (any FileDropPaneTarget)?
let hintBadgeView = FileDropHintBadgeView(frame: .zero)
let hintPresentation = FileDropHintPresentation()
var hintBadgeView: FileDropHintBadgeView { hintPresentation.badge }
var lastHitTestLogSignature: String?
var lastDragRouteLogSignatureByPhase: [String: String] = [:]
weak var hitTestReferenceView: NSView?
Expand Down Expand Up @@ -275,6 +248,7 @@ final class FileDropOverlayView: NSView {

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
hintPresentation.setHostWindow(window)
if window == nil {
clearForwardedMouseDragState(reason: "overlayDetached")
}
Expand All @@ -299,6 +273,7 @@ final class FileDropOverlayView: NSView {
// HTML5 drag events (dragenter, dragleave, drop) fire correctly.

override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
hintPresentation.begin(sequenceNumber: sender.draggingSequenceNumber)
return updateDragTarget(sender, phase: "entered")
}

Expand All @@ -307,7 +282,7 @@ final class FileDropOverlayView: NSView {
}

override func draggingExited(_ sender: (any NSDraggingInfo)?) {
hintBadgeView.hide()
hintPresentation.dismiss()
preparedDragWebView = nil
preparedPaneDropTarget = nil
didPerformDragAsText = false
Expand All @@ -316,6 +291,10 @@ final class FileDropOverlayView: NSView {
exitActiveDragTargets(sender)
}

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

private func exitActiveDragTargets(_ sender: (any NSDraggingInfo)?) {
if let prev = activeDragWebView {
prev.draggingExited(sender)
Expand Down Expand Up @@ -352,6 +331,7 @@ final class FileDropOverlayView: NSView {
}

override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
hintPresentation.dismiss()
let hasLocalDraggingSource = sender.draggingSource != nil
let types = sender.draggingPasteboard.types
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
Expand Down Expand Up @@ -414,14 +394,14 @@ final class FileDropOverlayView: NSView {
}

override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
hintPresentation.dismiss()
let hasLocalDraggingSource = sender.draggingSource != nil
let types = sender.draggingPasteboard.types
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
pasteboardTypes: types,
hasLocalDraggingSource: hasLocalDraggingSource
)
if shouldRouteFileDropToTextDestination(sender) {
hintBadgeView.hide()
didPerformDragAsText = false
performedTextDragWebView = nil
performedTextPaneDropTarget = nil
Expand Down Expand Up @@ -515,7 +495,7 @@ final class FileDropOverlayView: NSView {

override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
defer {
hintBadgeView.hide()
hintPresentation.dismiss()
preparedDragWebView = nil
activeDragWebView = nil
preparedPaneDropTarget = nil
Expand Down
18 changes: 14 additions & 4 deletions Sources/FileDropOverlayViewHitTesting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,15 @@ extension FileDropOverlayView {
!DragOverlayRoutingPolicy.currentModifierFlags.contains(.shift),
let hintText = FileDropTextDestinationKind.editor.hintText(for: .preview),
let targetBounds = hintBadgeTargetBoundsUnderPoint(windowPoint) else {
hintBadgeView.hide()
hintPresentation.hideBadge()
return
}
hintBadgeView.show(text: hintText, centeredIn: targetBounds, clippedTo: bounds)
hintPresentation.show(
sequenceNumber: sender.draggingSequenceNumber,
text: hintText,
centeredIn: targetBounds,
clippedTo: bounds
)
return
}

Expand All @@ -146,10 +151,15 @@ extension FileDropOverlayView {
), let kind,
let hintText = kind.hintText(for: alternateBehavior),
let targetBounds = hintBadgeTargetBoundsUnderPoint(windowPoint) else {
hintBadgeView.hide()
hintPresentation.hideBadge()
return
}
hintBadgeView.show(text: hintText, centeredIn: targetBounds, clippedTo: bounds)
hintPresentation.show(
sequenceNumber: sender.draggingSequenceNumber,
text: hintText,
centeredIn: targetBounds,
clippedTo: bounds
)
}

func textDropDestinationKindUnderPoint(_ windowPoint: NSPoint) -> FileDropTextDestinationKind? {
Expand Down
29 changes: 29 additions & 0 deletions Sources/FileDropPaneTarget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import AppKit

@MainActor
protocol FileDropPaneTarget: AnyObject {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?)
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?)
}

extension PaneDropTargetView: FileDropPaneTarget {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
}

extension BrowserPaneDropTargetView: FileDropPaneTarget {
func fileDropDraggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingEntered(sender) }
func fileDropDraggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }
func fileDropDraggingExited(_ sender: (any NSDraggingInfo)?) { draggingExited(sender) }
func fileDropPrepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { prepareForDragOperation(sender) }
func fileDropPerformDragOperation(_ sender: any NSDraggingInfo) -> Bool { performDragOperation(sender) }
func fileDropConcludeDragOperation(_ sender: (any NSDraggingInfo)?) { concludeDragOperation(sender) }
}
Loading
Loading