Jelajahi Sumber

Present scanner as an in-app overlay instead of a separate panel.

Keeps the scanner UI inside the main window like other features and aligns the scan result action buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 bulan lalu
induk
melakukan
923bc2e53f
2 mengubah file dengan 172 tambahan dan 191 penghapusan
  1. 118 186
      smart_printer/ScannerOverlayView.swift
  2. 54 5
      smart_printer/ViewController.swift

+ 118 - 186
smart_printer/ScannerOverlayView.swift

@@ -1,173 +1,6 @@
 import Cocoa
 import ImageCaptureCore
 
-// MARK: - Scanner Panel
-
-@MainActor
-final class ScannerPanelController: NSObject, NSWindowDelegate {
-    private static var activeController: ScannerPanelController?
-
-    private static let panelWidth: CGFloat = 560
-    private static let panelHeight: CGFloat = 480
-
-    private var panel: NSPanel?
-    private var panelView: ScannerOverlayView?
-    private weak var parentWindow: NSWindow?
-    private let showResultPreview: Bool
-    private var onComplete: ((Result<NSImage, Error>) -> Void)?
-    private var didComplete = false
-
-    static func present(
-        from parentWindow: NSWindow?,
-        showResultPreview: Bool,
-        onComplete: @escaping (Result<NSImage, Error>) -> Void
-    ) {
-        Task { @MainActor in
-            if let active = activeController {
-                active.teardownPanel()
-            }
-            let controller = ScannerPanelController(
-                parentWindow: parentWindow,
-                showResultPreview: showResultPreview,
-                onComplete: onComplete
-            )
-            activeController = controller
-            await controller.show()
-        }
-    }
-
-    private init(
-        parentWindow: NSWindow?,
-        showResultPreview: Bool,
-        onComplete: @escaping (Result<NSImage, Error>) -> Void
-    ) {
-        self.parentWindow = parentWindow
-        self.showResultPreview = showResultPreview
-        self.onComplete = onComplete
-        super.init()
-    }
-
-    private func show() async {
-        let contentSize = NSSize(width: Self.panelWidth, height: Self.panelHeight)
-        let panel = NSPanel(
-            contentRect: NSRect(origin: .zero, size: contentSize),
-            styleMask: [.titled, .closable, .fullSizeContentView],
-            backing: .buffered,
-            defer: false
-        )
-        panel.title = "Scanner"
-        panel.isFloatingPanel = false
-        panel.hidesOnDeactivate = false
-        panel.delegate = self
-        panel.isReleasedWhenClosed = false
-        panel.backgroundColor = AppTheme.background
-        hideTrafficLights(on: panel)
-
-        let container = NSView(frame: NSRect(origin: .zero, size: contentSize))
-        container.translatesAutoresizingMaskIntoConstraints = true
-        panel.contentView = container
-
-        let scannerView = ScannerOverlayView()
-        scannerView.translatesAutoresizingMaskIntoConstraints = false
-        scannerView.onScanComplete = { [weak self] image in
-            self?.handleScanComplete(image)
-        }
-        scannerView.onDismiss = { [weak self] in
-            self?.close()
-        }
-        container.addSubview(scannerView)
-        NSLayoutConstraint.activate([
-            scannerView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
-            scannerView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
-            scannerView.topAnchor.constraint(equalTo: container.topAnchor),
-            scannerView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
-        ])
-
-        center(panel, relativeTo: parentWindow)
-        self.panel = panel
-        self.panelView = scannerView
-
-        await scannerView.activate()
-        NSApp.activate(ignoringOtherApps: true)
-        parentWindow?.makeKeyAndOrderFront(nil)
-        panel.makeKeyAndOrderFront(nil)
-    }
-
-    private func teardownPanel() {
-        panelView?.teardown()
-        panel?.orderOut(nil)
-        panel = nil
-        panelView = nil
-        if Self.activeController === self {
-            Self.activeController = nil
-        }
-        onComplete = nil
-    }
-
-    private func handleScanComplete(_ image: NSImage) {
-        Task { @MainActor in
-            let processed: NSImage
-            if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
-                processed = await ScanDocumentProcessor.process(cgImage) ?? image
-            } else {
-                processed = image
-            }
-
-            if showResultPreview {
-                panel?.title = "Scan Result"
-                panelView?.showResult(processed)
-            } else {
-                didComplete = true
-                onComplete?(.success(processed))
-                close()
-            }
-        }
-    }
-
-    func windowWillClose(_ notification: Notification) {
-        let cancelled = !didComplete && panelView?.hasResult != true
-        Task { @MainActor in
-            finish(cancelled: cancelled)
-        }
-    }
-
-    private func finish(cancelled: Bool) {
-        panelView?.teardown()
-        panel?.orderOut(nil)
-        panel = nil
-        panelView = nil
-        if Self.activeController === self {
-            Self.activeController = nil
-        }
-        if cancelled {
-            onComplete?(.failure(ScanError.cancelled))
-        }
-        onComplete = nil
-    }
-
-    private func close() {
-        panel?.close()
-    }
-
-    private func center(_ panelWindow: NSWindow, relativeTo parentWindow: NSWindow?) {
-        guard let parentWindow else {
-            panelWindow.center()
-            return
-        }
-        let parentFrame = parentWindow.frame
-        var panelFrame = panelWindow.frame
-        panelFrame.origin.x = parentFrame.midX - panelFrame.width / 2
-        panelFrame.origin.y = parentFrame.midY - panelFrame.height / 2
-        panelWindow.setFrame(panelFrame, display: true)
-    }
-
-    private func hideTrafficLights(on window: NSWindow) {
-        for buttonType: NSWindow.ButtonType in [.miniaturizeButton, .zoomButton] {
-            window.standardWindowButton(buttonType)?.isHidden = true
-        }
-    }
-}
-
 // MARK: - Scanner View
 
 final class ScannerOverlayView: NSView, AppearanceRefreshable {
@@ -183,6 +16,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private let scanPhaseView = NSView()
     private let resultPhaseView = NSView()
 
+    private let backButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
     private let titleLabel = NSTextField(labelWithString: "Scanner")
     private let sidebarView = NSView()
     private let localHeader = ScannerSectionHeader(title: "DEVICES")
@@ -251,6 +85,38 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         resultPhaseView.isHidden = false
     }
 
