No Description

ChatThinkingIndicatorView.swift 5.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. //
  2. // ChatThinkingIndicatorView.swift
  3. // App for Indeed
  4. //
  5. import Cocoa
  6. import QuartzCore
  7. /// Single sparkle with a soft brand-blue glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
  8. final class ChatThinkingIndicatorView: NSView {
  9. private enum AnimationKey {
  10. static let dotOpacity = "thinkingDotOpacity"
  11. static let sparklePulse = "thinkingSparklePulse"
  12. }
  13. /// Matches `DashboardView.Theme.brandBlue` — Indeed-style `#2557a7`.
  14. private static let accentBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  15. /// Slightly lighter blue for the sparkle glow (reads clearly on light banner/chat surfaces).
  16. private static let accentBlueGlow = NSColor(srgbRed: 54 / 255, green: 130 / 255, blue: 220 / 255, alpha: 1)
  17. private let sparkleView = NSImageView()
  18. private let dotStack = NSStackView()
  19. private var dotViews: [NSView] = []
  20. init(compact: Bool, accessibilityLabel: String = "Assistant is searching") {
  21. super.init(frame: .zero)
  22. translatesAutoresizingMaskIntoConstraints = false
  23. let sparklePoint: CGFloat = compact ? 11 : 14
  24. let dotSize: CGFloat = compact ? 4 : 5
  25. let sparkleDotGap: CGFloat = compact ? 7 : 10
  26. let dotSpacing: CGFloat = compact ? 4 : 5
  27. sparkleView.translatesAutoresizingMaskIntoConstraints = false
  28. sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
  29. sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium)
  30. sparkleView.contentTintColor = Self.accentBlue
  31. sparkleView.wantsLayer = true
  32. sparkleView.layer?.masksToBounds = false
  33. sparkleView.layer?.shadowColor = Self.accentBlueGlow.cgColor
  34. sparkleView.layer?.shadowRadius = compact ? 5 : 9
  35. sparkleView.layer?.shadowOpacity = 0.55
  36. sparkleView.layer?.shadowOffset = .zero
  37. NSLayoutConstraint.activate([
  38. sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6),
  39. sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6)
  40. ])
  41. dotStack.orientation = .horizontal
  42. dotStack.spacing = dotSpacing
  43. dotStack.alignment = .centerY
  44. dotStack.translatesAutoresizingMaskIntoConstraints = false
  45. let dotFill = Self.accentBlue
  46. for _ in 0..<3 {
  47. let dot = NSView()
  48. dot.translatesAutoresizingMaskIntoConstraints = false
  49. dot.wantsLayer = true
  50. dot.layer?.cornerRadius = dotSize / 2
  51. dot.layer?.backgroundColor = dotFill.cgColor
  52. NSLayoutConstraint.activate([
  53. dot.widthAnchor.constraint(equalToConstant: dotSize),
  54. dot.heightAnchor.constraint(equalToConstant: dotSize)
  55. ])
  56. dotStack.addArrangedSubview(dot)
  57. dotViews.append(dot)
  58. }
  59. let row = NSStackView(views: [sparkleView, dotStack])
  60. row.orientation = .horizontal
  61. row.spacing = sparkleDotGap
  62. row.alignment = .centerY
  63. row.translatesAutoresizingMaskIntoConstraints = false
  64. addSubview(row)
  65. NSLayoutConstraint.activate([
  66. row.leadingAnchor.constraint(equalTo: leadingAnchor),
  67. row.trailingAnchor.constraint(equalTo: trailingAnchor),
  68. row.topAnchor.constraint(equalTo: topAnchor),
  69. row.bottomAnchor.constraint(equalTo: bottomAnchor)
  70. ])
  71. setAccessibilityElement(true)
  72. setAccessibilityRole(.group)
  73. setAccessibilityLabel(accessibilityLabel)
  74. }
  75. @available(*, unavailable)
  76. required init?(coder: NSCoder) {
  77. fatalError("init(coder:) has not been implemented")
  78. }
  79. private static var reducedMotion: Bool {
  80. NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
  81. }
  82. func startAnimatingIfNeeded() {
  83. stopAnimating()
  84. guard !Self.reducedMotion else {
  85. dotViews.forEach { $0.layer?.opacity = 0.78 }
  86. return
  87. }
  88. guard let sparkleLayer = sparkleView.layer else { return }
  89. let waveDuration: CFTimeInterval = 0.52
  90. let n = max(1, dotViews.count)
  91. for (i, dot) in dotViews.enumerated() {
  92. guard let layer = dot.layer else { continue }
  93. layer.opacity = 1
  94. let pulse = CABasicAnimation(keyPath: "opacity")
  95. pulse.fromValue = 0.2
  96. pulse.toValue = 1.0
  97. pulse.duration = waveDuration
  98. pulse.autoreverses = true
  99. pulse.repeatCount = .greatestFiniteMagnitude
  100. pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  101. pulse.timeOffset = waveDuration * Double(i) / Double(n)
  102. layer.add(pulse, forKey: AnimationKey.dotOpacity)
  103. }
  104. let scale = CABasicAnimation(keyPath: "transform.scale")
  105. scale.fromValue = 1.0
  106. scale.toValue = 1.07
  107. scale.duration = 1.15
  108. scale.autoreverses = true
  109. scale.repeatCount = .greatestFiniteMagnitude
  110. scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  111. sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse)
  112. }
  113. func stopAnimating() {
  114. sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse)
  115. sparkleView.layer?.transform = CATransform3DIdentity
  116. for dot in dotViews {
  117. dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity)
  118. dot.layer?.opacity = 1
  119. }
  120. }
  121. }