Переглянути джерело

Add Print Text screen and align text printing with photo and file flow.

Users can type text in a preview overlay and print through the same PDF-based pipeline used for images and documents.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 місяць тому
батько
коміт
93abbff96e

+ 89 - 7
smart_printer/PrintService.swift

@@ -3,6 +3,19 @@ import PDFKit
 import UniformTypeIdentifiers
 
 enum PrintService {
+    static func printText(_ text: String, title: String = "Print Text", from window: NSWindow? = nil) {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            showEmptyTextAlert()
+            return
+        }
+        guard let document = pdfDocument(from: trimmed) else {
+            showPrintFailedAlert()
+            return
+        }
+        printPDF(document, title: title, from: window ?? NSApp.keyWindow)
+    }
+
     static func print(urls: [URL], from window: NSWindow? = nil) {
         guard !urls.isEmpty else { return }
 
@@ -26,8 +39,9 @@ enum PrintService {
         } else if let image = NSImage(contentsOf: url) {
             printImage(image, title: url.lastPathComponent, from: window)
         } else if type.conforms(to: .plainText) || type.conforms(to: .text),
-                  let text = try? String(contentsOf: url, encoding: .utf8) {
-            printText(text, title: url.lastPathComponent, from: window)
+                  let text = try? String(contentsOf: url, encoding: .utf8),
+                  let document = pdfDocument(from: text) {
+            printPDF(document, title: url.lastPathComponent, from: window)
         } else if type.conforms(to: .rtf),
                   let data = try? Data(contentsOf: url),
                   let attributed = NSAttributedString(rtf: data, documentAttributes: nil) {
@@ -66,10 +80,6 @@ enum PrintService {
         printPDF(document, title: title, from: window)
     }
 
-    private static func printText(_ text: String, title: String, from window: NSWindow?) {
-        printAttributedText(NSAttributedString(string: text), title: title, from: window)
-    }
-
     private static func printAttributedText(_ text: NSAttributedString, title: String, from window: NSWindow?) {
         let printInfo = configuredPrintInfo()
         let pageSize = printInfo.paperSize
@@ -101,7 +111,7 @@ enum PrintService {
     }
 
     private static func configuredPrintInfo() -> NSPrintInfo {
-        let printInfo = NSPrintInfo.shared.copy() as! NSPrintInfo
+        let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo()
 
         let printerName = AppSettings.effectiveDefaultPrinter
         if let printer = NSPrinter(name: printerName) {
@@ -126,6 +136,60 @@ enum PrintService {
         return printInfo
     }
 
+    private static func pdfDocument(from text: String) -> PDFDocument? {
+        let printInfo = configuredPrintInfo()
+        let pageSize = printInfo.paperSize
+        guard pageSize.width > 0, pageSize.height > 0 else { return nil }
+
+        let attributes: [NSAttributedString.Key: Any] = [
+            .font: NSFont.systemFont(ofSize: 12),
+            .foregroundColor: NSColor.black,
+        ]
+        let attributed = NSAttributedString(string: text, attributes: attributes)
+
+        let textStorage = NSTextStorage(attributedString: attributed)
+        let layoutManager = NSLayoutManager()
+        textStorage.addLayoutManager(layoutManager)
+
+        let textWidth = pageSize.width - printInfo.leftMargin - printInfo.rightMargin
+        let textHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
+        let containerSize = NSSize(width: textWidth, height: textHeight)
+
+        let pdfData = NSMutableData()
+        var mediaBox = CGRect(origin: .zero, size: pageSize)
+
+        guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
+              let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
+            return nil
+        }
+
+        var glyphIndex = 0
+        while glyphIndex < layoutManager.numberOfGlyphs {
+            let textContainer = NSTextContainer(size: containerSize)
+            textContainer.lineFragmentPadding = 0
+            layoutManager.addTextContainer(textContainer)
+
+            let glyphRange = layoutManager.glyphRange(for: textContainer)
+            guard glyphRange.length > 0 else { break }
+
+            context.beginPDFPage(nil)
+
+            NSGraphicsContext.saveGraphicsState()
+            NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false)
+            layoutManager.drawGlyphs(
+                forGlyphRange: glyphRange,
+                at: NSPoint(x: printInfo.leftMargin, y: printInfo.bottomMargin)
+            )
+            NSGraphicsContext.restoreGraphicsState()
+
+            context.endPDFPage()
+            glyphIndex = NSMaxRange(glyphRange)
+        }
+
+        context.closePDF()
+        return PDFDocument(data: pdfData as Data)
+    }
+
     private static func pdfDocument(from image: NSImage) -> PDFDocument? {
         let printInfo = configuredPrintInfo()
         let pageSize = printInfo.paperSize
@@ -161,6 +225,24 @@ enum PrintService {
         return PDFDocument(data: pdfData as Data)
     }
 
+    private static func showPrintFailedAlert() {
+        let alert = NSAlert()
+        alert.messageText = "Print Failed"
+        alert.informativeText = "The text could not be prepared for printing."
+        alert.alertStyle = .warning
+        alert.addButton(withTitle: "OK")
+        alert.runModal()
+    }
+
+    private static func showEmptyTextAlert() {
+        let alert = NSAlert()
+        alert.messageText = "No Text to Print"
+        alert.informativeText = "Enter some text before printing."
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: "OK")
+        alert.runModal()
+    }
+
     private static func showUnsupportedAlert(for url: URL) {
         let alert = NSAlert()
         alert.messageText = "Unsupported File"

+ 296 - 0
smart_printer/PrintTextView.swift

@@ -0,0 +1,296 @@
+import Cocoa
+
+// MARK: - Print Text Service
+
+enum PrintTextService {
+    static func present(from window: NSWindow?) {
+        let hostWindow = window ?? NSApp.keyWindow
+        guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
+        viewController.presentPrintText()
+    }
+}
+
+// MARK: - Overlay
+
+final class PrintTextOverlayView: NSView, AppearanceRefreshable {
+    var onDismiss: (() -> Void)?
+
+    private let backButton = PrintTextToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
+    private let titleLabel = NSTextField(labelWithString: "Print Text")
+    private let textContainer = NSView()
+    private let textScrollView = NSScrollView()
+    private let textView = NSTextView()
+    private let printButton = PrintTextPrintButton()
+
+    init() {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        setup()
+        refreshAppearance()
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(appearanceDidChange),
+            name: .appearanceDidChange,
+            object: nil
+        )
+    }
+
+    deinit {
+        NotificationCenter.default.removeObserver(self)
+    }
+
+    @objc private func appearanceDidChange() {
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.background.cgColor
+        titleLabel.textColor = AppTheme.textPrimary
+        textContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        textContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
+        backButton.refreshAppearance()
+        printButton.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(textView)
+    }
+
+    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
+
+        titleLabel.font = AppTheme.semiboldFont(size: 18)
+        titleLabel.alignment = .center
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        textContainer.wantsLayer = true
+        textContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
+        textContainer.layer?.borderWidth = 1.5
+        textContainer.applyCardShadow()
+        textContainer.translatesAutoresizingMaskIntoConstraints = false
+
+        textScrollView.translatesAutoresizingMaskIntoConstraints = false
+        textScrollView.hasVerticalScroller = true
+        textScrollView.hasHorizontalScroller = false
+        textScrollView.autohidesScrollers = true
+        textScrollView.borderType = .noBorder
+        textScrollView.drawsBackground = false
+
+        textView.isRichText = false
+        textView.isEditable = true
+        textView.isSelectable = true
+        textView.drawsBackground = false
+        textView.textColor = AppTheme.textPrimary
+        textView.font = AppTheme.regularFont(size: 14)
+        textView.textContainerInset = NSSize(width: 16, height: 16)
+        textView.isAutomaticQuoteSubstitutionEnabled = true
+        textView.isAutomaticDashSubstitutionEnabled = true
+        textView.isAutomaticTextReplacementEnabled = true
+
+        textScrollView.documentView = textView
+
+        backButton.translatesAutoresizingMaskIntoConstraints = false
+        printButton.translatesAutoresizingMaskIntoConstraints = false
+
+        backButton.onClick = { [weak self] in self?.dismiss() }
+        printButton.onClick = { [weak self] in self?.printText() }
+
+        addSubview(backButton)
+        addSubview(titleLabel)
+        addSubview(textContainer)
+        textContainer.addSubview(textScrollView)
+        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),
+
+            titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
+            titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
+
+            textContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
+            textContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
+            textContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
+            textContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
+
+            textScrollView.leadingAnchor.constraint(equalTo: textContainer.leadingAnchor, constant: 12),
+            textScrollView.trailingAnchor.constraint(equalTo: textContainer.trailingAnchor, constant: -12),
+            textScrollView.topAnchor.constraint(equalTo: textContainer.topAnchor, constant: 12),
+            textScrollView.bottomAnchor.constraint(equalTo: textContainer.bottomAnchor, constant: -12),
+
+            printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
+            printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
+            printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
+        ])
+    }
+
+    private func printText() {
+        PrintService.printText(textView.string, from: window)
+    }
+}
+
+// MARK: - Toolbar Button
+
+private final class PrintTextToolbarButton: 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 PrintTextPrintButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let titleLabel = NSTextField(labelWithString: "Print Text")
+    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)
+    }
+}

