126 lines
4.5 KiB
Swift
126 lines
4.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
|
|
/// Resolves the live model at drag time. In particular, picking up the selected
|
|
/// workspace changes its merge neighbour before SwiftUI has rendered the deck.
|
|
@MainActor
|
|
struct PaneLayoutDropController {
|
|
let window: WindowModel
|
|
let workspace: Workspace
|
|
let pane: Pane
|
|
|
|
var draggedID: UUID? {
|
|
guard window.displayedWorkspace?.id == workspace.id,
|
|
workspace.layout.pane(pane.id) === pane
|
|
else { return nil }
|
|
switch window.dragging {
|
|
case .workspace(let id): return id
|
|
case .pane(let id):
|
|
// The source remains mounted to keep the native drag alive, but
|
|
// is omitted from the layout preview and must not receive drops.
|
|
return id == pane.id ? nil : id
|
|
case .assistantConversation, nil: return nil
|
|
}
|
|
}
|
|
|
|
private var acceptsDrop: Bool {
|
|
guard pane.isCombinable || pane.isEmptySlot else { return false }
|
|
switch window.dragging {
|
|
case .pane(let id):
|
|
return id != pane.id && workspace.layout.pane(id) != nil
|
|
case .workspace(let id):
|
|
guard id != workspace.id else { return false }
|
|
return window.workspaces.first { $0.id == id }?.isCombinable ?? false
|
|
case .assistantConversation, nil: return false
|
|
}
|
|
}
|
|
|
|
func preview(at point: CGPoint, in size: CGSize) -> CGRect? {
|
|
guard draggedID != nil, acceptsDrop else { return nil }
|
|
return zone(at: point, in: size).previewRect(in: size)
|
|
}
|
|
|
|
func perform(at point: CGPoint, in size: CGSize) -> Bool {
|
|
guard draggedID != nil else { return false }
|
|
// Clear only after mutation: restoring an omitted source first would
|
|
// briefly mount its native surface in both the old and new parents.
|
|
defer { window.dragging = nil }
|
|
guard acceptsDrop else { return true }
|
|
let zone = zone(at: point, in: size)
|
|
switch window.dragging {
|
|
case .workspace(let id):
|
|
if zone == .replace {
|
|
window.fill(pane.id, in: workspace.id, withPanesOf: id)
|
|
} else if let placement = zone.placement {
|
|
window.merge(
|
|
id, into: workspace.id, beside: pane.id,
|
|
axis: placement.axis, side: placement.side
|
|
)
|
|
}
|
|
case .pane(let id):
|
|
if zone == .replace {
|
|
window.movePane(id, into: pane.id)
|
|
} else if let placement = zone.placement {
|
|
window.movePane(
|
|
id, beside: pane.id,
|
|
axis: placement.axis, side: placement.side
|
|
)
|
|
}
|
|
case .assistantConversation, nil: return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func zone(at point: CGPoint, in size: CGSize) -> PaneDropZone {
|
|
pane.isEmptySlot ? .replace : PaneDropZone.nearestEdge(to: point, in: size)
|
|
}
|
|
}
|
|
|
|
/// Four proportional edge zones; an empty slot instead replaces its whole area.
|
|
enum PaneDropZone: Equatable {
|
|
case leading
|
|
case trailing
|
|
case top
|
|
case bottom
|
|
case replace
|
|
|
|
var placement: (axis: SplitAxis, side: SplitSide)? {
|
|
switch self {
|
|
case .leading: (.horizontal, .before)
|
|
case .trailing: (.horizontal, .after)
|
|
case .top: (.vertical, .before)
|
|
case .bottom: (.vertical, .after)
|
|
case .replace: nil
|
|
}
|
|
}
|
|
|
|
func previewRect(in size: CGSize) -> CGRect {
|
|
switch self {
|
|
case .leading: CGRect(x: 0, y: 0, width: size.width / 2, height: size.height)
|
|
case .trailing: CGRect(x: size.width / 2, y: 0, width: size.width / 2, height: size.height)
|
|
case .top: CGRect(x: 0, y: 0, width: size.width, height: size.height / 2)
|
|
case .bottom: CGRect(x: 0, y: size.height / 2, width: size.width, height: size.height / 2)
|
|
case .replace: CGRect(origin: .zero, size: size)
|
|
}
|
|
}
|
|
|
|
static func nearestEdge(to point: CGPoint, in size: CGSize) -> PaneDropZone {
|
|
guard size.width > 0, size.height > 0 else { return .trailing }
|
|
let x = point.x / size.width
|
|
let y = point.y / size.height
|
|
let fromLeading = x
|
|
let fromTrailing = 1 - x
|
|
let fromTop = y
|
|
let fromBottom = 1 - y
|
|
let nearest = min(fromLeading, fromTrailing, fromTop, fromBottom)
|
|
return switch nearest {
|
|
case fromLeading: .leading
|
|
case fromTrailing: .trailing
|
|
case fromTop: .top
|
|
default: .bottom
|
|
}
|
|
}
|
|
}
|