Преглед изворни кода

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 пре 11 часа
родитељ
комит
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 {
94
             : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
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
     static var premiumBackground: NSColor {
123
     static var premiumBackground: NSColor {
98
         isDark
124
         isDark
99
             ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
125
             ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
@@ -197,13 +223,65 @@ protocol AppearanceRefreshable: AnyObject {
197
     func refreshAppearance()
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
 extension NSView {
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
     func applyCardShadow() {
279
     func applyCardShadow() {
202
         wantsLayer = true
280
         wantsLayer = true
203
         layer?.shadowColor = NSColor.black.cgColor
281
         layer?.shadowColor = NSColor.black.cgColor
204
-        layer?.shadowOpacity = AppSettings.darkModeEnabled ? 0.25 : 0.07
282
+        layer?.shadowOpacity = AppTheme.cardShadowOpacity
205
         layer?.shadowOffset = NSSize(width: 0, height: -3)
283
         layer?.shadowOffset = NSSize(width: 0, height: -3)
206
-        layer?.shadowRadius = 14
284
+        layer?.shadowRadius = AppTheme.cardShadowRadius
207
         layer?.masksToBounds = false
285
         layer?.masksToBounds = false
208
     }
286
     }
209
 
287
 

+ 87 - 17
smart_printer/UIComponents.swift

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