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 let viewController = hostWindow?.contentViewController as? ViewController else { return } viewController.presentPrintContacts() } } // MARK: - Overlay final class PrintContactsOverlayView: NSView, AppearanceRefreshable { var onDismiss: (() -> Void)? 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() 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) { guard superview == nil else { return } 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 = 0 NSAnimationContext.runAnimationGroup { context in context.duration = 0.2 animator().alphaValue = 1 } window?.makeFirstResponder(searchField) } 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 Smart Printer 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 } }