Parcourir la Source

Add Quick Start pickers for photos, files, and imports.

Wire the gallery, file manager, and import cards to dedicated pickers with type filtering, printing, and imported file storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 il y a 4 heures
Parent
commit
7c1fe4003b

+ 188 - 0
smart_printer/DocumentPickerService.swift

@@ -0,0 +1,188 @@
1
+import Cocoa
2
+import UniformTypeIdentifiers
3
+
4
+enum DocumentPickerKind {
5
+    case photos
6
+    case files
7
+    case importFile
8
+}
9
+
10
+enum DocumentPickerService {
11
+    private static var activeDelegate: DocumentPickerPanelDelegate?
12
+
13
+    private static let photoExtensions: Set<String> = [
14
+        "jpg", "jpeg", "png", "heic", "heif", "gif", "tiff", "tif", "bmp", "webp", "raw", "svg",
15
+    ]
16
+
17
+    private static let documentExtensions: Set<String> = [
18
+        "pdf", "txt", "rtf", "csv", "xml", "html", "htm", "json", "md",
19
+        "doc", "docx", "xls", "xlsx", "ppt", "pptx", "pages", "numbers", "key",
20
+    ]
21
+
22
+    static func present(_ kind: DocumentPickerKind, from window: NSWindow?) {
23
+        let panel = NSOpenPanel()
24
+        panel.canChooseDirectories = false
25
+        panel.canChooseFiles = true
26
+        panel.allowsOtherFileTypes = false
27
+
28
+        switch kind {
29
+        case .photos:
30
+            panel.title = "Open Gallery"
31
+            panel.message = "Select photos from your gallery."
32
+            panel.prompt = "Select Photos"
33
+            panel.allowsMultipleSelection = true
34
+            panel.allowedContentTypes = photoContentTypes
35
+            panel.directoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
36
+        case .files:
37
+            panel.title = "Open File Manager"
38
+            panel.message = "Select photos or files from your file manager."
39
+            panel.prompt = "Select"
40
+            panel.allowsMultipleSelection = true
41
+            panel.allowedContentTypes = fileManagerContentTypes
42
+            panel.directoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
43
+        case .importFile:
44
+            panel.title = "Import Files"
45
+            panel.message = "Add files from storage."
46
+            panel.prompt = "Add Files"
47
+            panel.allowsMultipleSelection = true
48
+            panel.allowedContentTypes = documentContentTypes
49
+            panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
50
+        }
51
+
52
+        let delegate = DocumentPickerPanelDelegate(kind: kind)
53
+        activeDelegate = delegate
54
+        panel.delegate = delegate
55
+
56
+        let completion: (NSApplication.ModalResponse) -> Void = { response in
57
+            defer { activeDelegate = nil }
58
+            guard response == .OK else { return }
59
+            handleSelection(panel.urls, kind: kind)
60
+        }
61
+
62
+        if let window = window ?? NSApp.keyWindow {
63
+            panel.beginSheetModal(for: window, completionHandler: completion)
64
+        } else if panel.runModal() == .OK {
65
+            completion(.OK)
66
+        } else {
67
+            activeDelegate = nil
68
+        }
69
+    }
70
+
71
+    private static var photoContentTypes: [UTType] {
72
+        photoExtensions.compactMap { UTType(filenameExtension: $0) }
73
+    }
74
+
75
+    private static var documentContentTypes: [UTType] {
76
+        documentExtensions.compactMap { UTType(filenameExtension: $0) }
77
+    }
78
+
79
+    private static var fileManagerContentTypes: [UTType] {
80
+        photoContentTypes + documentContentTypes
81
+    }
82
+
83
+    fileprivate static func isPhoto(_ url: URL) -> Bool {
84
+        photoExtensions.contains(url.pathExtension.lowercased())
85
+    }
86
+
87
+    fileprivate static func isDocument(_ url: URL) -> Bool {
88
+        let ext = url.pathExtension.lowercased()
89
+        guard documentExtensions.contains(ext) else { return false }
90
+        if let type = contentType(for: url), type.conforms(to: .image) { return false }
91
+        return true
92
+    }
93
+
94
+    fileprivate static func isSelectableInFileManager(_ url: URL) -> Bool {
95
+        isPhoto(url) || isDocument(url)
96
+    }
97
+
98
+    fileprivate static func contentType(for url: URL) -> UTType? {
99
+        if let values = try? url.resourceValues(forKeys: [.contentTypeKey]),
100
+           let type = values.contentType {
101
+            return type
102
+        }
103
+        let ext = url.pathExtension.lowercased()
104
+        guard !ext.isEmpty else { return nil }
105
+        return UTType(filenameExtension: ext)
106
+    }
107
+
108
+    private static func handleSelection(_ urls: [URL], kind: DocumentPickerKind) {
109
+        let matched: [URL]
110
+        switch kind {
111
+        case .photos:
112
+            matched = urls.filter { isPhoto($0) }
113
+        case .files:
114
+            matched = urls.filter { isSelectableInFileManager($0) }
115
+        case .importFile:
116
+            matched = urls.filter { isDocument($0) }
117
+        }
118
+
119
+        guard !matched.isEmpty else {
120
+            showNoMatchingFilesAlert(for: kind)
121
+            return
122
+        }
123
+
124
+        switch kind {
125
+        case .photos, .files:
126
+            PrintService.print(urls: matched)
127
+        case .importFile:
128
+            let added = ImportedFilesStore.add(urls: matched)
129
+            showImportSuccessAlert(count: added > 0 ? added : matched.count)
130
+        }
131
+    }
132
+
133
+    private static func showImportSuccessAlert(count: Int) {
134
+        let alert = NSAlert()
135
+        alert.messageText = "Files Added"
136
+        alert.informativeText = count == 1
137
+            ? "1 file was added successfully."
138
+            : "\(count) files were added successfully."
139
+        alert.alertStyle = .informational
140
+        alert.addButton(withTitle: "OK")
141
+        alert.runModal()
142
+    }
143
+
144
+    private static func showNoMatchingFilesAlert(for kind: DocumentPickerKind) {
145
+        let alert = NSAlert()
146
+        switch kind {
147
+        case .photos:
148
+            alert.messageText = "No Photos Selected"
149
+            alert.informativeText = "Please select picture files such as JPEG, PNG, or HEIC."
150
+        case .files:
151
+            alert.messageText = "No Photos or Files Selected"
152
+            alert.informativeText = "Please select pictures or document files such as JPEG, PNG, PDF, or DOC."
153
+        case .importFile:
154
+            alert.messageText = "No Files to Add"
155
+            alert.informativeText = "Please select files such as PDF, TXT, or DOC to import."
156
+        }
157
+        alert.alertStyle = .warning
158
+        alert.addButton(withTitle: "OK")
159
+        alert.runModal()
160
+    }
161
+}
162
+
163
+private final class DocumentPickerPanelDelegate: NSObject, NSOpenSavePanelDelegate {
164
+    private let kind: DocumentPickerKind
165
+
166
+    init(kind: DocumentPickerKind) {
167
+        self.kind = kind
168
+    }
169
+
170
+    func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
171
+        guard let panel = sender as? NSOpenPanel else { return false }
172
+
173
+        var isDirectory: ObjCBool = false
174
+        if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
175
+           isDirectory.boolValue {
176
+            return panel.canChooseDirectories
177
+        }
178
+
179
+        switch kind {
180
+        case .photos:
181
+            return DocumentPickerService.isPhoto(url)
182
+        case .files:
183
+            return DocumentPickerService.isSelectableInFileManager(url)
184
+        case .importFile:
185
+            return DocumentPickerService.isDocument(url)
186
+        }
187
+    }
188
+}