+ 10 - 0
smart_printer/UIComponents.swift

@@ -481,6 +481,7 @@ struct FeatureCardData {
     let title: String
     let subtitle: String
     let iconKind: FeatureIconKind
+    var onActivate: (() -> Void)? = nil
 }
 
 final class FeatureCardView: NSView, AppearanceRefreshable {
@@ -492,8 +493,10 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
     private var iconHeightConstraint: NSLayoutConstraint!
     private var hoverTracker: HoverTracker?
     private var isHovered = false
+    private let onActivate: (() -> Void)?
 
     init(data: FeatureCardData) {
+        onActivate = data.onActivate
         iconView = FeatureIconView(kind: data.iconKind)
         titleLabel = NSTextField.themeLabel(data.title, style: .primary, font: AppTheme.semiboldFont(size: 14))
         subtitleLabel = NSTextField.themeLabel(data.subtitle, style: .secondary, font: AppTheme.regularFont(size: 11))
@@ -591,7 +594,14 @@ final class FeatureCardView: NSView, AppearanceRefreshable {
         }
     }
 
+    override func mouseUp(with event: NSEvent) {
+        let location = convert(event.locationInWindow, from: nil)
+        guard bounds.contains(location) else { return }
+        onActivate?()
+    }
+
     override func resetCursorRects() {
+        guard onActivate != nil else { return }
         addCursorRect(bounds, cursor: .pointingHand)
     }
 }