+    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
+        }
+    }
+
+    func dismiss(animated: Bool = true) {
+        let remove = { [weak self] in
+            self?.teardown()
+            self?.removeFromSuperview()
+            self?.onDismiss?()
+        }
+        guard animated else {
+            remove()
+            return
+        }
+        NSAnimationContext.runAnimationGroup({ context in
+            context.duration = 0.15
+            animator().alphaValue = 0
+        }, completionHandler: remove)
+    }
+
     func refreshAppearance() {
         layer?.backgroundColor = AppTheme.background.cgColor
         titleLabel.textColor = AppTheme.textPrimary
@@ -260,6 +126,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         resultPreviewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         resultPreviewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         statusLabel.textColor = AppTheme.textSecondary
+        backButton.refreshAppearance()
         scanButton.refreshAppearance()
         localHeader.refreshAppearance()
         sharedHeader.refreshAppearance()
@@ -351,6 +218,9 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         paperSizePopup.target = self
         paperSizePopup.action = #selector(paperSizeChanged)
 
+        backButton.translatesAutoresizingMaskIntoConstraints = false
+        backButton.onClick = { [weak self] in self?.dismiss() }
+
         scanButton.translatesAutoresizingMaskIntoConstraints = false
         scanButton.isEnabled = false
         scanButton.onClick = { [weak self] in self?.startScan() }
@@ -387,6 +257,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         previewContainer.addSubview(placeholderView)
         previewContainer.addSubview(previewImageView)
 
+        scanPhaseView.addSubview(backButton)
         scanPhaseView.addSubview(titleLabel)
         scanPhaseView.addSubview(sidebarView)
         scanPhaseView.addSubview(previewContainer)
@@ -396,11 +267,16 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         scanPhaseView.addSubview(scanButton)
 
         NSLayoutConstraint.activate([
-            titleLabel.topAnchor.constraint(equalTo: scanPhaseView.topAnchor, constant: 16),
+            backButton.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor, constant: 24),
+            backButton.topAnchor.constraint(equalTo: scanPhaseView.topAnchor, constant: 20),
+            backButton.widthAnchor.constraint(equalToConstant: 40),
+            backButton.heightAnchor.constraint(equalToConstant: 40),
+
             titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
+            titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
 
             sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor),
-            sidebarView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
+            sidebarView.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
             sidebarView.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor),
             sidebarView.widthAnchor.constraint(equalToConstant: 168),
 
