feat: improve drag-and-drop over alttab (closes #5350)

This commit is contained in:
lwouis committed 2026-03-01 19:26:28 +01:00
1 parent ea7284b387
commit a809ffad61
3 files changed
+109 -64

No files matched your search

+7 -4
View File
@@ -115,13 +115,17 @@ class CursorEvents {
}
private static func handleMouseMoved(_ cgEvent: CGEvent) -> Unmanaged<CGEvent>? {
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
-59
View File
@@ -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
+102 -1
View File
@@ -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