소스 검색

Add photo editing tools to the print preview screen.

Let users adjust whitening, saturation, contrast, crop, flip, and rotate before printing, with edit controls placed outside the image preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 개월 전
부모
커밋
2531783fd5
3개의 변경된 파일995개의 추가작업 그리고 8개의 파일을 삭제
  1. 106 0
      smart_printer/PhotoImageEditor.swift
  2. 881 8
      smart_printer/PhotoPreviewView.swift
  3. 8 0
      smart_printer/PrintService.swift

+ 106 - 0
smart_printer/PhotoImageEditor.swift

@@ -0,0 +1,106 @@
+import Cocoa
+import CoreImage
+
+enum PhotoImageEditor {
+
+    static func apply(
+        to image: NSImage,
+        saturation: Float = 1,
+        whitening: Float = 0,
+        contrast: Float = 1,
+        autoEnhance: Bool = false
+    ) -> NSImage {
+        guard let ciImage = ciImage(from: image) else { return image }
+
+        var output = ciImage
+        if autoEnhance {
+            for filter in output.autoAdjustmentFilters(options: [.enhance: true]) {
+                filter.setValue(output, forKey: kCIInputImageKey)
+                if let result = filter.outputImage {
+                    output = result
+                }
+            }
+        }
+
+        if whitening > 0 {
+            if let controls = CIFilter(name: "CIColorControls") {
+                controls.setValue(output, forKey: kCIInputImageKey)
+                controls.setValue(whitening * 0.22, forKey: kCIInputBrightnessKey)
+                controls.setValue(1 - whitening * 0.2, forKey: kCIInputSaturationKey)
+                if let result = controls.outputImage {
+                    output = result
+                }
+            }
+            if let exposure = CIFilter(name: "CIExposureAdjust") {
+                exposure.setValue(output, forKey: kCIInputImageKey)
+                exposure.setValue(whitening * 0.4, forKey: kCIInputEVKey)
+                if let result = exposure.outputImage {
+                    output = result
+                }
+            }
+        }
+
+        if saturation != 1 || contrast != 1, let controls = CIFilter(name: "CIColorControls") {
+            controls.setValue(output, forKey: kCIInputImageKey)
+            controls.setValue(saturation, forKey: kCIInputSaturationKey)
+            controls.setValue(contrast, forKey: kCIInputContrastKey)
+            if let result = controls.outputImage {
+                output = result
+            }
+        }
+
+        return render(output, fallback: image)
+    }
+
+    static func flipHorizontal(_ image: NSImage) -> NSImage {
+        transform(image) { extent in
+            CGAffineTransform(scaleX: -1, y: 1).translatedBy(x: -extent.width, y: 0)
+        }
+    }
+
+    static func flipVertical(_ image: NSImage) -> NSImage {
+        transform(image) { extent in
+            CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -extent.height)
+        }
+    }
+
+    static func rotateClockwise(_ image: NSImage) -> NSImage {
+        transform(image) { extent in
+            CGAffineTransform(translationX: extent.height, y: 0)
+                .rotated(by: .pi / 2)
+        }
+    }
+
+    static func crop(_ image: NSImage, normalizedRect: CGRect) -> NSImage {
+        guard let ciImage = ciImage(from: image) else { return image }
+        let extent = ciImage.extent
+        let cropRect = CGRect(
+            x: extent.origin.x + normalizedRect.origin.x * extent.width,
+            y: extent.origin.y + normalizedRect.origin.y * extent.height,
+            width: normalizedRect.width * extent.width,
+            height: normalizedRect.height * extent.height
+        ).integral
+        guard cropRect.width > 1, cropRect.height > 1 else { return image }
+        let cropped = ciImage.cropped(to: cropRect)
+        return render(cropped, fallback: image)
+    }
+
+    private static func transform(_ image: NSImage, makeTransform: (CGRect) -> CGAffineTransform) -> NSImage {
+        guard let ciImage = ciImage(from: image) else { return image }
+        let extent = ciImage.extent
+        let transformed = ciImage.transformed(by: makeTransform(extent))
+        return render(transformed, fallback: image)
+    }
+
+    private static func ciImage(from image: NSImage) -> CIImage? {
+        guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
+        return CIImage(cgImage: cgImage)
+    }
+
+    private static func render(_ ciImage: CIImage, fallback: NSImage) -> NSImage {
+        let context = CIContext(options: [.useSoftwareRenderer: false])
+        let extent = ciImage.extent.integral
+        guard let cgImage = context.createCGImage(ciImage, from: extent) else { return fallback }
+        return NSImage(cgImage: cgImage, size: NSSize(width: extent.width, height: extent.height))
+    }
+}

