Files
Termind/TFM/UI/Components/WorkspaceLayoutDragSource.swift
T

306 lines
11 KiB
Swift

import AppKit
import SwiftUI
/// Keeps ordinary row/header interaction in SwiftUI while giving layout drags
/// a native completion callback on every supported macOS version.
struct WorkspaceLayoutDragSource: ViewModifier {
let window: WindowModel
let payload: DragPayload
let title: String
let symbol: String
var excludedTrailingWidth: CGFloat = 0
let onTearOff: (CGPoint) -> Void
func body(content: Content) -> some View {
content.background {
WorkspaceLayoutDragSourceBridge(
model: window, payload: payload, title: title, symbol: symbol,
excludedTrailingWidth: excludedTrailingWidth, onTearOff: onTearOff
)
}
}
}
private struct WorkspaceLayoutDragSourceBridge: NSViewRepresentable {
let model: WindowModel
let payload: DragPayload
let title: String
let symbol: String
let excludedTrailingWidth: CGFloat
let onTearOff: (CGPoint) -> Void
func makeNSView(context _: Context) -> WorkspaceLayoutDragSourceNSView {
WorkspaceLayoutDragSourceNSView()
}
func updateNSView(_ view: WorkspaceLayoutDragSourceNSView, context _: Context) {
view.model = model
view.payload = payload
view.title = title
view.symbol = symbol
view.excludedTrailingWidth = excludedTrailingWidth
view.onTearOff = onTearOff
view.attachRecognizer()
}
static func dismantleNSView(_ view: WorkspaceLayoutDragSourceNSView, coordinator _: ()) {
view.detachRecognizer()
}
}
enum WorkspaceLayoutDragCompletion {
/// SwiftUI's native List owns Workspace drags. Its existing completion
/// callback can share the release policy without replacing its drag source.
static func shouldTearOffAtCurrentPointer(outside sourceFrame: CGRect?, cancelled: Bool) -> Bool {
guard let sourceFrame else { return false }
let event = NSApp.currentEvent
return shouldTearOff(
operation: [], primaryButtonPressed: NSEvent.pressedMouseButtons & 1 != 0,
cancelled: cancelled || (event?.type == .keyDown && event?.keyCode == 53),
payloadMatches: true, screenPoint: NSEvent.mouseLocation,
windowFrames: [sourceFrame]
)
}
static func shouldTearOff(
operation: NSDragOperation,
primaryButtonPressed: Bool,
cancelled: Bool,
payloadMatches: Bool,
screenPoint: CGPoint,
windowFrames: [CGRect]
) -> Bool {
operation.isEmpty && !primaryButtonPressed && !cancelled && payloadMatches
&& !windowFrames.contains { $0.contains(screenPoint) }
}
}
@MainActor
final class WorkspaceLayoutDragSourceNSView: NSView, NSGestureRecognizerDelegate, NSDraggingSource {
weak var model: WindowModel?
var payload: DragPayload?
var title = ""
var symbol = ""
var excludedTrailingWidth: CGFloat = 0
var onTearOff: (CGPoint) -> Void = { _ in }
private(set) lazy var panRecognizer: NSPanGestureRecognizer = {
let recognizer = WorkspaceLayoutPanGestureRecognizer(target: self, action: #selector(drag(_:)))
recognizer.buttonMask = 1
recognizer.delaysPrimaryMouseButtonEvents = false
recognizer.delaysSecondaryMouseButtonEvents = false
recognizer.delaysKeyEvents = false
recognizer.delegate = self
return recognizer
}()
private var activePayload: DragPayload?
private weak var activeModel: WindowModel?
private var cancelled = false
private var escapeMonitor: Any?
override func hitTest(_: NSPoint) -> NSView? { nil }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
attachRecognizer()
}
func attachRecognizer() {
let destination = window?.contentView
guard panRecognizer.view !== destination else { return }
panRecognizer.view?.removeGestureRecognizer(panRecognizer)
if let destination {
destination.addGestureRecognizer(panRecognizer)
} else {
cancelTracking()
}
}
func detachRecognizer() {
panRecognizer.view?.removeGestureRecognizer(panRecognizer)
cancelTracking()
}
func gestureRecognizer(
_: NSGestureRecognizer, shouldAttemptToRecognizeWith event: NSEvent
) -> Bool {
guard event.type == .leftMouseDown, event.clickCount == 1,
event.window === window, activePayload == nil,
isEligible
else { return false }
return acceptsDragStart(at: convert(event.locationInWindow, from: nil))
}
func gestureRecognizer(
_: NSGestureRecognizer, shouldRecognizeSimultaneouslyWith _: NSGestureRecognizer
) -> Bool {
true
}
var isEligible: Bool {
guard let model, !model.isClosed, model.dragging == nil else { return false }
switch payload {
case .workspace(let id):
return model.sidebar.isPresented && model.sidebarMode == .navigation
&& model.workspaces.contains { $0.id == id }
case .pane(let id):
return model.displayedWorkspace?.layout.pane(id)?.isCombinable == true
case .assistantConversation, nil:
return false
}
}
/// Includes clipping by the native List/scroll view, so a parked row or
/// collapsed pane cannot claim a gesture at its old frame.
func acceptsDragStart(at point: CGPoint) -> Bool {
guard bounds.contains(point), !isHiddenOrHasHiddenAncestor, visibleRect.contains(point) else { return false }
guard point.x < bounds.maxX - excludedTrailingWidth else { return false }
if let root = window?.contentView {
var hit = root.hitTest(convert(point, to: root.superview))
while let view = hit, view !== root {
// NSTableView is also NSControl; excluding that ancestor would
// disable every row in the native Navigator List.
if view is NSButton || view is NSTextField || view is NSTextView { return false }
hit = view.superview
}
}
return true
}
@objc private func drag(_ recognizer: WorkspaceLayoutPanGestureRecognizer) {
guard recognizer.state == .began || recognizer.state == .changed,
activePayload == nil, let event = recognizer.dragEvent,
let payload, let writer = pasteboardItem(for: payload), window != nil
else { return }
let image = dragImage()
let point = convert(event.locationInWindow, from: nil)
let item = NSDraggingItem(pasteboardWriter: writer)
item.setDraggingFrame(
CGRect(
x: point.x - 12, y: point.y - image.size.height / 2, width: image.size.width, height: image.size.height),
contents: image
)
guard beginTracking() else { return }
let session = beginDraggingSession(with: [item], event: event, source: self)
// Escape and an unhandled drop both report an empty operation. End
// immediately so button state still distinguishes Escape from release.
session.animatesToStartingPositionsOnCancelOrFail = false
}
@discardableResult
func beginTracking() -> Bool {
guard activePayload == nil, isEligible, let model, let payload else { return false }
activePayload = payload
activeModel = model
cancelled = false
model.dragging = payload
escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
if event.keyCode == 53 { self?.cancelled = true }
return event
}
return true
}
func draggingSession(
_: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation {
// The UUID is an internal routing value, never text to insert in a
// foreign application. An outside release belongs to our tear-off.
context == .withinApplication ? .move : []
}
func ignoreModifierKeys(for _: NSDraggingSession) -> Bool { true }
func draggingSession(
_: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation
) {
let event = NSApp.currentEvent
finishTracking(
at: screenPoint, operation: operation,
primaryButtonPressed: NSEvent.pressedMouseButtons & 1 != 0,
currentEventIsEscape: event?.type == .keyDown && event?.keyCode == 53,
windowFrames: window.map { [$0.frame] } ?? []
)
}
func finishTracking(
at screenPoint: CGPoint,
operation: NSDragOperation,
primaryButtonPressed: Bool,
currentEventIsEscape: Bool,
windowFrames: [CGRect]
) {
guard let activePayload, let model = activeModel else {
stopEscapeMonitor()
return
}
let shouldTearOff = WorkspaceLayoutDragCompletion.shouldTearOff(
operation: operation, primaryButtonPressed: primaryButtonPressed,
cancelled: cancelled || currentEventIsEscape,
payloadMatches: model.dragging == activePayload,
screenPoint: screenPoint, windowFrames: windowFrames
)
self.activePayload = nil
activeModel = nil
stopEscapeMonitor()
// Destinations mutate first and clear preview state second, preserving
// the single native terminal parent during pane reparenting.
defer {
if model.dragging == activePayload { model.dragging = nil }
}
if shouldTearOff { onTearOff(screenPoint) }
}
private func cancelTracking() {
if let activePayload, activeModel?.dragging == activePayload {
activeModel?.dragging = nil
}
activePayload = nil
activeModel = nil
stopEscapeMonitor()
}
private func stopEscapeMonitor() {
if let escapeMonitor {
NSEvent.removeMonitor(escapeMonitor)
self.escapeMonitor = nil
}
}
private func pasteboardItem(for payload: DragPayload) -> NSPasteboardItem? {
let id: UUID
switch payload {
case .workspace(let value), .pane(let value): id = value
case .assistantConversation: return nil
}
let item = NSPasteboardItem()
item.setString(id.uuidString, forType: .string)
return item
}
private func dragImage() -> NSImage {
let renderer = ImageRenderer(content: ItemDragPreview(title: title, symbol: symbol))
renderer.scale = window?.backingScaleFactor ?? 2
return renderer.nsImage ?? NSImage(size: CGSize(width: 120, height: 32))
}
}
/// AppKit schedules recognizer actions after processing their event. Retain the
/// actual drag event instead of depending on NSApp.currentEvent still being it.
@MainActor
private final class WorkspaceLayoutPanGestureRecognizer: NSPanGestureRecognizer {
private(set) var dragEvent: NSEvent?
override func mouseDragged(with event: NSEvent) {
dragEvent = event
super.mouseDragged(with: event)
}
override func reset() {
dragEvent = nil
super.reset()
}
}