mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 16:12:21 -06:00
6f917dfabb
* test(macos): remove remaining coverage exercisers * test(macos): remove dead skills typealias * test(macos): remove unused skill config constructor
393 lines
14 KiB
Swift
393 lines
14 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
extension CritterStatusLabel {
|
|
private var isWorkingNow: Bool {
|
|
self.iconState.isWorking || self.isWorking
|
|
}
|
|
|
|
private var effectiveAnimationsEnabled: Bool {
|
|
self.animationsEnabled && !self.isSleeping && !self.isPaused
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
self.iconImage
|
|
.frame(width: 18, height: 18)
|
|
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
|
.offset(x: self.wiggleOffset)
|
|
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
|
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
|
.task(id: self.tickTaskID) {
|
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
|
await MainActor.run { self.resetMotion() }
|
|
return
|
|
}
|
|
|
|
await MainActor.run { self.rescheduleElapsedAnimationTimers(from: Date()) }
|
|
while !Task.isCancelled {
|
|
let now = Date()
|
|
let delay = await MainActor.run {
|
|
self.tick(now)
|
|
return self.nextTickDelay(after: now)
|
|
}
|
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
}
|
|
}
|
|
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
|
.onChange(of: self.blinkTick) { _, _ in
|
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
|
self.blink()
|
|
}
|
|
.onChange(of: self.sendCelebrationTick) { _, _ in
|
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
|
self.celebrate()
|
|
}
|
|
.onChange(of: self.gatewayStatus) { oldStatus, newStatus in
|
|
self.handleGatewayStatusChange(from: oldStatus, to: newStatus)
|
|
}
|
|
.onChange(of: self.isWorkingNow) { wasWorking, isWorking in
|
|
self.handleWorkingChange(from: wasWorking, to: isWorking, at: Date())
|
|
}
|
|
.onChange(of: self.animationsEnabled) { _, enabled in
|
|
if enabled, !self.isSleeping {
|
|
self.scheduleRandomTimers(from: Date())
|
|
} else {
|
|
self.resetMotion()
|
|
}
|
|
}
|
|
.onChange(of: self.isSleeping) { _, _ in
|
|
self.resetMotion()
|
|
}
|
|
.onChange(of: self.earBoostActive) { _, active in
|
|
if active {
|
|
self.resetMotion()
|
|
} else if self.effectiveAnimationsEnabled {
|
|
self.scheduleRandomTimers(from: Date())
|
|
}
|
|
}
|
|
|
|
if self.gatewayNeedsAttention {
|
|
Circle()
|
|
.fill(self.gatewayBadgeColor)
|
|
.frame(width: 6, height: 6)
|
|
.padding(1)
|
|
}
|
|
|
|
if self.voiceWakeMeterActive {
|
|
Circle()
|
|
.fill(.orange)
|
|
.frame(width: 5, height: 5)
|
|
.padding(2)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
|
}
|
|
}
|
|
.frame(width: 18, height: 18)
|
|
}
|
|
|
|
private var tickTaskID: Int {
|
|
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
|
(self.effectiveAnimationsEnabled ? 1 : 0) |
|
|
(self.earBoostActive ? 2 : 0) |
|
|
(self.isWorkingNow ? 4 : 0)
|
|
}
|
|
|
|
private func nextTickDelay(after now: Date) -> TimeInterval {
|
|
Self.nextAnimationTickDelay(
|
|
now: now,
|
|
isWorking: self.isWorkingNow,
|
|
deadlines: [self.nextBlink, self.nextWiggle, self.nextLegWiggle, self.nextEarWiggle])
|
|
}
|
|
|
|
static func nextAnimationTickDelay(
|
|
now: Date,
|
|
isWorking: Bool,
|
|
deadlines: [Date]) -> TimeInterval
|
|
{
|
|
// Working motion needs a steady cadence; idle motion only wakes for its next visible event.
|
|
if isWorking { return 0.35 }
|
|
guard let nextDeadline = deadlines.min() else { return 1 }
|
|
return max(0.05, nextDeadline.timeIntervalSince(now))
|
|
}
|
|
|
|
static func reconnectBeat(
|
|
lastSettled: GatewayProcessManager.Status?,
|
|
to new: GatewayProcessManager.Status) -> Bool
|
|
{
|
|
// Only a comeback from .failed celebrates: cold starts settle from
|
|
// nil/.stopped and deliberate stop -> start cycles stay quiet.
|
|
guard case .failed = lastSettled else { return false }
|
|
switch new {
|
|
case .running, .attachedExisting:
|
|
return true
|
|
case .failed, .stopped, .starting:
|
|
return false
|
|
}
|
|
}
|
|
|
|
static func workCompletionBeat(
|
|
startedAt: Date?,
|
|
endedAt: Date,
|
|
minimumDuration: TimeInterval) -> Bool
|
|
{
|
|
guard let startedAt else { return false }
|
|
return endedAt.timeIntervalSince(startedAt) >= minimumDuration
|
|
}
|
|
|
|
private func handleGatewayStatusChange(
|
|
from oldStatus: GatewayProcessManager.Status,
|
|
to newStatus: GatewayProcessManager.Status)
|
|
{
|
|
// Seed from the pre-change status on the first observed transition so
|
|
// a crash that predates this view's appearance still counts as broken.
|
|
let lastSettled = self.lastSettledGatewayStatus
|
|
?? (Self.isSettled(oldStatus) ? oldStatus : nil)
|
|
let beat = Self.reconnectBeat(lastSettled: lastSettled, to: newStatus)
|
|
if Self.isSettled(newStatus) {
|
|
self.lastSettledGatewayStatus = newStatus
|
|
} else if self.lastSettledGatewayStatus == nil {
|
|
self.lastSettledGatewayStatus = lastSettled
|
|
}
|
|
guard beat, self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
|
|
|
self.celebrate()
|
|
self.wiggleEars()
|
|
}
|
|
|
|
/// `.starting` is transitional; every other status is a settled state the
|
|
/// next recovery judgment can compare against.
|
|
static func isSettled(_ status: GatewayProcessManager.Status) -> Bool {
|
|
if case .starting = status { return false }
|
|
return true
|
|
}
|
|
|
|
private func handleWorkingChange(from wasWorking: Bool, to isWorking: Bool, at date: Date) {
|
|
if isWorking {
|
|
// Hand the false -> true timestamp to the eventual completion transition.
|
|
if !wasWorking { self.workStartedAt = date }
|
|
return
|
|
}
|
|
|
|
guard wasWorking else { return }
|
|
let startedAt = self.workStartedAt
|
|
self.workStartedAt = nil
|
|
// Require sustained work so short menu-bar blips do not create celebration noise.
|
|
guard self.effectiveAnimationsEnabled,
|
|
!self.earBoostActive,
|
|
Self.workCompletionBeat(startedAt: startedAt, endedAt: date, minimumDuration: 10)
|
|
else { return }
|
|
|
|
self.celebrate()
|
|
}
|
|
|
|
private func tick(_ now: Date) {
|
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
|
self.resetMotion()
|
|
return
|
|
}
|
|
|
|
if now >= self.nextBlink {
|
|
self.blink()
|
|
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
|
}
|
|
|
|
if now >= self.nextWiggle {
|
|
self.wiggle()
|
|
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
|
}
|
|
|
|
if now >= self.nextLegWiggle {
|
|
self.wiggleLegs()
|
|
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
|
}
|
|
|
|
if now >= self.nextEarWiggle {
|
|
self.wiggleEars()
|
|
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
|
}
|
|
|
|
if self.isWorkingNow {
|
|
self.scurry()
|
|
}
|
|
}
|
|
|
|
private var iconImage: Image {
|
|
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
|
CritterIconRenderer.Badge(
|
|
symbolName: self.iconState.badgeSymbolName,
|
|
prominence: prominence)
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
if self.isPaused {
|
|
// Paused reads as "off duty": awake but with drooped antennae, distinct
|
|
// from idle (perked) and sleeping (drooped + closed eyes).
|
|
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, antennaDroop: 1, badge: nil))
|
|
}
|
|
|
|
if self.isSleeping {
|
|
return Image(nsImage: CritterIconRenderer.makeIcon(
|
|
blink: 1,
|
|
antennaDroop: 1,
|
|
eyesClosedLines: true,
|
|
badge: nil))
|
|
}
|
|
|
|
return Image(nsImage: CritterIconRenderer.makeIcon(
|
|
blink: self.blinkAmount,
|
|
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
|
earWiggle: self.earWiggle,
|
|
earScale: self.earBoostActive ? 1.9 : 1.0,
|
|
happyEyes: self.celebrating,
|
|
badge: badge))
|
|
}
|
|
|
|
private func resetMotion() {
|
|
self.blinkAmount = 0
|
|
self.celebrating = false
|
|
self.wiggleAngle = 0
|
|
self.wiggleOffset = 0
|
|
self.legWiggle = 0
|
|
self.earWiggle = 0
|
|
}
|
|
|
|
/// Message sent: flash happy "∩ ∩" eyes and kick the legs.
|
|
private func celebrate() {
|
|
self.celebrating = true
|
|
self.wiggleLegs()
|
|
// Generation advances only for celebrations that actually start, so the
|
|
// newest flash always owns the clear: older expiry tasks bail, and the
|
|
// eyes can never stick on after a skipped send tick.
|
|
self.celebrationGeneration += 1
|
|
let generation = self.celebrationGeneration
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 900_000_000)
|
|
guard self.celebrationGeneration == generation else { return }
|
|
self.celebrating = false
|
|
}
|
|
}
|
|
|
|
private func blink() {
|
|
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 160_000_000)
|
|
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
|
}
|
|
}
|
|
|
|
private func wiggle() {
|
|
let targetAngle = Double.random(in: -4.5...4.5)
|
|
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
|
self.wiggleAngle = targetAngle
|
|
self.wiggleOffset = targetOffset
|
|
}
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 360_000_000)
|
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
|
self.wiggleAngle = 0
|
|
self.wiggleOffset = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private func wiggleLegs() {
|
|
let target = CGFloat.random(in: 0.35...0.9)
|
|
withAnimation(.easeInOut(duration: 0.14)) {
|
|
self.legWiggle = target
|
|
}
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 220_000_000)
|
|
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
|
}
|
|
}
|
|
|
|
private func scurry() {
|
|
let target = CGFloat.random(in: 0.7...1.0)
|
|
withAnimation(.easeInOut(duration: 0.12)) {
|
|
self.legWiggle = target
|
|
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
|
}
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 180_000_000)
|
|
withAnimation(.easeOut(duration: 0.16)) {
|
|
self.legWiggle = 0.25
|
|
self.wiggleOffset = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private func wiggleEars() {
|
|
let target = CGFloat.random(in: -1.2...1.2)
|
|
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
|
self.earWiggle = target
|
|
}
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 320_000_000)
|
|
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
|
self.earWiggle = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleRandomTimers(from date: Date) {
|
|
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
|
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
|
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
|
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
|
}
|
|
|
|
private func rescheduleElapsedAnimationTimers(from date: Date) {
|
|
let deadlines = [self.nextBlink, self.nextWiggle, self.nextLegWiggle, self.nextEarWiggle]
|
|
guard deadlines.contains(where: { $0 <= date }) else { return }
|
|
self.scheduleRandomTimers(from: date)
|
|
}
|
|
|
|
private var gatewayNeedsAttention: Bool {
|
|
Self.needsAttention(
|
|
connectionMode: self.connectionMode,
|
|
controlState: self.controlChannelState,
|
|
gatewayStatus: self.gatewayStatus,
|
|
isPaused: self.isPaused,
|
|
isSleeping: self.isSleeping)
|
|
}
|
|
|
|
static func needsAttention(
|
|
connectionMode: AppState.ConnectionMode,
|
|
controlState: ControlChannel.ConnectionState,
|
|
gatewayStatus: GatewayProcessManager.Status,
|
|
isPaused: Bool,
|
|
isSleeping: Bool) -> Bool
|
|
{
|
|
guard !isPaused else { return false }
|
|
switch connectionMode {
|
|
case .unconfigured:
|
|
return false
|
|
case .remote:
|
|
return GatewayConnectionPresentation(state: controlState).needsAttention
|
|
case .local:
|
|
guard !isSleeping else { return false }
|
|
switch gatewayStatus {
|
|
case .failed, .stopped:
|
|
return true
|
|
case .starting, .running, .attachedExisting:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
private var gatewayBadgeColor: Color {
|
|
if self.connectionMode == .remote,
|
|
GatewayConnectionPresentation(state: self.controlChannelState).needsAttention
|
|
{
|
|
return .red
|
|
}
|
|
switch self.gatewayStatus {
|
|
case .failed: return .red
|
|
case .stopped: return .orange
|
|
default: return .clear
|
|
}
|
|
}
|
|
}
|