Files
Termind/TFI/UI/Components/MobileWindowDropTarget.swift

196 lines
6.9 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import SwiftUI
import UIKit
/// Uses the window for native drop routing, with a non-interactive view marking
/// the visible workspace's bounds. A terminal's own interaction forwards here too.
struct MobileWindowDropTarget: UIViewRepresentable {
@Binding var isTargeted: Bool
let isEnabled: Bool
let terminalView: UIView?
let receive: ([NSItemProvider]) -> Bool
func makeCoordinator() -> Coordinator {
Coordinator(isEnabled: isEnabled, targeted: setTargeted, receive: receive)
}
func makeUIView(context: Context) -> InstallerView {
let view = InstallerView()
view.coordinator = context.coordinator
return view
}
func updateUIView(_ view: InstallerView, context: Context) {
context.coordinator.receive = receive
context.coordinator.targeted = setTargeted
context.coordinator.isEnabled = isEnabled
view.coordinator = context.coordinator
context.coordinator.install(in: view, terminalView: terminalView)
}
static func dismantleUIView(_ view: InstallerView, coordinator: Coordinator) {
coordinator.uninstall()
view.coordinator = nil
}
private func setTargeted(_ value: Bool) {
isTargeted = value
}
final class InstallerView: UIView {
weak var coordinator: Coordinator?
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
backgroundColor = .clear
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func didMoveToWindow() {
super.didMoveToWindow()
coordinator?.moveToWindow()
}
}
@MainActor
final class Coordinator: NSObject, UIDropInteractionDelegate {
var receive: ([NSItemProvider]) -> Bool
var targeted: (Bool) -> Void
var isEnabled: Bool {
didSet {
if !isEnabled { clearTarget() }
}
}
private weak var region: UIView?
private weak var installedWindow: UIWindow?
private weak var terminalView: UIView?
private var terminalDropTarget: MobileTerminalDropTarget?
private lazy var interaction = UIDropInteraction(delegate: self)
private weak var activeInteraction: UIDropInteraction?
private weak var performedSession: (any UIDropSession)?
private var isTargeted = false
init(isEnabled: Bool, targeted: @escaping (Bool) -> Void, receive: @escaping ([NSItemProvider]) -> Bool) {
self.isEnabled = isEnabled
self.targeted = targeted
self.receive = receive
}
func install(in region: UIView, terminalView: UIView? = nil) {
self.region = region
if self.terminalView !== terminalView {
terminalDropTarget?.uninstall()
terminalDropTarget = nil
self.terminalView = terminalView
}
moveToWindow()
}
func moveToWindow() {
let window = region?.window
if installedWindow !== window {
clearTarget()
installedWindow?.removeInteraction(interaction)
installedWindow = window
window?.addInteraction(interaction)
terminalDropTarget?.uninstall()
terminalDropTarget = nil
}
if window != nil, terminalDropTarget == nil, let terminalView {
terminalDropTarget = MobileTerminalDropTarget(view: terminalView, uploadDelegate: self)
}
}
func uninstall() {
clearTarget()
installedWindow?.removeInteraction(interaction)
terminalDropTarget?.uninstall()
terminalDropTarget = nil
installedWindow = nil
terminalView = nil
region = nil
}
private var isVisible: Bool {
guard isEnabled, let region, let window = installedWindow, region.window === window,
!region.bounds.isEmpty, !hasPresentation(in: window.rootViewController)
else { return false }
var ancestor: UIView? = region
while let view = ancestor {
if view.isHidden || view.alpha <= 0.01 { return false }
ancestor = view.superview
}
return true
}
private func hasPresentation(in controller: UIViewController?) -> Bool {
guard let controller else { return false }
return controller.presentedViewController != nil || controller.children.contains { hasPresentation(in: $0) }
}
private func accepts(_ session: any UIDropSession, atLocation: Bool) -> Bool {
guard isVisible, MobileDropPayloadLoader.acceptsUpload(session.items.map(\.itemProvider)), let region else {
return false
}
return !atLocation || region.bounds.contains(session.location(in: region))
}
private func updateTarget(_ value: Bool, interaction: UIDropInteraction) {
if value {
activeInteraction = interaction
} else if activeInteraction !== interaction {
return
}
if isTargeted != value {
isTargeted = value
targeted(value)
}
}
private func clearTarget() {
activeInteraction = nil
if isTargeted {
isTargeted = false
targeted(false)
}
}
func dropInteraction(_: UIDropInteraction, canHandle session: any UIDropSession) -> Bool {
accepts(session, atLocation: false)
}
func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnter session: any UIDropSession) {
updateTarget(accepts(session, atLocation: true), interaction: interaction)
}
func dropInteraction(
_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession
) -> UIDropProposal {
let accepted = accepts(session, atLocation: true)
updateTarget(accepted, interaction: interaction)
return UIDropProposal(operation: accepted ? .copy : .cancel)
}
func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit _: any UIDropSession) {
updateTarget(false, interaction: interaction)
}
func dropInteraction(_: UIDropInteraction, sessionDidEnd _: any UIDropSession) {
clearTarget()
}
func dropInteraction(_: UIDropInteraction, performDrop session: any UIDropSession) {
clearTarget()
guard accepts(session, atLocation: true), performedSession !== session else { return }
performedSession = session
_ = receive(session.items.map(\.itemProvider))
}
}
}