Files
Termind/TFM/Features/Workspaces/PaneLayoutDropController.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
}
}
}