+ 881 - 8
smart_printer/PhotoPreviewView.swift

@@ -20,13 +20,48 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
     private var currentIndex = 0
     private var securityAccess: [URL: Bool] = [:]
 
+    private var originalImage: NSImage?
+    private var editedImage: NSImage?
+    private var whiteningAmount: Float = 0
+    private var saturationAmount: Float = 1
+    private var contrastAmount: Float = 1
+    private var autoEnhanceApplied = false
+
     private let backButton = PhotoToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
     private let titleLabel = NSTextField(labelWithString: "Print a photo")
     private let counterLabel = NSTextField(labelWithString: "")
+    private let previewSection = NSView()
+    private let editControlsArea = NSView()
     private let imageContainer = NSView()
     private let imageView = NSImageView()
+    private let editButton = PhotoToolbarButton(symbolName: "slider.horizontal.3", accessibilityLabel: "Edit")
+    private let editPanel = PhotoEditPanelView()
+    private let whiteningPanel = PhotoAdjustSliderPanelView(
+        title: "Whitening",
+        minValue: 0,
+        maxValue: 1,
+        defaultValue: 0
+    )
+    private let saturationPanel = PhotoAdjustSliderPanelView(
+        title: "Saturation",
+        minValue: 0,
+        maxValue: 2,
+        defaultValue: 1
+    )
+    private let contrastPanel = PhotoAdjustSliderPanelView(
+        title: "Contrast",
+        minValue: 0,
+        maxValue: 2,
+        defaultValue: 1
+    )
+    private var cropOverlay: PhotoCropOverlayView?
     private let printButton = PhotoPrintButton()
 
+    private var imageContainerTopConstraint: NSLayoutConstraint!
+
+    private var isEditPanelVisible = false
+    private var isCropMode = false
+
     init(urls: [URL]) {
         self.urls = urls
         super.init(frame: .zero)
@@ -60,6 +95,12 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
         imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         backButton.refreshAppearance()
+        editButton.refreshAppearance()
+        editPanel.refreshAppearance()
+        whiteningPanel.refreshAppearance()
+        saturationPanel.refreshAppearance()
+        contrastPanel.refreshAppearance()
+        cropOverlay?.refreshAppearance()
         printButton.refreshAppearance()
     }
 
@@ -125,16 +166,50 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
         imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
 
+        previewSection.translatesAutoresizingMaskIntoConstraints = false
+        editControlsArea.translatesAutoresizingMaskIntoConstraints = false
         backButton.translatesAutoresizingMaskIntoConstraints = false
+        editButton.translatesAutoresizingMaskIntoConstraints = false
+        editPanel.translatesAutoresizingMaskIntoConstraints = false
+        whiteningPanel.translatesAutoresizingMaskIntoConstraints = false
+        saturationPanel.translatesAutoresizingMaskIntoConstraints = false
+        contrastPanel.translatesAutoresizingMaskIntoConstraints = false
         printButton.translatesAutoresizingMaskIntoConstraints = false
 
+        editPanel.isHidden = true
+        hideAllAdjustmentPanels()
+
         backButton.onClick = { [weak self] in self?.dismiss() }
+        editButton.onClick = { [weak self] in self?.toggleEditPanel() }
         printButton.onClick = { [weak self] in self?.printCurrentPhoto() }
 
+        editPanel.onAction = { [weak self] action in
+            self?.handleEditAction(action)
+        }
+        whiteningPanel.onValueChanged = { [weak self] value in
+            self?.whiteningAmount = value
+            self?.refreshDisplayedImage()
+        }
+        saturationPanel.onValueChanged = { [weak self] value in
+            self?.saturationAmount = value
+            self?.refreshDisplayedImage()
+        }
+        contrastPanel.onValueChanged = { [weak self] value in
+            self?.contrastAmount = value
+            self?.refreshDisplayedImage()
+        }
+
         addSubview(backButton)
         addSubview(titleLabel)
         addSubview(counterLabel)
-        addSubview(imageContainer)
+        addSubview(previewSection)
+        previewSection.addSubview(editControlsArea)
+        previewSection.addSubview(imageContainer)
+        editControlsArea.addSubview(editButton)
+        editControlsArea.addSubview(editPanel)
+        editControlsArea.addSubview(whiteningPanel)
+        editControlsArea.addSubview(saturationPanel)
+        editControlsArea.addSubview(contrastPanel)
         imageContainer.addSubview(imageView)
         addSubview(printButton)
 
