UIComponents.swift 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. import Cocoa
  2. // MARK: - Content Panel
  3. final class ContentPanelView: NSView, AppearanceRefreshable {
  4. init(cornerRadius: CGFloat = AppTheme.contentPanelCornerRadius) {
  5. super.init(frame: .zero)
  6. wantsLayer = true
  7. layer?.cornerRadius = cornerRadius
  8. layer?.borderWidth = 1.5
  9. applyCardShadow()
  10. refreshAppearance()
  11. }
  12. @available(*, unavailable)
  13. required init?(coder: NSCoder) { nil }
  14. func refreshAppearance() {
  15. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  16. layer?.borderColor = AppTheme.paywallBorder.cgColor
  17. }
  18. }
  19. // MARK: - Gradient Card View
  20. final class GradientCardView: NSView {
  21. private let gradientLayer = CAGradientLayer()
  22. init(colors: [NSColor], startPoint: CGPoint = CGPoint(x: 0, y: 0.5), endPoint: CGPoint = CGPoint(x: 1, y: 0.5)) {
  23. super.init(frame: .zero)
  24. wantsLayer = true
  25. gradientLayer.colors = colors.map { $0.cgColor }
  26. gradientLayer.startPoint = startPoint
  27. gradientLayer.endPoint = endPoint
  28. gradientLayer.cornerRadius = AppTheme.cardCornerRadius
  29. layer?.addSublayer(gradientLayer)
  30. applyCardShadow()
  31. }
  32. @available(*, unavailable)
  33. required init?(coder: NSCoder) { nil }
  34. override func layout() {
  35. super.layout()
  36. gradientLayer.frame = bounds
  37. gradientLayer.cornerRadius = AppTheme.cardCornerRadius
  38. }
  39. }
  40. // MARK: - Wave Pattern
  41. final class WavePatternView: NSView, AppearanceRefreshable {
  42. var fixedDotColor: NSColor?
  43. override var isOpaque: Bool { false }
  44. func refreshAppearance() {
  45. guard fixedDotColor == nil else { return }
  46. needsDisplay = true
  47. }
  48. override func draw(_ dirtyRect: NSRect) {
  49. super.draw(dirtyRect)
  50. guard let context = NSGraphicsContext.current?.cgContext else { return }
  51. context.setFillColor((fixedDotColor ?? AppTheme.waveDotColor).cgColor)
  52. let spacing: CGFloat = 14
  53. let dotSize: CGFloat = 3
  54. let rows = Int(bounds.height / spacing) + 2
  55. let cols = Int(bounds.width / spacing) + 2
  56. for row in 0..<rows {
  57. for col in 0..<cols {
  58. let wave = sin(Double(col) * 0.35 + Double(row) * 0.25) * 8
  59. let x = CGFloat(col) * spacing + CGFloat(wave)
  60. let y = CGFloat(row) * spacing
  61. let rect = CGRect(x: x, y: y, width: dotSize, height: dotSize)
  62. context.fillEllipse(in: rect)
  63. }
  64. }
  65. }
  66. }
  67. // MARK: - Sidebar Nav Item
  68. final class SidebarNavItem: NSControl, AppearanceRefreshable {
  69. enum Style {
  70. case normal
  71. case hovered
  72. case active
  73. }
  74. enum Accent {
  75. case standard
  76. case premium
  77. }
  78. var onClick: (() -> Void)?
  79. private let accent: Accent
  80. private let iconView = NSImageView()
  81. private let titleLabel = NSTextField(labelWithString: "")
  82. private let container = NSView()
  83. private var hoverTracker: HoverTracker?
  84. private var isHovered = false
  85. var isSelected: Bool = false {
  86. didSet { updateAppearance() }
  87. }
  88. init(title: String, symbolName: String, isSelected: Bool = false, accent: Accent = .standard) {
  89. self.accent = accent
  90. super.init(frame: .zero)
  91. self.isSelected = isSelected
  92. titleLabel.stringValue = title
  93. titleLabel.font = AppTheme.mediumFont(size: 14)
  94. titleLabel.themeLabelStyle = .primary
  95. titleLabel.textColor = AppTheme.textPrimary
  96. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
  97. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
  98. iconView.image = image.withSymbolConfiguration(config)
  99. }
  100. iconView.contentTintColor = AppTheme.textPrimary
  101. container.translatesAutoresizingMaskIntoConstraints = false
  102. iconView.translatesAutoresizingMaskIntoConstraints = false
  103. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  104. translatesAutoresizingMaskIntoConstraints = false
  105. addSubview(container)
  106. container.addSubview(iconView)
  107. container.addSubview(titleLabel)
  108. NSLayoutConstraint.activate([
  109. heightAnchor.constraint(equalToConstant: 44),
  110. container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
  111. container.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  112. container.topAnchor.constraint(equalTo: topAnchor),
  113. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  114. iconView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
  115. iconView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  116. iconView.widthAnchor.constraint(equalToConstant: 20),
  117. iconView.heightAnchor.constraint(equalToConstant: 20),
  118. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10),
  119. titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  120. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -12),
  121. ])
  122. updateAppearance()
  123. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  124. self?.isHovered = hovering
  125. self?.updateAppearance()
  126. }
  127. }
  128. @available(*, unavailable)
  129. required init?(coder: NSCoder) { nil }
  130. func refreshAppearance() {
  131. updateAppearance()
  132. }
  133. private func updateAppearance() {
  134. if isSelected {
  135. applyStyle(.active)
  136. } else if isHovered {
  137. applyStyle(.hovered)
  138. } else {
  139. applyStyle(.normal)
  140. }
  141. }
  142. private func applyStyle(_ style: Style) {
  143. container.wantsLayer = true
  144. container.layer?.cornerRadius = AppTheme.cornerRadius
  145. let activeBackground = accent == .premium ? AppTheme.premiumBackground : AppTheme.homeActiveBackground
  146. let activeForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.homeActiveForeground
  147. let hoverBackground = accent == .premium ? AppTheme.premiumHoverBackground : AppTheme.sidebarHoverBackground
  148. let normalForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.textPrimary
  149. animateHover {
  150. switch style {
  151. case .normal:
  152. container.layer?.backgroundColor = NSColor.clear.cgColor
  153. titleLabel.textColor = normalForeground
  154. iconView.contentTintColor = normalForeground
  155. case .hovered:
  156. container.layer?.backgroundColor = hoverBackground.cgColor
  157. titleLabel.textColor = normalForeground
  158. iconView.contentTintColor = normalForeground
  159. case .active:
  160. container.layer?.backgroundColor = activeBackground.cgColor
  161. titleLabel.textColor = activeForeground
  162. iconView.contentTintColor = activeForeground
  163. }
  164. }
  165. }
  166. override func mouseUp(with event: NSEvent) {
  167. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  168. onClick?()
  169. }
  170. override func resetCursorRects() {
  171. addCursorRect(bounds, cursor: .pointingHand)
  172. }
  173. }
  174. // MARK: - Sidebar Premium Card
  175. final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
  176. var onUpgradeTapped: (() -> Void)?
  177. var onManageSubscriptionTapped: (() -> Void)?
  178. private let cardContainer = NSView()
  179. private let crownIcon = NSImageView()
  180. private let titleLabel = NSTextField.labelWithTheme("", style: .primary, font: AppTheme.semiboldFont(size: 12))
  181. private let descriptionLabel = NSTextField.wrappingThemeLabel(
  182. "",
  183. style: .secondary,
  184. font: AppTheme.regularFont(size: 10)
  185. )
  186. private let actionButton = PremiumCardActionButton()
  187. private var isPremium = false
  188. private var configObserver: NSObjectProtocol?
  189. init() {
  190. super.init(frame: .zero)
  191. translatesAutoresizingMaskIntoConstraints = false
  192. setup()
  193. observeConfigUpdates()
  194. update(isPremium: StoreManager.shared.isPremium)
  195. }
  196. deinit {
  197. if let configObserver {
  198. NotificationCenter.default.removeObserver(configObserver)
  199. }
  200. }
  201. @available(*, unavailable)
  202. required init?(coder: NSCoder) { nil }
  203. func update(isPremium: Bool) {
  204. self.isPremium = isPremium
  205. let sidebar = PaywallConfigService.shared.config.sidebar
  206. titleLabel.stringValue = isPremium ? sidebar.proTitle : sidebar.unlockTitle
  207. descriptionLabel.stringValue = isPremium ? sidebar.proDescription : sidebar.unlockDescription
  208. let actionTitle: String
  209. let showsArrow: Bool
  210. if isPremium {
  211. actionTitle = sidebar.manageSubscriptionAction
  212. showsArrow = true
  213. } else {
  214. actionTitle = sidebar.upgradeAction
  215. showsArrow = true
  216. }
  217. actionButton.configure(title: actionTitle, showsArrow: showsArrow)
  218. refreshAppearance()
  219. }
  220. private func observeConfigUpdates() {
  221. configObserver = NotificationCenter.default.addObserver(
  222. forName: .paywallConfigDidUpdate,
  223. object: nil,
  224. queue: .main
  225. ) { [weak self] _ in
  226. guard let self else { return }
  227. self.update(isPremium: self.isPremium)
  228. }
  229. }
  230. func refreshAppearance() {
  231. cardContainer.layer?.backgroundColor = AppTheme.premiumBackground.cgColor
  232. cardContainer.layer?.borderColor = AppTheme.premiumCardBorder.cgColor
  233. crownIcon.contentTintColor = AppTheme.premiumCrown
  234. titleLabel.refreshThemeLabelColor()
  235. descriptionLabel.refreshThemeLabelColor()
  236. actionButton.refreshAppearance()
  237. }
  238. private func setup() {
  239. cardContainer.translatesAutoresizingMaskIntoConstraints = false
  240. cardContainer.wantsLayer = true
  241. cardContainer.layer?.cornerRadius = 10
  242. cardContainer.layer?.borderWidth = 1
  243. crownIcon.translatesAutoresizingMaskIntoConstraints = false
  244. if let image = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium") {
  245. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
  246. crownIcon.image = image.withSymbolConfiguration(config)
  247. }
  248. titleLabel.alignment = .center
  249. descriptionLabel.alignment = .center
  250. actionButton.translatesAutoresizingMaskIntoConstraints = false
  251. actionButton.onClick = { [weak self] in
  252. guard let self else { return }
  253. if self.isPremium {
  254. self.onManageSubscriptionTapped?()
  255. } else {
  256. self.onUpgradeTapped?()
  257. }
  258. }
  259. let contentStack = NSStackView(views: [crownIcon, titleLabel, descriptionLabel, actionButton])
  260. contentStack.orientation = .vertical
  261. contentStack.alignment = .centerX
  262. contentStack.spacing = 6
  263. contentStack.translatesAutoresizingMaskIntoConstraints = false
  264. contentStack.setCustomSpacing(4, after: crownIcon)
  265. contentStack.setCustomSpacing(6, after: titleLabel)
  266. contentStack.setCustomSpacing(8, after: descriptionLabel)
  267. cardContainer.addSubview(contentStack)
  268. addSubview(cardContainer)
  269. NSLayoutConstraint.activate([
  270. cardContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  271. cardContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
  272. cardContainer.topAnchor.constraint(equalTo: topAnchor),
  273. cardContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
  274. contentStack.leadingAnchor.constraint(equalTo: cardContainer.leadingAnchor, constant: 10),
  275. contentStack.trailingAnchor.constraint(equalTo: cardContainer.trailingAnchor, constant: -10),
  276. contentStack.topAnchor.constraint(equalTo: cardContainer.topAnchor, constant: 10),
  277. contentStack.bottomAnchor.constraint(equalTo: cardContainer.bottomAnchor, constant: -10),
  278. crownIcon.widthAnchor.constraint(equalToConstant: 18),
  279. crownIcon.heightAnchor.constraint(equalToConstant: 18),
  280. descriptionLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  281. actionButton.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
  282. actionButton.heightAnchor.constraint(equalToConstant: 32),
  283. ])
  284. }
  285. }
  286. private final class PremiumCardActionButton: NSControl, AppearanceRefreshable {
  287. var onClick: (() -> Void)?
  288. private let titleLabel = NSTextField(labelWithString: "")
  289. private let arrowView = NSImageView()
  290. private let contentStack = NSStackView()
  291. private var hoverTracker: HoverTracker?
  292. private var isHovered = false
  293. private var showsArrow = true
  294. init() {
  295. super.init(frame: .zero)
  296. wantsLayer = true
  297. layer?.cornerRadius = 7
  298. layer?.borderWidth = 1
  299. titleLabel.font = AppTheme.semiboldFont(size: 11)
  300. titleLabel.themeLabelStyle = .primary
  301. titleLabel.textColor = AppTheme.textPrimary
  302. if let image = NSImage(systemSymbolName: "arrow.up.right", accessibilityDescription: nil) {
  303. let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
  304. arrowView.image = image.withSymbolConfiguration(config)
  305. }
  306. arrowView.contentTintColor = AppTheme.textPrimary
  307. arrowView.translatesAutoresizingMaskIntoConstraints = false
  308. contentStack.orientation = .horizontal
  309. contentStack.spacing = 6
  310. contentStack.alignment = .centerY
  311. contentStack.translatesAutoresizingMaskIntoConstraints = false
  312. contentStack.addArrangedSubview(titleLabel)
  313. contentStack.addArrangedSubview(arrowView)
  314. addSubview(contentStack)
  315. NSLayoutConstraint.activate([
  316. contentStack.centerXAnchor.constraint(equalTo: centerXAnchor),
  317. contentStack.centerYAnchor.constraint(equalTo: centerYAnchor),
  318. arrowView.widthAnchor.constraint(equalToConstant: 12),
  319. arrowView.heightAnchor.constraint(equalToConstant: 12),
  320. ])
  321. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  322. self?.isHovered = hovering
  323. self?.refreshAppearance()
  324. }
  325. refreshAppearance()
  326. }
  327. @available(*, unavailable)
  328. required init?(coder: NSCoder) { nil }
  329. func configure(title: String, showsArrow: Bool) {
  330. titleLabel.stringValue = title
  331. self.showsArrow = showsArrow
  332. arrowView.isHidden = !showsArrow
  333. refreshAppearance()
  334. }
  335. func refreshAppearance() {
  336. let usesFilledPremiumStyle = !showsArrow
  337. if usesFilledPremiumStyle {
  338. layer?.backgroundColor = (isHovered ? AppTheme.premiumButtonHoverBackground : AppTheme.premiumButtonBackground).cgColor
  339. layer?.borderColor = NSColor.clear.cgColor
  340. layer?.borderWidth = 0
  341. titleLabel.textColor = AppTheme.premiumButtonForeground
  342. arrowView.contentTintColor = AppTheme.premiumButtonForeground
  343. layer?.shadowColor = AppTheme.purple.cgColor
  344. layer?.shadowOpacity = isHovered ? 0.35 : 0.22
  345. } else {
  346. layer?.backgroundColor = (isHovered ? AppTheme.premiumActionHoverBackground : AppTheme.premiumActionBackground).cgColor
  347. layer?.borderColor = (isHovered ? AppTheme.premiumActionBorderHover : AppTheme.premiumActionBorder).cgColor
  348. layer?.borderWidth = 1
  349. let foreground = isHovered ? AppTheme.premiumForeground : AppTheme.textPrimary
  350. titleLabel.textColor = foreground
  351. arrowView.contentTintColor = foreground
  352. layer?.shadowColor = NSColor.black.cgColor
  353. layer?.shadowOpacity = isHovered ? 0.08 : 0.03
  354. }
  355. layer?.shadowOffset = NSSize(width: 0, height: isHovered ? -2 : -1)
  356. layer?.shadowRadius = isHovered ? 4 : 2
  357. animateHover {
  358. self.layer?.transform = isHovered
  359. ? CATransform3DMakeScale(1.02, 1.02, 1)
  360. : CATransform3DIdentity
  361. }
  362. }
  363. override func mouseUp(with event: NSEvent) {
  364. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  365. onClick?()
  366. }
  367. override func resetCursorRects() {
  368. addCursorRect(bounds, cursor: .pointingHand)
  369. }
  370. }
  371. private extension NSTextField {
  372. static func labelWithTheme(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
  373. let label = NSTextField(labelWithString: string)
  374. label.font = font
  375. label.themeLabelStyle = style
  376. label.textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
  377. label.translatesAutoresizingMaskIntoConstraints = false
  378. return label
  379. }
  380. static func wrappingThemeLabel(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
  381. let label = NSTextField(wrappingLabelWithString: string)
  382. label.font = font
  383. label.themeLabelStyle = style
  384. label.textColor = style == .secondary ? AppTheme.textSecondary : AppTheme.textPrimary
  385. label.isEditable = false
  386. label.isSelectable = false
  387. label.isBordered = false
  388. label.drawsBackground = false
  389. label.lineBreakMode = .byWordWrapping
  390. label.maximumNumberOfLines = 0
  391. label.translatesAutoresizingMaskIntoConstraints = false
  392. return label
  393. }
  394. }
  395. // MARK: - Action Button
  396. final class PillButton: NSButton {
  397. private let horizontalPadding: CGFloat = 16
  398. private let baseColor: NSColor
  399. private var hoverTracker: HoverTracker?
  400. private let clickTarget: ButtonClickTarget
  401. var onClick: (() -> Void)? {
  402. get { clickTarget.handler }
  403. set { clickTarget.handler = newValue }
  404. }
  405. init(title: String, color: NSColor) {
  406. clickTarget = ButtonClickTarget()
  407. baseColor = color
  408. super.init(frame: .zero)
  409. self.title = title
  410. isBordered = false
  411. wantsLayer = true
  412. layer?.backgroundColor = color.cgColor
  413. layer?.cornerRadius = 11
  414. font = AppTheme.semiboldFont(size: 13)
  415. contentTintColor = .white
  416. target = clickTarget
  417. action = #selector(ButtonClickTarget.activate)
  418. if let arrow = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil) {
  419. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  420. image = arrow.withSymbolConfiguration(config)
  421. imagePosition = .imageTrailing
  422. imageHugsTitle = true
  423. }
  424. heightAnchor.constraint(equalToConstant: 40).isActive = true
  425. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  426. self?.setHovered(hovering)
  427. }
  428. }
  429. private func setHovered(_ hovering: Bool) {
  430. let color = hovering ? baseColor.blended(withFraction: 0.15, of: .black) ?? baseColor : baseColor
  431. animateHover {
  432. layer?.backgroundColor = color.cgColor
  433. layer?.transform = hovering
  434. ? CATransform3DMakeScale(1.03, 1.03, 1)
  435. : CATransform3DIdentity
  436. }
  437. }
  438. @available(*, unavailable)
  439. required init?(coder: NSCoder) { nil }
  440. override var intrinsicContentSize: NSSize {
  441. var size = super.intrinsicContentSize
  442. size.width += horizontalPadding * 2
  443. return size
  444. }
  445. override func draw(_ dirtyRect: NSRect) {
  446. let originalBounds = bounds
  447. defer { bounds = originalBounds }
  448. bounds = originalBounds.insetBy(dx: horizontalPadding, dy: 0)
  449. super.draw(dirtyRect)
  450. }
  451. override func resetCursorRects() {
  452. addCursorRect(bounds, cursor: .pointingHand)
  453. }
  454. }
  455. // MARK: - Sparkle Icon
  456. final class SparkleIconView: NSView {
  457. var color: NSColor = AppTheme.blue
  458. override var isFlipped: Bool { true }
  459. override var isOpaque: Bool { false }
  460. override func draw(_ dirtyRect: NSRect) {
  461. super.draw(dirtyRect)
  462. guard let context = NSGraphicsContext.current?.cgContext else { return }
  463. let s = min(bounds.width, bounds.height)
  464. let cx = bounds.midX
  465. let cy = bounds.midY
  466. let tipR = s * 0.34
  467. let valleyR = s * 0.09
  468. let tips = [
  469. CGPoint(x: cx, y: cy - tipR),
  470. CGPoint(x: cx + tipR, y: cy),
  471. CGPoint(x: cx, y: cy + tipR),
  472. CGPoint(x: cx - tipR, y: cy),
  473. ]
  474. let valleys = [
  475. CGPoint(x: cx + valleyR, y: cy - valleyR),
  476. CGPoint(x: cx + valleyR, y: cy + valleyR),
  477. CGPoint(x: cx - valleyR, y: cy + valleyR),
  478. CGPoint(x: cx - valleyR, y: cy - valleyR),
  479. ]
  480. let starPath = CGMutablePath()
  481. starPath.move(to: tips[0])
  482. for i in 0..<4 {
  483. starPath.addQuadCurve(to: tips[(i + 1) % 4], control: valleys[i])
  484. }
  485. starPath.closeSubpath()
  486. context.setFillColor(color.cgColor)
  487. context.addPath(starPath)
  488. context.fillPath()
  489. let dotR = s * 0.052
  490. let dotOffset = s * 0.30
  491. let dotPositions = [
  492. CGPoint(x: cx + dotOffset, y: cy - dotOffset),
  493. CGPoint(x: cx + dotOffset, y: cy + dotOffset),
  494. CGPoint(x: cx - dotOffset, y: cy + dotOffset),
  495. CGPoint(x: cx - dotOffset, y: cy - dotOffset),
  496. ]
  497. for pos in dotPositions {
  498. context.fillEllipse(in: CGRect(x: pos.x - dotR, y: pos.y - dotR, width: dotR * 2, height: dotR * 2))
  499. }
  500. }
  501. }
  502. // MARK: - Quick Start Card
  503. struct QuickStartCardData {
  504. let title: String
  505. let subtitle: String
  506. let buttonTitle: String
  507. let accentColor: NSColor
  508. let gradientColors: [NSColor]
  509. let iconKind: QuickStartIconKind
  510. }
  511. final class QuickStartCardView: NSView, AppearanceRefreshable {
  512. private let iconView: QuickStartIconView
  513. private let gradientView: GradientCardView
  514. private var iconWidthConstraint: NSLayoutConstraint!
  515. private var iconHeightConstraint: NSLayoutConstraint!
  516. private var hoverTracker: HoverTracker?
  517. private var isHovered = false
  518. private let onActivate: () -> Void
  519. init(data: QuickStartCardData, onActivate: @escaping () -> Void) {
  520. self.onActivate = onActivate
  521. iconView = QuickStartIconView(kind: data.iconKind)
  522. gradientView = GradientCardView(
  523. colors: data.gradientColors,
  524. startPoint: CGPoint(x: 0, y: 0.5),
  525. endPoint: CGPoint(x: 1, y: 0.5)
  526. )
  527. super.init(frame: .zero)
  528. translatesAutoresizingMaskIntoConstraints = false
  529. let gradient = gradientView
  530. gradient.translatesAutoresizingMaskIntoConstraints = false
  531. let titleLabel = NSTextField(labelWithString: data.title)
  532. titleLabel.font = AppTheme.semiboldFont(size: 22)
  533. titleLabel.textColor = data.accentColor
  534. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  535. let subtitleLabel = NSTextField(labelWithString: data.subtitle)
  536. subtitleLabel.font = AppTheme.regularFont(size: 13)
  537. subtitleLabel.textColor = AppTheme.quickStartCardSubtitle
  538. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  539. let button = PillButton(title: data.buttonTitle, color: data.accentColor)
  540. button.translatesAutoresizingMaskIntoConstraints = false
  541. button.onClick = onActivate
  542. addSubview(gradient)
  543. gradient.addSubview(titleLabel)
  544. gradient.addSubview(subtitleLabel)
  545. gradient.addSubview(button)
  546. gradient.addSubview(iconView)
  547. NSLayoutConstraint.activate([
  548. gradient.leadingAnchor.constraint(equalTo: leadingAnchor),
  549. gradient.trailingAnchor.constraint(equalTo: trailingAnchor),
  550. gradient.topAnchor.constraint(equalTo: topAnchor),
  551. gradient.bottomAnchor.constraint(equalTo: bottomAnchor),
  552. heightAnchor.constraint(equalToConstant: 148),
  553. titleLabel.leadingAnchor.constraint(equalTo: gradient.leadingAnchor, constant: 18),
  554. titleLabel.topAnchor.constraint(equalTo: gradient.topAnchor, constant: 24),
  555. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  556. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  557. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: iconView.leadingAnchor, constant: -8),
  558. button.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  559. button.bottomAnchor.constraint(equalTo: gradient.bottomAnchor, constant: -20),
  560. iconView.trailingAnchor.constraint(equalTo: gradient.trailingAnchor, constant: -8),
  561. iconView.centerYAnchor.constraint(equalTo: gradient.centerYAnchor),
  562. ])
  563. iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
  564. iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
  565. iconWidthConstraint.isActive = true
  566. iconHeightConstraint.isActive = true
  567. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  568. self?.setHovered(hovering)
  569. }
  570. refreshAppearance()
  571. }
  572. @available(*, unavailable)
  573. required init?(coder: NSCoder) { nil }
  574. func refreshAppearance() {
  575. gradientView.layer?.cornerRadius = AppTheme.cardCornerRadius
  576. gradientView.layer?.borderWidth = isHovered ? 2 : 1.5
  577. gradientView.layer?.borderColor = AppTheme.paywallBorder.cgColor
  578. if isHovered {
  579. applyHoverLift(true, on: gradientView.layer)
  580. }
  581. }
  582. private func setHovered(_ hovering: Bool) {
  583. isHovered = hovering
  584. applyHoverLift(hovering, on: gradientView.layer)
  585. gradientView.layer?.borderWidth = hovering ? 2 : 1.5
  586. }
  587. override func layout() {
  588. super.layout()
  589. let size = AppTheme.quickStartIconSize(forCardWidth: bounds.width)
  590. if iconWidthConstraint.constant != size {
  591. iconWidthConstraint.constant = size
  592. iconHeightConstraint.constant = size
  593. }
  594. }
  595. override func mouseUp(with event: NSEvent) {
  596. let location = convert(event.locationInWindow, from: nil)
  597. guard bounds.contains(location) else { return }
  598. onActivate()
  599. }
  600. override func resetCursorRects() {
  601. addCursorRect(bounds, cursor: .pointingHand)
  602. }
  603. }
  604. private final class ButtonClickTarget: NSObject {
  605. var handler: (() -> Void)?
  606. @objc func activate() {
  607. handler?()
  608. }
  609. }
  610. // MARK: - Feature Card
  611. struct FeatureCardData {
  612. let title: String
  613. let subtitle: String
  614. let iconKind: FeatureIconKind
  615. var onActivate: (() -> Void)? = nil
  616. }
  617. final class FeatureCardView: NSView, AppearanceRefreshable {
  618. private let iconView: FeatureIconView
  619. private let titleLabel: NSTextField
  620. private let subtitleLabel: NSTextField
  621. private let arrowButton: NSButton
  622. private var iconWidthConstraint: NSLayoutConstraint!
  623. private var iconHeightConstraint: NSLayoutConstraint!
  624. private var hoverTracker: HoverTracker?
  625. private var isHovered = false
  626. private let onActivate: (() -> Void)?
  627. init(data: FeatureCardData) {
  628. onActivate = data.onActivate
  629. iconView = FeatureIconView(kind: data.iconKind)
  630. titleLabel = NSTextField.themeLabel(data.title, style: .primary, font: AppTheme.semiboldFont(size: 14))
  631. subtitleLabel = NSTextField.themeLabel(data.subtitle, style: .secondary, font: AppTheme.regularFont(size: 11))
  632. arrowButton = NSButton()
  633. super.init(frame: .zero)
  634. translatesAutoresizingMaskIntoConstraints = false
  635. setContentHuggingPriority(.defaultLow, for: .horizontal)
  636. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  637. wantsLayer = true
  638. layer?.cornerRadius = AppTheme.featureCardCornerRadius
  639. applyCardShadow()
  640. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  641. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  642. arrowButton.isBordered = false
  643. arrowButton.wantsLayer = true
  644. arrowButton.layer?.cornerRadius = 13
  645. arrowButton.layer?.borderWidth = 1.5
  646. arrowButton.translatesAutoresizingMaskIntoConstraints = false
  647. if let arrow = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Open") {
  648. let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
  649. arrowButton.image = arrow.withSymbolConfiguration(config)
  650. }
  651. arrowButton.contentTintColor = AppTheme.textSecondary
  652. addSubview(iconView)
  653. addSubview(titleLabel)
  654. addSubview(subtitleLabel)
  655. addSubview(arrowButton)
  656. NSLayoutConstraint.activate([
  657. heightAnchor.constraint(equalToConstant: 96),
  658. iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
  659. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  660. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10),
  661. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  662. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
  663. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  664. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  665. subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
  666. arrowButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
  667. arrowButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
  668. arrowButton.widthAnchor.constraint(equalToConstant: 26),
  669. arrowButton.heightAnchor.constraint(equalToConstant: 26),
  670. ])
  671. iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
  672. iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
  673. iconWidthConstraint.isActive = true
  674. iconHeightConstraint.isActive = true
  675. refreshAppearance()
  676. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  677. self?.setHovered(hovering)
  678. }
  679. }
  680. @available(*, unavailable)
  681. required init?(coder: NSCoder) { nil }
  682. private func setHovered(_ hovering: Bool) {
  683. isHovered = hovering
  684. applyHoverLift(hovering)
  685. let borderWidth: CGFloat = hovering ? 2 : 1.5
  686. layer?.borderWidth = borderWidth
  687. arrowButton.layer?.borderWidth = borderWidth
  688. }
  689. func refreshAppearance() {
  690. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  691. layer?.borderWidth = isHovered ? 2 : 1.5
  692. layer?.borderColor = AppTheme.paywallBorder.cgColor
  693. titleLabel.refreshThemeLabelColor()
  694. subtitleLabel.refreshThemeLabelColor()
  695. arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  696. arrowButton.layer?.borderWidth = isHovered ? 2 : 1.5
  697. arrowButton.layer?.borderColor = AppTheme.paywallBorder.cgColor
  698. arrowButton.contentTintColor = AppTheme.textSecondary
  699. if isHovered {
  700. applyHoverLift(true)
  701. }
  702. }
  703. override func layout() {
  704. super.layout()
  705. let size = AppTheme.featureIconSize(forCardWidth: bounds.width)
  706. if iconWidthConstraint.constant != size {
  707. iconWidthConstraint.constant = size
  708. iconHeightConstraint.constant = size
  709. }
  710. }
  711. override func mouseUp(with event: NSEvent) {
  712. let location = convert(event.locationInWindow, from: nil)
  713. guard bounds.contains(location) else { return }
  714. onActivate?()
  715. }
  716. override func resetCursorRects() {
  717. guard onActivate != nil else { return }
  718. addCursorRect(bounds, cursor: .pointingHand)
  719. }
  720. }