소스 검색

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 2 시간 전
부모
커밋
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)