Browse Source

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 month ago
parent
commit
2531783fd5

+ 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()