| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014 |
- import Cocoa
- import ImageCaptureCore
- // MARK: - Scanner View
- final class ScannerOverlayView: NSView, AppearanceRefreshable {
- var onScanComplete: ((NSImage) -> Void)?
- var onDismiss: (() -> Void)?
- private(set) var hasResult = false
- private let scanner: ScannerService
- private var pendingScan = false
- private var scannedPages: [NSImage] = []
- private let scanPhaseView = NSView()
- private let resultPhaseView = NSView()
- private let backButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
- private let titleLabel = NSTextField(labelWithString: "Scanner")
- private let documentsScannedLabel = NSTextField(labelWithString: "")
- private let sidebarView = NSView()
- private let localHeader = ScannerSectionHeader(title: "DEVICES")
- private let localList = NSStackView()
- private let sharedHeader = ScannerSectionHeader(title: "SHARED")
- private let sharedList = NSStackView()
- private let previewContainer = NSView()
- private let previewImageArea = NSView()
- private let editorView = EditableScanContentView()
- 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 resultEditorView = EditableScanContentView()
- private let savePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
- private let printButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
- init(scanner: ScannerService = .shared) {
- self.scanner = scanner
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- bindScanner()
- refreshAppearance()
- rebuildDeviceLists()
- updateSelectionUI()
- updateStateUI(scanner.state)
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(appearanceDidChange),
- name: .appearanceDidChange,
- object: nil
- )
- }
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func activate() async {
- bindScanner()
- await scanner.prepareForScanning()
- rebuildDeviceLists()
- updateSelectionUI()
- updateStateUI(scanner.state)
- }
- func teardown() {
- scanner.onStateChanged = nil
- scanner.onDevicesChanged = nil
- scanner.onSelectionChanged = nil
- scanner.onScanComplete = nil
- scanner.detachFromUI()
- }
- func showResult(_ image: NSImage) {
- hasResult = true
- processScannedImage(image)
- scanPhaseView.isHidden = true
- resultPhaseView.isHidden = false
- }
- func updateScanPreview(_ image: NSImage) {
- processScannedImage(image)
- }
- func present(in parent: NSView) {
- guard superview == nil else { return }
- parent.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: parent.leadingAnchor),
- trailingAnchor.constraint(equalTo: parent.trailingAnchor),
- topAnchor.constraint(equalTo: parent.topAnchor),
- bottomAnchor.constraint(equalTo: parent.bottomAnchor),
- ])
- alphaValue = 0
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.2
- animator().alphaValue = 1
- }
- }
- func dismiss(animated: Bool = true) {
- let remove = { [weak self] in
- self?.teardown()
- self?.removeFromSuperview()
- self?.onDismiss?()
- }
- guard animated else {
- remove()
- return
- }
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.15
- animator().alphaValue = 0
- }, completionHandler: remove)
- }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.background.cgColor
- titleLabel.textColor = AppTheme.textPrimary
- documentsScannedLabel.textColor = AppTheme.textSecondary
- 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
- resultPreviewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
- statusLabel.textColor = AppTheme.textSecondary
- backButton.refreshAppearance()
- scanButton.refreshAppearance()
- localHeader.refreshAppearance()
- sharedHeader.refreshAppearance()
- placeholderView.refreshAppearance()
- savePDFButton.refreshAppearance()
- printButton.refreshAppearance()
- }
- @objc private func appearanceDidChange() {
- refreshAppearance()
- rebuildDeviceLists()
- }
- private func setup() {
- wantsLayer = true
- setupScanPhase()
- setupResultPhase()
- addSubview(scanPhaseView)
- addSubview(resultPhaseView)
- resultPhaseView.isHidden = true
- NSLayoutConstraint.activate([
- scanPhaseView.leadingAnchor.constraint(equalTo: leadingAnchor),
- scanPhaseView.trailingAnchor.constraint(equalTo: trailingAnchor),
- scanPhaseView.topAnchor.constraint(equalTo: topAnchor),
- scanPhaseView.bottomAnchor.constraint(equalTo: bottomAnchor),
- resultPhaseView.leadingAnchor.constraint(equalTo: leadingAnchor),
- resultPhaseView.trailingAnchor.constraint(equalTo: trailingAnchor),
- resultPhaseView.topAnchor.constraint(equalTo: topAnchor),
- resultPhaseView.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- }
- private func setupScanPhase() {
- scanPhaseView.translatesAutoresizingMaskIntoConstraints = false
- titleLabel.font = AppTheme.semiboldFont(size: 16)
- titleLabel.alignment = .center
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- documentsScannedLabel.font = AppTheme.regularFont(size: 12)
- documentsScannedLabel.textColor = AppTheme.textSecondary
- documentsScannedLabel.alignment = .center
- documentsScannedLabel.translatesAutoresizingMaskIntoConstraints = false
- documentsScannedLabel.isHidden = true
- sidebarView.wantsLayer = true
- sidebarView.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
- sidebarView.layer?.borderWidth = 1.5
- sidebarView.layer?.masksToBounds = true
- sidebarView.translatesAutoresizingMaskIntoConstraints = false
- localList.orientation = .vertical
- localList.alignment = .leading
- localList.spacing = 2
- localList.translatesAutoresizingMaskIntoConstraints = false
- sharedList.orientation = .vertical
- sharedList.alignment = .leading
- sharedList.spacing = 2
- sharedList.translatesAutoresizingMaskIntoConstraints = false
- previewContainer.wantsLayer = true
- previewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
- previewContainer.layer?.borderWidth = 1.5
- previewContainer.applyCardShadow()
- previewContainer.translatesAutoresizingMaskIntoConstraints = false
- previewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- previewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
- previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- previewImageArea.translatesAutoresizingMaskIntoConstraints = false
- editorView.translatesAutoresizingMaskIntoConstraints = false
- editorView.isHidden = true
- editorView.onRecognitionComplete = { [weak self] in
- self?.editorView.makeFirstResponder()
- }
- placeholderView.translatesAutoresizingMaskIntoConstraints = false
- statusLabel.font = AppTheme.regularFont(size: 13)
- statusLabel.alignment = .center
- statusLabel.lineBreakMode = .byWordWrapping
- statusLabel.maximumNumberOfLines = 2
- statusLabel.translatesAutoresizingMaskIntoConstraints = false
- for size in PaperSize.allCases {
- paperSizePopup.addItem(withTitle: size.rawValue)
- }
- paperSizePopup.selectItem(withTitle: AppSettings.defaultPaperSize.rawValue)
- paperSizePopup.font = AppTheme.regularFont(size: 13)
- paperSizePopup.translatesAutoresizingMaskIntoConstraints = false
- paperSizePopup.target = self
- paperSizePopup.action = #selector(paperSizeChanged)
- backButton.translatesAutoresizingMaskIntoConstraints = false
- backButton.onClick = { [weak self] in self?.dismiss() }
- scanButton.translatesAutoresizingMaskIntoConstraints = false
- scanButton.isEnabled = false
- scanButton.onClick = { [weak self] in self?.startScan() }
- progressIndicator.style = .spinning
- progressIndicator.controlSize = .small
- 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 = 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
- sidebarScroll.hasHorizontalScroller = false
- sidebarScroll.autohidesScrollers = true
- sidebarScroll.drawsBackground = false
- sidebarScroll.borderType = .noBorder
- sidebarScroll.translatesAutoresizingMaskIntoConstraints = false
- let sidebarDocument = FlippedSidebarContentView()
- sidebarDocument.translatesAutoresizingMaskIntoConstraints = false
- sidebarDocument.addSubview(sidebarStack)
- sidebarScroll.documentView = sidebarDocument
- sidebarView.addSubview(sidebarScroll)
- previewImageArea.addSubview(editorView)
- previewImageArea.addSubview(placeholderView)
- previewContainer.addSubview(previewImageArea)
- previewContainer.addSubview(previewActionRow)
- scanPhaseView.addSubview(backButton)
- scanPhaseView.addSubview(titleLabel)
- scanPhaseView.addSubview(documentsScannedLabel)
- scanPhaseView.addSubview(sidebarView)
- scanPhaseView.addSubview(previewContainer)
- scanPhaseView.addSubview(statusLabel)
- scanPhaseView.addSubview(paperSizePopup)
- scanPhaseView.addSubview(progressIndicator)
- scanPhaseView.addSubview(scanButton)
- NSLayoutConstraint.activate([
- backButton.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor, constant: 24),
- backButton.topAnchor.constraint(equalTo: scanPhaseView.topAnchor, constant: 20),
- backButton.widthAnchor.constraint(equalToConstant: 40),
- backButton.heightAnchor.constraint(equalToConstant: 40),
- titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
- titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
- documentsScannedLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
- documentsScannedLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
- sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
- sidebarView.topAnchor.constraint(equalTo: documentsScannedLabel.bottomAnchor, constant: 12),
- 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),
- localList.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
- sharedList.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
- previewContainer.leadingAnchor.constraint(equalTo: sidebarView.trailingAnchor, constant: 12),
- previewContainer.trailingAnchor.constraint(equalTo: scanPhaseView.trailingAnchor, constant: -16),
- previewContainer.topAnchor.constraint(equalTo: sidebarView.topAnchor),
- previewContainer.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -12),
- previewImageArea.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 16),
- previewImageArea.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -16),
- previewImageArea.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 16),
- 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),
- editorView.leadingAnchor.constraint(equalTo: previewImageArea.leadingAnchor),
- editorView.trailingAnchor.constraint(equalTo: previewImageArea.trailingAnchor),
- editorView.topAnchor.constraint(equalTo: previewImageArea.topAnchor),
- editorView.bottomAnchor.constraint(equalTo: previewImageArea.bottomAnchor),
- statusLabel.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
- statusLabel.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
- statusLabel.bottomAnchor.constraint(equalTo: paperSizePopup.topAnchor, constant: -10),
- paperSizePopup.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
- paperSizePopup.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -20),
- paperSizePopup.widthAnchor.constraint(equalToConstant: 110),
- progressIndicator.centerYAnchor.constraint(equalTo: scanButton.centerYAnchor),
- progressIndicator.trailingAnchor.constraint(equalTo: scanButton.leadingAnchor, constant: -10),
- scanButton.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
- scanButton.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -16),
- scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
- 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() {
- resultPhaseView.translatesAutoresizingMaskIntoConstraints = false
- let resultBackButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
- resultBackButton.translatesAutoresizingMaskIntoConstraints = false
- resultBackButton.onClick = { [weak self] in self?.dismiss() }
- let resultTitle = NSTextField(labelWithString: "Scan Result")
- resultTitle.font = AppTheme.semiboldFont(size: 18)
- resultTitle.alignment = .center
- resultTitle.textColor = AppTheme.textPrimary
- resultTitle.translatesAutoresizingMaskIntoConstraints = false
- resultPreviewContainer.wantsLayer = true
- resultPreviewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
- resultPreviewContainer.layer?.borderWidth = 1.5
- resultPreviewContainer.applyCardShadow()
- resultPreviewContainer.translatesAutoresizingMaskIntoConstraints = false
- resultPreviewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- resultPreviewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
- resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- resultEditorView.translatesAutoresizingMaskIntoConstraints = false
- resultEditorView.onRecognitionComplete = { [weak self] in
- self?.resultEditorView.makeFirstResponder()
- }
- 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: [resultSavePDFButton, resultPrintButton])
- actionRow.orientation = .horizontal
- actionRow.spacing = 12
- actionRow.distribution = .fillEqually
- actionRow.translatesAutoresizingMaskIntoConstraints = false
- actionRow.setContentHuggingPriority(.required, for: .vertical)
- actionRow.setContentCompressionResistancePriority(.required, for: .vertical)
- resultPreviewContainer.addSubview(resultEditorView)
- resultPhaseView.addSubview(resultBackButton)
- resultPhaseView.addSubview(resultTitle)
- resultPhaseView.addSubview(resultPreviewContainer)
- resultPhaseView.addSubview(actionRow)
- NSLayoutConstraint.activate([
- resultBackButton.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 24),
- resultBackButton.topAnchor.constraint(equalTo: resultPhaseView.topAnchor, constant: 20),
- resultBackButton.widthAnchor.constraint(equalToConstant: 40),
- resultBackButton.heightAnchor.constraint(equalToConstant: 40),
- resultTitle.centerXAnchor.constraint(equalTo: resultPhaseView.centerXAnchor),
- resultTitle.centerYAnchor.constraint(equalTo: resultBackButton.centerYAnchor),
- resultPreviewContainer.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
- resultPreviewContainer.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -AppTheme.contentPanelInset),
- resultPreviewContainer.topAnchor.constraint(equalTo: resultBackButton.bottomAnchor, constant: 28),
- resultPreviewContainer.bottomAnchor.constraint(equalTo: actionRow.topAnchor, constant: -16),
- resultEditorView.leadingAnchor.constraint(equalTo: resultPreviewContainer.leadingAnchor, constant: 12),
- resultEditorView.trailingAnchor.constraint(equalTo: resultPreviewContainer.trailingAnchor, constant: -12),
- resultEditorView.topAnchor.constraint(equalTo: resultPreviewContainer.topAnchor, constant: 12),
- resultEditorView.bottomAnchor.constraint(equalTo: resultPreviewContainer.bottomAnchor, constant: -12),
- 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),
- resultSavePDFButton.heightAnchor.constraint(equalToConstant: 40),
- resultPrintButton.heightAnchor.constraint(equalToConstant: 40),
- ])
- }
- private func bindScanner() {
- scanner.onDevicesChanged = { [weak self] in
- self?.rebuildDeviceLists()
- }
- scanner.onSelectionChanged = { [weak self] in
- self?.updateSelectionUI()
- }
- scanner.onStateChanged = { [weak self] state in
- self?.updateStateUI(state)
- }
- scanner.onScanComplete = { [weak self] result in
- self?.handleScanResult(result)
- }
- }
- private func rebuildDeviceLists() {
- localList.arrangedSubviews.forEach { $0.removeFromSuperview() }
- sharedList.arrangedSubviews.forEach { $0.removeFromSuperview() }
- localHeader.count = scanner.localDevices.count
- sharedHeader.count = scanner.sharedDevices.count
- for device in scanner.localDevices {
- localList.addArrangedSubview(makeDeviceRow(device))
- }
- for device in scanner.sharedDevices {
- sharedList.addArrangedSubview(makeDeviceRow(device))
- }
- if scanner.localDevices.isEmpty {
- localList.addArrangedSubview(emptyRow("No devices connected"))
- }
- if scanner.sharedDevices.isEmpty {
- sharedList.addArrangedSubview(emptyRow("No shared scanners"))
- }
- }
- private func makeDeviceRow(_ device: ICScannerDevice) -> ScannerDeviceRow {
- let name = device.name ?? device.locationDescription ?? "Scanner"
- let row = ScannerDeviceRow(title: name, isSelected: scanner.selectedDevice === device)
- row.onClick = { [weak self] in
- self?.scanner.selectDevice(device)
- }
- return row
- }
- private func emptyRow(_ text: String) -> NSView {
- let label = NSTextField(labelWithString: text)
- label.font = AppTheme.regularFont(size: 12)
- label.textColor = AppTheme.textSecondary
- 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),
- 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
- }
- private func updateSelectionUI() {
- rebuildDeviceLists()
- let hasSelection = scanner.selectedDevice != nil
- scanButton.isEnabled = hasSelection && scanner.state != .scanning && scanner.state != .connecting
- }
- private func updateStateUI(_ state: ScannerService.State) {
- switch state {
- case .idle, .browsing:
- statusLabel.stringValue = "Looking for scanners…"
- progressIndicator.stopAnimation(nil)
- scanButton.isEnabled = scanner.selectedDevice != nil
- case .connecting:
- if scanner.isWarmReconnecting {
- statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Preparing scanner…"
- progressIndicator.stopAnimation(nil)
- scanButton.isEnabled = scanner.selectedDevice != nil
- } else {
- statusLabel.stringValue = "Connecting to scanner…"
- progressIndicator.startAnimation(nil)
- scanButton.isEnabled = false
- }
- case .ready:
- statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Ready to scan"
- progressIndicator.stopAnimation(nil)
- scanButton.isEnabled = true
- if pendingScan {
- pendingScan = false
- scanner.scan()
- }
- case .scanning:
- statusLabel.stringValue = "Scanning…"
- progressIndicator.startAnimation(nil)
- scanButton.isEnabled = false
- case .error(let message):
- statusLabel.stringValue = message
- progressIndicator.stopAnimation(nil)
- scanButton.isEnabled = false
- }
- }
- private func startScan() {
- guard scanner.selectedDevice != nil else { return }
- editorView.isHidden = true
- placeholderView.isHidden = false
- setPreviewActionsVisible(false)
- if case .ready = scanner.state {
- scanner.scan()
- } else {
- pendingScan = true
- }
- }
- private func setPreviewActionsVisible(_ visible: Bool) {
- previewActionRow.isHidden = !visible
- previewActionRowHeight.constant = visible ? 40 : 0
- previewImageAreaBottomToContainer.isActive = !visible
- previewImageAreaBottomToActions.isActive = visible
- previewContainer.layoutSubtreeIfNeeded()
- }
- private func processScannedImage(_ image: NSImage) {
- let displayImage = image.normalizedForDisplay()
- hasResult = true
- scannedPages.append(displayImage)
- placeholderView.isHidden = true
- editorView.isHidden = false
- setPreviewActionsVisible(true)
- if scannedPages.count == 1 {
- editorView.loadDocument(pages: [(displayImage, "")])
- } else {
- editorView.addPage(displayImage)
- }
- updateScanUI()
- previewContainer.layoutSubtreeIfNeeded()
- }
- private func updateScanUI() {
- let count = scannedPages.count
- scanButton.title = count > 0 ? "Scan Next Document" : "Scan"
- if count > 0 {
- documentsScannedLabel.stringValue = count == 1
- ? "1 document scanned"
- : "\(count) documents scanned"
- documentsScannedLabel.isHidden = false
- } else {
- documentsScannedLabel.isHidden = true
- }
- if count > 1 {
- statusLabel.stringValue = "Select pages to print below, or tap Scan Next Document to add more"
- } else if count == 1 {
- statusLabel.stringValue = "Tap Scan Next Document to add another, or print this one"
- }
- }
- private func handleScanResult(_ result: Result<NSImage, Error>) {
- progressIndicator.stopAnimation(nil)
- switch result {
- case .success(let image):
- if let onScanComplete {
- onScanComplete(image)
- } else {
- processScannedImage(image)
- }
- updateStateUI(scanner.state)
- case .failure(let error):
- if case ScanError.cancelled = error { return }
- statusLabel.stringValue = error.localizedDescription
- scanButton.isEnabled = scanner.selectedDevice != nil
- }
- }
- private func activeEditor() -> EditableScanContentView {
- editorView.isHidden ? resultEditorView : editorView
- }
- private func updateScanStatusMessage() -> String {
- let count = scannedPages.count
- if count > 1 {
- return "Select pages to print below, or tap Scan Next Document to add more"
- }
- return "Tap Scan Next Document to add another, or print this one"
- }
- private func saveAsPDF() {
- let pages = pagesForExport()
- guard !pages.isEmpty else {
- showNoPagesSelectedAlert()
- return
- }
- PrintService.saveParsedScansAsPDF(pages: pages, defaultName: "Scan", from: window)
- }
- private func printScan() {
- let pages = pagesForExport()
- guard !pages.isEmpty else {
- showNoPagesSelectedAlert()
- return
- }
- PrintService.printParsedScans(pages: pages, title: "Scan", from: window)
- }
- private func pagesForExport() -> [ParsedScanPage] {
- let editor = activeEditor()
- if editor.pageCount > 0 {
- return editor.exportSelectedPagesForPrint()
- }
- if !scannedPages.isEmpty {
- return scannedPages.map {
- ParsedScanPage(picture: $0, caption: nil, body: .single(NSAttributedString()), showsPicture: false)
- }
- }
- return []
- }
- private func showNoPagesSelectedAlert() {
- let alert = NSAlert()
- alert.messageText = "No Documents Selected"
- alert.informativeText = "Select at least one scanned document using the checkboxes below the preview."
- alert.alertStyle = .informational
- alert.addButton(withTitle: "OK")
- if let window {
- alert.beginSheetModal(for: window)
- } else {
- alert.runModal()
- }
- }
- @objc private func paperSizeChanged() {
- guard let title = paperSizePopup.selectedItem?.title,
- let size = PaperSize(rawValue: title) else { return }
- scanner.paperSize = size
- }
- }
- // MARK: - Controls
- private final class ScannerToolbarButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let iconView = NSImageView()
- init(symbolName: String, accessibilityLabel: String) {
- super.init(frame: .zero)
- toolTip = accessibilityLabel
- wantsLayer = true
- layer?.cornerRadius = 12
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1.5
- iconView.contentTintColor = AppTheme.textPrimary
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class FlippedSidebarContentView: NSView {
- override var isFlipped: Bool { true }
- }
- private final class ScannerSectionHeader: NSView, AppearanceRefreshable {
- var count: Int = 0 {
- didSet { countLabel.stringValue = "\(count)" }
- }
- private let titleLabel = NSTextField(labelWithString: "")
- private let countLabel = NSTextField(labelWithString: "0")
- init(title: String) {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- titleLabel.stringValue = title
- titleLabel.font = AppTheme.semiboldFont(size: 11)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- countLabel.font = AppTheme.regularFont(size: 11)
- countLabel.alignment = .right
- countLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(countLabel)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 20),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- countLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
- countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- titleLabel.textColor = AppTheme.textSecondary
- countLabel.textColor = AppTheme.textSecondary
- }
- }
- private final class ScannerDeviceRow: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let iconView = NSImageView()
- private let titleLabel = NSTextField(labelWithString: "")
- private var isSelected = false
- init(title: String, isSelected: Bool) {
- self.isSelected = isSelected
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 6
- titleLabel.stringValue = title
- titleLabel.font = AppTheme.regularFont(size: 13)
- titleLabel.lineBreakMode = .byTruncatingTail
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "scanner", accessibilityDescription: "Scanner") {
- iconView.image = image.withSymbolConfiguration(
- NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
- )
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- addSubview(titleLabel)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 32),
- iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.widthAnchor.constraint(equalToConstant: 18),
- titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6),
- titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- if isSelected {
- layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.2).cgColor
- titleLabel.textColor = AppTheme.blue
- iconView.contentTintColor = AppTheme.blue
- } else {
- layer?.backgroundColor = .clear
- titleLabel.textColor = AppTheme.textPrimary
- iconView.contentTintColor = AppTheme.textPrimary
- }
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class ScannerPlaceholderView: NSView, AppearanceRefreshable {
- override var isOpaque: Bool { false }
- func refreshAppearance() {
- needsDisplay = true
- }
- override func draw(_ dirtyRect: NSRect) {
- 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
- path.stroke()
- }
- }
- private final class ScannerPrimaryButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let titleLabel = NSTextField(labelWithString: "")
- private var isEnabledState = true
- var title: String {
- get { titleLabel.stringValue }
- set { titleLabel.stringValue = newValue }
- }
- override var isEnabled: Bool {
- get { isEnabledState }
- set {
- isEnabledState = newValue
- refreshAppearance()
- }
- }
- init(title: String) {
- super.init(frame: .zero)
- titleLabel.stringValue = title
- titleLabel.font = AppTheme.semiboldFont(size: 13)
- titleLabel.alignment = .center
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 6
- addSubview(titleLabel)
- NSLayoutConstraint.activate([
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- if isEnabledState {
- layer?.backgroundColor = NSColor.controlAccentColor.cgColor
- titleLabel.textColor = .white
- } else {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- titleLabel.textColor = AppTheme.textSecondary
- }
- }
- override func mouseUp(with event: NSEvent) {
- guard isEnabledState, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- guard isEnabledState else { return }
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class ScannerSecondaryButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- 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)
- wantsLayer = true
- layer?.cornerRadius = 10
- layer?.borderWidth = 1.5
- titleLabel.stringValue = title
- titleLabel.font = AppTheme.semiboldFont(size: 14)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
- iconView.image = image.withSymbolConfiguration(
- NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
- )
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- addSubview(titleLabel)
- NSLayoutConstraint.activate([
- iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
- ])
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- 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?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
|