Просмотр исходного кода

Add hover effects to sidebar, quick start, and feature cards.

Introduce shared hover tracking and lift animation so navigation items, action buttons, and all home screen cards respond consistently on mouse-over.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 месяц назад
Родитель
Сommit
0476628158
2 измененных файлов с 167 добавлено и 19 удалено
  1. 80 2
      smart_printer/AppTheme.swift
  2. 87 17
      smart_printer/UIComponents.swift

+ 80 - 2
smart_printer/AppTheme.swift

@@ -94,6 +94,32 @@ enum AppTheme {
             : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
             : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
     }
     }
 
 
+    static var sidebarHoverBackground: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.20, alpha: 1)
+            : NSColor(calibratedWhite: 0.96, alpha: 1)
+    }
+
+    static var premiumHoverBackground: NSColor {
+        isDark
+            ? NSColor(red: 0.24, green: 0.20, blue: 0.36, alpha: 1)
+            : NSColor(red: 0.97, green: 0.95, blue: 1.0, alpha: 1)
+    }
+
+    static let hoverAnimationDuration: TimeInterval = 0.15
+
+    static var cardShadowOpacity: Float {
+        AppSettings.darkModeEnabled ? 0.25 : 0.07
+    }
+
+    static var cardHoverShadowOpacity: Float {
+        AppSettings.darkModeEnabled ? 0.35 : 0.14
+    }
+
+    static let cardShadowRadius: CGFloat = 14
+    static let cardHoverShadowRadius: CGFloat = 18
+    static let cardHoverScale: CGFloat = 1.02
+
     static var premiumBackground: NSColor {
     static var premiumBackground: NSColor {
         isDark
         isDark
             ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
             ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
@@ -197,13 +223,65 @@ protocol AppearanceRefreshable: AnyObject {
     func refreshAppearance()
     func refreshAppearance()
 }
 }
 
 
+// MARK: - Hover Tracking
+
+final class HoverTracker: NSResponder {
+    private weak var view: NSView?
+    private let onHoverChanged: (Bool) -> Void
+
+    init(view: NSView, onHoverChanged: @escaping (Bool) -> Void) {
+        self.view = view
+        self.onHoverChanged = onHoverChanged
+        super.init()
+        let area = NSTrackingArea(
+            rect: .zero,
+            options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
+            owner: self,
+            userInfo: nil
+        )
+        view.addTrackingArea(area)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    override func mouseEntered(with event: NSEvent) {
+        onHoverChanged(true)
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        onHoverChanged(false)
+    }
+}
+
 extension NSView {
 extension NSView {
+    func animateHover(_ changes: () -> Void) {
+        NSAnimationContext.runAnimationGroup { context in
+            context.duration = AppTheme.hoverAnimationDuration
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+            context.allowsImplicitAnimation = true
+            changes()
+        }
+    }
+
+    func applyHoverLift(_ hovering: Bool, on targetLayer: CALayer? = nil) {
+        let layer = targetLayer ?? layer
+        animateHover {
+            guard let layer else { return }
+            layer.transform = hovering
+                ? CATransform3DMakeScale(AppTheme.cardHoverScale, AppTheme.cardHoverScale, 1)
+                : CATransform3DIdentity
+            layer.shadowOpacity = hovering ? AppTheme.cardHoverShadowOpacity : AppTheme.cardShadowOpacity
+            layer.shadowRadius = hovering ? AppTheme.cardHoverShadowRadius : AppTheme.cardShadowRadius
+        }
+    }
+
     func applyCardShadow() {
     func applyCardShadow() {
         wantsLayer = true
         wantsLayer = true
         layer?.shadowColor = NSColor.black.cgColor
         layer?.shadowColor = NSColor.black.cgColor
-        layer?.shadowOpacity = AppSettings.darkModeEnabled ? 0.25 : 0.07
+        layer?.shadowOpacity = AppTheme.cardShadowOpacity
         layer?.shadowOffset = NSSize(width: 0, height: -3)
         layer?.shadowOffset = NSSize(width: 0, height: -3)
-        layer?.shadowRadius = 14
+        layer?.shadowRadius = AppTheme.cardShadowRadius
         layer?.masksToBounds = false
         layer?.masksToBounds = false
     }
     }
 
 

+ 87 - 17
smart_printer/UIComponents.swift

@@ -66,6 +66,7 @@ final class WavePatternView: NSView, AppearanceRefreshable {
 final class SidebarNavItem: NSControl, AppearanceRefreshable {
 final class SidebarNavItem: NSControl, AppearanceRefreshable {
     enum Style {
     enum Style {
         case normal
         case normal
+        case hovered
         case active
         case active
     }
     }
 
 
@@ -80,9 +81,11 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
     private let iconView = NSImageView()
     private let iconView = NSImageView()
     private let titleLabel = NSTextField(labelWithString: "")
     private let titleLabel = NSTextField(labelWithString: "")
     private let container = NSView()
     private let container = NSView()
+    private var hoverTracker: HoverTracker?
+    private var isHovered = false
 
 
     var isSelected: Bool = false {
     var isSelected: Bool = false {
-        didSet { applyStyle(isSelected ? .active : .normal) }
+        didSet { updateAppearance() }
     }
     }
 
 
     init(title: String, symbolName: String, isSelected: Bool = false, accent: Accent = .standard) {
     init(title: String, symbolName: String, isSelected: Bool = false, accent: Accent = .standard) {
@@ -128,14 +131,29 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
             titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -12),
             titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -12),
         ])
         ])
 
 
-        applyStyle(isSelected ? .active : .normal)
+        updateAppearance()
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.isHovered = hovering
+            self?.updateAppearance()
+        }
     }
     }
 
 
     @available(*, unavailable)
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
     required init?(coder: NSCoder) { nil }
 
 
     func refreshAppearance() {
     func refreshAppearance() {
-        applyStyle(isSelected ? .active : .normal)
+        updateAppearance()
+    }
+
+    private func updateAppearance() {
+        if isSelected {
+            applyStyle(.active)
+        } else if isHovered {
+            applyStyle(.hovered)
+        } else {
+            applyStyle(.normal)
+        }
     }
     }
 
 
     private func applyStyle(_ style: Style) {
     private func applyStyle(_ style: Style) {
@@ -144,18 +162,24 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
 
 
         let activeBackground = accent == .premium ? AppTheme.premiumBackground : AppTheme.homeActiveBackground
         let activeBackground = accent == .premium ? AppTheme.premiumBackground : AppTheme.homeActiveBackground
         let activeForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.homeActiveForeground
         let activeForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.homeActiveForeground
-
+        let hoverBackground = accent == .premium ? AppTheme.premiumHoverBackground : AppTheme.sidebarHoverBackground
         let normalForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.textPrimary
         let normalForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.textPrimary
 
 
-        switch style {
-        case .normal:
-            container.layer?.backgroundColor = NSColor.clear.cgColor
-            titleLabel.textColor = normalForeground
-            iconView.contentTintColor = normalForeground
-        case .active:
-            container.layer?.backgroundColor = activeBackground.cgColor
-            titleLabel.textColor = activeForeground
-            iconView.contentTintColor = activeForeground
+        animateHover {
+            switch style {
+            case .normal:
+                container.layer?.backgroundColor = NSColor.clear.cgColor
+                titleLabel.textColor = normalForeground
+                iconView.contentTintColor = normalForeground
+            case .hovered:
+                container.layer?.backgroundColor = hoverBackground.cgColor
+                titleLabel.textColor = normalForeground
+                iconView.contentTintColor = normalForeground
+            case .active:
+                container.layer?.backgroundColor = activeBackground.cgColor
+                titleLabel.textColor = activeForeground
+                iconView.contentTintColor = activeForeground
+            }
         }
         }
     }
     }
 
 
