| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140 |
- //
- // 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
- }
- }
- }
|