dc47247340
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
10 KiB
Swift
223 lines
10 KiB
Swift
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 {
|
|
// 30 fps is plenty for the character (the display would drive 120 on ProMotion).
|
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0, 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<BotState> = [.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.isTall)
|
|
? 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(minimumInterval: 1.0 / 30.0)) { 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
|
|
}
|
|
}
|
|
}
|
|
}
|