From a809ffad610bd1f0af8a34abaae9b8a1fc4927c3 Mon Sep 17 00:00:00 2001 From: lwouis Date: Sun, 1 Mar 2026 19:14:19 +0100 Subject: [PATCH] feat: improve drag-and-drop over alttab (closes #5350) --- src/logic/events/CursorEvents.swift | 11 +-- src/ui/main-window/TileView.swift | 59 ---------------- src/ui/main-window/TilesView.swift | 103 +++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 64 deletions(-) diff --git a/src/logic/events/CursorEvents.swift b/src/logic/events/CursorEvents.swift index 11083809..c1d29ec0 100644 --- a/src/logic/events/CursorEvents.swift +++ b/src/logic/events/CursorEvents.swift @@ -115,13 +115,17 @@ class CursorEvents { } private static func handleMouseMoved(_ cgEvent: CGEvent) -> Unmanaged? { - updateDeadzoneSituation(cgEvent) - if isAllowedToMouseHover { + if isAllowedToReactToPointerMovement(cgEvent.location) { TilesView.thumbnailOverView.updateHover() } return Unmanaged.passUnretained(cgEvent) } + static func isAllowedToReactToPointerMovement(_ location: CGPoint) -> Bool { + updateDeadzoneSituation(location) + return isAllowedToMouseHover + } + private static func pointerLocationInWindow() -> NSPoint { TilesPanel.shared.mouseLocationOutsideOfEventStream } @@ -158,8 +162,7 @@ class CursorEvents { /// when using the trackpad, the user may swipe with a slight mistake. This will create a small cursor movement /// we ignore those, as they are not intended. Intended movements will be larger and not ignored - private static func updateDeadzoneSituation(_ cgEvent: CGEvent) { - let location = cgEvent.location + private static func updateDeadzoneSituation(_ location: CGPoint) { guard let deadZoneInitialPosition else { deadZoneInitialPosition = location isAllowedToMouseHover = false diff --git a/src/ui/main-window/TileView.swift b/src/ui/main-window/TileView.swift index 25af1440..6bb4d643 100644 --- a/src/ui/main-window/TileView.swift +++ b/src/ui/main-window/TileView.swift @@ -17,9 +17,7 @@ class TileView: FlippedView { var mouseUpCallback: (() -> Void)! var mouseMovedCallback: (() -> Void)! - var dragAndDropTimer: Timer? var indexInRecycledViews: Int! - private var lastDragLocation: NSPoint? var isFirstInRow = false var isLastInRow = false @@ -32,57 +30,6 @@ class TileView: FlippedView { override func isAccessibilityElement() -> Bool { true } - override func wantsPeriodicDraggingUpdates() -> Bool { false } - - override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { - let location = sender.draggingLocation - if location != lastDragLocation { - lastDragLocation = location - mouseMovedCallback() - } - return .link - } - - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - lastDragLocation = sender.draggingLocation - mouseMovedCallback() - setDraggingTimer() - return .link - } - - private func setDraggingTimer() { - dragAndDropTimer?.invalidate() - dragAndDropTimer = Timer(timeInterval: 2, repeats: false, block: { [weak self] _ in - guard let self else { return } - // the user can tab to focus the next thumbnail, while still dragging. We don't want to perform drag then - if Windows.selectedWindowIndex == Windows.hoveredWindowIndex { - self.mouseUpCallback() - } - }) - dragAndDropTimer?.tolerance = 0.2 - if let dragAndDropTimer { - // we need to target .common since dragging is running in .eventTracking on trackpad - RunLoop.main.add(dragAndDropTimer, forMode: .common) - } - } - - override func draggingExited(_ sender: NSDraggingInfo?) { - lastDragLocation = nil - dragAndDropTimer?.invalidate() - dragAndDropTimer = nil - TilesView.thumbnailOverView.resetHoveredWindow() - } - - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - dragAndDropTimer?.invalidate() - dragAndDropTimer = nil - let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL] - let appUrl = window_!.application.bundleURL! - let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:]) - App.hideUi() - return open != nil - } - func mouseMoved() { updateLabelTooltipIfNeeded() mouseMovedCallback() @@ -96,7 +43,6 @@ class TileView: FlippedView { convenience init() { self.init(frame: .zero) setupView() - observeDragAndDrop() } /// The frame used by TileUnderLayer to position the highlight rectangle. @@ -569,11 +515,6 @@ class TileView: FlippedView { return "Red badge" } - private func observeDragAndDrop() { - // we only handle URLs (i.e. not text, image, or other draggable things) - registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)]) - } - static func makeShadow(_ color: NSColor?) -> NSShadow? { let shadow = NSShadow() shadow.shadowColor = color diff --git a/src/ui/main-window/TilesView.swift b/src/ui/main-window/TilesView.swift index ae2b5cc1..4c2e5649 100644 --- a/src/ui/main-window/TilesView.swift +++ b/src/ui/main-window/TilesView.swift @@ -644,7 +644,7 @@ class ScrollView: NSScrollView { convenience init() { self.init(frame: .zero) - documentView = FlippedView(frame: .zero) + documentView = TilesDocumentView(frame: .zero) documentView!.wantsLayer = true drawsBackground = false hasVerticalScroller = true @@ -678,6 +678,107 @@ class ScrollView: NSScrollView { } } +class TilesDocumentView: FlippedView { + private static let dragAndDropTimerDelay = TimeInterval(2) + private static let dragAndDropTimerResetDistance = CGFloat(5) + + private weak var dragTarget: TileView? + private var lastDragLocation: NSPoint? + private var timerResetLocation: NSPoint? + private var dragAndDropTimer: Timer? + + override func wantsPeriodicDraggingUpdates() -> Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + // we only handle URLs (i.e. not text, image, or other draggable things) + registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)]) + } + + required init?(coder: NSCoder) { + fatalError("Class only supports programmatic initialization") + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + let location = sender.draggingLocation + lastDragLocation = location + return dragOperation(location) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + let location = sender.draggingLocation + if location != lastDragLocation { lastDragLocation = location } + return dragOperation(location) + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + resetDraggingState(true) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + defer { resetDraggingState(false) } + guard let target = targetView(sender.draggingLocation) ?? dragTarget, + let window = target.window_, + let appUrl = window.application.bundleURL else { return false } + guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + !urls.isEmpty else { return false } + let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:]) + if open != nil { App.hideUi() } + return open != nil + } + + override func concludeDragOperation(_ sender: NSDraggingInfo?) { + resetDraggingState(false) + } + + private func dragOperation(_ location: NSPoint) -> NSDragOperation { + guard let target = targetView(location) else { + resetDraggingState(true) + return [] + } + if dragTarget !== target { + dragTarget = target + restartDraggingTimer(location) + } else if shouldRestartDraggingTimer(location) { + restartDraggingTimer(location) + } + if let window, CursorEvents.isAllowedToReactToPointerMovement(window.convertPoint(toScreen: location)) { + target.mouseMovedCallback() + } + return .link + } + + private func shouldRestartDraggingTimer(_ location: NSPoint) -> Bool { + guard let timerResetLocation else { return true } + return hypot(location.x - timerResetLocation.x, location.y - timerResetLocation.y) >= Self.dragAndDropTimerResetDistance + } + + private func restartDraggingTimer(_ location: NSPoint) { + timerResetLocation = location + dragAndDropTimer?.invalidate() + dragAndDropTimer = Timer(timeInterval: Self.dragAndDropTimerDelay, repeats: false, block: { [weak self] _ in + guard let self, let dragTarget = self.dragTarget else { return } + dragTarget.mouseUpCallback() + }) + dragAndDropTimer?.tolerance = 0.2 + if let dragAndDropTimer { RunLoop.main.add(dragAndDropTimer, forMode: .common) } + } + + private func resetDraggingState(_ resetHoveredWindow: Bool) { + lastDragLocation = nil + dragTarget = nil + timerResetLocation = nil + dragAndDropTimer?.invalidate() + dragAndDropTimer = nil + if resetHoveredWindow { TilesView.thumbnailOverView.resetHoveredWindow() } + } + + private func targetView(_ location: NSPoint) -> TileView? { + let overlay = TilesView.thumbnailOverView + return overlay.findTarget(overlay.convert(location, from: nil)) + } +} + class FlippedView: NSView { override var isFlipped: Bool { true } // Prevent AppKit from recursively marking all NSControl subviews as needsDisplay during makeKeyAndOrderFront