Quellcode durchsuchen

Fix scan preview layout and show live results in the scanner overlay.

Normalize images for display so previews scale to their layout frame instead of expanding to bitmap size, and update the scanner UI with inline save/print actions and sidebar styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 vor 1 Monat
Ursprung
Commit
e7564fd2f5

+ 28 - 6
smart_printer/ScanFileView.swift

@@ -233,6 +233,29 @@ enum ScanFileService {
     }
 }
 
+// MARK: - Image Display
+
+/// Image view that scales to its layout frame instead of expanding to the bitmap's intrinsic size.
+final class ScanPreviewImageView: NSImageView {
+    override var intrinsicContentSize: NSSize {
+        NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
+    }
+}
+
+extension NSImage {
+    func normalizedForDisplay() -> NSImage {
+        guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return self }
+        return Self.normalizedForDisplay(cgImage: cgImage)
+    }
+
+    static func normalizedForDisplay(cgImage: CGImage) -> NSImage {
+        let size = NSSize(width: cgImage.width, height: cgImage.height)
+        let image = NSImage(size: size)
+        image.addRepresentation(NSBitmapImageRep(cgImage: cgImage))
+        return image
+    }
+}
+
 // MARK: - Document Processing
 
 enum ScanDocumentProcessor {
@@ -262,10 +285,7 @@ enum ScanDocumentProcessor {
     }
 
     private static func imageFromCGImage(_ cgImage: CGImage) -> NSImage {
-        let rep = NSBitmapImageRep(cgImage: cgImage)
-        let image = NSImage()
-        image.addRepresentation(rep)
-        return image
+        NSImage.normalizedForDisplay(cgImage: cgImage)
     }
 
     private static func perspectiveCorrectedImage(from cgImage: CGImage, observation: VNRectangleObservation) -> NSImage? {
@@ -454,7 +474,7 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
     private let titleLabel = NSTextField(labelWithString: "Scan File")
     private let counterLabel = NSTextField(labelWithString: "")
     private let previewContainer = NSView()
-    private let imageView = NSImageView()
+    private let imageView = ScanPreviewImageView()
     private let addPageButton = ScanFileSecondaryButton(title: "Add Page", symbolName: "plus")
     private let saveButton = ScanFileSecondaryButton(title: "Save", symbolName: "square.and.arrow.down")
     private let printButton = ScanFilePrintButton()
@@ -626,7 +646,9 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
     private func showPage(at index: Int) {
         guard pages.indices.contains(index) else { return }
         currentIndex = index
-        imageView.image = pages[index]
+        imageView.image = pages[index].normalizedForDisplay()
+        imageView.invalidateIntrinsicContentSize()
+        previewContainer.layoutSubtreeIfNeeded()
         if pages.count > 1 {
             counterLabel.stringValue = "Page \(index + 1) of \(pages.count)"
         } else {

+ 139 - 43
smart_printer/ScannerOverlayView.swift

@@ -24,15 +24,20 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private let sharedHeader = ScannerSectionHeader(title: "SHARED")
     private let sharedList = NSStackView()
     private let previewContainer = NSView()
-    private let previewImageView = NSImageView()
+    private let previewImageArea = NSView()
+    private let previewImageView = ScanPreviewImageView()
     private let placeholderView = ScannerPlaceholderView()
+    private let previewActionRow = NSStackView()
+    private var previewImageAreaBottomToContainer: NSLayoutConstraint!
+    private var previewImageAreaBottomToActions: NSLayoutConstraint!
+    private var previewActionRowHeight: NSLayoutConstraint!
     private let statusLabel = NSTextField(labelWithString: "Select a scanner to begin")
     private let paperSizePopup = NSPopUpButton()
     private let scanButton = ScannerPrimaryButton(title: "Scan")
     private let progressIndicator = NSProgressIndicator()
 
     private let resultPreviewContainer = NSView()
-    private let resultImageView = NSImageView()
+    private let resultImageView = ScanPreviewImageView()
     private let savePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
     private let printButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
 
@@ -79,12 +84,15 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
     func showResult(_ image: NSImage) {
         hasResult = true
-        scannedImage = image
-        resultImageView.image = image
+        displayScannedImage(image)
         scanPhaseView.isHidden = true
         resultPhaseView.isHidden = false
     }
 
+    func updateScanPreview(_ image: NSImage) {
+        displayScannedImage(image)
+    }
+
     func present(in parent: NSView) {
         guard superview == nil else { return }
         parent.addSubview(self)
@@ -120,7 +128,8 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     func refreshAppearance() {
         layer?.backgroundColor = AppTheme.background.cgColor
         titleLabel.textColor = AppTheme.textPrimary
-        sidebarView.layer?.backgroundColor = AppTheme.sidebarBackground.cgColor
+        sidebarView.layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        sidebarView.layer?.borderColor = AppTheme.paywallBorder.cgColor
         previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         resultPreviewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
@@ -170,6 +179,9 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
         sidebarView.wantsLayer = true
+        sidebarView.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
+        sidebarView.layer?.borderWidth = 1.5
+        sidebarView.layer?.masksToBounds = true
         sidebarView.translatesAutoresizingMaskIntoConstraints = false
 
         localList.orientation = .vertical
@@ -192,6 +204,8 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
 
+        previewImageArea.translatesAutoresizingMaskIntoConstraints = false
+
         previewImageView.imageScaling = .scaleProportionallyUpOrDown
         previewImageView.imageAlignment = .alignCenter
         previewImageView.translatesAutoresizingMaskIntoConstraints = false
@@ -230,15 +244,29 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         progressIndicator.isDisplayedWhenStopped = false
         progressIndicator.translatesAutoresizingMaskIntoConstraints = false
 
+        savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
+        printButton.onClick = { [weak self] in self?.printScan() }
+        savePDFButton.translatesAutoresizingMaskIntoConstraints = false
+        printButton.translatesAutoresizingMaskIntoConstraints = false
+
+        previewActionRow.orientation = .horizontal
+        previewActionRow.spacing = 12
+        previewActionRow.distribution = .fillEqually
+        previewActionRow.translatesAutoresizingMaskIntoConstraints = false
+        previewActionRow.isHidden = true
+        previewActionRow.addArrangedSubview(savePDFButton)
+        previewActionRow.addArrangedSubview(printButton)
+
         let sidebarStack = NSStackView()
         sidebarStack.orientation = .vertical
         sidebarStack.alignment = .leading
-        sidebarStack.spacing = 6
+        sidebarStack.spacing = 8
         sidebarStack.translatesAutoresizingMaskIntoConstraints = false
         sidebarStack.addArrangedSubview(localHeader)
         sidebarStack.addArrangedSubview(localList)
         sidebarStack.addArrangedSubview(sharedHeader)
         sidebarStack.addArrangedSubview(sharedList)
+        sidebarStack.setCustomSpacing(18, after: localList)
 
         let sidebarScroll = NSScrollView()
         sidebarScroll.hasVerticalScroller = true
@@ -254,8 +282,10 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         sidebarScroll.documentView = sidebarDocument
         sidebarView.addSubview(sidebarScroll)
 
-        previewContainer.addSubview(placeholderView)
-        previewContainer.addSubview(previewImageView)
+        previewImageArea.addSubview(previewImageView)
+        previewImageArea.addSubview(placeholderView)
+        previewContainer.addSubview(previewImageArea)
+        previewContainer.addSubview(previewActionRow)
 
         scanPhaseView.addSubview(backButton)
         scanPhaseView.addSubview(titleLabel)
@@ -275,20 +305,20 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
             titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
 
-            sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor),
+            sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
             sidebarView.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
-            sidebarView.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor),
-            sidebarView.widthAnchor.constraint(equalToConstant: 168),
-
-            sidebarScroll.leadingAnchor.constraint(equalTo: sidebarView.leadingAnchor),
-            sidebarScroll.trailingAnchor.constraint(equalTo: sidebarView.trailingAnchor),
-            sidebarScroll.topAnchor.constraint(equalTo: sidebarView.topAnchor),
-            sidebarScroll.bottomAnchor.constraint(equalTo: sidebarView.bottomAnchor),
-
-            sidebarStack.leadingAnchor.constraint(equalTo: sidebarDocument.leadingAnchor, constant: 8),
-            sidebarStack.trailingAnchor.constraint(equalTo: sidebarDocument.trailingAnchor, constant: -8),
-            sidebarStack.topAnchor.constraint(equalTo: sidebarDocument.topAnchor, constant: 8),
-            sidebarStack.bottomAnchor.constraint(equalTo: sidebarDocument.bottomAnchor, constant: -8),
+            sidebarView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor),
+            sidebarView.widthAnchor.constraint(equalToConstant: 180),
+
+            sidebarScroll.leadingAnchor.constraint(equalTo: sidebarView.leadingAnchor, constant: 4),
+            sidebarScroll.trailingAnchor.constraint(equalTo: sidebarView.trailingAnchor, constant: -4),
+            sidebarScroll.topAnchor.constraint(equalTo: sidebarView.topAnchor, constant: 6),
+            sidebarScroll.bottomAnchor.constraint(equalTo: sidebarView.bottomAnchor, constant: -4),
+
+            sidebarStack.leadingAnchor.constraint(equalTo: sidebarDocument.leadingAnchor, constant: 14),
+            sidebarStack.trailingAnchor.constraint(equalTo: sidebarDocument.trailingAnchor, constant: -14),
+            sidebarStack.topAnchor.constraint(equalTo: sidebarDocument.topAnchor, constant: 22),
+            sidebarStack.bottomAnchor.constraint(equalTo: sidebarDocument.bottomAnchor, constant: -14),
             sidebarDocument.widthAnchor.constraint(equalTo: sidebarScroll.widthAnchor),
             localHeader.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
             sharedHeader.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
@@ -300,15 +330,25 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             previewContainer.topAnchor.constraint(equalTo: sidebarView.topAnchor),
             previewContainer.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -12),
 
-            placeholderView.centerXAnchor.constraint(equalTo: previewContainer.centerXAnchor),
-            placeholderView.centerYAnchor.constraint(equalTo: previewContainer.centerYAnchor),
-            placeholderView.widthAnchor.constraint(equalTo: previewContainer.widthAnchor, multiplier: 0.55),
-            placeholderView.heightAnchor.constraint(equalTo: previewContainer.heightAnchor, multiplier: 0.7),
+            previewImageArea.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 16),
+            previewImageArea.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -16),
+            previewImageArea.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 16),
 