+ 60 - 0
smart_printer/ImportedFilesStore.swift

@@ -0,0 +1,60 @@
1
+import Cocoa
2
+
3
+enum ImportedFilesStore {
4
+    private static let bookmarksKey = "importedFileBookmarks"
5
+    private static let defaults = UserDefaults.standard
6
+
7
+    static var fileURLs: [URL] {
8
+        guard let bookmarks = defaults.array(forKey: bookmarksKey) as? [Data] else { return [] }
9
+        return bookmarks.compactMap { resolveBookmark($0) }
10
+    }
11
+
12
+    static func add(urls: [URL]) -> Int {
13
+        guard AppSettings.saveRecentFiles else { return urls.count }
14
+
15
+        var bookmarks = defaults.array(forKey: bookmarksKey) as? [Data] ?? []
16
+        var added = 0
17
+
18
+        for url in urls {
19
+            let accessed = url.startAccessingSecurityScopedResource()
20
+            defer {
21
+                if accessed { url.stopAccessingSecurityScopedResource() }
22
+            }
23
+
24
+            guard let bookmark = try? url.bookmarkData(
25
+                options: .withSecurityScope,
26
+                includingResourceValuesForKeys: nil,
27
+                relativeTo: nil
28
+            ) else { continue }
29
+
30
+            if !bookmarks.contains(bookmark) {
31
+                bookmarks.append(bookmark)
32
+                added += 1
33
+            }
34
+        }
35
+
36
+        defaults.set(bookmarks, forKey: bookmarksKey)
37
+        return added
38
+    }
39
+
40
+    private static func resolveBookmark(_ data: Data) -> URL? {
41
+        var isStale = false
42
+        guard let url = try? URL(
43
+            resolvingBookmarkData: data,
44
+            options: .withSecurityScope,
45
+            relativeTo: nil,
46
+            bookmarkDataIsStale: &isStale
47
+        ) else { return nil }
48
+
49
+        if isStale {
50
+            remove(url)
51
+        }
52
+        return url
53
+    }
54
+
55
+    private static func remove(_ url: URL) {
56
+        guard var bookmarks = defaults.array(forKey: bookmarksKey) as? [Data] else { return }
57
+        bookmarks.removeAll { resolveBookmark($0)?.path == url.path }
58
+        defaults.set(bookmarks, forKey: bookmarksKey)
59
+    }
60
+}

