Parcourir la Source

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 il y a 5 heures
Parent
commit
0476628158
2 fichiers modifiés avec 167 ajouts et 19 suppressions
  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 {
94 94
             : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
95 95
     }
96 96
 
97
+    static var sidebarHoverBackground: NSColor {
98
+        isDark
99
+            ? NSColor(calibratedWhite: 0.20, alpha: 1)
100
+            : NSColor(calibratedWhite: 0.96, alpha: 1)
101
+    }
102
+
103
+    static var premiumHoverBackground: NSColor {
104
+        isDark
105
+            ? NSColor(red: 0.24, green: 0.20, blue: 0.36, alpha: 1)
106
+            : NSColor(red: 0.97, green: 0.95, blue: 1.0, alpha: 1)
107
+    }
108
+
109
+    static let hoverAnimationDuration: TimeInterval = 0.15
110
+
111
+    static var cardShadowOpacity: Float {
112
+        AppSettings.darkModeEnabled ? 0.25 : 0.07
113
+    }
114
+
115
+    static var cardHoverShadowOpacity: Float {
116
+        AppSettings.darkModeEnabled ? 0.35 : 0.14
117
+    }
118
+
119
+    static let cardShadowRadius: CGFloat = 14
120
+    static let cardHoverShadowRadius: CGFloat = 18
121
+    static let cardHoverScale: CGFloat = 1.02
122
+
97 123
     static var premiumBackground: NSColor {
98 124
         isDark
99 125
             ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
@@ -197,13 +223,65 @@ protocol AppearanceRefreshable: AnyObject {
197 223
     func refreshAppearance()
198 224
 }
199 225
 
226
+// MARK: - Hover Tracking
227
+
228
+final class HoverTracker: NSResponder {
229
+    private weak var view: NSView?
230
+    private let onHoverChanged: (Bool) -> Void
231
+
232
+    init(view: NSView, onHoverChanged: @escaping (Bool) -> Void) {
233
+        self.view = view
234
+        self.onHoverChanged = onHoverChanged
235
+        super.init()
236
+        let area = NSTrackingArea(
237
+            rect: .zero,
238
+            options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
239
+            owner: self,
240
+            userInfo: nil
241
+        )
242
+        view.addTrackingArea(area)
243
+    }
244
+
245
+    @available(*, unavailable)
246
+    required init?(coder: NSCoder) { nil }
247
+
248
+    override func mouseEntered(with event: NSEvent) {
249
+        onHoverChanged(true)
250
+    }
251
+
252
+    override func mouseExited(with event: NSEvent) {
253
+        onHoverChanged(false)
254
+    }
255
+}
256
+
200 257
 extension NSView {
258
+    func animateHover(_ changes: () -> Void) {
259
+        NSAnimationContext.runAnimationGroup { context in
260
+            context.duration = AppTheme.hoverAnimationDuration
261
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
262
+            context.allowsImplicitAnimation = true
263
+            changes()
264
+        }
265
+    }
266
+
267
+    func applyHoverLift(_ hovering: Bool, on targetLayer: CALayer? = nil) {
268
+        let layer = targetLayer ?? layer
269
+        animateHover {
270
+            guard let layer else { return }
271
+            layer.transform = hovering
272
+                ? CATransform3DMakeScale(AppTheme.cardHoverScale, AppTheme.cardHoverScale, 1)
273
+                : CATransform3DIdentity
274
+            layer.shadowOpacity = hovering ? AppTheme.cardHoverShadowOpacity : AppTheme.cardShadowOpacity
275
+            layer.shadowRadius = hovering ? AppTheme.cardHoverShadowRadius : AppTheme.cardShadowRadius
276
+        }
277
+    }
278
+
201 279
     func applyCardShadow() {
202 280
         wantsLayer = true
203 281
         layer?.shadowColor = NSColor.black.cgColor
204
-        layer?.shadowOpacity = AppSettings.darkModeEnabled ? 0.25 : 0.07
282
+        layer?.shadowOpacity = AppTheme.cardShadowOpacity
205 283
         layer?.shadowOffset = NSSize(width: 0, height: -3)
206
-        layer?.shadowRadius = 14
284
+        layer?.shadowRadius = AppTheme.cardShadowRadius
207 285
         layer?.masksToBounds = false
208 286
     }
209 287
 

+ 87 - 17
smart_printer/UIComponents.swift

@@ -66,6 +66,7 @@ final class WavePatternView: NSView, AppearanceRefreshable {
66 66
 final class SidebarNavItem: NSControl, AppearanceRefreshable {
67 67
     enum Style {
68 68
         case normal
69
+        case hovered
69 70
         case active
70 71
     }
71 72
 
@@ -80,9 +81,11 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
80 81
     private let iconView = NSImageView()
81 82
     private let titleLabel = NSTextField(labelWithString: "")
82 83
     private let container = NSView()
84
+    private var hoverTracker: HoverTracker?
85
+    private var isHovered = false
83 86
 
84 87
     var isSelected: Bool = false {
85
-        didSet { applyStyle(isSelected ? .active : .normal) }
88
+        didSet { updateAppearance() }
86 89
     }
87 90
 
88 91
     init(title: String, symbolName: String, isSelected: Bool = false, accent: Accent = .standard) {
@@ -128,14 +131,29 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
128 131
             titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -12),
129 132
         ])
130 133
 
131
-        applyStyle(isSelected ? .active : .normal)
134
+        updateAppearance()
135
+
136
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
137
+            self?.isHovered = hovering
138
+            self?.updateAppearance()
139
+        }
132 140
     }
