|
@@ -0,0 +1,897 @@
|
|
|
|
|
+import Cocoa
|
|
|
|
|
+import ImageCaptureCore
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Scanner Panel
|
|
|
|
|
+
|
|
|
|
|
+@MainActor
|
|
|
|
|
+final class ScannerPanelController: NSObject, NSWindowDelegate {
|
|
|
|
|
+ private static var activeController: ScannerPanelController?
|
|
|
|
|
+
|
|
|
|
|
+ private static let panelWidth: CGFloat = 560
|
|
|
|
|
+ private static let panelHeight: CGFloat = 480
|
|
|
|
|
+
|
|
|
|
|
+ private var panel: NSPanel?
|
|
|
|
|
+ private var panelView: ScannerOverlayView?
|
|
|
|
|
+ private weak var parentWindow: NSWindow?
|
|
|
|
|
+ private let showResultPreview: Bool
|
|
|
|
|
+ private var onComplete: ((Result<NSImage, Error>) -> Void)?
|
|
|
|
|
+ private var didComplete = false
|
|
|
|
|
+
|
|
|
|
|
+ static func present(
|
|
|
|
|
+ from parentWindow: NSWindow?,
|
|
|
|
|
+ showResultPreview: Bool,
|
|
|
|
|
+ onComplete: @escaping (Result<NSImage, Error>) -> Void
|
|
|
|
|
+ ) {
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ if let active = activeController {
|
|
|
|
|
+ await active.teardownPanel()
|
|
|
|
|
+ }
|
|
|
|
|
+ let controller = ScannerPanelController(
|
|
|
|
|
+ parentWindow: parentWindow,
|
|
|
|
|
+ showResultPreview: showResultPreview,
|
|
|
|
|
+ onComplete: onComplete
|
|
|
|
|
+ )
|
|
|
|
|
+ activeController = controller
|
|
|
|
|
+ await controller.show()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private init(
|
|
|
|
|
+ parentWindow: NSWindow?,
|
|
|
|
|
+ showResultPreview: Bool,
|
|
|
|
|
+ onComplete: @escaping (Result<NSImage, Error>) -> Void
|
|
|
|
|
+ ) {
|
|
|
|
|
+ self.parentWindow = parentWindow
|
|
|
|
|
+ self.showResultPreview = showResultPreview
|
|
|
|
|
+ self.onComplete = onComplete
|
|
|
|
|
+ super.init()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func show() async {
|
|
|
|
|
+ let contentSize = NSSize(width: Self.panelWidth, height: Self.panelHeight)
|
|
|
|
|
+ let panel = NSPanel(
|
|
|
|
|
+ contentRect: NSRect(origin: .zero, size: contentSize),
|
|
|
|
|
+ styleMask: [.titled, .closable, .fullSizeContentView],
|
|
|
|
|
+ backing: .buffered,
|
|
|
|
|
+ defer: false
|
|
|
|
|
+ )
|
|
|
|
|
+ panel.title = "Scanner"
|
|
|
|
|
+ panel.isFloatingPanel = false
|
|
|
|
|
+ panel.hidesOnDeactivate = false
|
|
|
|
|
+ panel.delegate = self
|
|
|
|
|
+ panel.isReleasedWhenClosed = false
|
|
|
|
|
+ panel.backgroundColor = AppTheme.background
|
|
|
|
|
+ hideTrafficLights(on: panel)
|
|
|
|
|
+
|
|
|
|
|
+ let container = NSView(frame: NSRect(origin: .zero, size: contentSize))
|
|
|
|
|
+ container.translatesAutoresizingMaskIntoConstraints = true
|
|
|
|
|
+ panel.contentView = container
|
|
|
|
|
+
|
|
|
|
|
+ let scannerView = ScannerOverlayView()
|
|
|
|
|
+ scannerView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ scannerView.onScanComplete = { [weak self] image in
|
|
|
|
|
+ self?.handleScanComplete(image)
|
|
|
|
|
+ }
|
|
|
|
|
+ scannerView.onDismiss = { [weak self] in
|
|
|
|
|
+ self?.close()
|
|
|
|
|
+ }
|
|
|
|
|
+ container.addSubview(scannerView)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ scannerView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
|
|
+ scannerView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
+ scannerView.topAnchor.constraint(equalTo: container.topAnchor),
|
|
|
|
|
+ scannerView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ center(panel, relativeTo: parentWindow)
|
|
|
|
|
+ self.panel = panel
|
|
|
|
|
+ self.panelView = scannerView
|
|
|
|
|
+
|
|
|
|
|
+ await scannerView.activate()
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ parentWindow?.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ panel.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func teardownPanel() async {
|
|
|
|
|
+ await panelView?.teardown()
|
|
|
|
|
+ panel?.orderOut(nil)
|
|
|
|
|
+ panel = nil
|
|
|
|
|
+ panelView = nil
|
|
|
|
|
+ if Self.activeController === self {
|
|
|
|
|
+ Self.activeController = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ onComplete = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func handleScanComplete(_ image: NSImage) {
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ let processed: NSImage
|
|
|
|
|
+ if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
|
|
|
|
|
+ processed = await ScanDocumentProcessor.process(cgImage) ?? image
|
|
|
|
|
+ } else {
|
|
|
|
|
+ processed = image
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if showResultPreview {
|
|
|
|
|
+ panel?.title = "Scan Result"
|
|
|
|
|
+ panelView?.showResult(processed)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ didComplete = true
|
|
|
|
|
+ onComplete?(.success(processed))
|
|
|
|
|
+ close()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func windowWillClose(_ notification: Notification) {
|
|
|
|
|
+ let cancelled = !didComplete && panelView?.hasResult != true
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ await finish(cancelled: cancelled)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func finish(cancelled: Bool) async {
|
|
|
|
|
+ await panelView?.teardown()
|
|
|
|
|
+ panel?.orderOut(nil)
|
|
|
|
|
+ panel = nil
|
|
|
|
|
+ panelView = nil
|
|
|
|
|
+ if Self.activeController === self {
|
|
|
|
|
+ Self.activeController = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if cancelled {
|
|
|
|
|
+ onComplete?(.failure(ScanError.cancelled))
|
|
|
|
|
+ }
|
|
|
|
|
+ onComplete = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func close() {
|
|
|
|
|
+ panel?.close()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func center(_ panelWindow: NSWindow, relativeTo parentWindow: NSWindow?) {
|
|
|
|
|
+ guard let parentWindow else {
|
|
|
|
|
+ panelWindow.center()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ let parentFrame = parentWindow.frame
|
|
|
|
|
+ var panelFrame = panelWindow.frame
|
|
|
|
|
+ panelFrame.origin.x = parentFrame.midX - panelFrame.width / 2
|
|
|
|
|
+ panelFrame.origin.y = parentFrame.midY - panelFrame.height / 2
|
|
|
|
|
+ panelWindow.setFrame(panelFrame, display: true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func hideTrafficLights(on window: NSWindow) {
|
|
|
|
|
+ for buttonType: NSWindow.ButtonType in [.miniaturizeButton, .zoomButton] {
|
|
|
|
|
+ window.standardWindowButton(buttonType)?.isHidden = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Scanner View
|
|
|
|
|
+
|
|
|
|
|
+final class ScannerOverlayView: NSView, AppearanceRefreshable {
|
|
|
|
|
+ var onScanComplete: ((NSImage) -> Void)?
|
|
|
|
|
+ var onDismiss: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private(set) var hasResult = false
|
|
|
|
|
+
|
|
|
|
|
+ private let scanner: ScannerService
|
|
|
|
|
+ private var pendingScan = false
|
|
|
|
|
+ private var scannedImage: NSImage?
|
|
|
|
|
+
|
|
|
|
|
+ private let scanPhaseView = NSView()
|
|
|
|
|
+ private let resultPhaseView = NSView()
|
|
|
|
|
+
|
|
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "Scanner")
|
|
|
|
|
+ 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 previewImageView = NSImageView()
|
|
|
|
|
+ private let placeholderView = ScannerPlaceholderView()
|
|
|
|
|
+ private let statusLabel = NSTextField(labelWithString: "Select a scanner to begin")
|
|
|
|
|
+ private let paperSizePopup = NSPopUpButton()
|
|
|
|
|
+ private let scanButton = ScannerPrimaryButton(title: "Scan")
|
|
|
|
|
+ private let progressIndicator = NSProgressIndicator()
|
|
|
|
|
+
|
|
|
|
|
+ private let resultPreviewContainer = NSView()
|
|
|
|
|
+ private let resultImageView = NSImageView()
|
|
|
|
|
+ private let 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() async {
|
|
|
|
|
+ scanner.onStateChanged = nil
|
|
|
|
|
+ scanner.onDevicesChanged = nil
|
|
|
|
|
+ scanner.onSelectionChanged = nil
|
|
|
|
|
+ scanner.onScanComplete = nil
|
|
|
|
|
+ await scanner.stopBrowsing()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func showResult(_ image: NSImage) {
|
|
|
|
|
+ hasResult = true
|
|
|
|
|
+ scannedImage = image
|
|
|
|
|
+ resultImageView.image = image
|
|
|
|
|
+ scanPhaseView.isHidden = true
|
|
|
|
|
+ resultPhaseView.isHidden = false
|
|
|
|
|
+ // Session stays open until the panel closes; avoids breaking the next scan.
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.background.cgColor
|
|
|
|
|
+ titleLabel.textColor = AppTheme.textPrimary
|
|
|
|
|
+ sidebarView.layer?.backgroundColor = AppTheme.sidebarBackground.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
|
|
|
|
|
+ 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
|
|
|
|
|
+
|
|
|
|
|
+ sidebarView.wantsLayer = 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)
|
|
|
|
|
+
|
|
|
|
|
+ previewImageView.imageScaling = .scaleProportionallyUpOrDown
|
|
|
|
|
+ previewImageView.imageAlignment = .alignCenter
|
|
|
|
|
+ previewImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ previewImageView.isHidden = true
|
|
|
|
|
+ previewImageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ previewImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
|
|
+ previewImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ previewImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
|
+
|
|
|
|
|
+ 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)
|
|
|
|
|
+
|
|
|
|
|
+ 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
|
|
|
|
|
+
|
|
|
|
|
+ let sidebarStack = NSStackView()
|
|
|
|
|
+ sidebarStack.orientation = .vertical
|
|
|
|
|
+ sidebarStack.alignment = .leading
|
|
|
|
|
+ sidebarStack.spacing = 6
|
|
|
|
|
+ sidebarStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ sidebarStack.addArrangedSubview(localHeader)
|
|
|
|
|
+ sidebarStack.addArrangedSubview(localList)
|
|
|
|
|
+ sidebarStack.addArrangedSubview(sharedHeader)
|
|
|
|
|
+ sidebarStack.addArrangedSubview(sharedList)
|
|
|
|
|
+
|
|
|
|
|
+ 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)
|
|
|
|
|
+
|
|
|
|
|
+ previewContainer.addSubview(placeholderView)
|
|
|
|
|
+ previewContainer.addSubview(previewImageView)
|
|
|
|
|
+
|
|
|
|
|
+ scanPhaseView.addSubview(titleLabel)
|
|
|
|
|
+ scanPhaseView.addSubview(sidebarView)
|
|
|
|
|
+ scanPhaseView.addSubview(previewContainer)
|
|
|
|
|
+ scanPhaseView.addSubview(statusLabel)
|
|
|
|
|
+ scanPhaseView.addSubview(paperSizePopup)
|
|
|
|
|
+ scanPhaseView.addSubview(progressIndicator)
|
|
|
|
|
+ scanPhaseView.addSubview(scanButton)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ titleLabel.topAnchor.constraint(equalTo: scanPhaseView.topAnchor, constant: 16),
|
|
|
|
|
+ titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor),
|
|
|
|
|
+ sidebarView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
|
|
|
|
|
+ sidebarView.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor),
|
|
|
|
|
+ sidebarView.widthAnchor.constraint(equalToConstant: 168),
|
|
|
|
|
+
|
|
|
|
|
+ sidebarScroll.leadingAnchor.constraint(equalTo: sidebarView.leadingAnchor),
|
|
|
|
|
+ sidebarScroll.trailingAnchor.constraint(equalTo: sidebarView.trailingAnchor),
|
|
|
|
|
+ sidebarScroll.topAnchor.constraint(equalTo: sidebarView.topAnchor),
|
|
|
|
|
+ sidebarScroll.bottomAnchor.constraint(equalTo: sidebarView.bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ sidebarStack.leadingAnchor.constraint(equalTo: sidebarDocument.leadingAnchor, constant: 8),
|
|
|
|
|
+ sidebarStack.trailingAnchor.constraint(equalTo: sidebarDocument.trailingAnchor, constant: -8),
|
|
|
|
|
+ sidebarStack.topAnchor.constraint(equalTo: sidebarDocument.topAnchor, constant: 8),
|
|
|
|
|
+ sidebarStack.bottomAnchor.constraint(equalTo: sidebarDocument.bottomAnchor, constant: -8),
|
|
|
|
|
+ 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),
|
|
|
|
|
+
|
|
|
|
|
+ placeholderView.centerXAnchor.constraint(equalTo: previewContainer.centerXAnchor),
|
|
|
|
|
+ placeholderView.centerYAnchor.constraint(equalTo: previewContainer.centerYAnchor),
|
|
|
|
|
+ placeholderView.widthAnchor.constraint(equalTo: previewContainer.widthAnchor, multiplier: 0.55),
|
|
|
|
|
+ placeholderView.heightAnchor.constraint(equalTo: previewContainer.heightAnchor, multiplier: 0.7),
|
|
|
|
|
+
|
|
|
|
|
+ previewImageView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
|
|
|
|
|
+ previewImageView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
|
|
|
|
|
+ previewImageView.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
|
|
|
|
|
+ previewImageView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
|
|
|
|
|
+
|
|
|
|
|
+ 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: 96),
|
|
|
|
|
+ scanButton.heightAnchor.constraint(equalToConstant: 32),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setupResultPhase() {
|
|
|
|
|
+ resultPhaseView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let resultTitle = NSTextField(labelWithString: "Scan Result")
|
|
|
|
|
+ resultTitle.font = AppTheme.semiboldFont(size: 16)
|
|
|
|
|
+ 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)
|
|
|
|
|
+
|
|
|
|
|
+ resultImageView.imageScaling = .scaleProportionallyUpOrDown
|
|
|
|
|
+ resultImageView.imageAlignment = .alignCenter
|
|
|
|
|
+ resultImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ resultImageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ resultImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
|
|
+ resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
|
+
|
|
|
|
|
+ savePDFButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ printButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
|
|
|
|
|
+ printButton.onClick = { [weak self] in self?.printScan() }
|
|
|
|
|
+
|
|
|
|
|
+ resultPreviewContainer.addSubview(resultImageView)
|
|
|
|
|
+ resultPhaseView.addSubview(resultTitle)
|
|
|
|
|
+ resultPhaseView.addSubview(resultPreviewContainer)
|
|
|
|
|
+ resultPhaseView.addSubview(savePDFButton)
|
|
|
|
|
+ resultPhaseView.addSubview(printButton)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ resultTitle.topAnchor.constraint(equalTo: resultPhaseView.topAnchor, constant: 16),
|
|
|
|
|
+ resultTitle.centerXAnchor.constraint(equalTo: resultPhaseView.centerXAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ resultPreviewContainer.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 16),
|
|
|
|
|
+ resultPreviewContainer.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -16),
|
|
|
|
|
+ resultPreviewContainer.topAnchor.constraint(equalTo: resultTitle.bottomAnchor, constant: 12),
|
|
|
|
|
+ resultPreviewContainer.bottomAnchor.constraint(equalTo: savePDFButton.topAnchor, constant: -16),
|
|
|
|
|
+
|
|
|
|
|
+ resultImageView.leadingAnchor.constraint(equalTo: resultPreviewContainer.leadingAnchor, constant: 12),
|
|
|
|
|
+ resultImageView.trailingAnchor.constraint(equalTo: resultPreviewContainer.trailingAnchor, constant: -12),
|
|
|
|
|
+ resultImageView.topAnchor.constraint(equalTo: resultPreviewContainer.topAnchor, constant: 12),
|
|
|
|
|
+ resultImageView.bottomAnchor.constraint(equalTo: resultPreviewContainer.bottomAnchor, constant: -12),
|
|
|
|
|
+
|
|
|
|
|
+ savePDFButton.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 16),
|
|
|
|
|
+ savePDFButton.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -20),
|
|
|
|
|
+ savePDFButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
+ savePDFButton.widthAnchor.constraint(equalTo: resultPhaseView.widthAnchor, multiplier: 0.5, constant: -22),
|
|
|
|
|
+
|
|
|
|
|
+ printButton.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -16),
|
|
|
|
|
+ printButton.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -20),
|
|
|
|
|
+ printButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
+ printButton.widthAnchor.constraint(equalTo: resultPhaseView.widthAnchor, multiplier: 0.5, constant: -22),
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ let container = NSView()
|
|
|
|
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ container.addSubview(label)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
|
|
|
|
|
+ label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
|
|
|
|
|
+ label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
|
|
|
|
|
+ label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
|
|
|
|
|
+ container.heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
+ ])
|
|
|
|
|
+ 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:
|
|
|
|
|
+ statusLabel.stringValue = "Connecting to scanner…"
|
|
|
|
|
+ progressIndicator.startAnimation(nil)
|
|
|
|
|
+ scanButton.isEnabled = false
|
|
|
|
|
+ case .ready:
|
|
|
|
|
+ statusLabel.stringValue = "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 }
|
|
|
|
|
+ previewImageView.isHidden = true
|
|
|
|
|
+ placeholderView.isHidden = false
|
|
|
|
|
+
|
|
|
|
|
+ if case .ready = scanner.state {
|
|
|
|
|
+ scanner.scan()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ pendingScan = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func handleScanResult(_ result: Result<NSImage, Error>) {
|
|
|
|
|
+ progressIndicator.stopAnimation(nil)
|
|
|
|
|
+ switch result {
|
|
|
|
|
+ case .success(let image):
|
|
|
|
|
+ onScanComplete?(image)
|
|
|
|
|
+ case .failure(let error):
|
|
|
|
|
+ if case ScanError.cancelled = error { return }
|
|
|
|
|
+ statusLabel.stringValue = error.localizedDescription
|
|
|
|
|
+ scanButton.isEnabled = scanner.selectedDevice != nil
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func saveAsPDF() {
|
|
|
|
|
+ guard let image = scannedImage else { return }
|
|
|
|
|
+ ScanExporter.saveAsPDF(pages: [image], from: window)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func printScan() {
|
|
|
|
|
+ guard let image = scannedImage else { return }
|
|
|
|
|
+ PrintService.printPhoto(image, title: "Scan", from: window)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @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 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: 20, dy: 20)
|
|
|
|
|
+ let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4)
|
|
|
|
|
+ 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
|
|
|
|
|
+
|
|
|
|
|
+ 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()
|
|
|
|
|
+
|
|
|
|
|
+ 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),
|
|
|
|
|
+ ])
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
|
|
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|