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,744 @@
|
||||
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
|
||||
@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)
|
||||
|
||||
// Rigid roll when outfit is present (matches BotCanvasView)
|
||||
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 {
|
||||
let center = engine.bodyCenter(size: size)
|
||||
var rigidCtx = c
|
||||
rigidCtx.translateBy(x: center.x, y: center.y)
|
||||
rigidCtx.rotate(by: .radians(engine.roll))
|
||||
rigidCtx.translateBy(x: -center.x, y: -center.y)
|
||||
engine.drawHandsBehind(context: rigidCtx, size: size)
|
||||
engine.drawOutfitBehind(context: rigidCtx, size: size)
|
||||
engine.draw(context: rigidCtx, size: size)
|
||||
engine.drawOutfitFront(context: rigidCtx, size: size)
|
||||
} else {
|
||||
engine.drawHandsBehind(context: c, size: size)
|
||||
engine.drawOutfitBehind(context: c, size: size)
|
||||
engine.draw(context: c, size: size)
|
||||
engine.drawOutfitFront(context: c, size: size)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user