浏览代码

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 月之前
父节点
当前提交
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)
     }
 
+    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 {
         isDark
             ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
@@ -197,13 +223,65 @@ protocol AppearanceRefreshable: AnyObject {
     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 {
+    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() {
         wantsLayer = true
         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?.shadowRadius = 14
+        layer?.shadowRadius = AppTheme.cardShadowRadius
         layer?.masksToBounds = false
     }
 

+ 87 - 17
smart_printer/UIComponents.swift

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