import SwiftUI /// SwiftUI wrapper: TimelineView drives a Canvas that calls BotEngine.draw(). /// Uses a shared engine per-task; the main bot uses AppState's shared engine. struct BotCanvasView: View { @ObservedObject var state: AppState var particleOverhang: CGFloat = 0 /// When set, overrides island-based eye-tracking (used by desktop Mochi). /// CGPoint in the same coord space as state.mousePosition (y-down from screen top). var lookOriginOverride: CGPoint? = nil // One engine per view instance (main bot) @StateObject private var engine = BotEngine() @State private var fader = MemojiFader() var body: some View { TimelineView(.animation(paused: state.mode == .hidden)) { timeline in Canvas { context, size in let now = timeline.date.timeIntervalSinceReferenceDate let dtRaw = min(0.05, now - engine.lastTime) let dt = dtRaw engine.lookX = lookX(state: state, size: size) engine.lookY = lookY(state: state, size: size) engine.particleOverhang = particleOverhang // Widen slot when file is hovering over the mailbox (morph > 0.5) // Open mouth (hover=0.20R) when file dragged over box; close when not if engine.morph > 0.3 { engine.slotHTarget = state.fileDragOver ? 0.20 : 0 } else { engine.slotHTarget = 0 if engine.morph < 0.05 { engine.slotH = 0; engine.slotHVel = 0 } } // Integration pills have a fixed brand color → use it as bodyColor. // Claude Code tasks use state-based gradient (working=blue, thinking=purple, etc.). #if !APPSTORE if state.showingPlanDetail { let hex = ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) engine.bodyColor = cgColorFromHex(hex) } else { engine.bodyColor = (state.focusTask?.isIntegration == true) ? cgColorFromHex(state.focusTask!.color) : nil } #else engine.bodyColor = (state.focusTask?.isIntegration == true) ? cgColorFromHex(state.focusTask!.color) : nil #endif // Compute shouldDance per-frame (no observer lag) let dancing: Bool = { #if !APPSTORE guard AppState.shared.musicPlaying else { return false } guard AppState.shared.activeIntegrations.contains("integration_music") else { return false } let allowed: Set = [.idle, .working, .thinking, .searching, .finished] guard allowed.contains(state.effectiveState) else { return false } if state.mode == .compact { return true } return state.mode == .expanded && state.view == .overview && state.focusId == "integration_music" #else return false #endif }() engine.setDancing(dancing) let isWardrobe = state.mode == .expanded && state.view == .wardrobe let isFocusMain = state.focusId == state.mainPillId || state.focusId == nil let showOutfit = isFocusMain || state.mode != .expanded || isWardrobe engine.setOutfit(showOutfit ? state.resolvedOutfit : .none, animated: state.view != .wardrobe) engine.update(dt: dt) var ctx = context engine.applyDance(&ctx, 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 = ctx boxCtx.opacity = Double(min(1, engine.morph * 2)) engine.draw(context: boxCtx, size: size) } engine.drawMemoji(context: ctx, size: size, fader: fader, opacity: Double(max(0, 1 - engine.morph * 2))) engine.drawHandsAndExtras(context: ctx, size: size) } } .onChange(of: state.effectiveState) { _, newState in engine.setState(newState) } .onChange(of: state.view) { _, newView in // Morph up when upload view is active if state.mode == .expanded && newView == .upload { engine.anim("morph", keys: [TweenKey(target: 1, duration: 550, ease: Ease.inOut)]) } else if newView != .upload && newView != .uploading && engine.morph > 0.01 { // Any other view (not mid-gulp): morph back engine.anim("morph", keys: [TweenKey(target: 0, duration: 550, ease: Ease.inOut)]) } } .onChange(of: state.mode) { _, newMode in // Hard-reset morph when island collapses if newMode != .expanded { engine.tweens.removeValue(forKey: "morph") engine.locks.remove("morph") engine.morph = 0 } } .onReceive(NotificationCenter.default.publisher(for: .triggerEmote)) { notif in if let emote = notif.object as? BotEmote { engine.triggerEmote(emote) } } .onReceive(NotificationCenter.default.publisher(for: .triggerSlap)) { _ in engine.slap() } .onReceive(NotificationCenter.default.publisher(for: .botBlink)) { _ in engine.blink() } .onReceive(NotificationCenter.default.publisher(for: .botSetTgEs)) { notif in if let v = notif.object as? CGFloat { engine.tgEs = v } } .onReceive(NotificationCenter.default.publisher(for: .botGulp)) { _ in engine.gulp() } .onReceive(NotificationCenter.default.publisher(for: .botMorphTo)) { notif in if let target = notif.object as? CGFloat { let dur: CGFloat = target > 0.5 ? 550 : 650 engine.anim("morph", keys: [TweenKey(target: target, duration: dur, ease: Ease.inOut)]) } } .onReceive(NotificationCenter.default.publisher(for: .botGreet)) { _ in engine.greet() } .onAppear { engine.setState(state.effectiveState, force: true) let isWardrobe = state.mode == .expanded && state.view == .wardrobe let isFocusMain = state.focusId == state.mainPillId || state.focusId == nil let showOutfit = isFocusMain || state.mode != .expanded || isWardrobe engine.setOutfit(showOutfit ? state.resolvedOutfit : .none, animated: false) } } private func lookX(state: AppState, size: CGSize) -> CGFloat { if let origin = lookOriginOverride { return tanh((state.mousePosition.x - origin.x) / 260) } let screen = NSScreen.main ?? NSScreen.screens[0] let (islandW, islandH) = islandSize(mode: state.mode, view: state.view, progress: state.uploadProgress, nw: state.notchWidth, nh: state.notchHeight) let (botCx, _, _, _) = botPosition(mode: state.mode, view: state.view, islandW: islandW, islandH: islandH, uploadProgress: state.uploadProgress) // Island is centered on screen; bot is at botCx within island coords let botScreenX = screen.frame.midX - islandW / 2 + botCx return tanh((state.mousePosition.x - botScreenX) / 260) } private func lookY(state: AppState, size: CGSize) -> CGFloat { if let origin = lookOriginOverride { return -tanh((state.mousePosition.y - origin.y) / 200) } let (islandW, islandH) = islandSize(mode: state.mode, view: state.view, progress: state.uploadProgress, nw: state.notchWidth, nh: state.notchHeight) let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt) ? state.chatIslandHeight : islandH let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, islandW: islandW, islandH: actualH, uploadProgress: state.uploadProgress) // Island top = screen top → bot screen Y = botCy from island top return -tanh((state.mousePosition.y - botCy) / 200) } } /// Mini bot canvas (for agent pills/column) struct MiniBotCanvasView: View { let task: AgentTask var isDancing: Bool = false @StateObject private var engine: BotEngine init(task: AgentTask, isDancing: Bool = false) { self.task = task self.isDancing = isDancing _engine = StateObject(wrappedValue: { let e = BotEngine() e.isMini = true e.bodyColor = cgColorFromHex(task.color) return e }()) } var body: some View { TimelineView(.animation) { timeline in Canvas { context, size in let now = timeline.date.timeIntervalSinceReferenceDate let dt = min(0.05, now - engine.lastTime) engine.setDancing(isDancing) engine.update(dt: dt) var ctx = context engine.applyDance(&ctx, size: size) engine.draw(context: ctx, size: size) } } .onChange(of: task.state) { _, newState in engine.setState(newState) } .onAppear { engine.setState(task.state, force: true) if let emote = task.emote { engine.setPermanentEmote(emote) } // Direct eye override takes priority (e.g. .wide eyes for Research) if let eye = task.miniEye { engine.permanentEye = eye engine.eyeOverride = eye engine.eyeOverrideUntil = .greatestFiniteMagnitude } } } }