161 lines
5.5 KiB
Swift
161 lines
5.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AppKit
|
|
import SwiftUI
|
|
|
|
enum WorkspaceLayoutDropPreviewStyle {
|
|
case insertion
|
|
case pane
|
|
}
|
|
|
|
/// Mounted before a drag starts. AppKit can enter its tracking loop before
|
|
/// SwiftUI renders the source's drag state, so both eligibility and feedback
|
|
/// are resolved in the native callbacks instead of a subsequent render pass.
|
|
struct WorkspaceLayoutDropTarget: NSViewRepresentable {
|
|
let draggedID: () -> UUID?
|
|
var previewStyle: WorkspaceLayoutDropPreviewStyle = .pane
|
|
let preview: (CGPoint, CGSize) -> CGRect?
|
|
let perform: (CGPoint, CGSize) -> Bool
|
|
|
|
func makeNSView(context _: Context) -> WorkspaceLayoutDropNSView {
|
|
WorkspaceLayoutDropNSView()
|
|
}
|
|
|
|
func updateNSView(_ view: WorkspaceLayoutDropNSView, context _: Context) {
|
|
view.draggedID = draggedID
|
|
view.previewStyle = previewStyle
|
|
view.preview = preview
|
|
view.perform = perform
|
|
view.clearPreviewIfInactive()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class WorkspaceLayoutDropNSView: NSView {
|
|
var draggedID: () -> UUID? = { nil }
|
|
var previewStyle = WorkspaceLayoutDropPreviewStyle.pane
|
|
var preview: (CGPoint, CGSize) -> CGRect? = { _, _ in nil }
|
|
var perform: (CGPoint, CGSize) -> Bool = { _, _ in false }
|
|
|
|
private(set) var previewRect: CGRect?
|
|
|
|
override var isFlipped: Bool { true }
|
|
override var isOpaque: Bool { false }
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
// Keep registration stable even for a parked workspace: picking up the
|
|
// selected workspace can make its neighbour the live merge destination
|
|
// before SwiftUI updates either view. Every entry point checks the live
|
|
// owner so hidden and omitted panes still refuse the drag.
|
|
registerForDraggedTypes([.string])
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder _: NSCoder) { fatalError() }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
let localPoint = convert(point, from: superview)
|
|
return participates(
|
|
at: localPoint,
|
|
currentEventType: NSApp.currentEvent?.type,
|
|
pasteboard: NSPasteboard(name: .drag)
|
|
) ? self : nil
|
|
}
|
|
|
|
/// Explicit ambient inputs keep tests independent of real mouse events and
|
|
/// the machine-wide drag pasteboard. Points here are in this view's bounds.
|
|
func participates(
|
|
at point: CGPoint,
|
|
currentEventType: NSEvent.EventType?,
|
|
pasteboard: NSPasteboard
|
|
) -> Bool {
|
|
!FileURLDropNSView.isOrdinaryPointerEvent(currentEventType)
|
|
&& accepts(point, pasteboard: pasteboard)
|
|
}
|
|
|
|
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
|
update(at: location(of: sender), pasteboard: sender.draggingPasteboard)
|
|
}
|
|
|
|
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
|
update(at: location(of: sender), pasteboard: sender.draggingPasteboard)
|
|
}
|
|
|
|
override func draggingExited(_: (any NSDraggingInfo)?) {
|
|
clearPreview()
|
|
}
|
|
|
|
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
|
receive(at: location(of: sender), pasteboard: sender.draggingPasteboard)
|
|
}
|
|
|
|
override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
|
|
clearPreview()
|
|
super.concludeDragOperation(sender)
|
|
}
|
|
|
|
func update(at point: CGPoint, pasteboard: NSPasteboard) -> NSDragOperation {
|
|
guard accepts(point, pasteboard: pasteboard) else {
|
|
clearPreview()
|
|
return []
|
|
}
|
|
previewRect = preview(point, bounds.size)
|
|
needsDisplay = true
|
|
// Invalid inner targets still finish an internal drag and clear its
|
|
// transient state on release, matching the pane's cancellation policy.
|
|
return .move
|
|
}
|
|
|
|
func receive(at point: CGPoint, pasteboard: NSPasteboard) -> Bool {
|
|
defer { clearPreview() }
|
|
guard accepts(point, pasteboard: pasteboard) else { return false }
|
|
return perform(point, bounds.size)
|
|
}
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
guard let previewRect else { return }
|
|
let accent = TermindColor.nativeAccent
|
|
switch previewStyle {
|
|
case .insertion:
|
|
accent.setFill()
|
|
NSBezierPath(roundedRect: previewRect, xRadius: 1, yRadius: 1).fill()
|
|
case .pane:
|
|
accent.withAlphaComponent(0.25).setFill()
|
|
previewRect.fill()
|
|
accent.setStroke()
|
|
let border = NSBezierPath(rect: previewRect.insetBy(dx: 1, dy: 1))
|
|
border.lineWidth = 2
|
|
border.stroke()
|
|
}
|
|
}
|
|
|
|
override func viewDidChangeEffectiveAppearance() {
|
|
super.viewDidChangeEffectiveAppearance()
|
|
needsDisplay = true
|
|
}
|
|
|
|
private func accepts(_ point: CGPoint, pasteboard: NSPasteboard) -> Bool {
|
|
guard bounds.contains(point), let id = draggedID(),
|
|
let value = pasteboard.string(forType: .string)
|
|
else { return false }
|
|
// The model can outlive a cancelled drag. An unrelated text/file drag
|
|
// must not be mistaken for that old internal workspace payload.
|
|
return UUID(uuidString: value) == id
|
|
}
|
|
|
|
private func clearPreview() {
|
|
previewRect = nil
|
|
needsDisplay = true
|
|
}
|
|
|
|
func clearPreviewIfInactive() {
|
|
if draggedID() == nil { clearPreview() }
|
|
}
|
|
|
|
private func location(of sender: any NSDraggingInfo) -> CGPoint {
|
|
convert(sender.draggingLocation, from: nil)
|
|
}
|
|
}
|