Files
Termind/TFM/Platform/WorkspaceWindowDragSource.swift
T

228 lines
9.2 KiB
Swift

import AppKit
import SwiftUI
import UniformTypeIdentifiers
/// A private, process-local identifier, never a text or file export to another app.
nonisolated enum WorkspaceDragType {
static let identifier = "com.akinokaede.Termind.workspace-drag"
static let pasteboard = NSPasteboard.PasteboardType(identifier)
static let content = UTType(exportedAs: identifier)
}
struct WorkspaceWindowDragSource: NSViewRepresentable {
let model: WindowModel
let workspaceID: UUID
let title: String
let hint: String
let coordinator: MainWindowCoordinator
func makeNSView(context: Context) -> WorkspaceDragSourceView { WorkspaceDragSourceView() }
func updateNSView(_ view: WorkspaceDragSourceView, context: Context) {
view.model = model
view.workspaceID = workspaceID
view.title = title
view.hint = hint
view.coordinator = coordinator
}
static func dismantleNSView(_ view: WorkspaceDragSourceView, coordinator: ()) {
view.stopObserving()
}
}
/// Observes the row without intercepting clicks, its rename editor, context menu,
/// or trailing close button. Only a drag beyond the native-style threshold is consumed.
final class WorkspaceDragSourceView: NSView {
weak var model: WindowModel?
var workspaceID: UUID?
var title = ""
var hint = ""
weak var coordinator: MainWindowCoordinator?
private var monitor: Any?
private var mouseDownPoint: CGPoint?
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
stopObserving()
guard window != nil else { return }
monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) {
[weak self] event in
let consumed = MainActor.assumeIsolated {
guard let self else { return false }
return self.receive(event) == nil
}
return consumed ? nil : event
}
}
isolated deinit { stopObserving() }
func stopObserving() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
mouseDownPoint = nil
}
private func receive(_ event: NSEvent) -> NSEvent? {
guard event.window === window, !isHiddenOrHasHiddenAncestor else {
mouseDownPoint = nil
return event
}
let point = convert(event.locationInWindow, from: nil)
switch event.type {
case .leftMouseDown:
// The last 28 points belong to Close and its hover affordance.
mouseDownPoint = bounds.contains(point) && point.x < bounds.maxX - 28 ? point : nil
case .leftMouseUp:
mouseDownPoint = nil
case .leftMouseDragged:
guard let start = mouseDownPoint,
hypot(point.x - start.x, point.y - start.y) >= 6,
let model, !model.isClosed, let workspaceID,
model.workspaces.contains(where: { $0.id == workspaceID }),
let coordinator, coordinator.activeWorkspaceDrag == nil
else { return event }
mouseDownPoint = nil
let source = WorkspaceWindowDragSession(
model: model, workspaceID: workspaceID, title: title, hint: hint, coordinator: coordinator)
coordinator.activeWorkspaceDrag = source
model.dragging = .workspace(workspaceID)
let pasteboard = NSPasteboardItem()
pasteboard.setString(workspaceID.uuidString, forType: WorkspaceDragType.pasteboard)
let item = NSDraggingItem(pasteboardWriter: pasteboard)
let image = source.image(outside: false)
item.setDraggingFrame(
CGRect(
x: point.x - image.size.width / 2, y: point.y - image.size.height / 2,
width: image.size.width, height: image.size.height), contents: image)
let session = beginDraggingSession(with: [item], event: event, source: source)
session.draggingFormation = .none
return nil
default:
break
}
return event
}
}
/// Retained by the process coordinator until AppKit ends the drag, even when an
/// internal merge removes the source row. Cancellation is distinct from a refused drop.
final class WorkspaceWindowDragSession: NSObject, NSDraggingSource {
private weak var model: WindowModel?
private let workspaceID: UUID
private let title: String
private let hint: String
private weak var coordinator: MainWindowCoordinator?
private var keyMonitor: Any?
private var cancelled = false
private var finished = false
private var outside = false
init(model: WindowModel, workspaceID: UUID, title: String, hint: String, coordinator: MainWindowCoordinator) {
self.model = model
self.workspaceID = workspaceID
self.title = title
self.hint = hint
self.coordinator = coordinator
super.init()
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
MainActor.assumeIsolated {
if event.keyCode == 53 { self?.cancelled = true }
}
return event
}
}
isolated deinit {
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext)
-> NSDragOperation
{
context == .withinApplication ? .move : []
}
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool { true }
func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) {
let next = !Self.appWindowFrames.contains { $0.contains(screenPoint) }
guard next != outside else { return }
outside = next
session.animatesToStartingPositionsOnCancelOrFail = !outside
let image = image(outside: outside)
session.enumerateDraggingItems(options: [], for: nil, classes: [NSPasteboardItem.self], searchOptions: [:]) {
item, _, _ in
item.setDraggingFrame(CGRect(origin: item.draggingFrame.origin, size: image.size), contents: image)
}
}
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
guard !finished else { return }
finished = true
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
keyMonitor = nil
let currentEvent = NSApp.currentEvent
let escaped = currentEvent?.type == .keyDown && currentEvent?.keyCode == 53
let shouldMove = Self.shouldOpenWindow(
cancelled: cancelled || escaped,
mouseReleased: NSEvent.pressedMouseButtons & 1 == 0,
operation: operation,
hasUnhandledWorkspace: model?.isClosed == false && model?.dragging == .workspace(workspaceID),
at: screenPoint, windowFrames: Self.appWindowFrames)
if model?.dragging == .workspace(workspaceID) { model?.dragging = nil }
if coordinator?.activeWorkspaceDrag === self { coordinator?.activeWorkspaceDrag = nil }
if shouldMove, let model {
// Leave AppKit's drag callback before creating/activating a scene.
Task { @MainActor [weak coordinator] in
coordinator?.moveWorkspace(workspaceID, from: model, at: screenPoint)
}
}
}
static func shouldOpenWindow(
cancelled: Bool, mouseReleased: Bool, operation: NSDragOperation,
hasUnhandledWorkspace: Bool, at point: CGPoint, windowFrames: [CGRect]
) -> Bool {
!cancelled && mouseReleased && operation.isEmpty && hasUnhandledWorkspace
&& !windowFrames.contains { $0.contains(point) }
}
private static var appWindowFrames: [CGRect] {
NSApp.windows.filter {
$0.isVisible && !$0.isMiniaturized && $0.isOnActiveSpace
&& ($0.canBecomeKey || $0.styleMask.contains(.titled))
}.map(\.frame)
}
func image(outside: Bool) -> NSImage {
let size = NSSize(width: 280, height: outside ? 62 : 36)
let title = title
let hint = hint
return NSImage(size: size, flipped: false) { rect in
NSColor.windowBackgroundColor.withAlphaComponent(0.96).setFill()
NSBezierPath(roundedRect: rect.insetBy(dx: 1, dy: 1), xRadius: 9, yRadius: 9).fill()
let paragraph = NSMutableParagraphStyle()
paragraph.lineBreakMode = .byTruncatingTail
(title as NSString).draw(
in: CGRect(x: 12, y: rect.height - 27, width: rect.width - 24, height: 20),
withAttributes: [
.font: NSFont.systemFont(ofSize: 13, weight: .medium),
.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph,
])
if outside {
(hint as NSString).draw(
in: CGRect(x: 12, y: 9, width: rect.width - 24, height: 18),
withAttributes: [
.font: NSFont.systemFont(ofSize: 11),
.foregroundColor: NSColor.secondaryLabelColor, .paragraphStyle: paragraph,
])
}
return true
}
}
}