-            previewImageView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
-            previewImageView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
-            previewImageView.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
-            previewImageView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
+            previewActionRow.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 16),
+            previewActionRow.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -16),
+            previewActionRow.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -16),
+            savePDFButton.heightAnchor.constraint(equalToConstant: 40),
+            printButton.heightAnchor.constraint(equalToConstant: 40),
+
+            placeholderView.centerXAnchor.constraint(equalTo: previewImageArea.centerXAnchor),
+            placeholderView.centerYAnchor.constraint(equalTo: previewImageArea.centerYAnchor),
+            placeholderView.widthAnchor.constraint(equalTo: previewImageArea.widthAnchor, multiplier: 0.42),
+            placeholderView.heightAnchor.constraint(equalTo: placeholderView.widthAnchor),
+
+            previewImageView.leadingAnchor.constraint(equalTo: previewImageArea.leadingAnchor, constant: 20),
+            previewImageView.trailingAnchor.constraint(equalTo: previewImageArea.trailingAnchor, constant: -20),
+            previewImageView.topAnchor.constraint(equalTo: previewImageArea.topAnchor, constant: 20),
+            previewImageView.bottomAnchor.constraint(equalTo: previewImageArea.bottomAnchor, constant: -20),
 
             statusLabel.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
             statusLabel.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
