| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952 |
- import Cocoa
- import Contacts
- // MARK: - Models
- struct PrintableContact: Equatable {
- let identifier: String
- let displayName: String
- let organization: String?
- let phoneNumbers: [String]
- let emailAddresses: [String]
- let postalAddresses: [String]
- var searchableText: String {
- ([displayName, organization] + phoneNumbers + emailAddresses + postalAddresses)
- .compactMap { $0 }
- .joined(separator: " ")
- .lowercased()
- }
- }
- private enum ContactSortOrder: String, CaseIterable {
- case nameAscending
- case nameDescending
- var title: String {
- switch self {
- case .nameAscending: "Names A to Z"
- case .nameDescending: "Names Z to A"
- }
- }
- func sort(_ contacts: [PrintableContact]) -> [PrintableContact] {
- switch self {
- case .nameAscending:
- contacts.sorted {
- $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
- }
- case .nameDescending:
- contacts.sorted {
- $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedDescending
- }
- }
- }
- }
- // MARK: - Contacts Service
- enum ContactsError: Error {
- case accessDenied
- }
- enum ContactsService {
- private static let store = CNContactStore()
- private static var cachedContacts: [PrintableContact]?
- static var isAuthorized: Bool {
- switch CNContactStore.authorizationStatus(for: .contacts) {
- case .authorized, .limited:
- return true
- default:
- return false
- }
- }
- @MainActor
- static func ensureAccess() async -> Bool {
- switch CNContactStore.authorizationStatus(for: .contacts) {
- case .authorized, .limited:
- return true
- case .notDetermined:
- do {
- return try await store.requestAccess(for: .contacts)
- } catch {
- return false
- }
- case .denied, .restricted:
- return false
- @unknown default:
- return false
- }
- }
- @MainActor
- static func loadContacts(forceRefresh: Bool = false) async throws -> [PrintableContact] {
- if !forceRefresh, let cachedContacts, isAuthorized {
- return cachedContacts
- }
- guard await ensureAccess() else {
- throw ContactsError.accessDenied
- }
- let contacts = try fetchContacts()
- cachedContacts = contacts
- return contacts
- }
- static func invalidateCache() {
- cachedContacts = nil
- }
- static func fetchContacts() throws -> [PrintableContact] {
- let keys: [CNKeyDescriptor] = [
- CNContactIdentifierKey as CNKeyDescriptor,
- CNContactGivenNameKey as CNKeyDescriptor,
- CNContactFamilyNameKey as CNKeyDescriptor,
- CNContactOrganizationNameKey as CNKeyDescriptor,
- CNContactPhoneNumbersKey as CNKeyDescriptor,
- CNContactEmailAddressesKey as CNKeyDescriptor,
- CNContactPostalAddressesKey as CNKeyDescriptor,
- ]
- let request = CNContactFetchRequest(keysToFetch: keys)
- request.sortOrder = .givenName
- var contacts: [PrintableContact] = []
- try store.enumerateContacts(with: request) { contact, _ in
- let name = CNContactFormatter.string(from: contact, style: .fullName)?
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- let organization = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines)
- let displayName: String
- if !name.isEmpty {
- displayName = name
- } else if !organization.isEmpty {
- displayName = organization
- } else {
- displayName = "Unnamed Contact"
- }
- let phones = contact.phoneNumbers.map(\.value.stringValue).filter { !$0.isEmpty }
- let emails = contact.emailAddresses.map { $0.value as String }.filter { !$0.isEmpty }
- let addresses = contact.postalAddresses.map { entry in
- CNPostalAddressFormatter.string(from: entry.value, style: .mailingAddress)
- }.filter { !$0.isEmpty }
- contacts.append(
- PrintableContact(
- identifier: contact.identifier,
- displayName: displayName,
- organization: organization.isEmpty ? nil : organization,
- phoneNumbers: phones,
- emailAddresses: emails,
- postalAddresses: addresses
- )
- )
- }
- return contacts
- }
- }
- // MARK: - Print Contacts Service
- enum PrintContactsService {
- static func present(from window: NSWindow?) {
- let hostWindow = window ?? NSApp.keyWindow
- guard PremiumAccess.require(feature: .printContacts, from: hostWindow) else { return }
- guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
- viewController.presentPrintContacts()
- }
- }
- // MARK: - Overlay
- final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
- var onDismiss: (() -> Void)?
- override var mouseDownCanMoveWindow: Bool { false }
- private let backButton = PrintContactsToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
- private let sortButton = PrintContactsSortButton()
- private let searchContainer = NSView()
- private let searchIconView = NSImageView()
- private let searchField = NSTextField()
- private let scrollView = NSScrollView()
- private let listDocumentView = FlippedDocumentView()
- private let listStack = NSStackView()
- private let selectAllRow = PrintContactsSelectAllRow()
- private let printButton = PrintContactsPrintButton()
- private let emptyLabel = NSTextField(labelWithString: "No contacts found")
- private let horizontalInset: CGFloat = 16
- private let rowContentInset: CGFloat = 16
- private let checkboxNameSpacing: CGFloat = 12
- private let searchHeight: CGFloat = 44
- private let contactRowHeight: CGFloat = 52
- private var allContacts: [PrintableContact] = []
- private var sortOrder: ContactSortOrder = .nameAscending
- private var selectedIDs = Set<String>()
- private var rowViews: [ContactRowView] = []
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- refreshAppearance()
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(appearanceDidChange),
- name: .appearanceDidChange,
- object: nil
- )
- loadContacts()
- }
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
- @objc private func appearanceDidChange() {
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.background.cgColor
- searchContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
- searchContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
- searchField.textColor = AppTheme.textPrimary
- searchField.backgroundColor = .clear
- searchIconView.contentTintColor = AppTheme.textSecondary
- emptyLabel.textColor = AppTheme.textSecondary
- backButton.refreshAppearance()
- sortButton.refreshAppearance()
- selectAllRow.refreshAppearance()
- printButton.refreshAppearance()
- rowViews.forEach { $0.refreshAppearance() }
- }
- func present(in parent: NSView) {
- if superview == nil {
- parent.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: parent.leadingAnchor),
- trailingAnchor.constraint(equalTo: parent.trailingAnchor),
- topAnchor.constraint(equalTo: parent.topAnchor),
- bottomAnchor.constraint(equalTo: parent.bottomAnchor),
- ])
- alphaValue = 1
- } else {
- parent.addSubview(self, positioned: .above, relativeTo: nil)
- }
- window?.makeFirstResponder(searchField)
- }
- override func hitTest(_ point: NSPoint) -> NSView? {
- let hit = super.hitTest(point)
- if hit != nil { return hit }
- return bounds.contains(point) ? self : nil
- }
- func dismiss(animated: Bool = true) {
- let remove = { [weak self] in
- self?.removeFromSuperview()
- self?.onDismiss?()
- }
- guard animated else {
- remove()
- return
- }
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.15
- animator().alphaValue = 0
- }, completionHandler: remove)
- }
- private func setup() {
- wantsLayer = true
- searchContainer.wantsLayer = true
- searchContainer.layer?.cornerRadius = searchHeight / 2
- searchContainer.layer?.borderWidth = 1
- searchContainer.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search") {
- let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
- searchIconView.image = image.withSymbolConfiguration(config)
- }
- searchIconView.translatesAutoresizingMaskIntoConstraints = false
- searchField.placeholderAttributedString = NSAttributedString(
- string: "Search",
- attributes: [
- .foregroundColor: AppTheme.textSecondary,
- .font: AppTheme.regularFont(size: 15),
- ]
- )
- searchField.font = AppTheme.regularFont(size: 15)
- searchField.isBordered = false
- searchField.isBezeled = false
- searchField.drawsBackground = false
- searchField.focusRingType = .none
- searchField.translatesAutoresizingMaskIntoConstraints = false
- searchField.delegate = self
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.hasVerticalScroller = true
- scrollView.hasHorizontalScroller = false
- scrollView.autohidesScrollers = true
- scrollView.borderType = .noBorder
- scrollView.drawsBackground = false
- listStack.orientation = .vertical
- listStack.alignment = .leading
- listStack.spacing = 12
- listStack.translatesAutoresizingMaskIntoConstraints = false
- listDocumentView.translatesAutoresizingMaskIntoConstraints = false
- listDocumentView.addSubview(listStack)
- emptyLabel.font = AppTheme.regularFont(size: 14)
- emptyLabel.alignment = .center
- emptyLabel.isHidden = true
- emptyLabel.translatesAutoresizingMaskIntoConstraints = false
- scrollView.documentView = listDocumentView
- backButton.translatesAutoresizingMaskIntoConstraints = false
- sortButton.translatesAutoresizingMaskIntoConstraints = false
- printButton.translatesAutoresizingMaskIntoConstraints = false
- backButton.onClick = { [weak self] in self?.dismiss() }
- sortButton.sortOrder = sortOrder
- sortButton.onSortChange = { [weak self] order in
- self?.sortOrder = order
- self?.reloadList()
- }
- selectAllRow.applyLayout(contentInset: rowContentInset, checkboxNameSpacing: checkboxNameSpacing)
- selectAllRow.onClick = { [weak self] in self?.toggleSelectAll() }
- printButton.onClick = { [weak self] in self?.printContacts() }
- addSubview(backButton)
- addSubview(sortButton)
- addSubview(searchContainer)
- searchContainer.addSubview(searchIconView)
- searchContainer.addSubview(searchField)
- addSubview(selectAllRow)
- addSubview(scrollView)
- addSubview(emptyLabel)
- addSubview(printButton)
- NSLayoutConstraint.activate([
- backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
- backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
- backButton.widthAnchor.constraint(equalToConstant: 40),
- backButton.heightAnchor.constraint(equalToConstant: 40),
- searchContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
- searchContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
- searchContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
- searchContainer.heightAnchor.constraint(equalToConstant: searchHeight),
- searchIconView.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 16),
- searchIconView.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
- searchIconView.widthAnchor.constraint(equalToConstant: 16),
- searchIconView.heightAnchor.constraint(equalToConstant: 16),
- searchField.leadingAnchor.constraint(equalTo: searchIconView.trailingAnchor, constant: 10),
- searchField.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -16),
- searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
- selectAllRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
- selectAllRow.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 12),
- selectAllRow.trailingAnchor.constraint(lessThanOrEqualTo: sortButton.leadingAnchor, constant: -8),
- selectAllRow.heightAnchor.constraint(equalToConstant: 36),
- sortButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
- sortButton.centerYAnchor.constraint(equalTo: selectAllRow.centerYAnchor),
- sortButton.widthAnchor.constraint(equalToConstant: 40),
- sortButton.heightAnchor.constraint(equalToConstant: 40),
- scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
- scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
- scrollView.topAnchor.constraint(equalTo: selectAllRow.bottomAnchor, constant: 8),
- scrollView.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
- listDocumentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
- listDocumentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
- listDocumentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
- listDocumentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
- listStack.leadingAnchor.constraint(equalTo: listDocumentView.leadingAnchor),
- listStack.trailingAnchor.constraint(equalTo: listDocumentView.trailingAnchor),
- listStack.topAnchor.constraint(equalTo: listDocumentView.topAnchor),
- listStack.bottomAnchor.constraint(equalTo: listDocumentView.bottomAnchor),
- emptyLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
- emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
- printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
- printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
- printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
- ])
- }
- private func loadContacts() {
- Task { @MainActor in
- do {
- allContacts = try await ContactsService.loadContacts()
- selectedIDs = Set(allContacts.map(\.identifier))
- reloadList()
- } catch ContactsError.accessDenied {
- showAccessDeniedAlert()
- } catch {
- showLoadFailedAlert()
- }
- }
- }
- private func filteredContacts() -> [PrintableContact] {
- let query = searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- let contacts: [PrintableContact]
- if query.isEmpty {
- contacts = allContacts
- } else {
- contacts = allContacts.filter { $0.searchableText.contains(query) }
- }
- return sortOrder.sort(contacts)
- }
- private func reloadList() {
- rowViews.forEach { $0.removeFromSuperview() }
- rowViews.removeAll()
- let contacts = filteredContacts()
- emptyLabel.isHidden = !contacts.isEmpty
- for contact in contacts {
- let row = ContactRowView(
- name: contact.displayName,
- isSelected: selectedIDs.contains(contact.identifier),
- rowHeight: contactRowHeight,
- contentInset: rowContentInset,
- checkboxNameSpacing: checkboxNameSpacing
- )
- row.onToggle = { [weak self] in
- self?.toggleSelection(for: contact.identifier)
- }
- rowViews.append(row)
- listStack.addArrangedSubview(row)
- row.leadingAnchor.constraint(equalTo: listStack.leadingAnchor).isActive = true
- row.trailingAnchor.constraint(equalTo: listStack.trailingAnchor).isActive = true
- }
- updateSelectAllRow()
- }
- private func selectAllState() -> ContactSelectionState {
- let contacts = filteredContacts()
- guard !contacts.isEmpty else { return .unchecked }
- let selectedCount = contacts.filter { selectedIDs.contains($0.identifier) }.count
- if selectedCount == 0 { return .unchecked }
- if selectedCount == contacts.count { return .checked }
- return .indeterminate
- }
- private func updateSelectAllRow() {
- let contacts = filteredContacts()
- selectAllRow.isHidden = contacts.isEmpty
- selectAllRow.selectionState = selectAllState()
- }
- private func toggleSelectAll() {
- let contacts = filteredContacts()
- switch selectAllState() {
- case .checked, .indeterminate:
- contacts.forEach { selectedIDs.remove($0.identifier) }
- case .unchecked:
- contacts.forEach { selectedIDs.insert($0.identifier) }
- }
- for (row, contact) in zip(rowViews, contacts) {
- row.setSelected(selectedIDs.contains(contact.identifier))
- }
- updateSelectAllRow()
- }
- private func toggleSelection(for identifier: String) {
- if selectedIDs.contains(identifier) {
- selectedIDs.remove(identifier)
- } else {
- selectedIDs.insert(identifier)
- }
- let contacts = filteredContacts()
- for (row, contact) in zip(rowViews, contacts) where contact.identifier == identifier {
- row.setSelected(selectedIDs.contains(identifier))
- break
- }
- updateSelectAllRow()
- }
- private func printContacts() {
- let contacts = filteredContacts().filter { selectedIDs.contains($0.identifier) }
- PrintService.printContacts(contacts, from: window)
- }
- private func showAccessDeniedAlert() {
- let alert = NSAlert()
- alert.messageText = "Contacts Access Required"
- alert.informativeText = "Allow \(AppStoreConfig.displayName) to access your contacts in System Settings to print them."
- alert.alertStyle = .warning
- alert.addButton(withTitle: "Open Settings")
- alert.addButton(withTitle: "Cancel")
- if alert.runModal() == .alertFirstButtonReturn {
- if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts") {
- NSWorkspace.shared.open(url)
- }
- }
- }
- private func showLoadFailedAlert() {
- let alert = NSAlert()
- alert.messageText = "Could Not Load Contacts"
- alert.informativeText = "Your contacts could not be read. Try again after granting access."
- alert.alertStyle = .warning
- alert.addButton(withTitle: "OK")
- alert.runModal()
- }
- }
- // MARK: - Selection Checkbox
- private enum ContactSelectionState {
- case unchecked
- case checked
- case indeterminate
- var symbolName: String {
- switch self {
- case .unchecked: "square"
- case .checked: "checkmark.square.fill"
- case .indeterminate: "minus.square.fill"
- }
- }
- var accessibilityDescription: String {
- switch self {
- case .unchecked: "Not selected"
- case .checked: "Selected"
- case .indeterminate: "Partially selected"
- }
- }
- }
- private final class ContactCheckboxView: NSView, AppearanceRefreshable {
- var state: ContactSelectionState = .unchecked {
- didSet { refreshAppearance() }
- }
- private let iconView = NSImageView()
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.leadingAnchor.constraint(equalTo: leadingAnchor),
- iconView.trailingAnchor.constraint(equalTo: trailingAnchor),
- iconView.topAnchor.constraint(equalTo: topAnchor),
- iconView.bottomAnchor.constraint(equalTo: bottomAnchor),
- widthAnchor.constraint(equalToConstant: 20),
- heightAnchor.constraint(equalToConstant: 20),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- if let image = NSImage(
- systemSymbolName: state.symbolName,
- accessibilityDescription: state.accessibilityDescription
- ) {
- let weight: NSFont.Weight = state == .unchecked ? .regular : .semibold
- let config = NSImage.SymbolConfiguration(pointSize: 20, weight: weight)
- iconView.image = image.withSymbolConfiguration(config)
- }
- switch state {
- case .unchecked:
- iconView.contentTintColor = AppTheme.textSecondary
- case .checked, .indeterminate:
- iconView.contentTintColor = AppTheme.blue
- }
- }
- }
- // MARK: - Contact Row
- private final class ContactRowView: NSControl, AppearanceRefreshable {
- var onToggle: (() -> Void)?
- private let checkboxView = ContactCheckboxView()
- private let nameLabel = NSTextField(labelWithString: "")
- private var isSelected = false
- private let rowHeight: CGFloat
- init(
- name: String,
- isSelected: Bool,
- rowHeight: CGFloat,
- contentInset: CGFloat,
- checkboxNameSpacing: CGFloat
- ) {
- self.isSelected = isSelected
- self.rowHeight = rowHeight
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = rowHeight / 2
- nameLabel.stringValue = name
- nameLabel.font = AppTheme.semiboldFont(size: 16)
- nameLabel.lineBreakMode = .byTruncatingTail
- nameLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(checkboxView)
- addSubview(nameLabel)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: rowHeight),
- checkboxView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: contentInset),
- checkboxView.centerYAnchor.constraint(equalTo: centerYAnchor),
- nameLabel.leadingAnchor.constraint(equalTo: checkboxView.trailingAnchor, constant: checkboxNameSpacing),
- nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -contentInset),
- nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func setSelected(_ selected: Bool) {
- isSelected = selected
- refreshAppearance()
- }
- func refreshAppearance() {
- checkboxView.state = isSelected ? .checked : .unchecked
- if isSelected {
- layer?.borderWidth = 0
- layer?.backgroundColor = AppTheme.homeActiveBackground.cgColor
- nameLabel.textColor = AppTheme.homeActiveForeground
- } else {
- layer?.borderWidth = 1
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.backgroundColor = AppTheme.cardBackground.cgColor
- nameLabel.textColor = AppTheme.textPrimary
- }
- checkboxView.refreshAppearance()
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onToggle?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Search Delegate
- extension PrintContactsOverlayView: NSTextFieldDelegate {
- func controlTextDidChange(_ obj: Notification) {
- reloadList()
- }
- }
- // MARK: - Select All Row
- private final class PrintContactsSelectAllRow: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let checkboxView = ContactCheckboxView()
- private let titleLabel = NSTextField(labelWithString: "Select all")
- private var contentInset: CGFloat = 16
- private var checkboxNameSpacing: CGFloat = 12
- private var leadingConstraint: NSLayoutConstraint?
- private var titleLeadingConstraint: NSLayoutConstraint?
- var selectionState: ContactSelectionState {
- get { checkboxView.state }
- set { checkboxView.state = newValue }
- }
- func applyLayout(contentInset: CGFloat, checkboxNameSpacing: CGFloat) {
- self.contentInset = contentInset
- self.checkboxNameSpacing = checkboxNameSpacing
- leadingConstraint?.constant = contentInset
- titleLeadingConstraint?.constant = checkboxNameSpacing
- }
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- titleLabel.font = AppTheme.regularFont(size: 15)
- titleLabel.isSelectable = false
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(checkboxView)
- addSubview(titleLabel)
- let leading = checkboxView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: contentInset)
- let titleLeading = titleLabel.leadingAnchor.constraint(equalTo: checkboxView.trailingAnchor, constant: checkboxNameSpacing)
- leadingConstraint = leading
- titleLeadingConstraint = titleLeading
- NSLayoutConstraint.activate([
- leading,
- checkboxView.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLeading,
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- titleLabel.textColor = AppTheme.textPrimary
- checkboxView.refreshAppearance()
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Sort Button
- private final class PrintContactsSortButton: NSControl, AppearanceRefreshable {
- var sortOrder: ContactSortOrder = .nameAscending {
- didSet { rebuildMenu() }
- }
- var onSortChange: ((ContactSortOrder) -> Void)?
- private let iconView = NSImageView()
- private let sortMenu = NSMenu()
- init() {
- super.init(frame: .zero)
- toolTip = "Sort"
- wantsLayer = true
- layer?.cornerRadius = 12
- if let image = NSImage(systemSymbolName: "arrow.up.arrow.down", accessibilityDescription: "Sort") {
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- rebuildMenu()
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1.5
- iconView.contentTintColor = AppTheme.textPrimary
- }
- private func rebuildMenu() {
- sortMenu.removeAllItems()
- for order in ContactSortOrder.allCases {
- let item = NSMenuItem(title: order.title, action: #selector(menuItemSelected(_:)), keyEquivalent: "")
- item.target = self
- item.representedObject = order.rawValue
- item.state = order == sortOrder ? .on : .off
- sortMenu.addItem(item)
- }
- }
- @objc private func menuItemSelected(_ sender: NSMenuItem) {
- guard let raw = sender.representedObject as? String,
- let order = ContactSortOrder(rawValue: raw) else { return }
- sortOrder = order
- rebuildMenu()
- onSortChange?(order)
- }
- override func mouseDown(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- rebuildMenu()
- sortMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: bounds.height + 4), in: self)
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Toolbar Button
- private final class PrintContactsToolbarButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let symbolName: String
- private let iconView = NSImageView()
- init(symbolName: String, accessibilityLabel: String) {
- self.symbolName = symbolName
- super.init(frame: .zero)
- toolTip = accessibilityLabel
- wantsLayer = true
- layer?.cornerRadius = 12
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1.5
- iconView.contentTintColor = AppTheme.textPrimary
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Print Button
- private final class PrintContactsPrintButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let titleLabel = NSTextField(labelWithString: "Print Contacts")
- private let iconView = NSImageView()
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- init() {
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = 22
- titleLabel.font = AppTheme.semiboldFont(size: 16)
- titleLabel.textColor = .white
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
- let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.contentTintColor = .white
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(iconView)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 52),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
- iconView.widthAnchor.constraint(equalToConstant: 20),
- iconView.heightAnchor.constraint(equalToConstant: 20),
- ])
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- let color = isHovered
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- titleLabel.textColor = .white
- iconView.contentTintColor = .white
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- animateHover {
- let color = hovering
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.03, 1.03, 1)
- : CATransform3DIdentity
- }
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Flipped Document View
- private final class FlippedDocumentView: NSView {
- override var isFlipped: Bool { true }
- }
|