@@ -455,8 +331,12 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private func setupResultPhase() {
         resultPhaseView.translatesAutoresizingMaskIntoConstraints = false
 
+        let resultBackButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
+        resultBackButton.translatesAutoresizingMaskIntoConstraints = false
+        resultBackButton.onClick = { [weak self] in self?.dismiss() }
+
         let resultTitle = NSTextField(labelWithString: "Scan Result")
-        resultTitle.font = AppTheme.semiboldFont(size: 16)
+        resultTitle.font = AppTheme.semiboldFont(size: 18)
         resultTitle.alignment = .center
         resultTitle.textColor = AppTheme.textPrimary
         resultTitle.translatesAutoresizingMaskIntoConstraints = false
@@ -479,40 +359,47 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
 
-        savePDFButton.translatesAutoresizingMaskIntoConstraints = false
-        printButton.translatesAutoresizingMaskIntoConstraints = false
         savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
         printButton.onClick = { [weak self] in self?.printScan() }
 
+        let actionRow = NSStackView(views: [savePDFButton, printButton])
+        actionRow.orientation = .horizontal
+        actionRow.spacing = 12
+        actionRow.distribution = .fillEqually
+        actionRow.translatesAutoresizingMaskIntoConstraints = false
+        actionRow.setContentHuggingPriority(.required, for: .vertical)
+        actionRow.setContentCompressionResistancePriority(.required, for: .vertical)
+
         resultPreviewContainer.addSubview(resultImageView)
+        resultPhaseView.addSubview(resultBackButton)
         resultPhaseView.addSubview(resultTitle)
         resultPhaseView.addSubview(resultPreviewContainer)
-        resultPhaseView.addSubview(savePDFButton)
-        resultPhaseView.addSubview(printButton)
+        resultPhaseView.addSubview(actionRow)
 
         NSLayoutConstraint.activate([
-            resultTitle.topAnchor.constraint(equalTo: resultPhaseView.topAnchor, constant: 16),
+            resultBackButton.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 24),
+            resultBackButton.topAnchor.constraint(equalTo: resultPhaseView.topAnchor, constant: 20),
+            resultBackButton.widthAnchor.constraint(equalToConstant: 40),
+            resultBackButton.heightAnchor.constraint(equalToConstant: 40),
+
             resultTitle.centerXAnchor.constraint(equalTo: resultPhaseView.centerXAnchor),
+            resultTitle.centerYAnchor.constraint(equalTo: resultBackButton.centerYAnchor),
 
-            resultPreviewContainer.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 16),
-            resultPreviewContainer.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -16),
-            resultPreviewContainer.topAnchor.constraint(equalTo: resultTitle.bottomAnchor, constant: 12),
-            resultPreviewContainer.bottomAnchor.constraint(equalTo: savePDFButton.topAnchor, constant: -16),
+            resultPreviewContainer.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
+            resultPreviewContainer.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -AppTheme.contentPanelInset),
+            resultPreviewContainer.topAnchor.constraint(equalTo: resultBackButton.bottomAnchor, constant: 28),
+            resultPreviewContainer.bottomAnchor.constraint(equalTo: actionRow.topAnchor, constant: -16),
 
             resultImageView.leadingAnchor.constraint(equalTo: resultPreviewContainer.leadingAnchor, constant: 12),
             resultImageView.trailingAnchor.constraint(equalTo: resultPreviewContainer.trailingAnchor, constant: -12),
             resultImageView.topAnchor.constraint(equalTo: resultPreviewContainer.topAnchor, constant: 12),
             resultImageView.bottomAnchor.constraint(equalTo: resultPreviewContainer.bottomAnchor, constant: -12),
 
-            savePDFButton.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 16),
-            savePDFButton.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -20),
+            actionRow.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
+            actionRow.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -AppTheme.contentPanelInset),
+            actionRow.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -28),
             savePDFButton.heightAnchor.constraint(equalToConstant: 40),
-            savePDFButton.widthAnchor.constraint(equalTo: resultPhaseView.widthAnchor, multiplier: 0.5, constant: -22),
-
-            printButton.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -16),
-            printButton.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -20),
             printButton.heightAnchor.constraint(equalToConstant: 40),
-            printButton.widthAnchor.constraint(equalTo: resultPhaseView.widthAnchor, multiplier: 0.5, constant: -22),
         ])
     }
 
@@ -659,6 +546,51 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
 // MARK: - Controls
 
+private final class ScannerToolbarButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let iconView = NSImageView()
+
+    init(symbolName: String, accessibilityLabel: String) {
+        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)
+    }
+}
+
 private final class FlippedSidebarContentView: NSView {
     override var isFlipped: Bool { true }
 }

