fbf90fd4fb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
738 lines
29 KiB
Swift
738 lines
29 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
import Combine
|
||
|
||
// MARK: - Desktop bot view state
|
||
|
||
/// Observable bridge so DesktopMochiController can update view-level state without
|
||
/// coupling to SwiftUI @State.
|
||
@MainActor
|
||
final class DesktopBotViewState: ObservableObject {
|
||
/// Drop to 10 fps when sleeping (saves energy).
|
||
@Published var isSleeping: Bool = false
|
||
/// Pause entirely (screen sleep / lock).
|
||
@Published var paused: Bool = false
|
||
/// Bot center in the same coord space as AppState.mousePosition (y-down from screen top).
|
||
/// Updated every poll frame; Canvas reads it inside TimelineView — @Published not needed.
|
||
var lookOrigin: CGPoint = .zero
|
||
}
|
||
|
||
// MARK: - Desktop bot view
|
||
|
||
/// Full Mochi character rendered inside the desktop floating panel.
|
||
struct DesktopBotView: View {
|
||
@ObservedObject var appState: AppState
|
||
/// Engine owned by DesktopMochiController; controller calls methods on it directly.
|
||
let engine: BotEngine
|
||
@State private var fader = MemojiFader()
|
||
@ObservedObject var viewState: DesktopBotViewState
|
||
|
||
var body: some View {
|
||
TimelineView(.animation(
|
||
minimumInterval: viewState.isSleeping ? 1.0 / 10.0 : 1.0 / 30.0,
|
||
paused: viewState.paused
|
||
)) { timeline in
|
||
Canvas { ctx, size in
|
||
let now = timeline.date.timeIntervalSinceReferenceDate
|
||
let dt = min(0.05, now - engine.lastTime)
|
||
|
||
// Eye tracking based on the panel's own screen position
|
||
engine.lookX = tanh((appState.mousePosition.x - viewState.lookOrigin.x) / 260)
|
||
engine.lookY = -tanh((appState.mousePosition.y - viewState.lookOrigin.y) / 200)
|
||
|
||
// Desktop Mochi is always the "main" Mochi — always dressed
|
||
engine.setOutfit(appState.resolvedOutfit, animated: true)
|
||
|
||
// Dance when music plays (same rules as compact mode)
|
||
let dancing: Bool = {
|
||
#if !APPSTORE
|
||
guard appState.musicPlaying else { return false }
|
||
guard appState.activeIntegrations.contains("integration_music") else { return false }
|
||
let allowed: Set<BotState> = [.idle, .working, .thinking, .searching, .finished]
|
||
return allowed.contains(appState.effectiveState)
|
||
#else
|
||
return false
|
||
#endif
|
||
}()
|
||
engine.setDancing(dancing)
|
||
engine.update(dt: dt)
|
||
|
||
var c = ctx
|
||
engine.applyDance(&c, size: size)
|
||
|
||
// BroV: the Memoji is the character. The engine's box (file upload morph)
|
||
// fades in only while morphing; hands and outfits are not drawn.
|
||
if engine.morph > 0.02 {
|
||
var boxCtx = c
|
||
boxCtx.opacity = Double(min(1, engine.morph * 2))
|
||
engine.draw(context: boxCtx, size: size)
|
||
}
|
||
engine.drawMemoji(context: c, size: size, fader: fader,
|
||
opacity: Double(max(0, 1 - engine.morph * 2)))
|
||
engine.drawHandsAndExtras(context: c, size: size)
|
||
}
|
||
}
|
||
.onChange(of: appState.effectiveState) { _, newState in
|
||
engine.setState(newState)
|
||
}
|
||
.onAppear {
|
||
engine.setState(appState.effectiveState, force: true)
|
||
engine.setOutfit(appState.resolvedOutfit, animated: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Desktop Mochi controller
|
||
|
||
/// Manages the "Mochi on the desktop" floating panel.
|
||
///
|
||
/// Life cycle:
|
||
/// - **Install from drag**: `IslandWindowController.finishDrag` calls `install(ghostPanel:at:)`.
|
||
/// - **Launch restore**: `AppDelegate` observes `.greetComplete` → `launchFlyIfNeeded()`.
|
||
/// - **Alert**: `pendingApproval`/`pendingQuestion` goes non-nil → surprised emote →
|
||
/// `retractForAlert()` (panel gone, flag stays true) → both nil → `launchFlyIfNeeded()`.
|
||
/// - **User flies home**: double-click → `flyHome()` → full teardown.
|
||
@MainActor
|
||
final class DesktopMochiController {
|
||
static let shared = DesktopMochiController()
|
||
private init() {
|
||
observeScreenSleep()
|
||
observeScreenLock()
|
||
observeAlerts() // permanent — lives for the lifetime of the singleton
|
||
}
|
||
|
||
private var panel: NSPanel?
|
||
private var engine: BotEngine?
|
||
private var viewState: DesktopBotViewState?
|
||
private var frameTimer: Timer?
|
||
|
||
// Alert state machine
|
||
private var phase: DesktopPhase = .home
|
||
|
||
// Desktop drag repositioning
|
||
private var isDragging = false
|
||
private var dragMouseStart: NSPoint = .zero
|
||
private var dragOriginAtStart: NSPoint = .zero
|
||
|
||
// Deferred single-click slap
|
||
private var pendingSlapWorkItem: DispatchWorkItem?
|
||
|
||
// Sleep detection
|
||
private var lastAgentActive: Date = .distantPast
|
||
private var isSleeping = false
|
||
|
||
// Screen sleep / lock
|
||
private var screenSleeping = false
|
||
|
||
// Lifecycle subscriptions (cleared on retractForAlert + fullTearDown)
|
||
private var cancellables: Set<AnyCancellable> = []
|
||
// Alert subscription — permanent, only released with the singleton
|
||
private var alertSubscription: AnyCancellable?
|
||
|
||
// Event monitors
|
||
private var mouseDownMonitor: Any?
|
||
private var mouseDraggedMonitor: Any?
|
||
private var mouseUpMonitor: Any?
|
||
private var globalMouseUpMonitor: Any?
|
||
private var rightClickMonitor: Any?
|
||
|
||
// UserDefaults keys
|
||
private static let posXKey = "desktopMochiX"
|
||
private static let posYKey = "desktopMochiY"
|
||
private static let enabledKey = "mochiOnDesktop"
|
||
|
||
static let panelSize: CGFloat = DesktopMochiLogic.panelSize
|
||
|
||
// MARK: - Keyboard shortcut toggle (⌃⌥D)
|
||
|
||
/// Fly Mochi to the desktop if not there, or bring him back if he is.
|
||
func flyOutOrHome() {
|
||
if phase == .home {
|
||
UserDefaults.standard.set(true, forKey: DesktopMochiController.enabledKey)
|
||
launchFlyIfNeeded()
|
||
} else if phase == .onDesktop {
|
||
flyHome()
|
||
}
|
||
}
|
||
|
||
// MARK: - Install (from drag-drop)
|
||
|
||
/// Promote `ghostPanel` (the drag ghost) or create a fresh panel as the desktop Mochi,
|
||
/// centered on `screenPoint`. Called by `IslandWindowController.finishDrag`.
|
||
func install(ghostPanel: NSPanel?, at screenPoint: NSPoint) {
|
||
guard panel == nil, phase == .home else { ghostPanel?.close(); return }
|
||
let s = DesktopMochiController.panelSize
|
||
|
||
let p: NSPanel
|
||
if let ghost = ghostPanel {
|
||
p = ghost
|
||
} else {
|
||
p = makeBlankPanel()
|
||
p.setFrame(NSRect(x: screenPoint.x - s/2, y: screenPoint.y - s/2, width: s, height: s),
|
||
display: false)
|
||
}
|
||
|
||
// Build engine + hosting view (autoresizingMask lets it grow with the panel animation)
|
||
let eng = BotEngine()
|
||
eng.setState(AppState.shared.effectiveState, force: true)
|
||
eng.setOutfit(AppState.shared.resolvedOutfit, animated: false)
|
||
self.engine = eng
|
||
|
||
let vs = DesktopBotViewState()
|
||
vs.lookOrigin = lookOriginFor(panel: p)
|
||
vs.paused = screenSleeping
|
||
self.viewState = vs
|
||
|
||
let hosting = NSHostingView(rootView:
|
||
DesktopBotView(appState: AppState.shared, engine: eng, viewState: vs))
|
||
hosting.frame = CGRect(origin: .zero, size: p.frame.size)
|
||
hosting.autoresizingMask = [.width, .height]
|
||
p.contentView = hosting
|
||
|
||
p.level = .floating
|
||
p.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
|
||
p.ignoresMouseEvents = true
|
||
if !p.isVisible { p.orderFront(nil) }
|
||
|
||
// Squash emote + sound on landing
|
||
eng.triggerEmote(.happy, duration: 0.6, silent: true)
|
||
SoundEngine.shared.play("pop")
|
||
|
||
// Animate from current (ghost) size to 120 × 120, centered on drop point
|
||
let targetOrigin = clampToVisibleFrame(NSPoint(x: screenPoint.x - s/2, y: screenPoint.y - s/2))
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 0.25
|
||
ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.17, 0.67, 0.38, 1.3)
|
||
p.animator().setFrame(NSRect(origin: targetOrigin, size: CGSize(width: s, height: s)),
|
||
display: true)
|
||
}, completionHandler: {
|
||
Task { @MainActor in
|
||
self.panel = p
|
||
self.phase = .onDesktop
|
||
AppState.shared.mochiOnDesktop = true
|
||
UserDefaults.standard.set(true, forKey: DesktopMochiController.enabledKey)
|
||
self.persistPosition()
|
||
// Alert may have fired during the animation (observeAlerts skipped: phase wasn't .onDesktop)
|
||
let alertNow = AppState.shared.pendingApproval != nil || AppState.shared.pendingQuestion != nil
|
||
if DesktopMochiLogic.shouldRetractOnLanding(alertActive: alertNow) {
|
||
self.engine?.triggerEmote(.surprised)
|
||
self.phase = .retracting
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
|
||
guard let self else { return }
|
||
switch self.phase {
|
||
case .retracting: self.retractForAlert()
|
||
case .alertResolvedDuringRetract: self.phase = .home; self.launchFlyIfNeeded()
|
||
default: break
|
||
}
|
||
}
|
||
} else {
|
||
self.startPolling()
|
||
self.addEventMonitors()
|
||
self.observeLifecycle()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// MARK: - Launch fly (app-start restore or alert return)
|
||
|
||
/// Fly a new panel from the notch to the saved desktop position.
|
||
/// Called by AppDelegate after `.greetComplete`, and by the alert-return path.
|
||
func launchFlyIfNeeded() {
|
||
guard UserDefaults.standard.bool(forKey: DesktopMochiController.enabledKey) else { return }
|
||
guard phase == .home else { return }
|
||
guard panel == nil else { return }
|
||
// Alert active: don't fly yet — park in .atNotchForAlert so observeAlerts restores us when it clears
|
||
if AppState.shared.pendingApproval != nil || AppState.shared.pendingQuestion != nil {
|
||
phase = .atNotchForAlert
|
||
return
|
||
}
|
||
|
||
phase = .flyingOut
|
||
let s = DesktopMochiController.panelSize
|
||
let screen = IslandWindowController.notchScreen() ?? NSScreen.main!
|
||
let startOrigin = NSPoint(x: screen.frame.midX - s/2, y: screen.frame.maxY - s)
|
||
let target = loadSavedPosition()
|
||
|
||
let p = makeBlankPanel()
|
||
p.setFrame(NSRect(origin: startOrigin, size: CGSize(width: s, height: s)), display: false)
|
||
|
||
let eng = BotEngine()
|
||
eng.setState(AppState.shared.effectiveState, force: true)
|
||
eng.setOutfit(AppState.shared.resolvedOutfit, animated: false)
|
||
self.engine = eng
|
||
|
||
let vs = DesktopBotViewState()
|
||
vs.lookOrigin = lookOriginFor(panel: p)
|
||
vs.paused = screenSleeping
|
||
self.viewState = vs
|
||
|
||
let hosting = NSHostingView(rootView:
|
||
DesktopBotView(appState: AppState.shared, engine: eng, viewState: vs))
|
||
hosting.frame = CGRect(x: 0, y: 0, width: s, height: s)
|
||
hosting.autoresizingMask = [.width, .height]
|
||
p.contentView = hosting
|
||
p.alphaValue = 0
|
||
p.orderFront(nil)
|
||
|
||
AppState.shared.mochiOnDesktop = true
|
||
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 0.45
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||
p.animator().alphaValue = 1
|
||
p.animator().setFrame(NSRect(origin: target, size: CGSize(width: s, height: s)), display: true)
|
||
}, completionHandler: {
|
||
Task { @MainActor in
|
||
self.panel = p
|
||
self.phase = .onDesktop
|
||
UserDefaults.standard.set(true, forKey: DesktopMochiController.enabledKey)
|
||
self.persistPosition()
|
||
// Alert may have fired during the flight (observeAlerts skipped: phase was .flyingOut)
|
||
let alertNow = AppState.shared.pendingApproval != nil || AppState.shared.pendingQuestion != nil
|
||
if DesktopMochiLogic.shouldRetractOnLanding(alertActive: alertNow) {
|
||
self.engine?.triggerEmote(.surprised)
|
||
self.phase = .retracting
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
|
||
guard let self else { return }
|
||
switch self.phase {
|
||
case .retracting: self.retractForAlert()
|
||
case .alertResolvedDuringRetract: self.phase = .home; self.launchFlyIfNeeded()
|
||
default: break
|
||
}
|
||
}
|
||
} else {
|
||
self.startPolling()
|
||
self.addEventMonitors()
|
||
self.observeLifecycle()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// MARK: - Fly home (user-initiated: double-click)
|
||
|
||
/// Animate panel to notch then fully tear down.
|
||
func flyHome() {
|
||
guard let p = panel else { return }
|
||
phase = .home
|
||
pendingSlapWorkItem?.cancel()
|
||
stopPolling()
|
||
removeEventMonitors()
|
||
cancellables.removeAll()
|
||
isSleeping = false
|
||
let s = DesktopMochiController.panelSize
|
||
let screen = IslandWindowController.notchScreen() ?? NSScreen.main!
|
||
let targetOrigin = NSPoint(x: screen.frame.midX - s/2, y: screen.frame.maxY - s)
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 0.45
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||
p.animator().setFrame(NSRect(origin: targetOrigin, size: CGSize(width: s, height: s)),
|
||
display: true)
|
||
}, completionHandler: {
|
||
Task { @MainActor in
|
||
SoundEngine.shared.play("peek")
|
||
self.fullTearDown()
|
||
}
|
||
})
|
||
}
|
||
|
||
// MARK: - Retract for alert (panel flies home; comes back after alert resolves)
|
||
|
||
/// Close panel and show notch Mochi for the alert. UserDefaults flag stays true so
|
||
/// `launchFlyIfNeeded` restores Mochi once the alert is dismissed.
|
||
private func retractForAlert() {
|
||
guard let p = panel else { return }
|
||
stopPolling()
|
||
removeEventMonitors()
|
||
cancellables.removeAll()
|
||
pendingSlapWorkItem?.cancel()
|
||
isSleeping = false
|
||
|
||
let s = DesktopMochiController.panelSize
|
||
let screen = IslandWindowController.notchScreen() ?? NSScreen.main!
|
||
let targetOrigin = NSPoint(x: screen.frame.midX - s/2, y: screen.frame.maxY - s)
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 0.45
|
||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||
p.animator().setFrame(NSRect(origin: targetOrigin, size: CGSize(width: s, height: s)),
|
||
display: true)
|
||
}, completionHandler: {
|
||
Task { @MainActor in
|
||
p.close()
|
||
self.panel = nil
|
||
self.engine = nil
|
||
self.viewState = nil
|
||
self.isDragging = false
|
||
AppState.shared.mochiOnDesktop = false
|
||
// UserDefaults flag stays TRUE so launchFlyIfNeeded works
|
||
if self.phase == .alertResolvedDuringRetract {
|
||
self.phase = .home
|
||
self.launchFlyIfNeeded()
|
||
} else {
|
||
self.phase = .atNotchForAlert
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// MARK: - Uninstall (immediate, no animation)
|
||
|
||
func uninstall() {
|
||
stopPolling()
|
||
removeEventMonitors()
|
||
fullTearDown()
|
||
}
|
||
|
||
private func fullTearDown() {
|
||
phase = .home
|
||
cancellables.removeAll()
|
||
pendingSlapWorkItem?.cancel()
|
||
panel?.close()
|
||
panel = nil
|
||
engine = nil
|
||
viewState = nil
|
||
isDragging = false
|
||
isSleeping = false
|
||
AppState.shared.mochiOnDesktop = false
|
||
UserDefaults.standard.set(false, forKey: DesktopMochiController.enabledKey)
|
||
}
|
||
|
||
// MARK: - Panel factory
|
||
|
||
private func makeBlankPanel() -> NSPanel {
|
||
let p = NSPanel(contentRect: .zero,
|
||
styleMask: [.borderless, .nonactivatingPanel],
|
||
backing: .buffered, defer: false)
|
||
p.backgroundColor = .clear
|
||
p.isOpaque = false
|
||
p.hasShadow = false
|
||
return p
|
||
}
|
||
|
||
// MARK: - Lifecycle observation (active while panel is live on desktop)
|
||
|
||
private func observeLifecycle() {
|
||
cancellables.removeAll()
|
||
|
||
// effectiveState → .finished: joy jump (only when on desktop, not retracting)
|
||
Publishers.CombineLatest(AppState.shared.$stateOverride, AppState.shared.$tasks)
|
||
.map { _, _ in AppState.shared.effectiveState }
|
||
.removeDuplicates()
|
||
.dropFirst()
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] newState in
|
||
guard let self, self.phase == .onDesktop else { return }
|
||
if newState == .finished {
|
||
self.engine?.triggerEmote(.happy, duration: 1.2, silent: true)
|
||
}
|
||
}
|
||
.store(in: &cancellables)
|
||
}
|
||
|
||
// MARK: - Alert observation (permanent — installed once at init)
|
||
|
||
private func observeAlerts() {
|
||
alertSubscription = Publishers.CombineLatest(
|
||
AppState.shared.$pendingApproval,
|
||
AppState.shared.$pendingQuestion
|
||
)
|
||
.map { a, q in a != nil || q != nil }
|
||
.removeDuplicates()
|
||
.dropFirst()
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] alertActive in
|
||
guard let self else { return }
|
||
|
||
if alertActive {
|
||
guard self.phase == .onDesktop else { return }
|
||
self.engine?.triggerEmote(.surprised)
|
||
self.phase = .retracting
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
|
||
guard let self else { return }
|
||
switch self.phase {
|
||
case .retracting:
|
||
self.retractForAlert()
|
||
case .alertResolvedDuringRetract:
|
||
// Alert cleared before animation started — no need to retract
|
||
self.phase = .home
|
||
self.launchFlyIfNeeded()
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
} else {
|
||
switch self.phase {
|
||
case .atNotchForAlert:
|
||
self.phase = .home
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
||
self?.launchFlyIfNeeded()
|
||
}
|
||
case .retracting:
|
||
// Alert resolved while waiting to retract — mark it
|
||
self.phase = .alertResolvedDuringRetract
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 60 Hz polling (only while panel is live)
|
||
|
||
private func startPolling() {
|
||
frameTimer?.invalidate()
|
||
frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||
guard let self else { return }
|
||
Task { @MainActor in self.pollFrame() }
|
||
}
|
||
RunLoop.main.add(frameTimer!, forMode: .common)
|
||
}
|
||
|
||
private func stopPolling() {
|
||
frameTimer?.invalidate()
|
||
frameTimer = nil
|
||
}
|
||
|
||
private func pollFrame() {
|
||
guard let p = panel else { return }
|
||
let mouse = NSEvent.mouseLocation
|
||
let pf = p.frame
|
||
let local = CGPoint(x: mouse.x - pf.minX, y: mouse.y - pf.minY)
|
||
let s = DesktopMochiController.panelSize
|
||
|
||
// Toggle click-through
|
||
let overBody = DesktopMochiLogic.isOverBody(localPoint: local, panelSize: s)
|
||
let needsMouse = overBody || isDragging
|
||
if p.ignoresMouseEvents == needsMouse {
|
||
p.ignoresMouseEvents = !needsMouse
|
||
}
|
||
|
||
// Update eye-tracking origin every frame
|
||
viewState?.lookOrigin = lookOriginFor(panel: p)
|
||
|
||
// Sleep detection
|
||
let agentActive = AppState.shared.effectiveState != .idle &&
|
||
AppState.shared.effectiveState != .sleeping
|
||
if agentActive { lastAgentActive = .now }
|
||
let dist = hypot(mouse.x - pf.midX, mouse.y - pf.midY)
|
||
let interval = Date.now.timeIntervalSince(lastAgentActive)
|
||
let shouldSleep = DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: interval,
|
||
mouseDistanceToPanelCenter: dist)
|
||
if shouldSleep != isSleeping {
|
||
isSleeping = shouldSleep
|
||
viewState?.isSleeping = shouldSleep
|
||
engine?.setState(isSleeping ? .sleeping : AppState.shared.effectiveState)
|
||
}
|
||
}
|
||
|
||
// MARK: - Event monitors
|
||
|
||
private func addEventMonitors() {
|
||
mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
guard event.window === self.panel else { return }
|
||
self.dragMouseStart = NSEvent.mouseLocation
|
||
self.dragOriginAtStart = self.panel?.frame.origin ?? .zero
|
||
}
|
||
return event
|
||
}
|
||
|
||
mouseDraggedMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
let m = NSEvent.mouseLocation
|
||
if !self.isDragging {
|
||
let dist = hypot(m.x - self.dragMouseStart.x, m.y - self.dragMouseStart.y)
|
||
guard self.dragMouseStart != .zero, dist > 3 else { return }
|
||
self.isDragging = true
|
||
}
|
||
guard let p = self.panel else { return }
|
||
let dx = m.x - self.dragMouseStart.x
|
||
let dy = m.y - self.dragMouseStart.y
|
||
let newOrigin = self.clampToVisibleFrame(
|
||
NSPoint(x: self.dragOriginAtStart.x + dx, y: self.dragOriginAtStart.y + dy))
|
||
p.setFrameOrigin(newOrigin)
|
||
self.viewState?.lookOrigin = self.lookOriginFor(panel: p)
|
||
}
|
||
return event
|
||
}
|
||
|
||
// Local mouseUp (cursor still within panel)
|
||
mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
let wasDragging = self.isDragging
|
||
self.isDragging = false
|
||
self.dragMouseStart = .zero
|
||
if wasDragging {
|
||
self.handleDragRelease(at: NSEvent.mouseLocation)
|
||
} else if event.window === self.panel {
|
||
self.handleClick(clickCount: event.clickCount)
|
||
}
|
||
}
|
||
return event
|
||
}
|
||
|
||
// Global mouseUp (cursor moved outside panel during drag)
|
||
globalMouseUpMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self, self.isDragging else { return }
|
||
self.isDragging = false
|
||
self.dragMouseStart = .zero
|
||
self.handleDragRelease(at: NSEvent.mouseLocation)
|
||
}
|
||
}
|
||
|
||
rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in
|
||
guard let self else { return event }
|
||
MainActor.assumeIsolated {
|
||
guard event.window === self.panel else { return }
|
||
NotificationCenter.default.post(name: .openWardrobeFromDesktop, object: nil)
|
||
}
|
||
return event
|
||
}
|
||
}
|
||
|
||
// MARK: - Click / drag helpers
|
||
|
||
private func handleClick(clickCount: Int) {
|
||
if clickCount >= 2 {
|
||
pendingSlapWorkItem?.cancel()
|
||
flyHome()
|
||
} else {
|
||
pendingSlapWorkItem?.cancel()
|
||
let item = DispatchWorkItem { [weak self] in self?.engine?.slap() }
|
||
pendingSlapWorkItem = item
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + NSEvent.doubleClickInterval, execute: item)
|
||
}
|
||
}
|
||
|
||
private func handleDragRelease(at mouse: NSPoint) {
|
||
let islandController = (NSApp.delegate as? AppDelegate)?.islandController
|
||
let inNotchZone = islandController?.window?.frame.contains(mouse) == true
|
||
|
||
if inNotchZone {
|
||
flyHome()
|
||
return
|
||
}
|
||
|
||
#if !APPSTORE
|
||
if let ctx = islandController?.windowContextAtPoint(mouse) {
|
||
// Attach window context; Mochi returns to pre-drag position
|
||
AppState.shared.promptContext = ctx
|
||
SoundEngine.shared.play("approve")
|
||
engine?.triggerEmote(.happy, duration: 0.6, silent: true)
|
||
let origin = clampToVisibleFrame(dragOriginAtStart)
|
||
panel?.setFrameOrigin(origin)
|
||
persistPosition()
|
||
islandController?.expand(to: .prompt)
|
||
return
|
||
}
|
||
#endif
|
||
// Elsewhere: keep new position
|
||
persistPosition()
|
||
}
|
||
|
||
private func removeEventMonitors() {
|
||
if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil }
|
||
if let m = mouseDraggedMonitor { NSEvent.removeMonitor(m); mouseDraggedMonitor = nil }
|
||
if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil }
|
||
if let m = globalMouseUpMonitor { NSEvent.removeMonitor(m); globalMouseUpMonitor = nil }
|
||
if let m = rightClickMonitor { NSEvent.removeMonitor(m); rightClickMonitor = nil }
|
||
}
|
||
|
||
// MARK: - Screen sleep / wake
|
||
|
||
private func observeScreenSleep() {
|
||
NSWorkspace.shared.notificationCenter.addObserver(
|
||
forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.screenSleeping = true
|
||
self?.viewState?.paused = true
|
||
}
|
||
}
|
||
NSWorkspace.shared.notificationCenter.addObserver(
|
||
forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.screenSleeping = false
|
||
self?.viewState?.paused = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Screen lock / unlock
|
||
|
||
private func observeScreenLock() {
|
||
DistributedNotificationCenter.default().addObserver(
|
||
forName: NSNotification.Name("com.apple.screenIsLocked"), object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.screenSleeping = true
|
||
self?.viewState?.paused = true
|
||
}
|
||
}
|
||
DistributedNotificationCenter.default().addObserver(
|
||
forName: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.screenSleeping = false
|
||
self?.viewState?.paused = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Position helpers
|
||
|
||
private func lookOriginFor(panel: NSPanel) -> CGPoint {
|
||
let screen = panel.screen ?? NSScreen.main!
|
||
return DesktopMochiLogic.lookOrigin(
|
||
panelMinX: panel.frame.minX,
|
||
panelMinY: panel.frame.minY,
|
||
screenMinX: screen.frame.minX,
|
||
screenHeight: screen.frame.height,
|
||
panelSize: DesktopMochiController.panelSize)
|
||
}
|
||
|
||
private func clampToVisibleFrame(_ origin: NSPoint) -> NSPoint {
|
||
let screen = NSScreen.screens.min(by: {
|
||
let da = hypot(origin.x - $0.visibleFrame.midX, origin.y - $0.visibleFrame.midY)
|
||
let db = hypot(origin.x - $1.visibleFrame.midX, origin.y - $1.visibleFrame.midY)
|
||
return da < db
|
||
}) ?? NSScreen.main!
|
||
let pt = DesktopMochiLogic.clampOrigin(
|
||
CGPoint(x: origin.x, y: origin.y),
|
||
panelSize: DesktopMochiController.panelSize,
|
||
visibleFrame: screen.visibleFrame,
|
||
margin: DesktopMochiLogic.clampMargin)
|
||
return NSPoint(x: pt.x, y: pt.y)
|
||
}
|
||
|
||
private func loadSavedPosition() -> NSPoint {
|
||
let ud = UserDefaults.standard
|
||
guard ud.object(forKey: DesktopMochiController.posXKey) != nil else {
|
||
return defaultPosition()
|
||
}
|
||
let x = CGFloat(ud.double(forKey: DesktopMochiController.posXKey))
|
||
let y = CGFloat(ud.double(forKey: DesktopMochiController.posYKey))
|
||
return clampToVisibleFrame(NSPoint(x: x, y: y))
|
||
}
|
||
|
||
private func defaultPosition() -> NSPoint {
|
||
let s = DesktopMochiController.panelSize
|
||
let margin = DesktopMochiLogic.clampMargin
|
||
let vf = (NSScreen.main ?? NSScreen.screens[0]).visibleFrame
|
||
return NSPoint(x: vf.maxX - s - margin, y: vf.minY + margin)
|
||
}
|
||
|
||
private func persistPosition() {
|
||
guard let p = panel else { return }
|
||
let o = p.frame.origin
|
||
UserDefaults.standard.set(Double(o.x), forKey: DesktopMochiController.posXKey)
|
||
UserDefaults.standard.set(Double(o.y), forKey: DesktopMochiController.posYKey)
|
||
}
|
||
}
|