UIComponents.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  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: - Action Button
  175. final class PillButton: NSButton {
  176. private let horizontalPadding: CGFloat = 16
  177. private let baseColor: NSColor
  178. private var hoverTracker: HoverTracker?
  179. init(title: String, color: NSColor) {
  180. baseColor = color
  181. super.init(frame: .zero)
  182. self.title = title
  183. isBordered = false
  184. wantsLayer = true
  185. layer?.backgroundColor = color.cgColor
  186. layer?.cornerRadius = 11
  187. font = AppTheme.semiboldFont(size: 13)
  188. contentTintColor = .white
  189. if let arrow = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil) {
  190. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  191. image = arrow.withSymbolConfiguration(config)
  192. imagePosition = .imageTrailing
  193. imageHugsTitle = true
  194. }
  195. heightAnchor.constraint(equalToConstant: 40).isActive = true
  196. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  197. self?.setHovered(hovering)
  198. }
  199. }
  200. private func setHovered(_ hovering: Bool) {
  201. let color = hovering ? baseColor.blended(withFraction: 0.15, of: .black) ?? baseColor : baseColor
  202. animateHover {
  203. layer?.backgroundColor = color.cgColor
  204. layer?.transform = hovering
  205. ? CATransform3DMakeScale(1.03, 1.03, 1)
  206. : CATransform3DIdentity
  207. }
  208. }
  209. @available(*, unavailable)
  210. required init?(coder: NSCoder) { nil }
  211. override var intrinsicContentSize: NSSize {
  212. var size = super.intrinsicContentSize
  213. size.width += horizontalPadding * 2
  214. return size
  215. }
  216. override func draw(_ dirtyRect: NSRect) {
  217. let originalBounds = bounds
  218. defer { bounds = originalBounds }
  219. bounds = originalBounds.insetBy(dx: horizontalPadding, dy: 0)
  220. super.draw(dirtyRect)
  221. }
  222. override func resetCursorRects() {
  223. addCursorRect(bounds, cursor: .pointingHand)
  224. }
  225. }
  226. // MARK: - Sparkle Icon
  227. final class SparkleIconView: NSView {
  228. var color: NSColor = AppTheme.blue
  229. override var isFlipped: Bool { true }
  230. override var isOpaque: Bool { false }
  231. override func draw(_ dirtyRect: NSRect) {
  232. super.draw(dirtyRect)
  233. guard let context = NSGraphicsContext.current?.cgContext else { return }
  234. let s = min(bounds.width, bounds.height)
  235. let cx = bounds.midX
  236. let cy = bounds.midY
  237. let tipR = s * 0.34
  238. let valleyR = s * 0.09
  239. let tips = [
  240. CGPoint(x: cx, y: cy - tipR),
  241. CGPoint(x: cx + tipR, y: cy),
  242. CGPoint(x: cx, y: cy + tipR),
  243. CGPoint(x: cx - tipR, y: cy),
  244. ]
  245. let valleys = [
  246. CGPoint(x: cx + valleyR, y: cy - valleyR),
  247. CGPoint(x: cx + valleyR, y: cy + valleyR),
  248. CGPoint(x: cx - valleyR, y: cy + valleyR),
  249. CGPoint(x: cx - valleyR, y: cy - valleyR),
  250. ]
  251. let starPath = CGMutablePath()
  252. starPath.move(to: tips[0])
  253. for i in 0..<4 {
  254. starPath.addQuadCurve(to: tips[(i + 1) % 4], control: valleys[i])
  255. }
  256. starPath.closeSubpath()
  257. context.setFillColor(color.cgColor)
  258. context.addPath(starPath)
  259. context.fillPath()
  260. let dotR = s * 0.052
  261. let dotOffset = s * 0.30
  262. let dotPositions = [
  263. CGPoint(x: cx + dotOffset, y: cy - dotOffset),
  264. CGPoint(x: cx + dotOffset, y: cy + dotOffset),
  265. CGPoint(x: cx - dotOffset, y: cy + dotOffset),
  266. CGPoint(x: cx - dotOffset, y: cy - dotOffset),
  267. ]
  268. for pos in dotPositions {
  269. context.fillEllipse(in: CGRect(x: pos.x - dotR, y: pos.y - dotR, width: dotR * 2, height: dotR * 2))
  270. }
  271. }
  272. }
  273. // MARK: - Quick Start Card
  274. struct QuickStartCardData {
  275. let title: String
  276. let subtitle: String
  277. let buttonTitle: String
  278. let accentColor: NSColor
  279. let gradientColors: [NSColor]
  280. let iconKind: QuickStartIconKind
  281. }
  282. final class QuickStartCardView: NSView, AppearanceRefreshable {
  283. private let iconView: QuickStartIconView
  284. private let gradientView: GradientCardView
  285. private var iconWidthConstraint: NSLayoutConstraint!
  286. private var iconHeightConstraint: NSLayoutConstraint!
  287. private var hoverTracker: HoverTracker?
  288. private var isHovered = false
  289. init(data: QuickStartCardData) {
  290. iconView = QuickStartIconView(kind: data.iconKind)
  291. gradientView = GradientCardView(
  292. colors: data.gradientColors,
  293. startPoint: CGPoint(x: 0, y: 0.5),
  294. endPoint: CGPoint(x: 1, y: 0.5)
  295. )
  296. super.init(frame: .zero)
  297. translatesAutoresizingMaskIntoConstraints = false
  298. let gradient = gradientView
  299. gradient.translatesAutoresizingMaskIntoConstraints = false
  300. let titleLabel = NSTextField(labelWithString: data.title)
  301. titleLabel.font = AppTheme.semiboldFont(size: 22)
  302. titleLabel.textColor = data.accentColor
  303. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  304. let subtitleLabel = NSTextField(labelWithString: data.subtitle)
  305. subtitleLabel.font = AppTheme.regularFont(size: 13)
  306. subtitleLabel.textColor = AppTheme.quickStartCardSubtitle
  307. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  308. let button = PillButton(title: data.buttonTitle, color: data.accentColor)
  309. button.translatesAutoresizingMaskIntoConstraints = false
  310. addSubview(gradient)
  311. gradient.addSubview(titleLabel)
  312. gradient.addSubview(subtitleLabel)
  313. gradient.addSubview(button)
  314. gradient.addSubview(iconView)
  315. NSLayoutConstraint.activate([
  316. gradient.leadingAnchor.constraint(equalTo: leadingAnchor),
  317. gradient.trailingAnchor.constraint(equalTo: trailingAnchor),
  318. gradient.topAnchor.constraint(equalTo: topAnchor),
  319. gradient.bottomAnchor.constraint(equalTo: bottomAnchor),
  320. heightAnchor.constraint(equalToConstant: 148),
  321. titleLabel.leadingAnchor.constraint(equalTo: gradient.leadingAnchor, constant: 18),
  322. titleLabel.topAnchor.constraint(equalTo: gradient.topAnchor, constant: 24),
  323. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  324. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  325. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: iconView.leadingAnchor, constant: -8),
  326. button.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  327. button.bottomAnchor.constraint(equalTo: gradient.bottomAnchor, constant: -20),
  328. iconView.trailingAnchor.constraint(equalTo: gradient.trailingAnchor, constant: -8),
  329. iconView.centerYAnchor.constraint(equalTo: gradient.centerYAnchor),
  330. ])
  331. iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
  332. iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
  333. iconWidthConstraint.isActive = true
  334. iconHeightConstraint.isActive = true
  335. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  336. self?.setHovered(hovering)
  337. }
  338. refreshAppearance()
  339. }
  340. @available(*, unavailable)
  341. required init?(coder: NSCoder) { nil }
  342. func refreshAppearance() {
  343. gradientView.layer?.cornerRadius = AppTheme.cardCornerRadius
  344. gradientView.layer?.borderWidth = isHovered ? 2 : 1.5
  345. gradientView.layer?.borderColor = AppTheme.paywallBorder.cgColor
  346. if isHovered {
  347. applyHoverLift(true, on: gradientView.layer)
  348. }
  349. }
  350. private func setHovered(_ hovering: Bool) {
  351. isHovered = hovering
  352. applyHoverLift(hovering, on: gradientView.layer)
  353. gradientView.layer?.borderWidth = hovering ? 2 : 1.5
  354. }
  355. override func layout() {
  356. super.layout()
  357. let size = AppTheme.quickStartIconSize(forCardWidth: bounds.width)
  358. if iconWidthConstraint.constant != size {
  359. iconWidthConstraint.constant = size
  360. iconHeightConstraint.constant = size
  361. }
  362. }
  363. override func resetCursorRects() {
  364. addCursorRect(bounds, cursor: .pointingHand)
  365. }
  366. }
  367. // MARK: - Feature Card
  368. struct FeatureCardData {
  369. let title: String
  370. let subtitle: String
  371. let iconKind: FeatureIconKind
  372. }
  373. final class FeatureCardView: NSView, AppearanceRefreshable {
  374. private let iconView: FeatureIconView
  375. private let titleLabel: NSTextField
  376. private let subtitleLabel: NSTextField
  377. private let arrowButton: NSButton
  378. private var iconWidthConstraint: NSLayoutConstraint!
  379. private var iconHeightConstraint: NSLayoutConstraint!
  380. private var hoverTracker: HoverTracker?
  381. private var isHovered = false
  382. init(data: FeatureCardData) {
  383. iconView = FeatureIconView(kind: data.iconKind)
  384. titleLabel = NSTextField.themeLabel(data.title, style: .primary, font: AppTheme.semiboldFont(size: 14))
  385. subtitleLabel = NSTextField.themeLabel(data.subtitle, style: .secondary, font: AppTheme.regularFont(size: 11))
  386. arrowButton = NSButton()
  387. super.init(frame: .zero)
  388. translatesAutoresizingMaskIntoConstraints = false
  389. setContentHuggingPriority(.defaultLow, for: .horizontal)
  390. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  391. wantsLayer = true
  392. layer?.cornerRadius = AppTheme.featureCardCornerRadius
  393. applyCardShadow()
  394. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  395. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  396. arrowButton.isBordered = false
  397. arrowButton.wantsLayer = true
  398. arrowButton.layer?.cornerRadius = 13
  399. arrowButton.layer?.borderWidth = 1.5
  400. arrowButton.translatesAutoresizingMaskIntoConstraints = false
  401. if let arrow = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Open") {
  402. let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
  403. arrowButton.image = arrow.withSymbolConfiguration(config)
  404. }
  405. arrowButton.contentTintColor = AppTheme.textSecondary
  406. addSubview(iconView)
  407. addSubview(titleLabel)
  408. addSubview(subtitleLabel)
  409. addSubview(arrowButton)
  410. NSLayoutConstraint.activate([
  411. heightAnchor.constraint(equalToConstant: 96),
  412. iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
  413. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  414. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10),
  415. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  416. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
  417. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  418. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  419. subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
  420. arrowButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
  421. arrowButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
  422. arrowButton.widthAnchor.constraint(equalToConstant: 26),
  423. arrowButton.heightAnchor.constraint(equalToConstant: 26),
  424. ])
  425. iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
  426. iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
  427. iconWidthConstraint.isActive = true
  428. iconHeightConstraint.isActive = true
  429. refreshAppearance()
  430. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  431. self?.setHovered(hovering)
  432. }
  433. }
  434. @available(*, unavailable)
  435. required init?(coder: NSCoder) { nil }
  436. private func setHovered(_ hovering: Bool) {
  437. isHovered = hovering
  438. applyHoverLift(hovering)
  439. let borderWidth: CGFloat = hovering ? 2 : 1.5
  440. layer?.borderWidth = borderWidth
  441. arrowButton.layer?.borderWidth = borderWidth
  442. }
  443. func refreshAppearance() {
  444. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  445. layer?.borderWidth = isHovered ? 2 : 1.5
  446. layer?.borderColor = AppTheme.paywallBorder.cgColor
  447. titleLabel.refreshThemeLabelColor()
  448. subtitleLabel.refreshThemeLabelColor()
  449. arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  450. arrowButton.layer?.borderWidth = isHovered ? 2 : 1.5
  451. arrowButton.layer?.borderColor = AppTheme.paywallBorder.cgColor
  452. arrowButton.contentTintColor = AppTheme.textSecondary
  453. if isHovered {
  454. applyHoverLift(true)
  455. }
  456. }
  457. override func layout() {
  458. super.layout()
  459. let size = AppTheme.featureIconSize(forCardWidth: bounds.width)
  460. if iconWidthConstraint.constant != size {
  461. iconWidthConstraint.constant = size
  462. iconHeightConstraint.constant = size
  463. }
  464. }
  465. override func resetCursorRects() {
  466. addCursorRect(bounds, cursor: .pointingHand)
  467. }
  468. }