AppTheme.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import Cocoa
  2. import ObjectiveC
  3. enum AppTheme {
  4. static let windowWidth: CGFloat = 760
  5. static let windowHeight: CGFloat = 720
  6. static let windowMinWidth: CGFloat = 650
  7. static let windowMinHeight: CGFloat = windowHeight
  8. static let sidebarWidth: CGFloat = 200
  9. static let contentPadding: CGFloat = 20
  10. static let quickStartSpacing: CGFloat = 12
  11. static let featureGridSpacing: CGFloat = 10
  12. static let featureGridColumns = 3
  13. static let quickStartIconMax: CGFloat = 80
  14. static let quickStartIconMin: CGFloat = 56
  15. static let featureIconMax: CGFloat = 48
  16. static let featureIconMin: CGFloat = 36
  17. static func quickStartIconSize(forCardWidth width: CGFloat) -> CGFloat {
  18. min(quickStartIconMax, max(quickStartIconMin, width * 0.58))
  19. }
  20. static func featureIconSize(forCardWidth width: CGFloat) -> CGFloat {
  21. min(featureIconMax, max(featureIconMin, width * 0.24))
  22. }
  23. static let cornerRadius: CGFloat = 14
  24. static let cardCornerRadius: CGFloat = 20
  25. static let featureCardCornerRadius: CGFloat = 16
  26. static let contentPanelCornerRadius: CGFloat = 24
  27. static let contentPanelInset: CGFloat = 16
  28. static let headerTopInset: CGFloat = 14
  29. static let headerHeight: CGFloat = 32
  30. static let contentBelowHeaderGap: CGFloat = 2
  31. private static var isDark: Bool { AppSettings.darkModeEnabled }
  32. static var background: NSColor {
  33. isDark
  34. ? NSColor(calibratedWhite: 0.11, alpha: 1)
  35. : NSColor(calibratedWhite: 0.965, alpha: 1)
  36. }
  37. static var sidebarBackground: NSColor {
  38. isDark
  39. ? NSColor(calibratedWhite: 0.15, alpha: 1)
  40. : NSColor.white
  41. }
  42. static var cardBackground: NSColor {
  43. isDark
  44. ? NSColor(calibratedWhite: 0.18, alpha: 1)
  45. : NSColor.white
  46. }
  47. static var groupCardBackground: NSColor { cardBackground }
  48. static var elevatedBackground: NSColor {
  49. isDark
  50. ? NSColor(calibratedWhite: 0.22, alpha: 1)
  51. : NSColor.white
  52. }
  53. static var textPrimary: NSColor {
  54. isDark
  55. ? NSColor(calibratedWhite: 0.92, alpha: 1)
  56. : NSColor(calibratedWhite: 0.12, alpha: 1)
  57. }
  58. static var textSecondary: NSColor {
  59. isDark
  60. ? NSColor(calibratedWhite: 0.55, alpha: 1)
  61. : NSColor(calibratedWhite: 0.48, alpha: 1)
  62. }
  63. static var border: NSColor {
  64. isDark
  65. ? NSColor(calibratedWhite: 0.28, alpha: 1)
  66. : NSColor(calibratedWhite: 0.92, alpha: 1)
  67. }
  68. static var waveDotColor: NSColor {
  69. isDark
  70. ? NSColor(calibratedWhite: 0.35, alpha: 0.45)
  71. : NSColor(calibratedWhite: 0.82, alpha: 0.55)
  72. }
  73. static var homeActiveBackground: NSColor {
  74. isDark
  75. ? NSColor(red: 0.18, green: 0.28, blue: 0.48, alpha: 1)
  76. : NSColor(red: 0.91, green: 0.94, blue: 1.0, alpha: 1)
  77. }
  78. static var homeActiveForeground: NSColor {
  79. isDark
  80. ? NSColor(red: 0.45, green: 0.65, blue: 1.0, alpha: 1)
  81. : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
  82. }
  83. static var sidebarHoverBackground: NSColor {
  84. isDark
  85. ? NSColor(calibratedWhite: 0.20, alpha: 1)
  86. : NSColor(calibratedWhite: 0.96, alpha: 1)
  87. }
  88. static var premiumHoverBackground: NSColor {
  89. isDark
  90. ? NSColor(red: 0.24, green: 0.20, blue: 0.36, alpha: 1)
  91. : NSColor(red: 0.97, green: 0.95, blue: 1.0, alpha: 1)
  92. }
  93. static let hoverAnimationDuration: TimeInterval = 0.15
  94. static var cardShadowOpacity: Float {
  95. AppSettings.darkModeEnabled ? 0.25 : 0.07
  96. }
  97. static var cardHoverShadowOpacity: Float {
  98. AppSettings.darkModeEnabled ? 0.35 : 0.14
  99. }
  100. static let cardShadowRadius: CGFloat = 14
  101. static let cardHoverShadowRadius: CGFloat = 18
  102. static let cardHoverScale: CGFloat = 1.02
  103. static var premiumBackground: NSColor {
  104. isDark
  105. ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
  106. : NSColor(red: 0.95, green: 0.93, blue: 1.0, alpha: 1)
  107. }
  108. static var premiumForeground: NSColor {
  109. isDark
  110. ? NSColor(red: 0.72, green: 0.58, blue: 1.0, alpha: 1)
  111. : NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
  112. }
  113. static let blue = NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
  114. static let quickStartBlueLight = NSColor(red: 0.88, green: 0.93, blue: 1.0, alpha: 1)
  115. static let quickStartCardSubtitle = NSColor(calibratedWhite: 0.48, alpha: 1)
  116. static var blueLight: NSColor {
  117. isDark
  118. ? NSColor(red: 0.20, green: 0.28, blue: 0.42, alpha: 1)
  119. : quickStartBlueLight
  120. }
  121. static let green = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
  122. static let greenLight = NSColor(red: 0.88, green: 0.97, blue: 0.91, alpha: 1)
  123. static let orange = NSColor(red: 0.96, green: 0.52, blue: 0.18, alpha: 1)
  124. static let orangeLight = NSColor(red: 1.0, green: 0.94, blue: 0.88, alpha: 1)
  125. static let purple = NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
  126. static let teal = NSColor(red: 0.18, green: 0.72, blue: 0.82, alpha: 1)
  127. static let navy = NSColor(red: 0.12, green: 0.18, blue: 0.32, alpha: 1)
  128. static let paywallPink = NSColor(red: 0.98, green: 0.85, blue: 0.90, alpha: 1)
  129. static let paywallPinkText = NSColor(red: 0.75, green: 0.30, blue: 0.45, alpha: 1)
  130. static let paywallGold = NSColor(red: 0.96, green: 0.90, blue: 0.78, alpha: 1)
  131. static let paywallGoldText = NSColor(red: 0.65, green: 0.48, blue: 0.22, alpha: 1)
  132. static var paywallBorder: NSColor {
  133. isDark
  134. ? NSColor(calibratedWhite: 0.36, alpha: 1)
  135. : NSColor(calibratedWhite: 0.80, alpha: 1)
  136. }
  137. static var paywallBackground: NSColor { cardBackground }
  138. static var paywallAccent: NSColor {
  139. isDark ? textPrimary : navy
  140. }
  141. static var paywallLeftGradientColors: [NSColor] {
  142. isDark
  143. ? [
  144. NSColor(calibratedWhite: 0.17, alpha: 1),
  145. NSColor(calibratedWhite: 0.13, alpha: 1),
  146. ]
  147. : [
  148. NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1),
  149. NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1),
  150. ]
  151. }
  152. static var paywallCTABackground: NSColor {
  153. isDark
  154. ? NSColor(calibratedWhite: 0.96, alpha: 1)
  155. : navy
  156. }
  157. static var paywallCTAForeground: NSColor {
  158. isDark
  159. ? NSColor(calibratedWhite: 0.10, alpha: 1)
  160. : NSColor.white
  161. }
  162. static var paywallTrustIconBackground: NSColor {
  163. isDark
  164. ? NSColor(calibratedWhite: 0.24, alpha: 1)
  165. : quickStartBlueLight
  166. }
  167. static var paywallIconAccent: NSColor {
  168. isDark
  169. ? NSColor(calibratedWhite: 0.78, alpha: 1)
  170. : navy
  171. }
  172. static var paywallTrustBackground: NSColor { elevatedBackground }
  173. static var paywallOverlayBackdrop: NSColor {
  174. isDark
  175. ? NSColor(calibratedWhite: 0.05, alpha: 0.55)
  176. : NSColor(calibratedWhite: 0.15, alpha: 0.22)
  177. }
  178. static func semiboldFont(size: CGFloat) -> NSFont {
  179. .systemFont(ofSize: size, weight: .semibold)
  180. }
  181. static func mediumFont(size: CGFloat) -> NSFont {
  182. .systemFont(ofSize: size, weight: .medium)
  183. }
  184. static func regularFont(size: CGFloat) -> NSFont {
  185. .systemFont(ofSize: size, weight: .regular)
  186. }
  187. }
  188. protocol AppearanceRefreshable: AnyObject {
  189. func refreshAppearance()
  190. }
  191. // MARK: - Hover Tracking
  192. final class HoverTracker: NSResponder {
  193. private weak var view: NSView?
  194. private let onHoverChanged: (Bool) -> Void
  195. init(view: NSView, onHoverChanged: @escaping (Bool) -> Void) {
  196. self.view = view
  197. self.onHoverChanged = onHoverChanged
  198. super.init()
  199. let area = NSTrackingArea(
  200. rect: .zero,
  201. options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
  202. owner: self,
  203. userInfo: nil
  204. )
  205. view.addTrackingArea(area)
  206. }
  207. @available(*, unavailable)
  208. required init?(coder: NSCoder) { nil }
  209. override func mouseEntered(with event: NSEvent) {
  210. onHoverChanged(true)
  211. }
  212. override func mouseExited(with event: NSEvent) {
  213. onHoverChanged(false)
  214. }
  215. }
  216. extension NSView {
  217. func animateHover(_ changes: () -> Void) {
  218. NSAnimationContext.runAnimationGroup { context in
  219. context.duration = AppTheme.hoverAnimationDuration
  220. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  221. context.allowsImplicitAnimation = true
  222. changes()
  223. }
  224. }
  225. func applyHoverLift(_ hovering: Bool, on targetLayer: CALayer? = nil) {
  226. let layer = targetLayer ?? layer
  227. animateHover {
  228. guard let layer else { return }
  229. layer.transform = hovering
  230. ? CATransform3DMakeScale(AppTheme.cardHoverScale, AppTheme.cardHoverScale, 1)
  231. : CATransform3DIdentity
  232. layer.shadowOpacity = hovering ? AppTheme.cardHoverShadowOpacity : AppTheme.cardShadowOpacity
  233. layer.shadowRadius = hovering ? AppTheme.cardHoverShadowRadius : AppTheme.cardShadowRadius
  234. }
  235. }
  236. func applyCardShadow() {
  237. wantsLayer = true
  238. layer?.shadowColor = NSColor.black.cgColor
  239. layer?.shadowOpacity = AppTheme.cardShadowOpacity
  240. layer?.shadowOffset = NSSize(width: 0, height: -3)
  241. layer?.shadowRadius = AppTheme.cardShadowRadius
  242. layer?.masksToBounds = false
  243. }
  244. func roundCorners(_ radius: CGFloat) {
  245. wantsLayer = true
  246. layer?.cornerRadius = radius
  247. layer?.masksToBounds = true
  248. }
  249. func refreshAppearanceRecursively() {
  250. if let refreshable = self as? AppearanceRefreshable {
  251. refreshable.refreshAppearance()
  252. }
  253. if let label = self as? NSTextField {
  254. label.refreshThemeLabelColor()
  255. }
  256. if let imageView = self as? NSImageView, imageView.usesThemeTint {
  257. imageView.contentTintColor = AppTheme.textPrimary
  258. }
  259. subviews.forEach { $0.refreshAppearanceRecursively() }
  260. }
  261. }
  262. private enum ThemeAssociatedKeys {
  263. static var labelStyle = 0
  264. static var usesThemeTint = 0
  265. }
  266. enum ThemeLabelStyle: String {
  267. case primary
  268. case secondary
  269. }
  270. extension NSTextField {
  271. static func themeLabel(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
  272. let label = NSTextField(labelWithString: string)
  273. label.font = font
  274. label.themeLabelStyle = style
  275. label.textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
  276. return label
  277. }
  278. var themeLabelStyle: ThemeLabelStyle? {
  279. get {
  280. guard let rawValue = objc_getAssociatedObject(self, &ThemeAssociatedKeys.labelStyle) as? String else {
  281. return nil
  282. }
  283. return ThemeLabelStyle(rawValue: rawValue)
  284. }
  285. set {
  286. objc_setAssociatedObject(
  287. self,
  288. &ThemeAssociatedKeys.labelStyle,
  289. newValue?.rawValue,
  290. .OBJC_ASSOCIATION_RETAIN_NONATOMIC
  291. )
  292. }
  293. }
  294. func refreshThemeLabelColor() {
  295. guard let style = themeLabelStyle else { return }
  296. textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
  297. }
  298. }
  299. extension NSImageView {
  300. var usesThemeTint: Bool {
  301. get { (objc_getAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint) as? Bool) ?? false }
  302. set { objc_setAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
  303. }
  304. }