@@ -150,10 +225,47 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
             counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
             counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
 
-            imageContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
-            imageContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
-            imageContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
-            imageContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
+            previewSection.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
+            previewSection.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
+            previewSection.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
+            previewSection.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
+
+            editControlsArea.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
+            editControlsArea.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
+            editControlsArea.topAnchor.constraint(equalTo: previewSection.topAnchor),
+
+            editButton.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
+            editButton.topAnchor.constraint(equalTo: editControlsArea.topAnchor),
+            editButton.widthAnchor.constraint(equalToConstant: 40),
+            editButton.heightAnchor.constraint(equalToConstant: 40),
+
+            editPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
+            editPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
+            editPanel.topAnchor.constraint(equalTo: editButton.bottomAnchor, constant: 10),
+
+            whiteningPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
+            whiteningPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
+            whiteningPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
+
+            saturationPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
+            saturationPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
+            saturationPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
+
+            contrastPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
+            contrastPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
+            contrastPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
+
+            editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: whiteningPanel.bottomAnchor),
+            editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: saturationPanel.bottomAnchor),
+            editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: contrastPanel.bottomAnchor),
+            editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: editPanel.bottomAnchor),
+            editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: editButton.bottomAnchor),
+
+            imageContainer.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
+            imageContainer.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
+            imageContainer.heightAnchor.constraint(equalToConstant: 300),
+            imageContainer.heightAnchor.constraint(lessThanOrEqualToConstant: 340),
+            imageContainer.bottomAnchor.constraint(lessThanOrEqualTo: previewSection.bottomAnchor),
 
             imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
             imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
@@ -164,6 +276,174 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
             printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
             printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
         ])
