// // ChatThinkingIndicatorView.swift // App for Indeed // import Cocoa import QuartzCore /// Single sparkle with a soft brand-blue glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance). final class ChatThinkingIndicatorView: NSView { private enum AnimationKey { static let dotOpacity = "thinkingDotOpacity" static let sparklePulse = "thinkingSparklePulse" } /// Matches `DashboardView.Theme.brandBlue` — Indeed-style `#2557a7`. private static let accentBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) /// Slightly lighter blue for the sparkle glow (reads clearly on light banner/chat surfaces). private static let accentBlueGlow = NSColor(srgbRed: 54 / 255, green: 130 / 255, blue: 220 / 255, alpha: 1) private let sparkleView = NSImageView() private let dotStack = NSStackView() private var dotViews: [NSView] = [] init(compact: Bool, accessibilityLabel: String = "Assistant is searching") { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false let sparklePoint: CGFloat = compact ? 11 : 14 let dotSize: CGFloat = compact ? 4 : 5 let sparkleDotGap: CGFloat = compact ? 7 : 10 let dotSpacing: CGFloat = compact ? 4 : 5 sparkleView.translatesAutoresizingMaskIntoConstraints = false sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil) sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium) sparkleView.contentTintColor = Self.accentBlue sparkleView.wantsLayer = true sparkleView.layer?.masksToBounds = false sparkleView.layer?.shadowColor = Self.accentBlueGlow.cgColor sparkleView.layer?.shadowRadius = compact ? 5 : 9 sparkleView.layer?.shadowOpacity = 0.55 sparkleView.layer?.shadowOffset = .zero NSLayoutConstraint.activate([ sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6), sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6) ]) dotStack.orientation = .horizontal dotStack.spacing = dotSpacing dotStack.alignment = .centerY dotStack.translatesAutoresizingMaskIntoConstraints = false let dotFill = Self.accentBlue for _ in 0..<3 { let dot = NSView() dot.translatesAutoresizingMaskIntoConstraints = false dot.wantsLayer = true dot.layer?.cornerRadius = dotSize / 2 dot.layer?.backgroundColor = dotFill.cgColor NSLayoutConstraint.activate([ dot.widthAnchor.constraint(equalToConstant: dotSize), dot.heightAnchor.constraint(equalToConstant: dotSize) ]) dotStack.addArrangedSubview(dot) dotViews.append(dot) } let row = NSStackView(views: [sparkleView, dotStack]) row.orientation = .horizontal row.spacing = sparkleDotGap row.alignment = .centerY row.translatesAutoresizingMaskIntoConstraints = false addSubview(row) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: leadingAnchor), row.trailingAnchor.constraint(equalTo: trailingAnchor), row.topAnchor.constraint(equalTo: topAnchor), row.bottomAnchor.constraint(equalTo: bottomAnchor) ]) setAccessibilityElement(true) setAccessibilityRole(.group) setAccessibilityLabel(accessibilityLabel) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private static var reducedMotion: Bool { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } func startAnimatingIfNeeded() { stopAnimating() guard !Self.reducedMotion else { dotViews.forEach { $0.layer?.opacity = 0.78 } return } guard let sparkleLayer = sparkleView.layer else { return } let waveDuration: CFTimeInterval = 0.52 let n = max(1, dotViews.count) for (i, dot) in dotViews.enumerated() { guard let layer = dot.layer else { continue } layer.opacity = 1 let pulse = CABasicAnimation(keyPath: "opacity") pulse.fromValue = 0.2 pulse.toValue = 1.0 pulse.duration = waveDuration pulse.autoreverses = true pulse.repeatCount = .greatestFiniteMagnitude pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) pulse.timeOffset = waveDuration * Double(i) / Double(n) layer.add(pulse, forKey: AnimationKey.dotOpacity) } let scale = CABasicAnimation(keyPath: "transform.scale") scale.fromValue = 1.0 scale.toValue = 1.07 scale.duration = 1.15 scale.autoreverses = true scale.repeatCount = .greatestFiniteMagnitude scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse) } func stopAnimating() { sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse) sparkleView.layer?.transform = CATransform3DIdentity for dot in dotViews { dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity) dot.layer?.opacity = 1 } } }