+ 32 - 6
smart_printer/ViewController.swift

@@ -17,6 +17,7 @@ class ViewController: NSViewController {
     private var paywallOverlay: PaywallOverlayView?
     private var photoPreviewOverlay: PhotoPreviewOverlayView?
     private var filePreviewOverlay: FilePreviewOverlayView?
+    private var printTextOverlay: PrintTextOverlayView?
 
     private var headerView: NSView!
     private var contentTopBelowHeader: NSLayoutConstraint!
@@ -80,6 +81,19 @@ class ViewController: NSViewController {
         paywallOverlay?.refreshAppearance()
         photoPreviewOverlay?.refreshAppearance()
         filePreviewOverlay?.refreshAppearance()
+        printTextOverlay?.refreshAppearance()
+    }
+
+    func presentPrintText() {
+        photoPreviewOverlay?.dismiss(animated: false)
+        filePreviewOverlay?.dismiss(animated: false)
+
+        let overlay = PrintTextOverlayView()
+        overlay.onDismiss = { [weak self] in
+            self?.printTextOverlay = nil
+        }
+        printTextOverlay = overlay
+        overlay.present(in: mainContentView)
     }
 
     func presentPhotoPreview(urls: [URL]) {
@@ -274,12 +288,12 @@ class ViewController: NSViewController {
     private func makeScanAndHomeContentView() -> NSView {
         makeDestinationScrollView(
             title: "Premium",
-            features: [
+            features: featureCards(
                 FeatureCardData(title: "From Photos", subtitle: "Take a photo from gallery", iconKind: .scanFile),
                 FeatureCardData(title: "Scan File", subtitle: "Scan any document", iconKind: .scanFile),
                 FeatureCardData(title: "Print Text", subtitle: "Type text and print", iconKind: .printText),
-                FeatureCardData(title: "Print Contacts", subtitle: "Print your contacts", iconKind: .printContacts),
-            ]
+                FeatureCardData(title: "Print Contacts", subtitle: "Print your contacts", iconKind: .printContacts)
+            )
         )
     }
 
@@ -543,14 +557,14 @@ class ViewController: NSViewController {
         let title = makeSectionTitle("Create & Print", icon: .grid)
         section.addSubview(title)
 
-        let features: [FeatureCardData] = [
+        let features = featureCards(
             FeatureCardData(title: "Scan File", subtitle: "Scan any document", iconKind: .scanFile),
             FeatureCardData(title: "Print Text", subtitle: "Type text and print", iconKind: .printText),
             FeatureCardData(title: "Print Contacts", subtitle: "Print your contacts", iconKind: .printContacts),
             FeatureCardData(title: "Draw & Print", subtitle: "Add drawings, text and more", iconKind: .drawPrint),
             FeatureCardData(title: "OCR File", subtitle: "Scan and print text from images", iconKind: .ocrFile),
-            FeatureCardData(title: "Print Website", subtitle: "Print any website", iconKind: .printWebsite),
-        ]
+            FeatureCardData(title: "Print Website", subtitle: "Print any website", iconKind: .printWebsite)
+        )
 
         let grid = makeFeatureGrid(features: features, columns: AppTheme.featureGridColumns)
 
@@ -569,6 +583,18 @@ class ViewController: NSViewController {
         return section
     }
 
+    private func featureCards(_ cards: FeatureCardData...) -> [FeatureCardData] {
+        cards.map { card in
+            var updated = card
+            if card.iconKind == .printText {
+                updated.onActivate = { [weak self] in
+                    PrintTextService.present(from: self?.view.window)
+                }
+            }
+            return updated
+        }
+    }
+
     private func makeFeatureGrid(features: [FeatureCardData], columns: Int) -> NSView {
         let grid = NSStackView()
         grid.orientation = .vertical