+
+        updateImageContainerTopConstraint()
+    }
+
+    private func updateImageContainerTopConstraint() {
+        imageContainerTopConstraint?.isActive = false
+
+        let anchor: NSLayoutYAxisAnchor
+        if !whiteningPanel.isHidden {
+            anchor = whiteningPanel.bottomAnchor
+        } else if !saturationPanel.isHidden {
+            anchor = saturationPanel.bottomAnchor
+        } else if !contrastPanel.isHidden {
+            anchor = contrastPanel.bottomAnchor
+        } else if !editPanel.isHidden {
+            anchor = editPanel.bottomAnchor
+        } else {
+            anchor = editButton.bottomAnchor
+        }
+
+        imageContainerTopConstraint = imageContainer.topAnchor.constraint(equalTo: anchor, constant: 14)
+        imageContainerTopConstraint.isActive = true
+    }
+
+    private func hideAllAdjustmentPanels() {
+        whiteningPanel.isHidden = true
+        saturationPanel.isHidden = true
+        contrastPanel.isHidden = true
+    }
+
+    private func toggleAdjustmentPanel(_ panel: PhotoAdjustSliderPanelView, action: PhotoEditAction, currentValue: Float) {
+        let willShow = panel.isHidden
+        hideAllAdjustmentPanels()
+        editPanel.setActive(.whitening, isActive: false)
+        editPanel.setActive(.saturation, isActive: false)
+        editPanel.setActive(.contrast, isActive: false)
+
+        if willShow {
+            panel.isHidden = false
+            panel.setValue(currentValue)
+            editPanel.setActive(action, isActive: true)
+        }
+        updateImageContainerTopConstraint()
+    }
+
+    private func toggleEditPanel() {
+        isEditPanelVisible.toggle()
+        editPanel.isHidden = !isEditPanelVisible
+        hideAllAdjustmentPanels()
+        updateImageContainerTopConstraint()
+        if !isEditPanelVisible {
+            exitCropMode()
+        }
+    }
+
+    private func handleEditAction(_ action: PhotoEditAction) {
+        switch action {
+        case .whitening:
+            toggleAdjustmentPanel(whiteningPanel, action: .whitening, currentValue: whiteningAmount)
+        case .autoMode:
+            autoEnhanceApplied = true
+            editPanel.setActive(.autoMode, isActive: true)
+            refreshDisplayedImage()
+        case .saturation:
+            toggleAdjustmentPanel(saturationPanel, action: .saturation, currentValue: saturationAmount)
+        case .contrast:
+            toggleAdjustmentPanel(contrastPanel, action: .contrast, currentValue: contrastAmount)
+        case .crop:
+            hideAllAdjustmentPanels()
+            updateImageContainerTopConstraint()
+            enterCropMode()
+        case .flip:
+            applyGeometricEdit { PhotoImageEditor.flipHorizontal($0) }
+        case .rotate:
+            applyGeometricEdit { PhotoImageEditor.rotateClockwise($0) }
+        case .done:
+            isEditPanelVisible = false
+            editPanel.isHidden = true
+            hideAllAdjustmentPanels()
+            updateImageContainerTopConstraint()
+            exitCropMode()
+        }
+    }
+
+    private func applyGeometricEdit(_ transform: (NSImage) -> NSImage) {
+        guard let base = currentBaseImage() else { return }
+        let transformed = transform(base)
+        originalImage = transformed
+        whiteningAmount = 0
+        saturationAmount = 1
+        contrastAmount = 1
+        autoEnhanceApplied = false
+        editedImage = nil
+        editPanel.resetActiveStates()
+        whiteningPanel.setValue(0)
+        saturationPanel.setValue(1)
+        contrastPanel.setValue(1)
+        refreshDisplayedImage()
+    }
+
+    private func enterCropMode() {
+        guard !isCropMode, currentBaseImage() != nil else { return }
+        isCropMode = true
+        editPanel.setActive(.crop, isActive: true)
+        hideAllAdjustmentPanels()
+        updateImageContainerTopConstraint()
+
+        let overlay = PhotoCropOverlayView(imageView: imageView)
+        overlay.translatesAutoresizingMaskIntoConstraints = false
+        overlay.onCancel = { [weak self] in
+            self?.exitCropMode()
+        }
+        overlay.onApply = { [weak self] normalizedRect in
+            self?.applyCrop(normalizedRect)
+        }
+        imageContainer.addSubview(overlay, positioned: .above, relativeTo: imageView)
+        NSLayoutConstraint.activate([
+            overlay.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
+            overlay.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
+            overlay.topAnchor.constraint(equalTo: imageView.topAnchor),
+            overlay.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
+        ])
+        cropOverlay = overlay
+        overlay.refreshAppearance()
+    }
+
+    private func exitCropMode() {
+        isCropMode = false
+        editPanel.setActive(.crop, isActive: false)
+        cropOverlay?.removeFromSuperview()
+        cropOverlay = nil
+    }
+
+    private func applyCrop(_ normalizedRect: CGRect) {
+        guard let base = currentBaseImage() else {
+            exitCropMode()
+            return
+        }
+        originalImage = PhotoImageEditor.crop(base, normalizedRect: normalizedRect)
+        whiteningAmount = 0
+        saturationAmount = 1
+        contrastAmount = 1
+        autoEnhanceApplied = false
+        editedImage = nil
+        editPanel.resetActiveStates()
+        whiteningPanel.setValue(0)
+        saturationPanel.setValue(1)
+        contrastPanel.setValue(1)
+        exitCropMode()
+        refreshDisplayedImage()
+    }
+
+    private func currentBaseImage() -> NSImage? {
+        originalImage ?? imageView.image
+    }
+
+    private func refreshDisplayedImage() {
+        guard let base = originalImage else { return }
+        let processed = PhotoImageEditor.apply(
+            to: base,
+            saturation: saturationAmount,
+            whitening: whiteningAmount,
+            contrast: contrastAmount,
+            autoEnhance: autoEnhanceApplied
+        )
+        editedImage = processed
+        imageView.image = processed
+        cropOverlay?.imageDidChange()
     }
 
     private func showPhoto(at index: Int) {
@@ -175,7 +455,23 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
             securityAccess[url] = url.startAccessingSecurityScopedResource()
         }
 
-        imageView.image = NSImage(contentsOf: url)
+        originalImage = NSImage(contentsOf: url)
+        editedImage = nil
+        whiteningAmount = 0
+        saturationAmount = 1
+        contrastAmount = 1
+        autoEnhanceApplied = false
+        isEditPanelVisible = false
+        editPanel.isHidden = true
+        editPanel.resetActiveStates()
+        hideAllAdjustmentPanels()
+        whiteningPanel.setValue(0)
+        saturationPanel.setValue(1)
+        contrastPanel.setValue(1)
+        updateImageContainerTopConstraint()
+        exitCropMode()
+
+        imageView.image = originalImage
 
         if urls.count > 1 {
             counterLabel.stringValue = "\(index + 1) of \(urls.count)"
@@ -191,8 +487,11 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
     }
 
     private func printCurrentPhoto() {
-        guard let url = currentURL() else { return }
-        PrintService.print(urls: [url], from: window)
+        let image = editedImage ?? originalImage ?? currentURL().flatMap { NSImage(contentsOf: $0) }
+        guard let image else { return }
+
+        let title = currentURL()?.lastPathComponent ?? "Photo"
+        PrintService.printPhoto(image, title: title, from: window)
 
         if currentIndex < urls.count - 1 {
             showPhoto(at: currentIndex + 1)
@@ -209,6 +508,580 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
     }
 }
 
