Quellcode durchsuchen

Redesign print contacts selection with Google-style left checkboxes.

Adds a select-all row with partial-selection state and aligns checkboxes with contact rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 vor 1 Monat
Ursprung
Commit
d7043fd4fe
1 geänderte Dateien mit 198 neuen und 21 gelöschten Zeilen
  1. 198 21
      smart_printer/PrintContactsView.swift

+ 198 - 21
smart_printer/PrintContactsView.swift

@@ -147,10 +147,13 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
     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
 
@@ -192,6 +195,7 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
         searchIconView.contentTintColor = AppTheme.textSecondary
         emptyLabel.textColor = AppTheme.textSecondary
         backButton.refreshAppearance()
+        selectAllRow.refreshAppearance()
         printButton.refreshAppearance()
         rowViews.forEach { $0.refreshAppearance() }
     }
@@ -283,12 +287,15 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
         printButton.translatesAutoresizingMaskIntoConstraints = false
 
         backButton.onClick = { [weak self] in self?.dismiss() }
+        selectAllRow.applyLayout(contentInset: rowContentInset, checkboxNameSpacing: checkboxNameSpacing)
+        selectAllRow.onClick = { [weak self] in self?.toggleSelectAll() }
         printButton.onClick = { [weak self] in self?.printContacts() }
 
         addSubview(backButton)
         addSubview(searchContainer)
         searchContainer.addSubview(searchIconView)
         searchContainer.addSubview(searchField)
+        addSubview(selectAllRow)
         addSubview(scrollView)
         addSubview(emptyLabel)
         addSubview(printButton)
@@ -313,9 +320,14 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
             searchField.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -16),
             searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
 
+            selectAllRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
+            selectAllRow.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
+            selectAllRow.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 12),
+            selectAllRow.heightAnchor.constraint(equalToConstant: 36),
+
             scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
             scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
-            scrollView.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 16),
+            scrollView.topAnchor.constraint(equalTo: selectAllRow.bottomAnchor, constant: 8),
             scrollView.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
 
             listDocumentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
@@ -368,7 +380,9 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
             let row = ContactRowView(
                 name: contact.displayName,
                 isSelected: selectedIDs.contains(contact.identifier),
-                rowHeight: contactRowHeight
+                rowHeight: contactRowHeight,
+                contentInset: rowContentInset,
+                checkboxNameSpacing: checkboxNameSpacing
             )
             row.onToggle = { [weak self] in
                 self?.toggleSelection(for: contact.identifier)
@@ -378,6 +392,36 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
             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) {
@@ -391,6 +435,7 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
             row.setSelected(selectedIDs.contains(identifier))
             break
         }
+        updateSelectAllRow()
     }
 
     private func printContacts() {
@@ -422,17 +467,91 @@ final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
     }
 }
 
+// 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 let checkmarkView = NSImageView()
     private var isSelected = false
     private let rowHeight: CGFloat
 
-    init(name: String, isSelected: Bool, rowHeight: CGFloat) {
+    init(
+        name: String,
+        isSelected: Bool,
+        rowHeight: CGFloat,
+        contentInset: CGFloat,
+        checkboxNameSpacing: CGFloat
+    ) {
         self.isSelected = isSelected
         self.rowHeight = rowHeight
         super.init(frame: .zero)
@@ -444,19 +563,15 @@ private final class ContactRowView: NSControl, AppearanceRefreshable {
         nameLabel.lineBreakMode = .byTruncatingTail
         nameLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        checkmarkView.translatesAutoresizingMaskIntoConstraints = false
-
+        addSubview(checkboxView)
         addSubview(nameLabel)
-        addSubview(checkmarkView)
         NSLayoutConstraint.activate([
             heightAnchor.constraint(equalToConstant: rowHeight),
-            nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
-            nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: checkmarkView.leadingAnchor, constant: -12),
+            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),
-            checkmarkView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
-            checkmarkView.centerYAnchor.constraint(equalTo: centerYAnchor),
-            checkmarkView.widthAnchor.constraint(equalToConstant: 22),
-            checkmarkView.heightAnchor.constraint(equalToConstant: 22),
         ])
         refreshAppearance()
     }
@@ -470,25 +585,19 @@ private final class ContactRowView: NSControl, AppearanceRefreshable {
     }
 
     func refreshAppearance() {
-        let symbolName = isSelected ? "checkmark.circle.fill" : "circle"
-        let symbolWeight: NSFont.Weight = isSelected ? .semibold : .regular
-        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: isSelected ? "Selected" : "Not selected") {
-            let config = NSImage.SymbolConfiguration(pointSize: 20, weight: symbolWeight)
-            checkmarkView.image = image.withSymbolConfiguration(config)
-        }
+        checkboxView.state = isSelected ? .checked : .unchecked
 
         if isSelected {
             layer?.borderWidth = 0
             layer?.backgroundColor = AppTheme.homeActiveBackground.cgColor
             nameLabel.textColor = AppTheme.homeActiveForeground
-            checkmarkView.contentTintColor = AppTheme.homeActiveForeground
         } else {
             layer?.borderWidth = 1
             layer?.borderColor = AppTheme.paywallBorder.cgColor
             layer?.backgroundColor = AppTheme.cardBackground.cgColor
             nameLabel.textColor = AppTheme.textPrimary
-            checkmarkView.contentTintColor = AppTheme.textSecondary
         }
+        checkboxView.refreshAppearance()
     }
 
     override func mouseUp(with event: NSEvent) {
@@ -509,6 +618,74 @@ extension PrintContactsOverlayView: NSTextFieldDelegate {
     }
 }
 
+// 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: - Toolbar Button
 
 private final class PrintContactsToolbarButton: NSControl, AppearanceRefreshable {