Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user