+// MARK: - Edit Actions
+
+private enum PhotoEditAction {
+    case whitening
+    case autoMode
+    case saturation
+    case contrast
+    case crop
+    case flip
+    case rotate
+    case done
+}
+
+// MARK: - Edit Panel
+
+private final class PhotoEditPanelView: NSView, AppearanceRefreshable {
+    var onAction: ((PhotoEditAction) -> Void)?
+
+    private let stack = NSStackView()
+    private var activeActions: Set<PhotoEditAction> = []
+
+    private struct Tool {
+        let action: PhotoEditAction
+        let title: String
+        let symbol: String
+    }
+
+    private let tools: [Tool] = [
+        Tool(action: .whitening, title: "Whitening", symbol: "sparkles"),
+        Tool(action: .autoMode, title: "Auto", symbol: "wand.and.stars"),
+        Tool(action: .saturation, title: "Saturation", symbol: "drop.halffull"),
+        Tool(action: .contrast, title: "Contrast", symbol: "circle.lefthalf.filled"),
+        Tool(action: .crop, title: "Crop", symbol: "crop"),
+        Tool(action: .flip, title: "Flip", symbol: "arrow.left.and.right.righttriangle.left.righttriangle.right"),
+        Tool(action: .rotate, title: "Rotate", symbol: "rotate.right"),
+        Tool(action: .done, title: "Done", symbol: "checkmark"),
+    ]
+
+    init() {
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.cornerRadius = 14
+        translatesAutoresizingMaskIntoConstraints = false
+
+        stack.orientation = .horizontal
+        stack.spacing = 6
+        stack.distribution = .fillEqually
+        stack.translatesAutoresizingMaskIntoConstraints = false
+
+        for tool in tools {
+            let button = PhotoEditToolButton(title: tool.title, symbolName: tool.symbol)
+            button.onClick = { [weak self] in
+                self?.onAction?(tool.action)
+            }
+            stack.addArrangedSubview(button)
+        }
+
+        addSubview(stack)
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 72),
+            stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
+            stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
+            stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
+            stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.elevatedBackground.withAlphaComponent(0.96).cgColor
+        layer?.borderColor = AppTheme.paywallBorder.cgColor
+        layer?.borderWidth = 1
+        stack.arrangedSubviews.compactMap { $0 as? PhotoEditToolButton }.forEach { $0.refreshAppearance() }
+    }
+
+    func setActive(_ action: PhotoEditAction, isActive: Bool) {
+        if isActive {
+            if action == .whitening || action == .autoMode || action == .saturation || action == .contrast || action == .crop {
+                activeActions.insert(action)
+            }
+        } else {
+            activeActions.remove(action)
+        }
+        updateButtonStates()
+    }
+
+    func resetActiveStates() {
+        activeActions.removeAll()
+        updateButtonStates()
+    }
+
+    private func updateButtonStates() {
+        for (index, tool) in tools.enumerated() {
+            guard let button = stack.arrangedSubviews[index] as? PhotoEditToolButton else { continue }
+            button.isActive = activeActions.contains(tool.action)
+        }
+    }
+}
+
+// MARK: - Edit Tool Button
+
+private final class PhotoEditToolButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+    var isActive = false {
+        didSet { refreshAppearance() }
+    }
+
+    private let title: String
+    private let iconView = NSImageView()
+    private let titleLabel = NSTextField(labelWithString: "")
+    private var hoverTracker: HoverTracker?
+    private var isHovered = false
+
+    init(title: String, symbolName: String) {
+        self.title = title
+        super.init(frame: .zero)
+
+        wantsLayer = true
+        layer?.cornerRadius = 10
+
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
+            let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
+            iconView.image = image.withSymbolConfiguration(config)
+        }
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.mediumFont(size: 10)
+        titleLabel.alignment = .center
+        titleLabel.maximumNumberOfLines = 1
+        titleLabel.lineBreakMode = .byTruncatingTail
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(iconView)
+        addSubview(titleLabel)
+
+        NSLayoutConstraint.activate([
+            iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
+            iconView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
+            iconView.widthAnchor.constraint(equalToConstant: 18),
+            iconView.heightAnchor.constraint(equalToConstant: 18),
+
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
+            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2),
+            titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 4),
+            titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6),
+        ])
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.isHovered = hovering
+            self?.refreshAppearance()
+        }
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        if isActive {
+            layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.15).cgColor
+            layer?.borderColor = AppTheme.blue.cgColor
+            layer?.borderWidth = 1.5
+            iconView.contentTintColor = AppTheme.blue
+            titleLabel.textColor = AppTheme.blue
+        } else if isHovered {
+            layer?.backgroundColor = AppTheme.border.withAlphaComponent(0.35).cgColor
+            layer?.borderColor = AppTheme.paywallBorder.cgColor
+            layer?.borderWidth = 1
+            iconView.contentTintColor = AppTheme.textPrimary
+            titleLabel.textColor = AppTheme.textPrimary
+        } else {
+            layer?.backgroundColor = NSColor.clear.cgColor
+            layer?.borderColor = NSColor.clear.cgColor
+            layer?.borderWidth = 0
+            iconView.contentTintColor = AppTheme.textPrimary
+            titleLabel.textColor = AppTheme.textSecondary
+        }
+    }
+
+    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: - Adjustment Slider Panel
+
+private final class PhotoAdjustSliderPanelView: NSView, AppearanceRefreshable {
+    var onValueChanged: ((Float) -> Void)?
+
+    private let titleLabel = NSTextField(labelWithString: "")
+    private let valueLabel = NSTextField(labelWithString: "0%")
+    private let slider: NSSlider
+    private let defaultValue: Float
+
+    init(title: String, minValue: Float, maxValue: Float, defaultValue: Float) {
+        self.defaultValue = defaultValue
+        slider = NSSlider(value: Double(defaultValue), minValue: Double(minValue), maxValue: Double(maxValue), target: nil, action: nil)
+        super.init(frame: .zero)
+
+        wantsLayer = true
+        layer?.cornerRadius = 12
+        translatesAutoresizingMaskIntoConstraints = false
+
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.mediumFont(size: 13)
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        valueLabel.font = AppTheme.regularFont(size: 12)
+        valueLabel.alignment = .right
+        valueLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        slider.target = self
+        slider.action = #selector(sliderChanged)
+        slider.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(titleLabel)
+        addSubview(valueLabel)
+        addSubview(slider)
+
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 56),
+
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+            titleLabel.widthAnchor.constraint(equalToConstant: 78),
+
+            valueLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
+            valueLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+            valueLabel.widthAnchor.constraint(equalToConstant: 48),
+
+            slider.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8),
+            slider.trailingAnchor.constraint(equalTo: valueLabel.leadingAnchor, constant: -12),
+            slider.centerYAnchor.constraint(equalTo: centerYAnchor),
+        ])
+        setValue(defaultValue)
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func setValue(_ value: Float) {
+        slider.floatValue = value
+        updateValueLabel()
+    }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.elevatedBackground.withAlphaComponent(0.96).cgColor
+        layer?.borderColor = AppTheme.paywallBorder.cgColor
+        layer?.borderWidth = 1
+        titleLabel.textColor = AppTheme.textPrimary
+        valueLabel.textColor = AppTheme.textSecondary
+    }
+
+    @objc private func sliderChanged() {
+        updateValueLabel()
+        onValueChanged?(slider.floatValue)
+    }
+
+    private func updateValueLabel() {
+        valueLabel.stringValue = "\(Int(slider.floatValue * 100))%"
+    }
+}
+
+// MARK: - Crop Overlay
+
+private final class PhotoCropOverlayView: NSView, AppearanceRefreshable {
+    var onApply: ((CGRect) -> Void)?
+    var onCancel: (() -> Void)?
+
+    private weak var imageView: NSImageView?
+    private var cropRect = CGRect(x: 0.1, y: 0.1, width: 0.8, height: 0.8)
+    private var dragMode: CropDragMode = .none
+    private var dragStartPoint = NSPoint.zero
+    private var dragStartRect = CGRect.zero
+
+    private let applyButton = PhotoCropActionButton(title: "Apply", isPrimary: true)
+    private let cancelButton = PhotoCropActionButton(title: "Cancel", isPrimary: false)
+
+    private enum CropDragMode {
+        case none
+        case move
+        case resize(CropHandle)
+    }
+
+    private enum CropHandle {
+        case topLeft, topRight, bottomLeft, bottomRight
+    }
+
+    init(imageView: NSImageView) {
+        self.imageView = imageView
+        super.init(frame: .zero)
+        wantsLayer = true
+
+        applyButton.translatesAutoresizingMaskIntoConstraints = false
+        cancelButton.translatesAutoresizingMaskIntoConstraints = false
+        applyButton.onClick = { [weak self] in
+            guard let self else { return }
+            onApply?(cropRect)
+        }
+        cancelButton.onClick = { [weak self] in
+            self?.onCancel?()
+        }
+
+        addSubview(applyButton)
+        addSubview(cancelButton)
+
+        NSLayoutConstraint.activate([
+            applyButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
+            applyButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
+
+            cancelButton.trailingAnchor.constraint(equalTo: applyButton.leadingAnchor, constant: -8),
+            cancelButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func imageDidChange() {
+        needsDisplay = true
+    }
+
+    func refreshAppearance() {
+        applyButton.refreshAppearance()
+        cancelButton.refreshAppearance()
+        needsDisplay = true
+    }
+
+    override func draw(_ dirtyRect: NSRect) {
+        guard let imageFrame = currentImageFrame() else { return }
+
+        let selection = rectInView(from: cropRect, imageFrame: imageFrame)
+        let dim = NSColor.black.withAlphaComponent(0.45)
+        dim.setFill()
+        NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: selection.maxY - bounds.minY).fill()
+        NSRect(x: bounds.minX, y: selection.minY, width: selection.minX - bounds.minX, height: selection.height).fill()
+        NSRect(x: selection.maxX, y: selection.minY, width: bounds.maxX - selection.maxX, height: selection.height).fill()
+        NSRect(x: bounds.minX, y: selection.maxY, width: bounds.width, height: bounds.maxY - selection.maxY).fill()
+
+        NSColor.white.setStroke()
+        let path = NSBezierPath(rect: selection)
+        path.lineWidth = 2
+        path.stroke()
+
+        let handleSize: CGFloat = 10
+        for point in handlePoints(in: selection) {
+            let handle = NSRect(
+                x: point.x - handleSize / 2,
+                y: point.y - handleSize / 2,
+                width: handleSize,
+                height: handleSize
+            )
+            NSColor.white.setFill()
+            NSBezierPath(ovalIn: handle).fill()
+            AppTheme.blue.setStroke()
+            NSBezierPath(ovalIn: handle).lineWidth = 1.5
+            NSBezierPath(ovalIn: handle).stroke()
+        }
+    }
+
+    override func mouseDown(with event: NSEvent) {
+        guard let imageFrame = currentImageFrame() else { return }
+        let point = convert(event.locationInWindow, from: nil)
+        let selection = rectInView(from: cropRect, imageFrame: imageFrame)
+
+        if let handle = hitTestHandle(at: point, in: selection) {
+            dragMode = .resize(handle)
+        } else if selection.contains(point) {
+            dragMode = .move
+        } else {
+            dragMode = .none
+            return
+        }
+
+        dragStartPoint = point
+        dragStartRect = cropRect
+    }
+
+    override func mouseDragged(with event: NSEvent) {
+        guard case .none = dragMode else {
+            performDrag(with: event)
+            return
+        }
+    }
+
+    private func performDrag(with event: NSEvent) {
+        guard let imageFrame = currentImageFrame() else { return }
+        let point = convert(event.locationInWindow, from: nil)
+        let dx = (point.x - dragStartPoint.x) / imageFrame.width
+        let dy = (point.y - dragStartPoint.y) / imageFrame.height
+
+        switch dragMode {
+        case .move:
+            var rect = dragStartRect
+            rect.origin.x += dx
+            rect.origin.y += dy
+            cropRect = clamp(rect)
+        case .resize(let handle):
+            cropRect = clamp(resizeRect(from: dragStartRect, handle: handle, dx: dx, dy: dy))
+        case .none:
+            return
+        }
+        needsDisplay = true
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        dragMode = .none
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .crosshair)
+    }
+
+    private func currentImageFrame() -> NSRect? {
+        guard let imageView, let image = imageView.image else { return nil }
+        let bounds = self.bounds
+        guard image.size.width > 0, image.size.height > 0, bounds.width > 0, bounds.height > 0 else {
+            return nil
+        }
+        let imageAspect = image.size.width / image.size.height
+        let boundsAspect = bounds.width / bounds.height
+        if imageAspect > boundsAspect {
+            let height = bounds.width / imageAspect
+            let y = (bounds.height - height) / 2
+            return NSRect(x: 0, y: y, width: bounds.width, height: height)
+        }
+        let width = bounds.height * imageAspect
+        let x = (bounds.width - width) / 2
+        return NSRect(x: x, y: 0, width: width, height: bounds.height)
+    }
+
+    private func rectInView(from normalized: CGRect, imageFrame: NSRect) -> NSRect {
+        NSRect(
+            x: imageFrame.origin.x + normalized.origin.x * imageFrame.width,
+            y: imageFrame.origin.y + normalized.origin.y * imageFrame.height,
+            width: normalized.width * imageFrame.width,
+            height: normalized.height * imageFrame.height
+        )
+    }
+
+    private func handlePoints(in rect: NSRect) -> [NSPoint] {
+        [
+            NSPoint(x: rect.minX, y: rect.minY),
+            NSPoint(x: rect.maxX, y: rect.minY),
+            NSPoint(x: rect.minX, y: rect.maxY),
+            NSPoint(x: rect.maxX, y: rect.maxY),
+        ]
+    }
+
+    private func hitTestHandle(at point: NSPoint, in rect: NSRect) -> CropHandle? {
+        let threshold: CGFloat = 14
+        let handles: [(CropHandle, NSPoint)] = [
+            (.bottomLeft, NSPoint(x: rect.minX, y: rect.minY)),
+            (.bottomRight, NSPoint(x: rect.maxX, y: rect.minY)),
+            (.topLeft, NSPoint(x: rect.minX, y: rect.maxY)),
+            (.topRight, NSPoint(x: rect.maxX, y: rect.maxY)),
+        ]
+        for (handle, center) in handles {
+            let handleRect = NSRect(
+                x: center.x - threshold / 2,
+                y: center.y - threshold / 2,
+                width: threshold,
+                height: threshold
+            )
+            if handleRect.contains(point) {
+                return handle
+            }
+        }
+        return nil
+    }
+
+    private func resizeRect(from start: CGRect, handle: CropHandle, dx: CGFloat, dy: CGFloat) -> CGRect {
+        var rect = start
+        switch handle {
+        case .bottomLeft:
+            rect.origin.x += dx
+            rect.size.width -= dx
+            rect.origin.y += dy
+            rect.size.height -= dy
+        case .bottomRight:
+            rect.size.width += dx
+            rect.origin.y += dy
+            rect.size.height -= dy
+        case .topLeft:
+            rect.origin.x += dx
+            rect.size.width -= dx
+            rect.size.height += dy
+        case .topRight:
+            rect.size.width += dx
+            rect.size.height += dy
+        }
+        return rect
+    }
+
+    private func clamp(_ rect: CGRect) -> CGRect {
+        let minSize: CGFloat = 0.12
+        var result = rect
+        result.size.width = max(minSize, result.size.width)
+        result.size.height = max(minSize, result.size.height)
+        result.origin.x = min(max(0, result.origin.x), 1 - result.size.width)
+        result.origin.y = min(max(0, result.origin.y), 1 - result.size.height)
+        return result
+    }
+}
+
+private final class PhotoCropActionButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let title: String
+    private let isPrimary: Bool
+    private let titleLabel = NSTextField(labelWithString: "")
+
+    init(title: String, isPrimary: Bool) {
+        self.title = title
+        self.isPrimary = isPrimary
+        super.init(frame: .zero)
+
+        wantsLayer = true
+        layer?.cornerRadius = 8
+
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.semiboldFont(size: 12)
+        titleLabel.alignment = .center
+        titleLabel.isBordered = false
+        titleLabel.isEditable = false
+        titleLabel.drawsBackground = false
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(titleLabel)
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 30),
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
+            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        if isPrimary {
+            layer?.backgroundColor = AppTheme.blue.cgColor
+            titleLabel.textColor = .white
+        } else {
+            layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
+            layer?.borderColor = AppTheme.paywallBorder.cgColor
+            layer?.borderWidth = 1
+            titleLabel.textColor = 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: - Toolbar Button
 
 private final class PhotoToolbarButton: NSControl, AppearanceRefreshable {

+ 8 - 0
smart_printer/PrintService.swift

@@ -24,6 +24,14 @@ enum PrintService {
         printImage(image, title: "Blank Canvas", from: window)
     }
 
+    static func printPhoto(_ image: NSImage, title: String = "Photo", from window: NSWindow? = nil) {
+        guard image.size.width > 0, image.size.height > 0 else {
+            showPrintFailedAlert()
+            return
+        }
+        printImage(image, title: title, from: window)
+    }
+
     static func saveCanvasPDF(_ image: NSImage) {
         guard image.size.width > 0, image.size.height > 0 else {
             showEmptyCanvasAlert()