SettingsView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import Cocoa
  2. // MARK: - Root
  3. final class SettingsView: NSView, AppearanceRefreshable {
  4. init() {
  5. super.init(frame: .zero)
  6. wantsLayer = true
  7. layer?.backgroundColor = AppTheme.background.cgColor
  8. translatesAutoresizingMaskIntoConstraints = false
  9. setup()
  10. }
  11. @available(*, unavailable)
  12. required init?(coder: NSCoder) { nil }
  13. func refreshAppearance() {
  14. layer?.backgroundColor = AppTheme.background.cgColor
  15. }
  16. private func setup() {
  17. let scrollView = NSScrollView()
  18. scrollView.translatesAutoresizingMaskIntoConstraints = false
  19. scrollView.hasVerticalScroller = true
  20. scrollView.autohidesScrollers = true
  21. scrollView.drawsBackground = false
  22. scrollView.borderType = .noBorder
  23. let document = FlippedSettingsDocumentView()
  24. document.translatesAutoresizingMaskIntoConstraints = false
  25. let panel = SettingsPanelView()
  26. panel.translatesAutoresizingMaskIntoConstraints = false
  27. let stack = NSStackView()
  28. stack.orientation = .vertical
  29. stack.alignment = .leading
  30. stack.spacing = 28
  31. stack.translatesAutoresizingMaskIntoConstraints = false
  32. let appSection = makeSection(title: "App", card: makeAppCard())
  33. let aboutSection = makeSection(title: "About", card: makeAboutCard())
  34. stack.addArrangedSubview(appSection)
  35. stack.addArrangedSubview(aboutSection)
  36. [appSection, aboutSection].forEach { section in
  37. section.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  38. }
  39. panel.addSubview(stack)
  40. document.addSubview(panel)
  41. scrollView.documentView = document
  42. addSubview(scrollView)
  43. let guide = scrollView.contentView
  44. NSLayoutConstraint.activate([
  45. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
  46. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
  47. scrollView.topAnchor.constraint(equalTo: topAnchor),
  48. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  49. document.topAnchor.constraint(equalTo: guide.topAnchor),
  50. document.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
  51. document.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
  52. document.widthAnchor.constraint(equalTo: guide.widthAnchor),
  53. panel.topAnchor.constraint(equalTo: document.topAnchor, constant: 8),
  54. panel.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
  55. panel.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
  56. panel.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -32),
  57. panel.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -48),
  58. stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  59. stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  60. stack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 28),
  61. stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -28),
  62. stack.widthAnchor.constraint(equalTo: panel.widthAnchor, constant: -56),
  63. ])
  64. }
  65. private func makeSection(title: String, card: NSView) -> NSView {
  66. let container = NSStackView()
  67. container.orientation = .vertical
  68. container.alignment = .leading
  69. container.spacing = 12
  70. container.translatesAutoresizingMaskIntoConstraints = false
  71. let label = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 18))
  72. label.translatesAutoresizingMaskIntoConstraints = false
  73. container.addArrangedSubview(label)
  74. container.addArrangedSubview(card)
  75. card.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  76. return container
  77. }
  78. private func makeAppCard() -> NSView {
  79. let card = SettingsGroupCard()
  80. card.addRow(SettingsActionRow(symbolName: "square.and.arrow.up", title: "Share App") {
  81. guard let url = Bundle.main.bundleURL as URL? else { return }
  82. let picker = NSSharingServicePicker(items: [url])
  83. if let view = NSApp.keyWindow?.contentView {
  84. picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
  85. }
  86. })
  87. card.addRow(SettingsToggleRow(
  88. symbolName: "moon.fill",
  89. title: "Dark Mode",
  90. isOn: AppSettings.darkModeEnabled,
  91. isLast: true
  92. ) { enabled in
  93. AppSettings.darkModeEnabled = enabled
  94. AppSettings.applyAppearance()
  95. })
  96. return card
  97. }
  98. private func makeAboutCard() -> NSView {
  99. let card = SettingsGroupCard()
  100. card.addRow(SettingsActionRow(symbolName: "link", title: "Website") {
  101. NSWorkspace.shared.open(URL(string: "https://example.com")!)
  102. })
  103. card.addRow(SettingsActionRow(symbolName: "questionmark.circle", title: "Support") {
  104. NSWorkspace.shared.open(URL(string: "mailto:support@example.com")!)
  105. })
  106. card.addRow(SettingsActionRow(symbolName: "doc.text", title: "Terms of Use") {
  107. NSWorkspace.shared.open(URL(string: "https://example.com/terms")!)
  108. })
  109. card.addRow(SettingsActionRow(symbolName: "shield", title: "Privacy Policy", isLast: true) {
  110. NSWorkspace.shared.open(URL(string: "https://example.com/privacy")!)
  111. })
  112. return card
  113. }
  114. }
  115. // MARK: - Panel
  116. private final class SettingsPanelView: NSView, AppearanceRefreshable {
  117. init() {
  118. super.init(frame: .zero)
  119. wantsLayer = true
  120. layer?.cornerRadius = 22
  121. layer?.borderWidth = 1.5
  122. applyCardShadow()
  123. refreshAppearance()
  124. }
  125. func refreshAppearance() {
  126. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  127. layer?.borderColor = AppTheme.paywallBorder.cgColor
  128. }
  129. @available(*, unavailable)
  130. required init?(coder: NSCoder) { nil }
  131. }
  132. private final class SettingsGroupCard: NSView, AppearanceRefreshable {
  133. private let stack = NSStackView()
  134. init() {
  135. super.init(frame: .zero)
  136. translatesAutoresizingMaskIntoConstraints = false
  137. wantsLayer = true
  138. layer?.cornerRadius = 16
  139. layer?.borderWidth = 1.5
  140. layer?.masksToBounds = true
  141. refreshAppearance()
  142. stack.orientation = .vertical
  143. stack.spacing = 0
  144. stack.translatesAutoresizingMaskIntoConstraints = false
  145. addSubview(stack)
  146. NSLayoutConstraint.activate([
  147. stack.leadingAnchor.constraint(equalTo: leadingAnchor),
  148. stack.trailingAnchor.constraint(equalTo: trailingAnchor),
  149. stack.topAnchor.constraint(equalTo: topAnchor),
  150. stack.bottomAnchor.constraint(equalTo: bottomAnchor),
  151. ])
  152. }
  153. @available(*, unavailable)
  154. required init?(coder: NSCoder) { nil }
  155. func addRow(_ row: NSView) {
  156. stack.addArrangedSubview(row)
  157. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  158. }
  159. func refreshAppearance() {
  160. layer?.backgroundColor = AppTheme.groupCardBackground.cgColor
  161. layer?.borderColor = AppTheme.paywallBorder.cgColor
  162. }
  163. }
  164. // MARK: - Icon
  165. private final class SettingsIconBadge: NSView, AppearanceRefreshable {
  166. init(symbolName: String) {
  167. super.init(frame: .zero)
  168. translatesAutoresizingMaskIntoConstraints = false
  169. wantsLayer = true
  170. layer?.cornerRadius = 10
  171. refreshAppearance()
  172. let icon = NSImageView()
  173. icon.translatesAutoresizingMaskIntoConstraints = false
  174. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
  175. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
  176. icon.image = image.withSymbolConfiguration(config)
  177. }
  178. icon.contentTintColor = AppTheme.blue
  179. addSubview(icon)
  180. NSLayoutConstraint.activate([
  181. widthAnchor.constraint(equalToConstant: 36),
  182. heightAnchor.constraint(equalToConstant: 36),
  183. icon.centerXAnchor.constraint(equalTo: centerXAnchor),
  184. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  185. icon.widthAnchor.constraint(equalToConstant: 18),
  186. icon.heightAnchor.constraint(equalToConstant: 18),
  187. ])
  188. }
  189. @available(*, unavailable)
  190. required init?(coder: NSCoder) { nil }
  191. func refreshAppearance() {
  192. layer?.backgroundColor = AppTheme.blueLight.cgColor
  193. }
  194. }
  195. // MARK: - Rows
  196. private class SettingsRowBase: NSView {
  197. init(isLast: Bool) {
  198. super.init(frame: .zero)
  199. translatesAutoresizingMaskIntoConstraints = false
  200. heightAnchor.constraint(equalToConstant: 56).isActive = true
  201. if !isLast { addDivider() }
  202. }
  203. @available(*, unavailable)
  204. required init?(coder: NSCoder) { nil }
  205. func addDivider() {
  206. let divider = NSBox()
  207. divider.boxType = .separator
  208. divider.translatesAutoresizingMaskIntoConstraints = false
  209. addSubview(divider)
  210. NSLayoutConstraint.activate([
  211. divider.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 60),
  212. divider.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  213. divider.bottomAnchor.constraint(equalTo: bottomAnchor),
  214. divider.heightAnchor.constraint(equalToConstant: 1),
  215. ])
  216. }
  217. func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
  218. let badge = SettingsIconBadge(symbolName: symbolName)
  219. let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
  220. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  221. trailing.translatesAutoresizingMaskIntoConstraints = false
  222. addSubview(badge)
  223. addSubview(titleLabel)
  224. addSubview(trailing)
  225. NSLayoutConstraint.activate([
  226. badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  227. badge.centerYAnchor.constraint(equalTo: centerYAnchor),
  228. titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
  229. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  230. trailing.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  231. trailing.centerYAnchor.constraint(equalTo: centerYAnchor),
  232. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailing.leadingAnchor, constant: -12),
  233. ])
  234. return titleLabel
  235. }
  236. }
  237. private final class SettingsActionRow: SettingsRowBase {
  238. init(symbolName: String, title: String, isLast: Bool = false, action: @escaping () -> Void) {
  239. super.init(isLast: isLast)
  240. let badge = SettingsIconBadge(symbolName: symbolName)
  241. let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
  242. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  243. addSubview(badge)
  244. addSubview(titleLabel)
  245. NSLayoutConstraint.activate([
  246. badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  247. badge.centerYAnchor.constraint(equalTo: centerYAnchor),
  248. titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
  249. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  250. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
  251. ])
  252. let control = LinkControl(onActivate: action)
  253. control.translatesAutoresizingMaskIntoConstraints = false
  254. addSubview(control)
  255. NSLayoutConstraint.activate([
  256. control.leadingAnchor.constraint(equalTo: leadingAnchor),
  257. control.trailingAnchor.constraint(equalTo: trailingAnchor),
  258. control.topAnchor.constraint(equalTo: topAnchor),
  259. control.bottomAnchor.constraint(equalTo: bottomAnchor),
  260. ])
  261. }
  262. @available(*, unavailable)
  263. required init?(coder: NSCoder) { nil }
  264. }
  265. private final class SettingsPopupRow: SettingsRowBase {
  266. private let popupTarget: PopupTarget
  267. init(symbolName: String, title: String, options: [String], selection: String, isLast: Bool = false, onChange: @escaping (String) -> Void) {
  268. popupTarget = PopupTarget(handler: onChange)
  269. super.init(isLast: isLast)
  270. let popup = NSPopUpButton()
  271. popup.bezelStyle = .rounded
  272. popup.addItems(withTitles: options)
  273. popup.selectItem(withTitle: selection)
  274. popup.font = AppTheme.regularFont(size: 13)
  275. popup.target = popupTarget
  276. popup.action = #selector(PopupTarget.changed(_:))
  277. _ = install(icon: symbolName, title: title, trailing: popup)
  278. }
  279. @available(*, unavailable)
  280. required init?(coder: NSCoder) { nil }
  281. }
  282. private final class SettingsToggleRow: SettingsRowBase {
  283. private let toggleTarget: ToggleTarget
  284. init(symbolName: String, title: String, isOn: Bool, isLast: Bool = false, onChange: @escaping (Bool) -> Void) {
  285. toggleTarget = ToggleTarget(handler: onChange)
  286. super.init(isLast: isLast)
  287. let toggle = NSSwitch()
  288. toggle.state = isOn ? .on : .off
  289. toggle.target = toggleTarget
  290. toggle.action = #selector(ToggleTarget.changed(_:))
  291. _ = install(icon: symbolName, title: title, trailing: toggle)
  292. }
  293. @available(*, unavailable)
  294. required init?(coder: NSCoder) { nil }
  295. }
  296. // MARK: - Helpers
  297. private final class LinkControl: NSControl {
  298. private let onActivate: () -> Void
  299. init(onActivate: @escaping () -> Void) {
  300. self.onActivate = onActivate
  301. super.init(frame: .zero)
  302. }
  303. @available(*, unavailable)
  304. required init?(coder: NSCoder) { nil }
  305. override func mouseUp(with event: NSEvent) {
  306. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  307. onActivate()
  308. }
  309. override func resetCursorRects() {
  310. addCursorRect(bounds, cursor: .pointingHand)
  311. }
  312. }
  313. private final class PopupTarget: NSObject {
  314. private let handler: (String) -> Void
  315. init(handler: @escaping (String) -> Void) {
  316. self.handler = handler
  317. }
  318. @objc func changed(_ sender: NSPopUpButton) {
  319. handler(sender.titleOfSelectedItem ?? "")
  320. }
  321. }
  322. private final class ToggleTarget: NSObject {
  323. private let handler: (Bool) -> Void
  324. init(handler: @escaping (Bool) -> Void) {
  325. self.handler = handler
  326. }
  327. @objc func changed(_ sender: NSSwitch) {
  328. handler(sender.state == .on)
  329. }
  330. }
  331. private final class FlippedSettingsDocumentView: NSView {
  332. override var isFlipped: Bool { true }
  333. }