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

Add hover feedback to premium plan CTAs and close button

Introduce PlanPurchaseHoverButton and PremiumCloseHoverButton with
NSTrackingArea-driven hover styles, shadows, and pointing-hand cursor.
Use AnyObject? for button targets to satisfy NSButton weak target typing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 недель назад: 3
Родитель
Сommit
6a8ab9919e
1 измененных файлов с 216 добавлено и 26 удалено
  1. 216 26
      App for Indeed/Controllers/PremiumPlansWindowController.swift

+ 216 - 26
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -151,6 +151,214 @@ private final class PremiumPlansViewController: NSViewController {
151 151
         }
152 152
     }
153 153
 
154
+    /// Purchase CTAs: fill/border/shadow + pointing hand on hover.
155
+    private final class PlanPurchaseHoverButton: NSButton {
156
+        private var trackingAreaRef: NSTrackingArea?
157
+        private var didPushCursor = false
158
+        private let isPrimaryStyle: Bool
159
+
160
+        private static let primaryFill = NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
161
+        private static let primaryFillHover = NSColor(srgbRed: 205 / 255, green: 88 / 255, blue: 255 / 255, alpha: 1)
162
+
163
+        init(planId: String, title: String, isPrimaryStyle: Bool, target: AnyObject?, action: Selector) {
164
+            self.isPrimaryStyle = isPrimaryStyle
165
+            super.init(frame: .zero)
166
+            identifier = NSUserInterfaceItemIdentifier(planId)
167
+            self.title = title
168
+            self.target = target
169
+            self.action = action
170
+            isBordered = false
171
+            bezelStyle = .rounded
172
+            font = .systemFont(ofSize: 14, weight: .semibold)
173
+            wantsLayer = true
174
+            layer?.cornerRadius = 12
175
+            focusRingType = .none
176
+            translatesAutoresizingMaskIntoConstraints = false
177
+            applyBaseStyle(hovered: false)
178
+        }
179
+
180
+        @available(*, unavailable)
181
+        required init?(coder: NSCoder) {
182
+            nil
183
+        }
184
+
185
+        override var isEnabled: Bool {
186
+            didSet {
187
+                if !isEnabled {
188
+                    applyBaseStyle(hovered: false, animated: true)
189
+                    if didPushCursor {
190
+                        NSCursor.pop()
191
+                        didPushCursor = false
192
+                    }
193
+                }
194
+            }
195
+        }
196
+
197
+        override func updateTrackingAreas() {
198
+            super.updateTrackingAreas()
199
+            if let trackingAreaRef {
200
+                removeTrackingArea(trackingAreaRef)
201
+            }
202
+            let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
203
+            let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
204
+            addTrackingArea(area)
205
+            trackingAreaRef = area
206
+        }
207
+
208
+        override func mouseEntered(with event: NSEvent) {
209
+            super.mouseEntered(with: event)
210
+            guard isEnabled else { return }
211
+            applyBaseStyle(hovered: true, animated: true)
212
+            if !didPushCursor {
213
+                NSCursor.pointingHand.push()
214
+                didPushCursor = true
215
+            }
216
+        }
217
+
218
+        override func mouseExited(with event: NSEvent) {
219
+            super.mouseExited(with: event)
220
+            applyBaseStyle(hovered: false, animated: true)
221
+            if didPushCursor {
222
+                NSCursor.pop()
223
+                didPushCursor = false
224
+            }
225
+        }
226
+
227
+        override func viewWillMove(toWindow newWindow: NSWindow?) {
228
+            super.viewWillMove(toWindow: newWindow)
229
+            if newWindow == nil, didPushCursor {
230
+                NSCursor.pop()
231
+                didPushCursor = false
232
+            }
233
+            if newWindow == nil {
234
+                applyBaseStyle(hovered: false, animated: false)
235
+            }
236
+        }
237
+
238
+        private func applyBaseStyle(hovered: Bool, animated: Bool = true) {
239
+            let updates = {
240
+                if self.isPrimaryStyle {
241
+                    self.layer?.backgroundColor = (hovered ? Self.primaryFillHover : Self.primaryFill).cgColor
242
+                    self.layer?.borderColor = Theme.accent.cgColor
243
+                    self.layer?.borderWidth = hovered ? 2 : 1
244
+                    self.contentTintColor = .white
245
+                    self.layer?.shadowColor = Self.primaryFill.cgColor
246
+                    self.layer?.shadowOpacity = hovered ? 0.28 : 0
247
+                    self.layer?.shadowRadius = hovered ? 12 : 0
248
+                    self.layer?.shadowOffset = CGSize(width: 0, height: -2)
249
+                } else {
250
+                    let baseFill = Theme.mutedButtonFill
251
+                    let hoverFill = baseFill.blended(withFraction: 0.22, of: Theme.accent) ?? baseFill
252
+                    self.layer?.backgroundColor = (hovered ? hoverFill : baseFill).cgColor
253
+                    self.layer?.borderColor = (hovered ? Theme.accent : Theme.divider).cgColor
254
+                    self.layer?.borderWidth = hovered ? 2 : 1
255
+                    self.contentTintColor = Theme.primaryText
256
+                    self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.35).cgColor
257
+                    self.layer?.shadowOpacity = hovered ? 0.18 : 0
258
+                    self.layer?.shadowRadius = hovered ? 10 : 0
259
+                    self.layer?.shadowOffset = CGSize(width: 0, height: -2)
260
+                }
261
+            }
262
+            if animated {
263
+                NSAnimationContext.runAnimationGroup { context in
264
+                    context.duration = 0.16
265
+                    updates()
266
+                }
267
+            } else {
268
+                updates()
269
+            }
270
+        }
271
+    }
272
+
273
+    /// Close control: subtle lift + accent tint on hover.
274
+    private final class PremiumCloseHoverButton: NSButton {
275
+        private var trackingAreaRef: NSTrackingArea?
276
+        private var didPushCursor = false
277
+
278
+        init(target: AnyObject?, action: Selector) {
279
+            super.init(frame: .zero)
280
+            self.target = target
281
+            self.action = action
282
+            isBordered = false
283
+            wantsLayer = true
284
+            layer?.cornerRadius = 15
285
+            bezelStyle = .regularSquare
286
+            image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
287
+            imageScaling = .scaleProportionallyDown
288
+            focusRingType = .none
289
+            translatesAutoresizingMaskIntoConstraints = false
290
+            applyStyle(hovered: false, animated: false)
291
+        }
292
+
293
+        @available(*, unavailable)
294
+        required init?(coder: NSCoder) {
295
+            nil
296
+        }
297
+
298
+        override func updateTrackingAreas() {
299
+            super.updateTrackingAreas()
300
+            if let trackingAreaRef {
301
+                removeTrackingArea(trackingAreaRef)
302
+            }
303
+            let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
304
+            let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
305
+            addTrackingArea(area)
306
+            trackingAreaRef = area
307
+        }
308
+
309
+        override func mouseEntered(with event: NSEvent) {
310
+            super.mouseEntered(with: event)
311
+            applyStyle(hovered: true, animated: true)
312
+            if !didPushCursor {
313
+                NSCursor.pointingHand.push()
314
+                didPushCursor = true
315
+            }
316
+        }
317
+
318
+        override func mouseExited(with event: NSEvent) {
319
+            super.mouseExited(with: event)
320
+            applyStyle(hovered: false, animated: true)
321
+            if didPushCursor {
322
+                NSCursor.pop()
323
+                didPushCursor = false
324
+            }
325
+        }
326
+
327
+        override func viewWillMove(toWindow newWindow: NSWindow?) {
328
+            super.viewWillMove(toWindow: newWindow)
329
+            if newWindow == nil, didPushCursor {
330
+                NSCursor.pop()
331
+                didPushCursor = false
332
+            }
333
+            if newWindow == nil {
334
+                applyStyle(hovered: false, animated: false)
335
+            }
336
+        }
337
+
338
+        private func applyStyle(hovered: Bool, animated: Bool) {
339
+            let updates = {
340
+                self.layer?.backgroundColor = (hovered
341
+                    ? NSColor.white.withAlphaComponent(0.98)
342
+                    : NSColor.white.withAlphaComponent(0.92)).cgColor
343
+                self.layer?.borderColor = (hovered ? Theme.accent.withAlphaComponent(0.45) : Theme.divider).cgColor
344
+                self.layer?.borderWidth = hovered ? 1.5 : 1
345
+                self.contentTintColor = hovered ? Theme.accent : Theme.secondaryText
346
+                self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.25).cgColor
347
+                self.layer?.shadowOpacity = hovered ? 0.2 : 0
348
+                self.layer?.shadowRadius = hovered ? 8 : 0
349
+                self.layer?.shadowOffset = CGSize(width: 0, height: -1)
350
+            }
351
+            if animated {
352
+                NSAnimationContext.runAnimationGroup { context in
353
+                    context.duration = 0.15
354
+                    updates()
355
+                }
356
+            } else {
357
+                updates()
358
+            }
359
+        }
360
+    }
361
+
154 362
     private struct Plan {
155 363
         let id: String
156 364
         let title: String
@@ -300,18 +508,7 @@ private final class PremiumPlansViewController: NSViewController {
300 508
     }
301 509
 
302 510
     private func setupLayout() {
303
-        let closeButton = NSButton(title: "", target: self, action: #selector(didTapClose))
304
-        closeButton.translatesAutoresizingMaskIntoConstraints = false
305
-        closeButton.isBordered = false
306
-        closeButton.wantsLayer = true
307
-        closeButton.layer?.cornerRadius = 15
308
-        closeButton.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor
309
-        closeButton.layer?.borderWidth = 1
310
-        closeButton.layer?.borderColor = Theme.divider.cgColor
311
-        closeButton.contentTintColor = Theme.secondaryText
312
-        closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
313
-        closeButton.imageScaling = .scaleProportionallyDown
314
-        closeButton.bezelStyle = .regularSquare
511
+        let closeButton = PremiumCloseHoverButton(target: self, action: #selector(didTapClose))
315 512
 
316 513
         let crownIcon = NSImageView()
317 514
         crownIcon.translatesAutoresizingMaskIntoConstraints = false
@@ -441,22 +638,15 @@ private final class PremiumPlansViewController: NSViewController {
441 638
         featuresStack.alignment = .leading
442 639
         featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
443 640
 
444
-        let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
445
-        selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
641
+        let selectButton = PlanPurchaseHoverButton(
642
+            planId: plan.id,
643
+            title: "Get \(plan.title)",
644
+            isPrimaryStyle: plan.highlight,
645
+            target: self,
646
+            action: #selector(didTapSelectPlan)
647
+        )
446 648
         planPurchaseButtons[plan.id] = selectButton
447 649
         planPriceFields[plan.id] = (priceLabel, periodLabel)
448
-        selectButton.isBordered = false
449
-        selectButton.bezelStyle = .rounded
450
-        selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
451
-        selectButton.contentTintColor = plan.highlight ? .white : Theme.primaryText
452
-        selectButton.wantsLayer = true
453
-        selectButton.layer?.cornerRadius = 12
454
-        selectButton.layer?.borderWidth = 1
455
-        selectButton.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.divider).cgColor
456
-        selectButton.layer?.backgroundColor = (plan.highlight
457
-            ? NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
458
-            : Theme.mutedButtonFill).cgColor
459
-        selectButton.translatesAutoresizingMaskIntoConstraints = false
460 650
         selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
461 651
 
462 652
         var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]