Files

126 lines
4.9 KiB
Swift

import AppKit
import SwiftUI
// MARK: - NSView drag destination
// Wired at the AppKit level in IslandWindowController (not via SwiftUI NSViewRepresentable)
// so it never interferes with SwiftUI hit-testing.
final class FileDropNSView: NSView {
var onDragEntered: ((CGPoint) -> Void)?
var onDragUpdated: ((CGPoint) -> Void)?
var onDragExited: (() -> Void)?
var onFilesDropped: (([URL]) -> Void)?
override init(frame: NSRect) {
super.init(frame: frame)
registerForDraggedTypes([.fileURL])
}
required init?(coder: NSCoder) { fatalError() }
// Pass all mouse events through — drag-drop uses NSDraggingDestination, not hitTest
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
onDragEntered?(sender.draggingLocation)
return .copy
}
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
onDragUpdated?(sender.draggingLocation)
return .copy
}
override func draggingExited(_ sender: NSDraggingInfo?) { onDragExited?() }
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
guard let urls = sender.draggingPasteboard.readObjects(
forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]
) as? [URL], !urls.isEmpty else { return false }
onFilesDropped?(urls)
return true
}
}
// MARK: - File drop handler
enum FileDropHandler {
@MainActor
static func handle(urls: [URL], state: AppState) async {
guard let url = urls.first else { return }
let name = url.lastPathComponent
state.lastDroppedURLs = urls
// Start animation immediately — do NOT block on file copy.
// Use original URL first; swap to inbox copy once background copy finishes.
state.droppedFile = DroppedFile(url: url, name: name)
state.uploadProgress = 0
state.fileDragOver = false
state.promptContext = .file(name: name, fileURL: url)
let dur = 2.4
UploadSequenceEngine.shared.performDrop(uploadDuration: dur)
// Copy to inbox in background — update state when done
let inbox = HookServer.supportDir.appendingPathComponent("inbox")
Task.detached {
try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
let dest = inbox.appendingPathComponent(name)
try? FileManager.default.removeItem(at: dest)
if (try? FileManager.default.copyItem(at: url, to: dest)) != nil {
await MainActor.run {
state.droppedFile = DroppedFile(url: dest, name: name)
state.promptContext = .file(name: name, fileURL: dest)
}
}
}
// Drop feedback
NotificationCenter.default.post(name: .botGulp, object: nil)
SoundEngine.shared.play("approve")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0))
state.uploadDuration = dur
state.uploadStartTime = Date()
state.view = .uploading // canvas stays active: uploadActive covers .uploading
// Canvas timeline from drop:
// T_DROP → T_PROG_START : ≈1.30s gulp + shrink + bar reveal
// T_PROG_START → progEnd: dur progress bar fills
// progEnd → growEnd : 0.70s Mochi grows back to choose position
let preProgress = USC.T_PROG_START - USC.T_DROP // ≈1.30s
// Tick sounds — delayed to sync with canvas progress start
Task { @MainActor in
var lastTens = 0
let progStart = Date().addingTimeInterval(preProgress)
while lastTens < 9 {
try? await Task.sleep(nanoseconds: 80_000_000)
let approxP = min(1.0, max(0, Date().timeIntervalSince(progStart) / dur))
let tens = Int(approxP * 10)
if tens > lastTens {
SoundEngine.shared.play("tick")
lastTens = tens
}
}
}
// Wait for canvas progress to complete (gulp/shrink phase + upload duration)
try? await Task.sleep(nanoseconds: UInt64((preProgress + dur) * 1_000_000_000))
// Canvas shows checkmark at this point
SoundEngine.shared.play("approve")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
// Wait for grow-back animation + choose overlay settle
try? await Task.sleep(nanoseconds: UInt64(1_000_000_000))
// Clean up upload state
state.uploadProgress = 0
state.uploadStartTime = nil
// Switch to choose — canvas stays active (uploadActive covers .choose).
// Engine deactivates when user clicks a canvas choose button or navigates away.
state.view = .choose
}
}