+ 93 - 0
smart_printer/PrintService.swift

@@ -0,0 +1,93 @@
1
+import Cocoa
2
+import PDFKit
3
+import UniformTypeIdentifiers
4
+
5
+enum PrintService {
6
+    static func print(urls: [URL]) {
7
+        guard !urls.isEmpty else { return }
8
+
9
+        for url in urls {
10
+            let accessed = url.startAccessingSecurityScopedResource()
11
+            defer {
12
+                if accessed { url.stopAccessingSecurityScopedResource() }
13
+            }
14
+            printFile(at: url)
15
+        }
16
+    }
17
+
18
+    private static func printFile(at url: URL) {
19
+        let type = UTType(filenameExtension: url.pathExtension) ?? .data
20
+
21
+        if type.conforms(to: .pdf), let document = PDFDocument(url: url) {
22
+            printPDF(document)
23
+        } else if type.conforms(to: .image), let image = NSImage(contentsOf: url) {
24
+            printImage(image)
25
+        } else if type.conforms(to: .plainText) || type.conforms(to: .text),
26
+                  let text = try? String(contentsOf: url, encoding: .utf8) {
27
+            printText(text)
28
+        } else if type.conforms(to: .rtf),
29
+                  let data = try? Data(contentsOf: url),
30
+                  let attributed = NSAttributedString(rtf: data, documentAttributes: nil) {
31
+            printAttributedText(attributed)
32
+        } else if type.conforms(to: .html),
33
+                  let data = try? Data(contentsOf: url),
34
+                  let attributed = NSAttributedString(html: data, documentAttributes: nil) {
35
+            printAttributedText(attributed)
36
+        } else {
37
+            showUnsupportedAlert(for: url)
38
+        }
39
+    }
40
+
41
+    private static func printPDF(_ document: PDFDocument) {
42
+        let printInfo = configuredPrintInfo()
43
+        guard let operation = document.printOperation(
44
+            for: printInfo,
45
+            scalingMode: .pageScaleToFit,
46
+            autoRotate: true
47
+        ) else { return }
48
+        operation.run()
49
+    }
50
+
51
+    private static func printImage(_ image: NSImage) {
52
+        let size = image.size
53
+        guard size.width > 0, size.height > 0 else { return }
54
+
55
+        let imageView = NSImageView(frame: NSRect(origin: .zero, size: size))
56
+        imageView.image = image
57
+        imageView.imageScaling = .scaleProportionallyUpOrDown
58
+
59
+        let operation = NSPrintOperation(view: imageView, printInfo: configuredPrintInfo())
60
+        operation.run()
61
+    }
62
+
63
+    private static func printText(_ text: String) {
64
+        printAttributedText(NSAttributedString(string: text))
65
+    }
66
+
67
+    private static func printAttributedText(_ text: NSAttributedString) {
68
+        let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 612, height: 792))
69
+        textView.textStorage?.setAttributedString(text)
70
+        textView.isEditable = false
71
+
72
+        let operation = NSPrintOperation(view: textView, printInfo: configuredPrintInfo())
73
+        operation.run()
74
+    }
75
+
76
+    private static func configuredPrintInfo() -> NSPrintInfo {
77
+        let printInfo = NSPrintInfo.shared.copy() as! NSPrintInfo
78
+        let printerName = AppSettings.effectiveDefaultPrinter
79
+        if let printer = NSPrinter(name: printerName) {
80
+            printInfo.printer = printer
81
+        }
82
+        return printInfo
83
+    }
84
+
85
+    private static func showUnsupportedAlert(for url: URL) {
86
+        let alert = NSAlert()
87
+        alert.messageText = "Unsupported File"
88
+        alert.informativeText = "\"\(url.lastPathComponent)\" cannot be printed."
89
+        alert.alertStyle = .warning
90
+        alert.addButton(withTitle: "OK")
91
+        alert.runModal()
92
+    }
93
+}

