No Description

UIComponents.swift 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import Cocoa
  2. // MARK: - Gradient Card View
  3. final class GradientCardView: NSView {
  4. private let gradientLayer = CAGradientLayer()
  5. init(colors: [NSColor], startPoint: CGPoint = CGPoint(x: 0, y: 0.5), endPoint: CGPoint = CGPoint(x: 1, y: 0.5)) {
  6. super.init(frame: .zero)
  7. wantsLayer = true
  8. gradientLayer.colors = colors.map { $0.cgColor }
  9. gradientLayer.startPoint = startPoint
  10. gradientLayer.endPoint = endPoint
  11. gradientLayer.cornerRadius = AppTheme.cardCornerRadius
  12. layer?.addSublayer(gradientLayer)
  13. applyCardShadow()
  14. }
  15. @available(*, unavailable)
  16. required init?(coder: NSCoder) { nil }
  17. override func layout() {
  18. super.layout()
  19. gradientLayer.frame = bounds
  20. gradientLayer.cornerRadius = AppTheme.cardCornerRadius
  21. }
  22. }
  23. // MARK: - Wave Pattern
  24. final class WavePatternView: NSView, AppearanceRefreshable {
  25. var fixedDotColor: NSColor?
  26. override var isOpaque: Bool { false }
  27. func refreshAppearance() {
  28. guard fixedDotColor == nil else { return }
  29. needsDisplay = true
  30. }
  31. override func draw(_ dirtyRect: NSRect) {
  32. super.draw(dirtyRect)
  33. guard let context = NSGraphicsContext.current?.cgContext else { return }
  34. context.setFillColor((fixedDotColor ?? AppTheme.waveDotColor).cgColor)
  35. let spacing: CGFloat = 14
  36. let dotSize: CGFloat = 3
  37. let rows = Int(bounds.height / spacing) + 2
  38. let cols = Int(bounds.width / spacing) + 2
  39. for row in 0..<rows {
  40. for col in 0..<cols {
  41. let wave = sin(Double(col) * 0.35 + Double(row) * 0.25) * 8
  42. let x = CGFloat(col) * spacing + CGFloat(wave)
  43. let y = CGFloat(row) * spacing
  44. let rect = CGRect(x: x, y: y, width: dotSize, height: dotSize)
  45. context.fillEllipse(in: rect)
  46. }
  47. }
  48. }
  49. }
  50. // MARK: - Sidebar Nav Item
  51. final class SidebarNavItem: NSControl, AppearanceRefreshable {
  52. enum Style {
  53. case normal
  54. case active
  55. }
  56. enum Accent {
  57. case standard
  58. case premium
  59. }
  60. var onClick: (() -> Void)?
  61. private let accent: Accent
  62. private let iconView = NSImageView()
  63. private let titleLabel = NSTextField(labelWithString: "")
  64. private let container = NSView()
  65. var isSelected: Bool = false {
  66. didSet { applyStyle(isSelected ? .active : .normal) }
  67. }
  68. init(title: String, symbolName: String, isSelected: Bool = false, accent: Accent = .standard) {
  69. self.accent = accent
  70. super.init(frame: .zero)
  71. self.isSelected = isSelected
  72. titleLabel.stringValue = title
  73. titleLabel.font = AppTheme.mediumFont(size: 14)
  74. titleLabel.themeLabelStyle = .primary
  75. titleLabel.textColor = AppTheme.textPrimary
  76. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
  77. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
  78. iconView.image = image.withSymbolConfiguration(config)
  79. }
  80. iconView.contentTintColor = AppTheme.textPrimary
  81. container.translatesAutoresizingMaskIntoConstraints = false
  82. iconView.translatesAutoresizingMaskIntoConstraints = false
  83. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  84. translatesAutoresizingMaskIntoConstraints = false
  85. addSubview(container)
  86. container.addSubview(iconView)
  87. container.addSubview(titleLabel)
  88. NSLayoutConstraint.activate([
  89. heightAnchor.constraint(equalToConstant: 44),
  90. container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
  91. container.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  92. container.topAnchor.constraint(equalTo: topAnchor),
  93. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  94. iconView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
  95. iconView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  96. iconView.widthAnchor.constraint(equalToConstant: 20),
  97. iconView.heightAnchor.constraint(equalToConstant: 20),
  98. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10),
  99. titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  100. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -12),
  101. ])
  102. applyStyle(isSelected ? .active : .normal)
  103. }
  104. @available(*, unavailable)
  105. required init?(coder: NSCoder) { nil }
  106. func refreshAppearance() {
  107. applyStyle(isSelected ? .active : .normal)
  108. }
  109. private func applyStyle(_ style: Style) {
  110. container.wantsLayer = true
  111. container.layer?.cornerRadius = AppTheme.cornerRadius
  112. let activeBackground = accent == .premium ? AppTheme.premiumBackground : AppTheme.homeActiveBackground
  113. let activeForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.homeActiveForeground
  114. let normalForeground = accent == .premium ? AppTheme.premiumForeground : AppTheme.textPrimary
  115. switch style {
  116. case .normal:
  117. container.layer?.backgroundColor = NSColor.clear.cgColor
  118. titleLabel.textColor = normalForeground
  119. iconView.contentTintColor = normalForeground
  120. case .active:
  121. container.layer?.backgroundColor = activeBackground.cgColor
  122. titleLabel.textColor = activeForeground
  123. iconView.contentTintColor = activeForeground
  124. }
  125. }
  126. override func mouseUp(with event: NSEvent) {
  127. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  128. onClick?()
  129. }
  130. override func resetCursorRects() {
  131. addCursorRect(bounds, cursor: .pointingHand)
  132. }
  133. }
  134. // MARK: - Action Button
  135. final class PillButton: NSButton {
  136. private let horizontalPadding: CGFloat = 16
  137. init(title: String, color: NSColor) {
  138. super.init(frame: .zero)
  139. self.title = title
  140. isBordered = false
  141. wantsLayer = true
  142. layer?.backgroundColor = color.cgColor
  143. layer?.cornerRadius = 11
  144. font = AppTheme.semiboldFont(size: 13)
  145. contentTintColor = .white
  146. if let arrow = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil) {
  147. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
  148. image = arrow.withSymbolConfiguration(config)
  149. imagePosition = .imageTrailing
  150. imageHugsTitle = true
  151. }
  152. heightAnchor.constraint(equalToConstant: 40).isActive = true
  153. }
  154. @available(*, unavailable)
  155. required init?(coder: NSCoder) { nil }
  156. override var intrinsicContentSize: NSSize {
  157. var size = super.intrinsicContentSize
  158. size.width += horizontalPadding * 2
  159. return size
  160. }
  161. override func draw(_ dirtyRect: NSRect) {
  162. let originalBounds = bounds
  163. defer { bounds = originalBounds }
  164. bounds = originalBounds.insetBy(dx: horizontalPadding, dy: 0)
  165. super.draw(dirtyRect)
  166. }
  167. override func resetCursorRects() {
  168. addCursorRect(bounds, cursor: .pointingHand)
  169. }
  170. }
  171. // MARK: - Sparkle Icon
  172. final class SparkleIconView: NSView {
  173. var color: NSColor = AppTheme.blue
  174. override var isFlipped: Bool { true }
  175. override var isOpaque: Bool { false }
  176. override func draw(_ dirtyRect: NSRect) {
  177. super.draw(dirtyRect)
  178. guard let context = NSGraphicsContext.current?.cgContext else { return }
  179. let s = min(bounds.width, bounds.height)
  180. let cx = bounds.midX
  181. let cy = bounds.midY
  182. let tipR = s * 0.34
  183. let valleyR = s * 0.09
  184. let tips = [
  185. CGPoint(x: cx, y: cy - tipR),
  186. CGPoint(x: cx + tipR, y: cy),
  187. CGPoint(x: cx, y: cy + tipR),
  188. CGPoint(x: cx - tipR, y: cy),
  189. ]
  190. let valleys = [
  191. CGPoint(x: cx + valleyR, y: cy - valleyR),
  192. CGPoint(x: cx + valleyR, y: cy + valleyR),
  193. CGPoint(x: cx - valleyR, y: cy + valleyR),
  194. CGPoint(x: cx - valleyR, y: cy - valleyR),
  195. ]
  196. let starPath = CGMutablePath()
  197. starPath.move(to: tips[0])
  198. for i in 0..<4 {
  199. starPath.addQuadCurve(to: tips[(i + 1) % 4], control: valleys[i])
  200. }
  201. starPath.closeSubpath()
  202. context.setFillColor(color.cgColor)
  203. context.addPath(starPath)
  204. context.fillPath()
  205. let dotR = s * 0.052
  206. let dotOffset = s * 0.30
  207. let dotPositions = [
  208. CGPoint(x: cx + dotOffset, y: cy - dotOffset),
  209. CGPoint(x: cx + dotOffset, y: cy + dotOffset),
  210. CGPoint(x: cx - dotOffset, y: cy + dotOffset),
  211. CGPoint(x: cx - dotOffset, y: cy - dotOffset),
  212. ]
  213. for pos in dotPositions {
  214. context.fillEllipse(in: CGRect(x: pos.x - dotR, y: pos.y - dotR, width: dotR * 2, height: dotR * 2))
  215. }
  216. }
  217. }
  218. // MARK: - Quick Start Card
  219. struct QuickStartCardData {
  220. let title: String
  221. let subtitle: String
  222. let buttonTitle: String
  223. let accentColor: NSColor
  224. let gradientColors: [NSColor]
  225. let iconKind: QuickStartIconKind
  226. }
  227. final class QuickStartCardView: NSView {
  228. private let iconView: QuickStartIconView
  229. private var iconWidthConstraint: NSLayoutConstraint!
  230. private var iconHeightConstraint: NSLayoutConstraint!
  231. init(data: QuickStartCardData) {
  232. iconView = QuickStartIconView(kind: data.iconKind)
  233. super.init(frame: .zero)
  234. translatesAutoresizingMaskIntoConstraints = false
  235. let gradient = GradientCardView(
  236. colors: data.gradientColors,
  237. startPoint: CGPoint(x: 0, y: 0.5),
  238. endPoint: CGPoint(x: 1, y: 0.5)
  239. )
  240. gradient.translatesAutoresizingMaskIntoConstraints = false
  241. let titleLabel = NSTextField(labelWithString: data.title)
  242. titleLabel.font = AppTheme.semiboldFont(size: 22)
  243. titleLabel.textColor = data.accentColor
  244. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  245. let subtitleLabel = NSTextField(labelWithString: data.subtitle)
  246. subtitleLabel.font = AppTheme.regularFont(size: 13)
  247. subtitleLabel.textColor = AppTheme.quickStartCardSubtitle
  248. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  249. let button = PillButton(title: data.buttonTitle, color: data.accentColor)
  250. button.translatesAutoresizingMaskIntoConstraints = false
  251. addSubview(gradient)
  252. gradient.addSubview(titleLabel)
  253. gradient.addSubview(subtitleLabel)
  254. gradient.addSubview(button)
  255. gradient.addSubview(iconView)
  256. NSLayoutConstraint.activate([
  257. gradient.leadingAnchor.constraint(equalTo: leadingAnchor),
  258. gradient.trailingAnchor.constraint(equalTo: trailingAnchor),
  259. gradient.topAnchor.constraint(equalTo: topAnchor),
  260. gradient.bottomAnchor.constraint(equalTo: bottomAnchor),
  261. heightAnchor.constraint(equalToConstant: 148),
  262. titleLabel.leadingAnchor.constraint(equalTo: gradient.leadingAnchor, constant: 18),
  263. titleLabel.topAnchor.constraint(equalTo: gradient.topAnchor, constant: 24),
  264. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  265. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  266. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: iconView.leadingAnchor, constant: -8),
  267. button.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  268. button.bottomAnchor.constraint(equalTo: gradient.bottomAnchor, constant: -20),
  269. iconView.trailingAnchor.constraint(equalTo: gradient.trailingAnchor, constant: -8),
  270. iconView.centerYAnchor.constraint(equalTo: gradient.centerYAnchor),
  271. ])
  272. iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
  273. iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.quickStartIconMax)
  274. iconWidthConstraint.isActive = true
  275. iconHeightConstraint.isActive = true
  276. }
  277. @available(*, unavailable)
  278. required init?(coder: NSCoder) { nil }
  279. override func layout() {
  280. super.layout()
  281. let size = AppTheme.quickStartIconSize(forCardWidth: bounds.width)
  282. if iconWidthConstraint.constant != size {
  283. iconWidthConstraint.constant = size
  284. iconHeightConstraint.constant = size
  285. }
  286. }
  287. }
  288. // MARK: - Feature Card
  289. struct FeatureCardData {
  290. let title: String
  291. let subtitle: String
  292. let iconKind: FeatureIconKind
  293. }
  294. final class FeatureCardView: NSView, AppearanceRefreshable {
  295. private let iconView: FeatureIconView
  296. private let titleLabel: NSTextField
  297. private let subtitleLabel: NSTextField
  298. private let arrowButton: NSButton
  299. private var iconWidthConstraint: NSLayoutConstraint!
  300. private var iconHeightConstraint: NSLayoutConstraint!
  301. init(data: FeatureCardData) {
  302. iconView = FeatureIconView(kind: data.iconKind)
  303. titleLabel = NSTextField.themeLabel(data.title, style: .primary, font: AppTheme.semiboldFont(size: 14))
  304. subtitleLabel = NSTextField.themeLabel(data.subtitle, style: .secondary, font: AppTheme.regularFont(size: 11))
  305. arrowButton = NSButton()
  306. super.init(frame: .zero)
  307. translatesAutoresizingMaskIntoConstraints = false
  308. setContentHuggingPriority(.defaultLow, for: .horizontal)
  309. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  310. wantsLayer = true
  311. layer?.cornerRadius = AppTheme.featureCardCornerRadius
  312. applyCardShadow()
  313. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  314. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  315. arrowButton.isBordered = false
  316. arrowButton.wantsLayer = true
  317. arrowButton.layer?.cornerRadius = 13
  318. arrowButton.layer?.borderWidth = 1
  319. arrowButton.translatesAutoresizingMaskIntoConstraints = false
  320. if let arrow = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Open") {
  321. let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
  322. arrowButton.image = arrow.withSymbolConfiguration(config)
  323. }
  324. arrowButton.contentTintColor = AppTheme.textSecondary
  325. addSubview(iconView)
  326. addSubview(titleLabel)
  327. addSubview(subtitleLabel)
  328. addSubview(arrowButton)
  329. NSLayoutConstraint.activate([
  330. heightAnchor.constraint(equalToConstant: 96),
  331. iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
  332. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  333. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10),
  334. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  335. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
  336. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  337. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  338. subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
  339. arrowButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
  340. arrowButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
  341. arrowButton.widthAnchor.constraint(equalToConstant: 26),
  342. arrowButton.heightAnchor.constraint(equalToConstant: 26),
  343. ])
  344. iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
  345. iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
  346. iconWidthConstraint.isActive = true
  347. iconHeightConstraint.isActive = true
  348. refreshAppearance()
  349. }
  350. @available(*, unavailable)
  351. required init?(coder: NSCoder) { nil }
  352. func refreshAppearance() {
  353. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  354. titleLabel.refreshThemeLabelColor()
  355. subtitleLabel.refreshThemeLabelColor()
  356. arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  357. arrowButton.layer?.borderColor = AppTheme.border.cgColor
  358. arrowButton.contentTintColor = AppTheme.textSecondary
  359. }
  360. override func layout() {
  361. super.layout()
  362. let size = AppTheme.featureIconSize(forCardWidth: bounds.width)
  363. if iconWidthConstraint.constant != size {
  364. iconWidthConstraint.constant = size
  365. iconHeightConstraint.constant = size
  366. }
  367. }
  368. override func resetCursorRects() {
  369. addCursorRect(bounds, cursor: .pointingHand)
  370. }
  371. }