@@ -173,8 +197,11 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
 
 
 final class PillButton: NSButton {
 final class PillButton: NSButton {
     private let horizontalPadding: CGFloat = 16
     private let horizontalPadding: CGFloat = 16
+    private let baseColor: NSColor
+    private var hoverTracker: HoverTracker?
 
 
     init(title: String, color: NSColor) {
     init(title: String, color: NSColor) {
+        baseColor = color
         super.init(frame: .zero)
         super.init(frame: .zero)
         self.title = title
         self.title = title
         isBordered = false
         isBordered = false
@@ -192,6 +219,20 @@ final class PillButton: NSButton {
         }
         }
 
 
         heightAnchor.constraint(equalToConstant: 40).isActive = true
         heightAnchor.constraint(equalToConstant: 40).isActive = true
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.setHovered(hovering)
+        }
+    }
+
+    private func setHovered(_ hovering: Bool) {
+        let color = hovering ? baseColor.blended(withFraction: 0.15, of: .black) ?? baseColor : baseColor
+        animateHover {
+            layer?.backgroundColor = color.cgColor
+            layer?.transform = hovering
+                ? CATransform3DMakeScale(1.03, 1.03, 1)
+                : CATransform3DIdentity
+        }
     }
     }
 
 
     @available(*, unavailable)
     @available(*, unavailable)