@@ -326,6 +366,20 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
             scanButton.heightAnchor.constraint(equalToConstant: 32),
         ])
+
+        previewImageAreaBottomToContainer = previewImageArea.bottomAnchor.constraint(
+            equalTo: previewContainer.bottomAnchor,
+            constant: -16
+        )
+        previewImageAreaBottomToActions = previewImageArea.bottomAnchor.constraint(
+            equalTo: previewActionRow.topAnchor,
+            constant: -12
+        )
+        previewActionRowHeight = previewActionRow.heightAnchor.constraint(equalToConstant: 0)
+
+        previewImageAreaBottomToContainer.isActive = true
+        previewImageAreaBottomToActions.isActive = false
+        previewActionRowHeight.isActive = true
     }
 
     private func setupResultPhase() {
@@ -359,10 +413,12 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
 
-        savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
-        printButton.onClick = { [weak self] in self?.printScan() }
+        let resultSavePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
+        let resultPrintButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
+        resultSavePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
+        resultPrintButton.onClick = { [weak self] in self?.printScan() }
 
-        let actionRow = NSStackView(views: [savePDFButton, printButton])
+        let actionRow = NSStackView(views: [resultSavePDFButton, resultPrintButton])
         actionRow.orientation = .horizontal
         actionRow.spacing = 12
         actionRow.distribution = .fillEqually
@@ -398,8 +454,8 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             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),
-            printButton.heightAnchor.constraint(equalToConstant: 40),
+            resultSavePDFButton.heightAnchor.constraint(equalToConstant: 40),
+            resultPrintButton.heightAnchor.constraint(equalToConstant: 40),
         ])
     }
 
@@ -453,17 +509,19 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         let label = NSTextField(labelWithString: text)
         label.font = AppTheme.regularFont(size: 12)
         label.textColor = AppTheme.textSecondary
