| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421 |
- import Cocoa
- // MARK: - Root
- final class SettingsView: NSView, AppearanceRefreshable {
- init() {
- super.init(frame: .zero)
- wantsLayer = true
- layer?.backgroundColor = AppTheme.background.cgColor
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.background.cgColor
- }
- private func setup() {
- let scrollView = NSScrollView()
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.hasVerticalScroller = true
- scrollView.autohidesScrollers = true
- scrollView.drawsBackground = false
- scrollView.borderType = .noBorder
- let document = FlippedSettingsDocumentView()
- document.translatesAutoresizingMaskIntoConstraints = false
- let panel = ContentPanelView(cornerRadius: 22)
- panel.translatesAutoresizingMaskIntoConstraints = false
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.alignment = .leading
- stack.spacing = 28
- stack.translatesAutoresizingMaskIntoConstraints = false
- let appSection = makeSection(title: "App", card: makeAppCard())
- let aboutSection = makeSection(title: "About", card: makeAboutCard())
- stack.addArrangedSubview(appSection)
- stack.addArrangedSubview(aboutSection)
- [appSection, aboutSection].forEach { section in
- section.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- panel.addSubview(stack)
- document.addSubview(panel)
- scrollView.documentView = document
- addSubview(scrollView)
- let guide = scrollView.contentView
- NSLayoutConstraint.activate([
- scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
- scrollView.topAnchor.constraint(equalTo: topAnchor),
- scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
- document.topAnchor.constraint(equalTo: guide.topAnchor),
- document.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
- document.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
- document.widthAnchor.constraint(equalTo: guide.widthAnchor),
- panel.topAnchor.constraint(equalTo: document.topAnchor, constant: 8),
- panel.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
- panel.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
- panel.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -32),
- panel.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -48),
- stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- stack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 28),
- stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -28),
- stack.widthAnchor.constraint(equalTo: panel.widthAnchor, constant: -56),
- ])
- }
- private func makeSection(title: String, card: NSView) -> NSView {
- let container = NSStackView()
- container.orientation = .vertical
- container.alignment = .leading
- container.spacing = 12
- container.translatesAutoresizingMaskIntoConstraints = false
- let label = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 18))
- label.translatesAutoresizingMaskIntoConstraints = false
- container.addArrangedSubview(label)
- container.addArrangedSubview(card)
- card.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
- return container
- }
- private func makeAppCard() -> NSView {
- let card = SettingsGroupCard()
- card.addRow(SettingsActionRow(symbolName: "square.and.arrow.up", title: "Share App") {
- guard let url = Bundle.main.bundleURL as URL? else { return }
- let picker = NSSharingServicePicker(items: [url])
- if let view = NSApp.keyWindow?.contentView {
- picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
- }
- })
- card.addRow(SettingsToggleRow(
- symbolName: "moon.fill",
- title: "Dark Mode",
- isOn: AppSettings.darkModeEnabled,
- isLast: true
- ) { enabled in
- AppSettings.darkModeEnabled = enabled
- AppSettings.applyAppearance()
- })
- return card
- }
- private func makeAboutCard() -> NSView {
- let card = SettingsGroupCard()
- card.addRow(SettingsActionRow(symbolName: "link", title: "Website") {
- NSWorkspace.shared.open(URL(string: "https://example.com")!)
- })
- card.addRow(SettingsActionRow(symbolName: "questionmark.circle", title: "Support") {
- NSWorkspace.shared.open(URL(string: "mailto:support@example.com")!)
- })
- card.addRow(SettingsActionRow(symbolName: "doc.text", title: "Terms of Use") {
- NSWorkspace.shared.open(URL(string: "https://example.com/terms")!)
- })
- card.addRow(SettingsActionRow(symbolName: "shield", title: "Privacy Policy", isLast: true) {
- NSWorkspace.shared.open(URL(string: "https://example.com/privacy")!)
- })
- return card
- }
- }
- private final class SettingsGroupCard: NSView, AppearanceRefreshable {
- private let stack = NSStackView()
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 16
- layer?.borderWidth = 1.5
- layer?.masksToBounds = true
- refreshAppearance()
- stack.orientation = .vertical
- stack.spacing = 0
- stack.translatesAutoresizingMaskIntoConstraints = false
- addSubview(stack)
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: leadingAnchor),
- stack.trailingAnchor.constraint(equalTo: trailingAnchor),
- stack.topAnchor.constraint(equalTo: topAnchor),
- stack.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func addRow(_ row: NSView) {
- stack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.groupCardBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- }
- }
- // MARK: - Icon
- private final class SettingsIconBadge: NSView, AppearanceRefreshable {
- init(symbolName: String) {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 10
- refreshAppearance()
- let icon = NSImageView()
- icon.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
- icon.image = image.withSymbolConfiguration(config)
- }
- icon.contentTintColor = AppTheme.blue
- addSubview(icon)
- NSLayoutConstraint.activate([
- widthAnchor.constraint(equalToConstant: 36),
- heightAnchor.constraint(equalToConstant: 36),
- icon.centerXAnchor.constraint(equalTo: centerXAnchor),
- icon.centerYAnchor.constraint(equalTo: centerYAnchor),
- icon.widthAnchor.constraint(equalToConstant: 18),
- icon.heightAnchor.constraint(equalToConstant: 18),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.blueLight.cgColor
- }
- }
- // MARK: - Rows
- private class SettingsRowBase: NSView, AppearanceRefreshable {
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- private let isInteractive: Bool
- init(isLast: Bool, isInteractive: Bool = false) {
- self.isInteractive = isInteractive
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- heightAnchor.constraint(equalToConstant: 56).isActive = true
- if !isLast { addDivider() }
- if isInteractive {
- wantsLayer = true
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- updateHoverAppearance()
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- updateHoverAppearance()
- }
- private func updateHoverAppearance() {
- guard isInteractive else { return }
- animateHover {
- layer?.backgroundColor = (isHovered ? AppTheme.sidebarHoverBackground : .clear).cgColor
- }
- }
- func addDivider() {
- let divider = NSBox()
- divider.boxType = .separator
- divider.translatesAutoresizingMaskIntoConstraints = false
- addSubview(divider)
- NSLayoutConstraint.activate([
- divider.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 60),
- divider.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- divider.bottomAnchor.constraint(equalTo: bottomAnchor),
- divider.heightAnchor.constraint(equalToConstant: 1),
- ])
- }
- func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
- let badge = SettingsIconBadge(symbolName: symbolName)
- let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- trailing.translatesAutoresizingMaskIntoConstraints = false
- addSubview(badge)
- addSubview(titleLabel)
- addSubview(trailing)
- NSLayoutConstraint.activate([
- badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
- badge.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- trailing.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- trailing.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailing.leadingAnchor, constant: -12),
- ])
- return titleLabel
- }
- }
- private final class SettingsActionRow: SettingsRowBase {
- init(symbolName: String, title: String, isLast: Bool = false, action: @escaping () -> Void) {
- super.init(isLast: isLast, isInteractive: true)
- let badge = SettingsIconBadge(symbolName: symbolName)
- let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(badge)
- addSubview(titleLabel)
- NSLayoutConstraint.activate([
- badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
- badge.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
- ])
- let control = LinkControl(onActivate: action)
- control.translatesAutoresizingMaskIntoConstraints = false
- addSubview(control)
- NSLayoutConstraint.activate([
- control.leadingAnchor.constraint(equalTo: leadingAnchor),
- control.trailingAnchor.constraint(equalTo: trailingAnchor),
- control.topAnchor.constraint(equalTo: topAnchor),
- control.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- }
- private final class SettingsPopupRow: SettingsRowBase {
- private let popupTarget: PopupTarget
- init(symbolName: String, title: String, options: [String], selection: String, isLast: Bool = false, onChange: @escaping (String) -> Void) {
- popupTarget = PopupTarget(handler: onChange)
- super.init(isLast: isLast, isInteractive: true)
- let popup = NSPopUpButton()
- popup.bezelStyle = .rounded
- popup.addItems(withTitles: options)
- popup.selectItem(withTitle: selection)
- popup.font = AppTheme.regularFont(size: 13)
- popup.target = popupTarget
- popup.action = #selector(PopupTarget.changed(_:))
- _ = install(icon: symbolName, title: title, trailing: popup)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- }
- private final class SettingsToggleRow: SettingsRowBase {
- private let toggleTarget: ToggleTarget
- init(symbolName: String, title: String, isOn: Bool, isLast: Bool = false, onChange: @escaping (Bool) -> Void) {
- toggleTarget = ToggleTarget(handler: onChange)
- super.init(isLast: isLast, isInteractive: true)
- let toggle = NSSwitch()
- toggle.state = isOn ? .on : .off
- toggle.target = toggleTarget
- toggle.action = #selector(ToggleTarget.changed(_:))
- _ = install(icon: symbolName, title: title, trailing: toggle)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- }
- // MARK: - Helpers
- private final class LinkControl: NSControl {
- private let onActivate: () -> Void
- init(onActivate: @escaping () -> Void) {
- self.onActivate = onActivate
- super.init(frame: .zero)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onActivate()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class PopupTarget: NSObject {
- private let handler: (String) -> Void
- init(handler: @escaping (String) -> Void) {
- self.handler = handler
- }
- @objc func changed(_ sender: NSPopUpButton) {
- handler(sender.titleOfSelectedItem ?? "")
- }
- }
- private final class ToggleTarget: NSObject {
- private let handler: (Bool) -> Void
- init(handler: @escaping (Bool) -> Void) {
- self.handler = handler
- }
- @objc func changed(_ sender: NSSwitch) {
- handler(sender.state == .on)
- }
- }
- private final class FlippedSettingsDocumentView: NSView {
- override var isFlipped: Bool { true }
- }
|