+ 54 - 5
smart_printer/ViewController.swift

@@ -22,6 +22,8 @@ class ViewController: NSViewController {
     private var drawPrintOverlay: DrawPrintOverlayView?
     private var ocrFileOverlay: OCRFileOverlayView?
     private var scanFileOverlay: ScanFileOverlayView?
+    private var scannerOverlay: ScannerOverlayView?
+    private var scannerDidComplete = false
 
     private var headerView: NSView!
     private var contentTopBelowHeader: NSLayoutConstraint!
@@ -90,6 +92,7 @@ class ViewController: NSViewController {
         drawPrintOverlay?.refreshAppearance()
         ocrFileOverlay?.refreshAppearance()
         scanFileOverlay?.refreshAppearance()
+        scannerOverlay?.refreshAppearance()
     }
 
     func presentScanner(showScanPreview: Bool = false, onComplete: @escaping (Result<NSImage, Error>) -> Void) {
@@ -99,12 +102,42 @@ class ViewController: NSViewController {
         printContactsOverlay?.dismiss(animated: false)
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
 
-        ScannerPanelController.present(
-            from: view.window,
-            showResultPreview: showScanPreview,
-            onComplete: onComplete
-        )
+        scannerDidComplete = false
+
+        dismissScannerOverlay()
+
+        let overlay = ScannerOverlayView()
+        overlay.onDismiss = { [weak self] in
+            guard let self else { return }
+            let cancelled = !self.scannerDidComplete && self.scannerOverlay?.hasResult != true
+            self.scannerOverlay = nil
+            if cancelled {
+                onComplete(.failure(ScanError.cancelled))
+            }
+        }
+        overlay.onScanComplete = { [weak self] image in
+            Task { @MainActor in
+                let processed: NSImage
+                if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
+                    processed = await ScanDocumentProcessor.process(cgImage) ?? image
+                } else {
+                    processed = image
+                }
+
+                if showScanPreview {
+                    overlay.showResult(processed)
+                } else {
+                    self?.scannerDidComplete = true
+                    onComplete(.success(processed))
+                    overlay.dismiss()
+                }
+            }
+        }
+        scannerOverlay = overlay
+        overlay.present(in: mainContentView)
+        Task { await overlay.activate() }
     }
 
     func presentPrintText() {
@@ -114,6 +147,7 @@ class ViewController: NSViewController {
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
         scanFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
 
         let overlay = PrintTextOverlayView()
         overlay.onDismiss = { [weak self] in
@@ -130,6 +164,7 @@ class ViewController: NSViewController {
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
         scanFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
 
         if let overlay = printContactsOverlay {
             overlay.present(in: mainContentView)
@@ -148,6 +183,7 @@ class ViewController: NSViewController {
         printContactsOverlay?.dismiss(animated: false)
         drawPrintOverlay?.dismiss(animated: false)
         scanFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
 
         let overlay = OCRFileOverlayView(imageURL: imageURL)
         overlay.onDismiss = { [weak self] in
@@ -164,6 +200,7 @@ class ViewController: NSViewController {
         printContactsOverlay?.dismiss(animated: false)
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
 
         if let overlay = scanFileOverlay {
             overlay.appendPages(pages)
@@ -189,6 +226,7 @@ class ViewController: NSViewController {
         printContactsOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
         scanFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
 
         let overlay = DrawPrintOverlayView()
         overlay.onDismiss = { [weak self] in
@@ -203,6 +241,7 @@ class ViewController: NSViewController {
         filePreviewOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
         scanFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
 
         let overlay = PhotoPreviewOverlayView(urls: urls)
         overlay.onDismiss = { [weak self] in
@@ -217,6 +256,7 @@ class ViewController: NSViewController {
         filePreviewOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
         scanFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
 
         let overlay = FilePreviewOverlayView(urls: urls)
         overlay.onDismiss = { [weak self] in
@@ -302,6 +342,14 @@ class ViewController: NSViewController {
         ])
     }
 
+    private func dismissScannerOverlay() {
+        guard let scanner = scannerOverlay else { return }
+        scanner.onDismiss = nil
+        scanner.teardown()
+        scanner.removeFromSuperview()
+        scannerOverlay = nil
+    }
+
     private func dismissFeatureOverlays() {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
@@ -310,6 +358,7 @@ class ViewController: NSViewController {
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
         scanFileOverlay?.dismiss(animated: false)
+        dismissScannerOverlay()
     }
 
     private func showDestination(_ destination: SidebarDestination) {