-        label.lineBreakMode = .byTruncatingTail
+        label.alignment = .center
+        label.lineBreakMode = .byWordWrapping
+        label.maximumNumberOfLines = 2
         label.translatesAutoresizingMaskIntoConstraints = false
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false
         container.addSubview(label)
         NSLayoutConstraint.activate([
-            label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
-            label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
-            label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
-            label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
-            container.heightAnchor.constraint(equalToConstant: 28),
+            label.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            label.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            label.topAnchor.constraint(equalTo: container.topAnchor, constant: 6),
+            label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -6),
+            container.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
         ])
         return container
     }
@@ -507,6 +565,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         guard scanner.selectedDevice != nil else { return }
         previewImageView.isHidden = true
         placeholderView.isHidden = false
+        setPreviewActionsVisible(false)
 
         if case .ready = scanner.state {
             scanner.scan()
@@ -515,10 +574,34 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         }
     }
 
+    private func setPreviewActionsVisible(_ visible: Bool) {
+        previewActionRow.isHidden = !visible
+        previewActionRowHeight.constant = visible ? 40 : 0
+        previewImageAreaBottomToContainer.isActive = !visible
+        previewImageAreaBottomToActions.isActive = visible
+        previewContainer.layoutSubtreeIfNeeded()
+    }
+
+    private func displayScannedImage(_ image: NSImage) {
+        let displayImage = image.normalizedForDisplay()
+        hasResult = true
+        scannedImage = displayImage
+        previewImageView.image = displayImage
+        previewImageView.isHidden = false
+        placeholderView.isHidden = true
+        setPreviewActionsVisible(true)
+        resultImageView.image = displayImage
+        previewImageView.invalidateIntrinsicContentSize()
+        resultImageView.invalidateIntrinsicContentSize()
+        previewContainer.layoutSubtreeIfNeeded()
+        resultPreviewContainer.layoutSubtreeIfNeeded()
+    }
+
     private func handleScanResult(_ result: Result<NSImage, Error>) {
         progressIndicator.stopAnimation(nil)
         switch result {
         case .success(let image):
+            displayScannedImage(image)
             onScanComplete?(image)
         case .failure(let error):
             if case ScanError.cancelled = error { return }
@@ -708,8 +791,8 @@ private final class ScannerPlaceholderView: NSView, AppearanceRefreshable {
     }
 
     override func draw(_ dirtyRect: NSRect) {
-        let rect = bounds.insetBy(dx: 20, dy: 20)
-        let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4)
+        let rect = bounds.insetBy(dx: 1, dy: 1)
+        let path = NSBezierPath(roundedRect: rect, xRadius: 16, yRadius: 16)
         path.setLineDash([6, 4], count: 2, phase: 0)
         AppTheme.border.setStroke()
         path.lineWidth = 1.5
@@ -777,6 +860,8 @@ private final class ScannerSecondaryButton: NSControl, AppearanceRefreshable {
 
     private let titleLabel = NSTextField(labelWithString: "")
     private let iconView = NSImageView()
+    private var hoverTracker: HoverTracker?
+    private var isHovered = false
 
     init(title: String, symbolName: String) {
         super.init(frame: .zero)
@@ -804,6 +889,10 @@ private final class ScannerSecondaryButton: NSControl, AppearanceRefreshable {
             titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
             titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
         ])
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.setHovered(hovering)
+        }
         refreshAppearance()
     }
 
@@ -811,12 +900,19 @@ private final class ScannerSecondaryButton: NSControl, AppearanceRefreshable {
     required init?(coder: NSCoder) { nil }
 
     func refreshAppearance() {
-        layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
+        layer?.backgroundColor = (isHovered
+            ? AppTheme.elevatedBackground.highlight(withLevel: 0.05) ?? AppTheme.elevatedBackground
+            : AppTheme.elevatedBackground).cgColor
         layer?.borderColor = AppTheme.paywallBorder.cgColor
         titleLabel.textColor = AppTheme.textPrimary
         iconView.contentTintColor = AppTheme.textPrimary
     }
 
+    private func setHovered(_ hovering: Bool) {
+        isHovered = hovering
+        refreshAppearance()
+    }
+
     override func mouseUp(with event: NSEvent) {
         guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
         onClick?()

+ 3 - 2
smart_printer/ViewController.swift

@@ -126,11 +126,12 @@ class ViewController: NSViewController {
                     processed = image
                 }
 
+                let displayImage = processed.normalizedForDisplay()
                 if showScanPreview {
-                    overlay.showResult(processed)
+                    overlay.updateScanPreview(displayImage)
                 } else {
                     self?.scannerDidComplete = true
-                    onComplete(.success(processed))
+                    onComplete(.success(displayImage))
                     overlay.dismiss()
                 }
             }