Files

738 lines
29 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}