|
@@ -89,7 +89,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private let statusBannerRow = NSStackView()
|
|
private let statusBannerRow = NSStackView()
|
|
|
private let chatStatusSymbolContainer = NSView()
|
|
private let chatStatusSymbolContainer = NSView()
|
|
|
private let chatStatusIcon = NSImageView()
|
|
private let chatStatusIcon = NSImageView()
|
|
|
- private let chatStatusLoadingIndicator = NSProgressIndicator()
|
|
|
|
|
|
|
+ private let bannerChatThinkingIndicator = ChatThinkingIndicatorView(compact: true)
|
|
|
private let chatStatusLabel = NSTextField(wrappingLabelWithString: "Opening the vault...")
|
|
private let chatStatusLabel = NSTextField(wrappingLabelWithString: "Opening the vault...")
|
|
|
private let statusBrandTrailing = NSStackView()
|
|
private let statusBrandTrailing = NSStackView()
|
|
|
private let statusBrandStarIcon = NSImageView()
|
|
private let statusBrandStarIcon = NSImageView()
|
|
@@ -124,6 +124,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private var savedJobOrder: [JobListing] = []
|
|
private var savedJobOrder: [JobListing] = []
|
|
|
private var chatMessages: [ChatMessage] = []
|
|
private var chatMessages: [ChatMessage] = []
|
|
|
private var isAwaitingResponse = false
|
|
private var isAwaitingResponse = false
|
|
|
|
|
+ /// Shown under the latest user message while a job search request is in flight.
|
|
|
|
|
+ private var chatThinkingRowHost: NSView?
|
|
|
|
|
+ private var chatStatusSymbolWidthConstraint: NSLayoutConstraint?
|
|
|
private let jobSearchService = OpenAIJobSearchService()
|
|
private let jobSearchService = OpenAIJobSearchService()
|
|
|
|
|
|
|
|
/// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
|
|
/// Upper bound sent to the model per request (was fixed at 8 in the prompt). Clamped when calling the API.
|
|
@@ -136,6 +139,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
|
|
private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
|
|
|
|
|
+ /// Status label value while the model/API is working; drives the thinking affordance in the banner and chat.
|
|
|
|
|
+ private static let chatStatusLoadingMessage = "Searching…"
|
|
|
private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
|
|
private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
|
|
|
|
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
override init(frame frameRect: NSRect) {
|
|
@@ -365,27 +370,22 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
|
chatStatusIcon.contentTintColor = .white
|
|
chatStatusIcon.contentTintColor = .white
|
|
|
|
|
|
|
|
- chatStatusLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- chatStatusLoadingIndicator.style = .spinning
|
|
|
|
|
- chatStatusLoadingIndicator.isIndeterminate = true
|
|
|
|
|
- chatStatusLoadingIndicator.isDisplayedWhenStopped = false
|
|
|
|
|
- chatStatusLoadingIndicator.controlSize = .regular
|
|
|
|
|
- chatStatusLoadingIndicator.isHidden = true
|
|
|
|
|
- chatStatusLoadingIndicator.setAccessibilityRole(.progressIndicator)
|
|
|
|
|
- chatStatusLoadingIndicator.setAccessibilityLabel("Searching for jobs")
|
|
|
|
|
|
|
+ bannerChatThinkingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ bannerChatThinkingIndicator.isHidden = true
|
|
|
|
|
|
|
|
chatStatusSymbolContainer.addSubview(chatStatusIcon)
|
|
chatStatusSymbolContainer.addSubview(chatStatusIcon)
|
|
|
- chatStatusSymbolContainer.addSubview(chatStatusLoadingIndicator)
|
|
|
|
|
|
|
+ chatStatusSymbolContainer.addSubview(bannerChatThinkingIndicator)
|
|
|
|
|
+
|
|
|
|
|
+ let symbolWidth = chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40)
|
|
|
|
|
+ chatStatusSymbolWidthConstraint = symbolWidth
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
- chatStatusSymbolContainer.widthAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
|
|
+ symbolWidth,
|
|
|
chatStatusSymbolContainer.heightAnchor.constraint(equalToConstant: 40),
|
|
chatStatusSymbolContainer.heightAnchor.constraint(equalToConstant: 40),
|
|
|
chatStatusIcon.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
chatStatusIcon.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
|
chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
|
- chatStatusLoadingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
|
|
|
- chatStatusLoadingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
|
|
|
- chatStatusLoadingIndicator.widthAnchor.constraint(equalToConstant: 26),
|
|
|
|
|
- chatStatusLoadingIndicator.heightAnchor.constraint(equalToConstant: 26)
|
|
|
|
|
|
|
+ bannerChatThinkingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
|
|
|
+ bannerChatThinkingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor)
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
chatStatusLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
|
chatStatusLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
|
@@ -490,7 +490,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private func updateStatusBannerLabelWidth() {
|
|
private func updateStatusBannerLabelWidth() {
|
|
|
guard statusBannerContainer.bounds.width > 1 else { return }
|
|
guard statusBannerContainer.bounds.width > 1 else { return }
|
|
|
let trailingWidth = max(96, statusBrandTrailing.fittingSize.width)
|
|
let trailingWidth = max(96, statusBrandTrailing.fittingSize.width)
|
|
|
- let chrome: CGFloat = 14 + 40 + 12 + 16 + 16 + trailingWidth
|
|
|
|
|
|
|
+ let symbolChrome = chatStatusSymbolWidthConstraint?.constant ?? 40
|
|
|
|
|
+ let chrome: CGFloat = 14 + symbolChrome + 12 + 16 + 16 + trailingWidth
|
|
|
let target = max(80, statusBannerContainer.bounds.width - chrome)
|
|
let target = max(80, statusBannerContainer.bounds.width - chrome)
|
|
|
if abs(chatStatusLabel.preferredMaxLayoutWidth - target) > 0.5 {
|
|
if abs(chatStatusLabel.preferredMaxLayoutWidth - target) > 0.5 {
|
|
|
chatStatusLabel.preferredMaxLayoutWidth = target
|
|
chatStatusLabel.preferredMaxLayoutWidth = target
|
|
@@ -500,7 +501,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
|
|
/// Leading status glyph: sparkles for prompts, magnifying glass for search-result summaries and errors.
|
|
/// Leading status glyph: sparkles for prompts, magnifying glass for search-result summaries and errors.
|
|
|
private func syncChatStatusBannerVisuals(forStatusText text: String) {
|
|
private func syncChatStatusBannerVisuals(forStatusText text: String) {
|
|
|
- if text == "Thinking..." { return }
|
|
|
|
|
|
|
+ if text == Self.chatStatusLoadingMessage { return }
|
|
|
let lower = text.lowercased()
|
|
let lower = text.lowercased()
|
|
|
let useMagnifyingGlass =
|
|
let useMagnifyingGlass =
|
|
|
text.hasPrefix("Found ")
|
|
text.hasPrefix("Found ")
|
|
@@ -522,16 +523,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func syncChatStatusLoadingIndicator(forStatusText text: String) {
|
|
private func syncChatStatusLoadingIndicator(forStatusText text: String) {
|
|
|
- let loading = (text == "Thinking...")
|
|
|
|
|
|
|
+ let loading = (text == Self.chatStatusLoadingMessage)
|
|
|
if loading {
|
|
if loading {
|
|
|
chatStatusIcon.isHidden = true
|
|
chatStatusIcon.isHidden = true
|
|
|
- chatStatusLoadingIndicator.isHidden = false
|
|
|
|
|
- chatStatusLoadingIndicator.startAnimation(nil)
|
|
|
|
|
- chatStatusSymbolContainer.layer?.backgroundColor = Theme.featureIconWell.cgColor
|
|
|
|
|
|
|
+ bannerChatThinkingIndicator.isHidden = false
|
|
|
|
|
+ bannerChatThinkingIndicator.startAnimatingIfNeeded()
|
|
|
|
|
+ chatStatusSymbolContainer.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
|
+ chatStatusSymbolWidthConstraint?.constant = 56
|
|
|
} else {
|
|
} else {
|
|
|
- chatStatusLoadingIndicator.stopAnimation(nil)
|
|
|
|
|
|
|
+ bannerChatThinkingIndicator.stopAnimating()
|
|
|
|
|
+ bannerChatThinkingIndicator.isHidden = true
|
|
|
chatStatusIcon.isHidden = false
|
|
chatStatusIcon.isHidden = false
|
|
|
- chatStatusLoadingIndicator.isHidden = true
|
|
|
|
|
|
|
+ chatStatusSymbolWidthConstraint?.constant = 40
|
|
|
chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -1587,13 +1590,15 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
|
|
private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
|
|
private func startJobSearchRequest(effectiveQuery: String, isContinuation: Bool) {
|
|
|
isAwaitingResponse = true
|
|
isAwaitingResponse = true
|
|
|
- setChatStatusLabel("Thinking...")
|
|
|
|
|
|
|
+ setChatStatusLabel(Self.chatStatusLoadingMessage)
|
|
|
|
|
+ addInlineChatThinkingRow()
|
|
|
setInputEnabled(false)
|
|
setInputEnabled(false)
|
|
|
let contextMessages = chatMessages
|
|
let contextMessages = chatMessages
|
|
|
let maxJobs = Self.clampedJobsPerRequest()
|
|
let maxJobs = Self.clampedJobsPerRequest()
|
|
|
jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages, maxJobs: maxJobs) { [weak self] result in
|
|
jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages, maxJobs: maxJobs) { [weak self] result in
|
|
|
DispatchQueue.main.async {
|
|
DispatchQueue.main.async {
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
|
|
+ self.removeInlineChatThinkingRow()
|
|
|
self.isAwaitingResponse = false
|
|
self.isAwaitingResponse = false
|
|
|
self.setInputEnabled(true)
|
|
self.setInputEnabled(true)
|
|
|
switch result {
|
|
switch result {
|
|
@@ -1753,6 +1758,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func resetChatState() {
|
|
private func resetChatState() {
|
|
|
|
|
+ removeInlineChatThinkingRow()
|
|
|
trailingLoadMoreJobsRow = nil
|
|
trailingLoadMoreJobsRow = nil
|
|
|
trailingLoadMoreJobsButton = nil
|
|
trailingLoadMoreJobsButton = nil
|
|
|
chatMessages.removeAll()
|
|
chatMessages.removeAll()
|
|
@@ -1965,6 +1971,37 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
|
|
chatScrollView.reflectScrolledClipView(chatScrollView.contentView)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func addInlineChatThinkingRow() {
|
|
|
|
|
+ removeInlineChatThinkingRow()
|
|
|
|
|
+ let host = NSView()
|
|
|
|
|
+ host.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ let indicator = ChatThinkingIndicatorView(compact: false)
|
|
|
|
|
+ host.addSubview(indicator)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ indicator.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 8),
|
|
|
|
|
+ indicator.topAnchor.constraint(equalTo: host.topAnchor),
|
|
|
|
|
+ indicator.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -2)
|
|
|
|
|
+ ])
|
|
|
|
|
+ chatThinkingRowHost = host
|
|
|
|
|
+ chatStack.addArrangedSubview(host)
|
|
|
|
|
+ host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
|
|
|
|
|
+ indicator.startAnimatingIfNeeded()
|
|
|
|
|
+ DispatchQueue.main.async { [weak self] in
|
|
|
|
|
+ self?.updateChatBubbleWidths()
|
|
|
|
|
+ self?.scrollChatToBottom()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func removeInlineChatThinkingRow() {
|
|
|
|
|
+ guard let host = chatThinkingRowHost else { return }
|
|
|
|
|
+ for sub in host.subviews {
|
|
|
|
|
+ (sub as? ChatThinkingIndicatorView)?.stopAnimating()
|
|
|
|
|
+ }
|
|
|
|
|
+ chatStack.removeArrangedSubview(host)
|
|
|
|
|
+ host.removeFromSuperview()
|
|
|
|
|
+ chatThinkingRowHost = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func setInputEnabled(_ enabled: Bool) {
|
|
private func setInputEnabled(_ enabled: Bool) {
|
|
|
jobKeywordsField.isEnabled = enabled
|
|
jobKeywordsField.isEnabled = enabled
|
|
|
findJobsButton.isEnabled = enabled
|
|
findJobsButton.isEnabled = enabled
|
|
@@ -2820,6 +2857,133 @@ private class HoverableView: NSView {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// Single sparkle with a soft warm glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
|
|
|
|
|
+private final class ChatThinkingIndicatorView: NSView {
|
|
|
|
|
+ private enum AnimationKey {
|
|
|
|
|
+ static let dotOpacity = "thinkingDotOpacity"
|
|
|
|
|
+ static let sparklePulse = "thinkingSparklePulse"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private let sparkleView = NSImageView()
|
|
|
|
|
+ private let dotStack = NSStackView()
|
|
|
|
|
+ private var dotViews: [NSView] = []
|
|
|
|
|
+
|
|
|
|
|
+ init(compact: Bool) {
|
|
|
|
|
+ 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 = NSColor(srgbRed: 232 / 255, green: 104 / 255, blue: 128 / 255, alpha: 1)
|
|
|
|
|
+ sparkleView.wantsLayer = true
|
|
|
|
|
+ sparkleView.layer?.masksToBounds = false
|
|
|
|
|
+ sparkleView.layer?.shadowColor = NSColor(srgbRed: 242 / 255, green: 96 / 255, blue: 132 / 255, alpha: 1).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 = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 122 / 255, alpha: 1)
|
|
|
|
|
+ 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("Assistant is searching")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @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
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
|
|
/// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
|
|
|
private final class JobListingsDocumentView: NSView {
|
|
private final class JobListingsDocumentView: NSView {
|
|
|
override var isFlipped: Bool { true }
|
|
override var isFlipped: Bool { true }
|