133 141
 
134 142
     @available(*, unavailable)
135 143
     required init?(coder: NSCoder) { nil }
136 144
 
137 145
     func refreshAppearance() {
138
-        applyStyle(isSelected ? .active : .normal)
146
+        updateAppearance()
147
+    }
148
+
149
+    private func updateAppearance() {
150
+        if isSelected {
151
+            applyStyle(.active)
152
+        } else if isHovered {
153
+            applyStyle(.hovered)
154
+        } else {
155
+            applyStyle(.normal)
156
+        }
139 157
     }
140 158
 
141 159
     private func applyStyle(_ style: Style) {
@@ -144,18 +162,24 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
144 162
 
145 163
         let activeBackground = accent == .premium ? AppTheme.premiumBackground : AppTheme.homeActiveBackground
146 164
         let activeForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.homeActiveForeground
147
-
165
+        let hoverBackground = accent == .premium ? AppTheme.premiumHoverBackground : AppTheme.sidebarHoverBackground
148 166
         let normalForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.textPrimary
149 167
 
150
-        switch style {
151
-        case .normal:
152
-            container.layer?.backgroundColor = NSColor.clear.cgColor
153
-            titleLabel.textColor = normalForeground
154
-            iconView.contentTintColor = normalForeground
155
-        case .active:
156
-            container.layer?.backgroundColor = activeBackground.cgColor
157
-            titleLabel.textColor = activeForeground
158
-            iconView.contentTintColor = activeForeground
168
+        animateHover {
169
+            switch style {
170
+            case .normal:
171
+                container.layer?.backgroundColor = NSColor.clear.cgColor
172
+                titleLabel.textColor = normalForeground
173
+                iconView.contentTintColor = normalForeground
174
+            case .hovered:
175
+                container.layer?.backgroundColor = hoverBackground.cgColor
176
+                titleLabel.textColor = normalForeground
177
+                iconView.contentTintColor = normalForeground
178
+            case .active:
179
+                container.layer?.backgroundColor = activeBackground.cgColor
180
+                titleLabel.textColor = activeForeground
181
+                iconView.contentTintColor = activeForeground
182
+            }
159 183
         }
160 184
     }
161 185
 
@@ -173,8 +197,11 @@ final class SidebarNavItem: NSControl, AppearanceRefreshable {
173 197
 
174 198
 final class PillButton: NSButton {
175 199
     private let horizontalPadding: CGFloat = 16
200
+    private let baseColor: NSColor
201
+    private var hoverTracker: HoverTracker?
176 202
 
177 203
     init(title: String, color: NSColor) {
204
+        baseColor = color
178 205
         super.init(frame: .zero)
179 206
         self.title = title
180 207
         isBordered = false
@@ -192,6 +219,20 @@ final class PillButton: NSButton {
192 219
         }
193 220
 
194 221
         heightAnchor.constraint(equalToConstant: 40).isActive = true
222
+
223
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
224
+            self?.setHovered(hovering)
225
+        }
226
+    }
227
+
228
+    private func setHovered(_ hovering: Bool) {
229
+        let color = hovering ? baseColor.blended(withFraction: 0.15, of: .black) ?? baseColor : baseColor
230
+        animateHover {
231
+            layer?.backgroundColor = color.cgColor
232
+            layer?.transform = hovering
233
+                ? CATransform3DMakeScale(1.03, 1.03, 1)
234
+                : CATransform3DIdentity
235
+        }
195 236
     }
196 237
 
197 238
     @available(*, unavailable)
@@ -285,19 +326,22 @@ struct QuickStartCardData {
285 326
 
286 327
 final class QuickStartCardView: NSView {
287 328
     private let iconView: QuickStartIconView
329
+    private let gradientView: GradientCardView
288 330
     private var iconWidthConstraint: NSLayoutConstraint!
289 331
     private var iconHeightConstraint: NSLayoutConstraint!
332
+    private var hoverTracker: HoverTracker?
290 333
 
291 334
     init(data: QuickStartCardData) {
292 335
         iconView = QuickStartIconView(kind: data.iconKind)
293
-        super.init(frame: .zero)
294
-        translatesAutoresizingMaskIntoConstraints = false
295
-
296
-        let gradient = GradientCardView(
336
+        gradientView = GradientCardView(
297 337
             colors: data.gradientColors,
298 338
             startPoint: CGPoint(x: 0, y: 0.5),
299 339
             endPoint: CGPoint(x: 1, y: 0.5)
300 340
         )
341
+        super.init(frame: .zero)
342
+        translatesAutoresizingMaskIntoConstraints = false
343
+
344
+        let gradient = gradientView
301 345
         gradient.translatesAutoresizingMaskIntoConstraints = false
302 346
 
303 347
         let titleLabel = NSTextField(labelWithString: data.title)
@@ -344,11 +388,19 @@ final class QuickStartCardView: NSView {
344 388
         iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
345 389
         iconWidthConstraint.isActive = true
346 390
         iconHeightConstraint.isActive = true
391
+
392
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
393
+            self?.setHovered(hovering)
394
+        }
347 395
     }
348 396
 
349 397
     @available(*, unavailable)
350 398
     required init?(coder: NSCoder) { nil }
351 399
 
400
+    private func setHovered(_ hovering: Bool) {
401
+        applyHoverLift(hovering, on: gradientView.layer)
402
+    }
403
+
352 404
     override func layout() {
353 405
         super.layout()
354 406
         let size = AppTheme.quickStartIconSize(forCardWidth: bounds.width)
@@ -357,6 +409,10 @@ final class QuickStartCardView: NSView {
357 409
             iconHeightConstraint.constant = size
358 410
         }
359 411
     }
412
+
413
+    override func resetCursorRects() {
414
+        addCursorRect(bounds, cursor: .pointingHand)
415
+    }
360 416
 }
361 417
 
362 418
 // MARK: - Feature Card
@@ -374,6 +430,8 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
374 430
     private let arrowButton: NSButton
375 431
     private var iconWidthConstraint: NSLayoutConstraint!
376 432
     private var iconHeightConstraint: NSLayoutConstraint!
433
+    private var hoverTracker: HoverTracker?
434
+    private var isHovered = false
377 435
 
378 436
     init(data: FeatureCardData) {
379 437
         iconView = FeatureIconView(kind: data.iconKind)
@@ -432,11 +490,20 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
432 490
         iconWidthConstraint.isActive = true
433 491
         iconHeightConstraint.isActive = true
434 492
         refreshAppearance()
493
+
494
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
495
+            self?.setHovered(hovering)
496
+        }
435 497
     }
436 498
 
437 499
     @available(*, unavailable)
438 500
     required init?(coder: NSCoder) { nil }
439 501
 
502
+    private func setHovered(_ hovering: Bool) {
503
+        isHovered = hovering
504
+        applyHoverLift(hovering)
505
+    }
506
+
440 507
     func refreshAppearance() {
441 508
         layer?.backgroundColor = AppTheme.cardBackground.cgColor
442 509
         titleLabel.refreshThemeLabelColor()
@@ -444,6 +511,9 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
444 511
         arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
445 512
         arrowButton.layer?.borderColor = AppTheme.border.cgColor
446 513
         arrowButton.contentTintColor = AppTheme.textSecondary
514
+        if isHovered {
515
+            applyHoverLift(true)
516
+        }
447 517
     }
448 518
 
449 519
     override func layout() {