@@ -285,19 +326,22 @@ struct QuickStartCardData {
 
 
 final class QuickStartCardView: NSView {
 final class QuickStartCardView: NSView {
     private let iconView: QuickStartIconView
     private let iconView: QuickStartIconView
+    private let gradientView: GradientCardView
     private var iconWidthConstraint: NSLayoutConstraint!
     private var iconWidthConstraint: NSLayoutConstraint!
     private var iconHeightConstraint: NSLayoutConstraint!
     private var iconHeightConstraint: NSLayoutConstraint!
+    private var hoverTracker: HoverTracker?
 
 
     init(data: QuickStartCardData) {
     init(data: QuickStartCardData) {
         iconView = QuickStartIconView(kind: data.iconKind)
         iconView = QuickStartIconView(kind: data.iconKind)
-        super.init(frame: .zero)
-        translatesAutoresizingMaskIntoConstraints = false
-
-        let gradient = GradientCardView(
+        gradientView = GradientCardView(
             colors: data.gradientColors,
             colors: data.gradientColors,
             startPoint: CGPoint(x: 0, y: 0.5),
             startPoint: CGPoint(x: 0, y: 0.5),
             endPoint: CGPoint(x: 1, y: 0.5)
             endPoint: CGPoint(x: 1, y: 0.5)
         )
         )
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+
+        let gradient = gradientView
         gradient.translatesAutoresizingMaskIntoConstraints = false
         gradient.translatesAutoresizingMaskIntoConstraints = false
 
 
         let titleLabel = NSTextField(labelWithString: data.title)
         let titleLabel = NSTextField(labelWithString: data.title)
@@ -344,11 +388,19 @@ final class QuickStartCardView: NSView {
         iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
         iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
         iconWidthConstraint.isActive = true
         iconWidthConstraint.isActive = true
         iconHeightConstraint.isActive = true
         iconHeightConstraint.isActive = true
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.setHovered(hovering)
+        }
     }
     }
 
 
     @available(*, unavailable)
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
     required init?(coder: NSCoder) { nil }
 
 
+    private func setHovered(_ hovering: Bool) {
+        applyHoverLift(hovering, on: gradientView.layer)
+    }
+
     override func layout() {
     override func layout() {
         super.layout()
         super.layout()
         let size = AppTheme.quickStartIconSize(forCardWidth: bounds.width)
         let size = AppTheme.quickStartIconSize(forCardWidth: bounds.width)
@@ -357,6 +409,10 @@ final class QuickStartCardView: NSView {
             iconHeightConstraint.constant = size
             iconHeightConstraint.constant = size
         }
         }
     }
     }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
 }
 }
 
 
 // MARK: - Feature Card
 // MARK: - Feature Card
@@ -374,6 +430,8 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
     private let arrowButton: NSButton
     private let arrowButton: NSButton
     private var iconWidthConstraint: NSLayoutConstraint!
     private var iconWidthConstraint: NSLayoutConstraint!
     private var iconHeightConstraint: NSLayoutConstraint!
     private var iconHeightConstraint: NSLayoutConstraint!
+    private var hoverTracker: HoverTracker?
+    private var isHovered = false
 
 
     init(data: FeatureCardData) {
     init(data: FeatureCardData) {
         iconView = FeatureIconView(kind: data.iconKind)
         iconView = FeatureIconView(kind: data.iconKind)
@@ -432,11 +490,20 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
         iconWidthConstraint.isActive = true
         iconWidthConstraint.isActive = true
         iconHeightConstraint.isActive = true
         iconHeightConstraint.isActive = true
         refreshAppearance()
         refreshAppearance()
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.setHovered(hovering)
+        }
     }
     }
 
 
     @available(*, unavailable)
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
     required init?(coder: NSCoder) { nil }
 
 
+    private func setHovered(_ hovering: Bool) {
+        isHovered = hovering
+        applyHoverLift(hovering)
+    }
+
     func refreshAppearance() {
     func refreshAppearance() {
         layer?.backgroundColor = AppTheme.cardBackground.cgColor
         layer?.backgroundColor = AppTheme.cardBackground.cgColor
         titleLabel.refreshThemeLabelColor()
         titleLabel.refreshThemeLabelColor()
@@ -444,6 +511,9 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
         arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
         arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
         arrowButton.layer?.borderColor = AppTheme.border.cgColor
         arrowButton.layer?.borderColor = AppTheme.border.cgColor
         arrowButton.contentTintColor = AppTheme.textSecondary
         arrowButton.contentTintColor = AppTheme.textSecondary
+        if isHovered {
+            applyHoverLift(true)
+        }
     }
     }
 
 
     override func layout() {
     override func layout() {