Aucune description

SettingsView.swift 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  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 = ContentPanelView(cornerRadius: 22)
  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. private final class SettingsGroupCard: NSView, AppearanceRefreshable {
  116. private let stack = NSStackView()
  117. init() {
  118. super.init(frame: .zero)
  119. translatesAutoresizingMaskIntoConstraints = false
  120. wantsLayer = true
  121. layer?.cornerRadius = 16
  122. layer?.borderWidth = 1.5
  123. layer?.masksToBounds = true
  124. refreshAppearance()
  125. stack.orientation = .vertical
  126. stack.spacing = 0
  127. stack.translatesAutoresizingMaskIntoConstraints = false
  128. addSubview(stack)
  129. NSLayoutConstraint.activate([
  130. stack.leadingAnchor.constraint(equalTo: leadingAnchor),
  131. stack.trailingAnchor.constraint(equalTo: trailingAnchor),
  132. stack.topAnchor.constraint(equalTo: topAnchor),
  133. stack.bottomAnchor.constraint(equalTo: bottomAnchor),
  134. ])
  135. }
  136. @available(*, unavailable)
  137. required init?(coder: NSCoder) { nil }
  138. func addRow(_ row: NSView) {
  139. stack.addArrangedSubview(row)
  140. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  141. }
  142. func refreshAppearance() {
  143. layer?.backgroundColor = AppTheme.groupCardBackground.cgColor
  144. layer?.borderColor = AppTheme.paywallBorder.cgColor
  145. }
  146. }
  147. // MARK: - Icon
  148. private final class SettingsIconBadge: NSView, AppearanceRefreshable {
  149. init(symbolName: String) {
  150. super.init(frame: .zero)
  151. translatesAutoresizingMaskIntoConstraints = false
  152. wantsLayer = true
  153. layer?.cornerRadius = 10
  154. refreshAppearance()
  155. let icon = NSImageView()
  156. icon.translatesAutoresizingMaskIntoConstraints = false
  157. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
  158. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
  159. icon.image = image.withSymbolConfiguration(config)
  160. }
  161. icon.contentTintColor = AppTheme.blue
  162. addSubview(icon)
  163. NSLayoutConstraint.activate([
  164. widthAnchor.constraint(equalToConstant: 36),
  165. heightAnchor.constraint(equalToConstant: 36),
  166. icon.centerXAnchor.constraint(equalTo: centerXAnchor),
  167. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  168. icon.widthAnchor.constraint(equalToConstant: 18),
  169. icon.heightAnchor.constraint(equalToConstant: 18),
  170. ])
  171. }
  172. @available(*, unavailable)
  173. required init?(coder: NSCoder) { nil }
  174. func refreshAppearance() {
  175. layer?.backgroundColor = AppTheme.blueLight.cgColor
  176. }
  177. }
  178. // MARK: - Rows
  179. private class SettingsRowBase: NSView, AppearanceRefreshable {
  180. private var hoverTracker: HoverTracker?
  181. private var isHovered = false
  182. private let isInteractive: Bool
  183. init(isLast: Bool, isInteractive: Bool = false) {
  184. self.isInteractive = isInteractive
  185. super.init(frame: .zero)
  186. translatesAutoresizingMaskIntoConstraints = false
  187. heightAnchor.constraint(equalToConstant: 56).isActive = true
  188. if !isLast { addDivider() }
  189. if isInteractive {
  190. wantsLayer = true
  191. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  192. self?.setHovered(hovering)
  193. }
  194. }
  195. }
  196. @available(*, unavailable)
  197. required init?(coder: NSCoder) { nil }
  198. func refreshAppearance() {
  199. updateHoverAppearance()
  200. }
  201. private func setHovered(_ hovering: Bool) {
  202. isHovered = hovering
  203. updateHoverAppearance()
  204. }
  205. private func updateHoverAppearance() {
  206. guard isInteractive else { return }
  207. animateHover {
  208. layer?.backgroundColor = (isHovered ? AppTheme.sidebarHoverBackground : .clear).cgColor
  209. }
  210. }
  211. func addDivider() {
  212. let divider = NSBox()
  213. divider.boxType = .separator
  214. divider.translatesAutoresizingMaskIntoConstraints = false
  215. addSubview(divider)
  216. NSLayoutConstraint.activate([
  217. divider.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 60),
  218. divider.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  219. divider.bottomAnchor.constraint(equalTo: bottomAnchor),
  220. divider.heightAnchor.constraint(equalToConstant: 1),
  221. ])
  222. }
  223. func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
  224. let badge = SettingsIconBadge(symbolName: symbolName)
  225. let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
  226. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  227. trailing.translatesAutoresizingMaskIntoConstraints = false
  228. addSubview(badge)
  229. addSubview(titleLabel)
  230. addSubview(trailing)
  231. NSLayoutConstraint.activate([
  232. badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  233. badge.centerYAnchor.constraint(equalTo: centerYAnchor),
  234. titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
  235. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  236. trailing.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  237. trailing.centerYAnchor.constraint(equalTo: centerYAnchor),
  238. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailing.leadingAnchor, constant: -12),
  239. ])
  240. return titleLabel
  241. }
  242. }
  243. private final class SettingsActionRow: SettingsRowBase {
  244. init(symbolName: String, title: String, isLast: Bool = false, action: @escaping () -> Void) {
  245. super.init(isLast: isLast, isInteractive: true)
  246. let badge = SettingsIconBadge(symbolName: symbolName)
  247. let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
  248. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  249. addSubview(badge)
  250. addSubview(titleLabel)
  251. NSLayoutConstraint.activate([
  252. badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  253. badge.centerYAnchor.constraint(equalTo: centerYAnchor),
  254. titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
  255. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  256. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
  257. ])
  258. let control = LinkControl(onActivate: action)
  259. control.translatesAutoresizingMaskIntoConstraints = false
  260. addSubview(control)
  261. NSLayoutConstraint.activate([
  262. control.leadingAnchor.constraint(equalTo: leadingAnchor),
  263. control.trailingAnchor.constraint(equalTo: trailingAnchor),
  264. control.topAnchor.constraint(equalTo: topAnchor),
  265. control.bottomAnchor.constraint(equalTo: bottomAnchor),
  266. ])
  267. }
  268. @available(*, unavailable)
  269. required init?(coder: NSCoder) { nil }
  270. }
  271. private final class SettingsPopupRow: SettingsRowBase {
  272. private let popupTarget: PopupTarget
  273. init(symbolName: String, title: String, options: [String], selection: String, isLast: Bool = false, onChange: @escaping (String) -> Void) {
  274. popupTarget = PopupTarget(handler: onChange)
  275. super.init(isLast: isLast, isInteractive: true)
  276. let popup = NSPopUpButton()
  277. popup.bezelStyle = .rounded
  278. popup.addItems(withTitles: options)
  279. popup.selectItem(withTitle: selection)
  280. popup.font = AppTheme.regularFont(size: 13)
  281. popup.target = popupTarget
  282. popup.action = #selector(PopupTarget.changed(_:))
  283. _ = install(icon: symbolName, title: title, trailing: popup)
  284. }
  285. @available(*, unavailable)
  286. required init?(coder: NSCoder) { nil }
  287. }
  288. private final class SettingsToggleRow: SettingsRowBase {
  289. private let toggleTarget: ToggleTarget
  290. init(symbolName: String, title: String, isOn: Bool, isLast: Bool = false, onChange: @escaping (Bool) -> Void) {
  291. toggleTarget = ToggleTarget(handler: onChange)
  292. super.init(isLast: isLast, isInteractive: true)
  293. let toggle = NSSwitch()
  294. toggle.state = isOn ? .on : .off
  295. toggle.target = toggleTarget
  296. toggle.action = #selector(ToggleTarget.changed(_:))
  297. _ = install(icon: symbolName, title: title, trailing: toggle)
  298. }
  299. @available(*, unavailable)
  300. required init?(coder: NSCoder) { nil }
  301. }
  302. // MARK: - Helpers
  303. private final class LinkControl: NSControl {
  304. private let onActivate: () -> Void
  305. init(onActivate: @escaping () -> Void) {
  306. self.onActivate = onActivate
  307. super.init(frame: .zero)
  308. }
  309. @available(*, unavailable)
  310. required init?(coder: NSCoder) { nil }
  311. override func mouseUp(with event: NSEvent) {
  312. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  313. onActivate()
  314. }
  315. override func resetCursorRects() {
  316. addCursorRect(bounds, cursor: .pointingHand)
  317. }
  318. }
  319. private final class PopupTarget: NSObject {
  320. private let handler: (String) -> Void
  321. init(handler: @escaping (String) -> Void) {
  322. self.handler = handler
  323. }
  324. @objc func changed(_ sender: NSPopUpButton) {
  325. handler(sender.titleOfSelectedItem ?? "")
  326. }
  327. }
  328. private final class ToggleTarget: NSObject {
  329. private let handler: (Bool) -> Void
  330. init(handler: @escaping (Bool) -> Void) {
  331. self.handler = handler
  332. }
  333. @objc func changed(_ sender: NSSwitch) {
  334. handler(sender.state == .on)
  335. }
  336. }
  337. private final class FlippedSettingsDocumentView: NSView {
  338. override var isFlipped: Bool { true }
  339. }