+ 27 - 1
smart_printer/UIComponents.swift

@@ -220,8 +220,15 @@ final class PillButton: NSButton {
220 220
     private let horizontalPadding: CGFloat = 16
221 221
     private let baseColor: NSColor
222 222
     private var hoverTracker: HoverTracker?
223
+    private let clickTarget: ButtonClickTarget
224
+
225
+    var onClick: (() -> Void)? {
226
+        get { clickTarget.handler }
227
+        set { clickTarget.handler = newValue }
228
+    }
223 229
 
224 230
     init(title: String, color: NSColor) {
231
+        clickTarget = ButtonClickTarget()
225 232
         baseColor = color
226 233
         super.init(frame: .zero)
227 234
         self.title = title
@@ -231,6 +238,8 @@ final class PillButton: NSButton {
231 238
         layer?.cornerRadius = 11
232 239
         font = AppTheme.semiboldFont(size: 13)
233 240
         contentTintColor = .white
241
+        target = clickTarget
242
+        action = #selector(ButtonClickTarget.activate)
234 243
 
235 244
         if let arrow = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil) {
236 245
             let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .bold)
@@ -352,8 +361,10 @@ final class QuickStartCardView: NSView, AppearanceRefreshable {
352 361
     private var iconHeightConstraint: NSLayoutConstraint!
353 362
     private var hoverTracker: HoverTracker?
354 363
     private var isHovered = false
364
+    private let onActivate: () -> Void
355 365
 
356
-    init(data: QuickStartCardData) {
366
+    init(data: QuickStartCardData, onActivate: @escaping () -> Void) {
367
+        self.onActivate = onActivate
357 368
         iconView = QuickStartIconView(kind: data.iconKind)
358 369
         gradientView = GradientCardView(
359 370
             colors: data.gradientColors,
@@ -378,6 +389,7 @@ final class QuickStartCardView: NSView, AppearanceRefreshable {
378 389
 
379 390
         let button = PillButton(title: data.buttonTitle, color: data.accentColor)
380 391
         button.translatesAutoresizingMaskIntoConstraints = false
392
+        button.onClick = onActivate
381 393
 
382 394
         addSubview(gradient)
383 395
         gradient.addSubview(titleLabel)
@@ -444,11 +456,25 @@ final class QuickStartCardView: NSView, AppearanceRefreshable {
444 456
         }
445 457
     }
446 458
 
459
+    override func mouseUp(with event: NSEvent) {
460
+        let location = convert(event.locationInWindow, from: nil)
461
+        guard bounds.contains(location) else { return }
462
+        onActivate()
463
+    }
464
+
447 465
     override func resetCursorRects() {
448 466
         addCursorRect(bounds, cursor: .pointingHand)
449 467
     }
450 468
 }
451 469
 
470
+private final class ButtonClickTarget: NSObject {
471
+    var handler: (() -> Void)?
472
+
473
+    @objc func activate() {
474
+        handler?()
475
+    }
476
+}
477
+
452 478
 // MARK: - Feature Card
453 479
 
454 480
 struct FeatureCardData {

+ 10 - 5
smart_printer/ViewController.swift

@@ -460,7 +460,7 @@ class ViewController: NSViewController {
460 460
         let cards: [QuickStartCardData] = [
461 461
             QuickStartCardData(
462 462
                 title: "From Photos",
463
-                subtitle: "Take a photo from gallery",
463
+                subtitle: "Select photos from gallery",
464 464
                 buttonTitle: "Open Gallery",
465 465
                 accentColor: AppTheme.blue,
466 466
                 gradientColors: [AppTheme.quickStartBlueLight, NSColor(red: 0.82, green: 0.90, blue: 1.0, alpha: 1)],
@@ -468,7 +468,7 @@ class ViewController: NSViewController {
468 468
             ),
469 469
             QuickStartCardData(
470 470
                 title: "From Files",
471
-                subtitle: "Take a photo from file manager",
471
+                subtitle: "Select Photos from file manager",
472 472
                 buttonTitle: "Browse Files",
473 473
                 accentColor: AppTheme.green,
474 474
                 gradientColors: [AppTheme.greenLight, NSColor(red: 0.78, green: 0.94, blue: 0.84, alpha: 1)],
@@ -476,7 +476,7 @@ class ViewController: NSViewController {
476 476
             ),
477 477
             QuickStartCardData(
478 478
                 title: "Import File",
479
-                subtitle: "Import a file from storage",
479
+                subtitle: "Add files from storage",
480 480
                 buttonTitle: "Import Now",
481 481
                 accentColor: AppTheme.orange,
482 482
                 gradientColors: [AppTheme.orangeLight, NSColor(red: 1.0, green: 0.88, blue: 0.76, alpha: 1)],
@@ -484,8 +484,13 @@ class ViewController: NSViewController {
484 484
             ),
485 485
         ]
486 486
 
487
-        for cardData in cards {
488
-            cardsStack.addArrangedSubview(QuickStartCardView(data: cardData))
487
+        let pickerKinds: [DocumentPickerKind] = [.photos, .files, .importFile]
488
+        for (index, cardData) in cards.enumerated() {
489
+            let kind = pickerKinds[index]
490
+            let card = QuickStartCardView(data: cardData) { [weak self] in
491
+                DocumentPickerService.present(kind, from: self?.view.window)
492
+            }
493
+            cardsStack.addArrangedSubview(card)
489 494
         }
490 495
 
491 496
         section.addSubview(cardsStack)