ScannerOverlayView.swift 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014
  1. import Cocoa
  2. import ImageCaptureCore
  3. // MARK: - Scanner View
  4. final class ScannerOverlayView: NSView, AppearanceRefreshable {
  5. var onScanComplete: ((NSImage) -> Void)?
  6. var onDismiss: (() -> Void)?
  7. private(set) var hasResult = false
  8. private let scanner: ScannerService
  9. private var pendingScan = false
  10. private var scannedPages: [NSImage] = []
  11. private let scanPhaseView = NSView()
  12. private let resultPhaseView = NSView()
  13. private let backButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  14. private let titleLabel = NSTextField(labelWithString: "Scanner")
  15. private let documentsScannedLabel = NSTextField(labelWithString: "")
  16. private let sidebarView = NSView()
  17. private let localHeader = ScannerSectionHeader(title: "DEVICES")
  18. private let localList = NSStackView()
  19. private let sharedHeader = ScannerSectionHeader(title: "SHARED")
  20. private let sharedList = NSStackView()
  21. private let previewContainer = NSView()
  22. private let previewImageArea = NSView()
  23. private let editorView = EditableScanContentView()
  24. private let placeholderView = ScannerPlaceholderView()
  25. private let previewActionRow = NSStackView()
  26. private var previewImageAreaBottomToContainer: NSLayoutConstraint!
  27. private var previewImageAreaBottomToActions: NSLayoutConstraint!
  28. private var previewActionRowHeight: NSLayoutConstraint!
  29. private let statusLabel = NSTextField(labelWithString: "Select a scanner to begin")
  30. private let paperSizePopup = NSPopUpButton()
  31. private let scanButton = ScannerPrimaryButton(title: "Scan")
  32. private let progressIndicator = NSProgressIndicator()
  33. private let resultPreviewContainer = NSView()
  34. private let resultEditorView = EditableScanContentView()
  35. private let savePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
  36. private let printButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
  37. init(scanner: ScannerService = .shared) {
  38. self.scanner = scanner
  39. super.init(frame: .zero)
  40. translatesAutoresizingMaskIntoConstraints = false
  41. setup()
  42. bindScanner()
  43. refreshAppearance()
  44. rebuildDeviceLists()
  45. updateSelectionUI()
  46. updateStateUI(scanner.state)
  47. NotificationCenter.default.addObserver(
  48. self,
  49. selector: #selector(appearanceDidChange),
  50. name: .appearanceDidChange,
  51. object: nil
  52. )
  53. }
  54. deinit {
  55. NotificationCenter.default.removeObserver(self)
  56. }
  57. @available(*, unavailable)
  58. required init?(coder: NSCoder) { nil }
  59. func activate() async {
  60. bindScanner()
  61. await scanner.prepareForScanning()
  62. rebuildDeviceLists()
  63. updateSelectionUI()
  64. updateStateUI(scanner.state)
  65. }
  66. func teardown() {
  67. scanner.onStateChanged = nil
  68. scanner.onDevicesChanged = nil
  69. scanner.onSelectionChanged = nil
  70. scanner.onScanComplete = nil
  71. scanner.detachFromUI()
  72. }
  73. func showResult(_ image: NSImage) {
  74. hasResult = true
  75. processScannedImage(image)
  76. scanPhaseView.isHidden = true
  77. resultPhaseView.isHidden = false
  78. }
  79. func updateScanPreview(_ image: NSImage) {
  80. processScannedImage(image)
  81. }
  82. func present(in parent: NSView) {
  83. guard superview == nil else { return }
  84. parent.addSubview(self)
  85. NSLayoutConstraint.activate([
  86. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  87. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  88. topAnchor.constraint(equalTo: parent.topAnchor),
  89. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  90. ])
  91. alphaValue = 0
  92. NSAnimationContext.runAnimationGroup { context in
  93. context.duration = 0.2
  94. animator().alphaValue = 1
  95. }
  96. }
  97. func dismiss(animated: Bool = true) {
  98. let remove = { [weak self] in
  99. self?.teardown()
  100. self?.removeFromSuperview()
  101. self?.onDismiss?()
  102. }
  103. guard animated else {
  104. remove()
  105. return
  106. }
  107. NSAnimationContext.runAnimationGroup({ context in
  108. context.duration = 0.15
  109. animator().alphaValue = 0
  110. }, completionHandler: remove)
  111. }
  112. func refreshAppearance() {
  113. layer?.backgroundColor = AppTheme.background.cgColor
  114. titleLabel.textColor = AppTheme.textPrimary
  115. documentsScannedLabel.textColor = AppTheme.textSecondary
  116. sidebarView.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  117. sidebarView.layer?.borderColor = AppTheme.paywallBorder.cgColor
  118. previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  119. previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  120. resultPreviewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  121. resultPreviewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  122. statusLabel.textColor = AppTheme.textSecondary
  123. backButton.refreshAppearance()
  124. scanButton.refreshAppearance()
  125. localHeader.refreshAppearance()
  126. sharedHeader.refreshAppearance()
  127. placeholderView.refreshAppearance()
  128. savePDFButton.refreshAppearance()
  129. printButton.refreshAppearance()
  130. }
  131. @objc private func appearanceDidChange() {
  132. refreshAppearance()
  133. rebuildDeviceLists()
  134. }
  135. private func setup() {
  136. wantsLayer = true
  137. setupScanPhase()
  138. setupResultPhase()
  139. addSubview(scanPhaseView)
  140. addSubview(resultPhaseView)
  141. resultPhaseView.isHidden = true
  142. NSLayoutConstraint.activate([
  143. scanPhaseView.leadingAnchor.constraint(equalTo: leadingAnchor),
  144. scanPhaseView.trailingAnchor.constraint(equalTo: trailingAnchor),
  145. scanPhaseView.topAnchor.constraint(equalTo: topAnchor),
  146. scanPhaseView.bottomAnchor.constraint(equalTo: bottomAnchor),
  147. resultPhaseView.leadingAnchor.constraint(equalTo: leadingAnchor),
  148. resultPhaseView.trailingAnchor.constraint(equalTo: trailingAnchor),
  149. resultPhaseView.topAnchor.constraint(equalTo: topAnchor),
  150. resultPhaseView.bottomAnchor.constraint(equalTo: bottomAnchor),
  151. ])
  152. }
  153. private func setupScanPhase() {
  154. scanPhaseView.translatesAutoresizingMaskIntoConstraints = false
  155. titleLabel.font = AppTheme.semiboldFont(size: 16)
  156. titleLabel.alignment = .center
  157. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  158. documentsScannedLabel.font = AppTheme.regularFont(size: 12)
  159. documentsScannedLabel.textColor = AppTheme.textSecondary
  160. documentsScannedLabel.alignment = .center
  161. documentsScannedLabel.translatesAutoresizingMaskIntoConstraints = false
  162. documentsScannedLabel.isHidden = true
  163. sidebarView.wantsLayer = true
  164. sidebarView.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  165. sidebarView.layer?.borderWidth = 1.5
  166. sidebarView.layer?.masksToBounds = true
  167. sidebarView.translatesAutoresizingMaskIntoConstraints = false
  168. localList.orientation = .vertical
  169. localList.alignment = .leading
  170. localList.spacing = 2
  171. localList.translatesAutoresizingMaskIntoConstraints = false
  172. sharedList.orientation = .vertical
  173. sharedList.alignment = .leading
  174. sharedList.spacing = 2
  175. sharedList.translatesAutoresizingMaskIntoConstraints = false
  176. previewContainer.wantsLayer = true
  177. previewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  178. previewContainer.layer?.borderWidth = 1.5
  179. previewContainer.applyCardShadow()
  180. previewContainer.translatesAutoresizingMaskIntoConstraints = false
  181. previewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  182. previewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
  183. previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  184. previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  185. previewImageArea.translatesAutoresizingMaskIntoConstraints = false
  186. editorView.translatesAutoresizingMaskIntoConstraints = false
  187. editorView.isHidden = true
  188. editorView.onRecognitionComplete = { [weak self] in
  189. self?.editorView.makeFirstResponder()
  190. }
  191. placeholderView.translatesAutoresizingMaskIntoConstraints = false
  192. statusLabel.font = AppTheme.regularFont(size: 13)
  193. statusLabel.alignment = .center
  194. statusLabel.lineBreakMode = .byWordWrapping
  195. statusLabel.maximumNumberOfLines = 2
  196. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  197. for size in PaperSize.allCases {
  198. paperSizePopup.addItem(withTitle: size.rawValue)
  199. }
  200. paperSizePopup.selectItem(withTitle: AppSettings.defaultPaperSize.rawValue)
  201. paperSizePopup.font = AppTheme.regularFont(size: 13)
  202. paperSizePopup.translatesAutoresizingMaskIntoConstraints = false
  203. paperSizePopup.target = self
  204. paperSizePopup.action = #selector(paperSizeChanged)
  205. backButton.translatesAutoresizingMaskIntoConstraints = false
  206. backButton.onClick = { [weak self] in self?.dismiss() }
  207. scanButton.translatesAutoresizingMaskIntoConstraints = false
  208. scanButton.isEnabled = false
  209. scanButton.onClick = { [weak self] in self?.startScan() }
  210. progressIndicator.style = .spinning
  211. progressIndicator.controlSize = .small
  212. progressIndicator.isDisplayedWhenStopped = false
  213. progressIndicator.translatesAutoresizingMaskIntoConstraints = false
  214. savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
  215. printButton.onClick = { [weak self] in self?.printScan() }
  216. savePDFButton.translatesAutoresizingMaskIntoConstraints = false
  217. printButton.translatesAutoresizingMaskIntoConstraints = false
  218. previewActionRow.orientation = .horizontal
  219. previewActionRow.spacing = 12
  220. previewActionRow.distribution = .fillEqually
  221. previewActionRow.translatesAutoresizingMaskIntoConstraints = false
  222. previewActionRow.isHidden = true
  223. previewActionRow.addArrangedSubview(savePDFButton)
  224. previewActionRow.addArrangedSubview(printButton)
  225. let sidebarStack = NSStackView()
  226. sidebarStack.orientation = .vertical
  227. sidebarStack.alignment = .leading
  228. sidebarStack.spacing = 8
  229. sidebarStack.translatesAutoresizingMaskIntoConstraints = false
  230. sidebarStack.addArrangedSubview(localHeader)
  231. sidebarStack.addArrangedSubview(localList)
  232. sidebarStack.addArrangedSubview(sharedHeader)
  233. sidebarStack.addArrangedSubview(sharedList)
  234. sidebarStack.setCustomSpacing(18, after: localList)
  235. let sidebarScroll = NSScrollView()
  236. sidebarScroll.hasVerticalScroller = true
  237. sidebarScroll.hasHorizontalScroller = false
  238. sidebarScroll.autohidesScrollers = true
  239. sidebarScroll.drawsBackground = false
  240. sidebarScroll.borderType = .noBorder
  241. sidebarScroll.translatesAutoresizingMaskIntoConstraints = false
  242. let sidebarDocument = FlippedSidebarContentView()
  243. sidebarDocument.translatesAutoresizingMaskIntoConstraints = false
  244. sidebarDocument.addSubview(sidebarStack)
  245. sidebarScroll.documentView = sidebarDocument
  246. sidebarView.addSubview(sidebarScroll)
  247. previewImageArea.addSubview(editorView)
  248. previewImageArea.addSubview(placeholderView)
  249. previewContainer.addSubview(previewImageArea)
  250. previewContainer.addSubview(previewActionRow)
  251. scanPhaseView.addSubview(backButton)
  252. scanPhaseView.addSubview(titleLabel)
  253. scanPhaseView.addSubview(documentsScannedLabel)
  254. scanPhaseView.addSubview(sidebarView)
  255. scanPhaseView.addSubview(previewContainer)
  256. scanPhaseView.addSubview(statusLabel)
  257. scanPhaseView.addSubview(paperSizePopup)
  258. scanPhaseView.addSubview(progressIndicator)
  259. scanPhaseView.addSubview(scanButton)
  260. NSLayoutConstraint.activate([
  261. backButton.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor, constant: 24),
  262. backButton.topAnchor.constraint(equalTo: scanPhaseView.topAnchor, constant: 20),
  263. backButton.widthAnchor.constraint(equalToConstant: 40),
  264. backButton.heightAnchor.constraint(equalToConstant: 40),
  265. titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
  266. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  267. documentsScannedLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
  268. documentsScannedLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
  269. sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
  270. sidebarView.topAnchor.constraint(equalTo: documentsScannedLabel.bottomAnchor, constant: 12),
  271. sidebarView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor),
  272. sidebarView.widthAnchor.constraint(equalToConstant: 180),
  273. sidebarScroll.leadingAnchor.constraint(equalTo: sidebarView.leadingAnchor, constant: 4),
  274. sidebarScroll.trailingAnchor.constraint(equalTo: sidebarView.trailingAnchor, constant: -4),
  275. sidebarScroll.topAnchor.constraint(equalTo: sidebarView.topAnchor, constant: 6),
  276. sidebarScroll.bottomAnchor.constraint(equalTo: sidebarView.bottomAnchor, constant: -4),
  277. sidebarStack.leadingAnchor.constraint(equalTo: sidebarDocument.leadingAnchor, constant: 14),
  278. sidebarStack.trailingAnchor.constraint(equalTo: sidebarDocument.trailingAnchor, constant: -14),
  279. sidebarStack.topAnchor.constraint(equalTo: sidebarDocument.topAnchor, constant: 22),
  280. sidebarStack.bottomAnchor.constraint(equalTo: sidebarDocument.bottomAnchor, constant: -14),
  281. sidebarDocument.widthAnchor.constraint(equalTo: sidebarScroll.widthAnchor),
  282. localHeader.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
  283. sharedHeader.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
  284. localList.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
  285. sharedList.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
  286. previewContainer.leadingAnchor.constraint(equalTo: sidebarView.trailingAnchor, constant: 12),
  287. previewContainer.trailingAnchor.constraint(equalTo: scanPhaseView.trailingAnchor, constant: -16),
  288. previewContainer.topAnchor.constraint(equalTo: sidebarView.topAnchor),
  289. previewContainer.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -12),
  290. previewImageArea.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 16),
  291. previewImageArea.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -16),
  292. previewImageArea.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 16),
  293. previewActionRow.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 16),
  294. previewActionRow.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -16),
  295. previewActionRow.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -16),
  296. savePDFButton.heightAnchor.constraint(equalToConstant: 40),
  297. printButton.heightAnchor.constraint(equalToConstant: 40),
  298. placeholderView.centerXAnchor.constraint(equalTo: previewImageArea.centerXAnchor),
  299. placeholderView.centerYAnchor.constraint(equalTo: previewImageArea.centerYAnchor),
  300. placeholderView.widthAnchor.constraint(equalTo: previewImageArea.widthAnchor, multiplier: 0.42),
  301. placeholderView.heightAnchor.constraint(equalTo: placeholderView.widthAnchor),
  302. editorView.leadingAnchor.constraint(equalTo: previewImageArea.leadingAnchor),
  303. editorView.trailingAnchor.constraint(equalTo: previewImageArea.trailingAnchor),
  304. editorView.topAnchor.constraint(equalTo: previewImageArea.topAnchor),
  305. editorView.bottomAnchor.constraint(equalTo: previewImageArea.bottomAnchor),
  306. statusLabel.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
  307. statusLabel.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
  308. statusLabel.bottomAnchor.constraint(equalTo: paperSizePopup.topAnchor, constant: -10),
  309. paperSizePopup.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
  310. paperSizePopup.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -20),
  311. paperSizePopup.widthAnchor.constraint(equalToConstant: 110),
  312. progressIndicator.centerYAnchor.constraint(equalTo: scanButton.centerYAnchor),
  313. progressIndicator.trailingAnchor.constraint(equalTo: scanButton.leadingAnchor, constant: -10),
  314. scanButton.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
  315. scanButton.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -16),
  316. scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
  317. scanButton.heightAnchor.constraint(equalToConstant: 32),
  318. ])
  319. previewImageAreaBottomToContainer = previewImageArea.bottomAnchor.constraint(
  320. equalTo: previewContainer.bottomAnchor,
  321. constant: -16
  322. )
  323. previewImageAreaBottomToActions = previewImageArea.bottomAnchor.constraint(
  324. equalTo: previewActionRow.topAnchor,
  325. constant: -12
  326. )
  327. previewActionRowHeight = previewActionRow.heightAnchor.constraint(equalToConstant: 0)
  328. previewImageAreaBottomToContainer.isActive = true
  329. previewImageAreaBottomToActions.isActive = false
  330. previewActionRowHeight.isActive = true
  331. }
  332. private func setupResultPhase() {
  333. resultPhaseView.translatesAutoresizingMaskIntoConstraints = false
  334. let resultBackButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  335. resultBackButton.translatesAutoresizingMaskIntoConstraints = false
  336. resultBackButton.onClick = { [weak self] in self?.dismiss() }
  337. let resultTitle = NSTextField(labelWithString: "Scan Result")
  338. resultTitle.font = AppTheme.semiboldFont(size: 18)
  339. resultTitle.alignment = .center
  340. resultTitle.textColor = AppTheme.textPrimary
  341. resultTitle.translatesAutoresizingMaskIntoConstraints = false
  342. resultPreviewContainer.wantsLayer = true
  343. resultPreviewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  344. resultPreviewContainer.layer?.borderWidth = 1.5
  345. resultPreviewContainer.applyCardShadow()
  346. resultPreviewContainer.translatesAutoresizingMaskIntoConstraints = false
  347. resultPreviewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  348. resultPreviewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
  349. resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  350. resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  351. resultEditorView.translatesAutoresizingMaskIntoConstraints = false
  352. resultEditorView.onRecognitionComplete = { [weak self] in
  353. self?.resultEditorView.makeFirstResponder()
  354. }
  355. let resultSavePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
  356. let resultPrintButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
  357. resultSavePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
  358. resultPrintButton.onClick = { [weak self] in self?.printScan() }
  359. let actionRow = NSStackView(views: [resultSavePDFButton, resultPrintButton])
  360. actionRow.orientation = .horizontal
  361. actionRow.spacing = 12
  362. actionRow.distribution = .fillEqually
  363. actionRow.translatesAutoresizingMaskIntoConstraints = false
  364. actionRow.setContentHuggingPriority(.required, for: .vertical)
  365. actionRow.setContentCompressionResistancePriority(.required, for: .vertical)
  366. resultPreviewContainer.addSubview(resultEditorView)
  367. resultPhaseView.addSubview(resultBackButton)
  368. resultPhaseView.addSubview(resultTitle)
  369. resultPhaseView.addSubview(resultPreviewContainer)
  370. resultPhaseView.addSubview(actionRow)
  371. NSLayoutConstraint.activate([
  372. resultBackButton.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 24),
  373. resultBackButton.topAnchor.constraint(equalTo: resultPhaseView.topAnchor, constant: 20),
  374. resultBackButton.widthAnchor.constraint(equalToConstant: 40),
  375. resultBackButton.heightAnchor.constraint(equalToConstant: 40),
  376. resultTitle.centerXAnchor.constraint(equalTo: resultPhaseView.centerXAnchor),
  377. resultTitle.centerYAnchor.constraint(equalTo: resultBackButton.centerYAnchor),
  378. resultPreviewContainer.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
  379. resultPreviewContainer.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -AppTheme.contentPanelInset),
  380. resultPreviewContainer.topAnchor.constraint(equalTo: resultBackButton.bottomAnchor, constant: 28),
  381. resultPreviewContainer.bottomAnchor.constraint(equalTo: actionRow.topAnchor, constant: -16),
  382. resultEditorView.leadingAnchor.constraint(equalTo: resultPreviewContainer.leadingAnchor, constant: 12),
  383. resultEditorView.trailingAnchor.constraint(equalTo: resultPreviewContainer.trailingAnchor, constant: -12),
  384. resultEditorView.topAnchor.constraint(equalTo: resultPreviewContainer.topAnchor, constant: 12),
  385. resultEditorView.bottomAnchor.constraint(equalTo: resultPreviewContainer.bottomAnchor, constant: -12),
  386. actionRow.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
  387. actionRow.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -AppTheme.contentPanelInset),
  388. actionRow.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -28),
  389. resultSavePDFButton.heightAnchor.constraint(equalToConstant: 40),
  390. resultPrintButton.heightAnchor.constraint(equalToConstant: 40),
  391. ])
  392. }
  393. private func bindScanner() {
  394. scanner.onDevicesChanged = { [weak self] in
  395. self?.rebuildDeviceLists()
  396. }
  397. scanner.onSelectionChanged = { [weak self] in
  398. self?.updateSelectionUI()
  399. }
  400. scanner.onStateChanged = { [weak self] state in
  401. self?.updateStateUI(state)
  402. }
  403. scanner.onScanComplete = { [weak self] result in
  404. self?.handleScanResult(result)
  405. }
  406. }
  407. private func rebuildDeviceLists() {
  408. localList.arrangedSubviews.forEach { $0.removeFromSuperview() }
  409. sharedList.arrangedSubviews.forEach { $0.removeFromSuperview() }
  410. localHeader.count = scanner.localDevices.count
  411. sharedHeader.count = scanner.sharedDevices.count
  412. for device in scanner.localDevices {
  413. localList.addArrangedSubview(makeDeviceRow(device))
  414. }
  415. for device in scanner.sharedDevices {
  416. sharedList.addArrangedSubview(makeDeviceRow(device))
  417. }
  418. if scanner.localDevices.isEmpty {
  419. localList.addArrangedSubview(emptyRow("No devices connected"))
  420. }
  421. if scanner.sharedDevices.isEmpty {
  422. sharedList.addArrangedSubview(emptyRow("No shared scanners"))
  423. }
  424. }
  425. private func makeDeviceRow(_ device: ICScannerDevice) -> ScannerDeviceRow {
  426. let name = device.name ?? device.locationDescription ?? "Scanner"
  427. let row = ScannerDeviceRow(title: name, isSelected: scanner.selectedDevice === device)
  428. row.onClick = { [weak self] in
  429. self?.scanner.selectDevice(device)
  430. }
  431. return row
  432. }
  433. private func emptyRow(_ text: String) -> NSView {
  434. let label = NSTextField(labelWithString: text)
  435. label.font = AppTheme.regularFont(size: 12)
  436. label.textColor = AppTheme.textSecondary
  437. label.alignment = .center
  438. label.lineBreakMode = .byWordWrapping
  439. label.maximumNumberOfLines = 2
  440. label.translatesAutoresizingMaskIntoConstraints = false
  441. let container = NSView()
  442. container.translatesAutoresizingMaskIntoConstraints = false
  443. container.addSubview(label)
  444. NSLayoutConstraint.activate([
  445. label.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  446. label.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  447. label.topAnchor.constraint(equalTo: container.topAnchor, constant: 6),
  448. label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -6),
  449. container.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
  450. ])
  451. return container
  452. }
  453. private func updateSelectionUI() {
  454. rebuildDeviceLists()
  455. let hasSelection = scanner.selectedDevice != nil
  456. scanButton.isEnabled = hasSelection && scanner.state != .scanning && scanner.state != .connecting
  457. }
  458. private func updateStateUI(_ state: ScannerService.State) {
  459. switch state {
  460. case .idle, .browsing:
  461. statusLabel.stringValue = "Looking for scanners…"
  462. progressIndicator.stopAnimation(nil)
  463. scanButton.isEnabled = scanner.selectedDevice != nil
  464. case .connecting:
  465. if scanner.isWarmReconnecting {
  466. statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Preparing scanner…"
  467. progressIndicator.stopAnimation(nil)
  468. scanButton.isEnabled = scanner.selectedDevice != nil
  469. } else {
  470. statusLabel.stringValue = "Connecting to scanner…"
  471. progressIndicator.startAnimation(nil)
  472. scanButton.isEnabled = false
  473. }
  474. case .ready:
  475. statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Ready to scan"
  476. progressIndicator.stopAnimation(nil)
  477. scanButton.isEnabled = true
  478. if pendingScan {
  479. pendingScan = false
  480. scanner.scan()
  481. }
  482. case .scanning:
  483. statusLabel.stringValue = "Scanning…"
  484. progressIndicator.startAnimation(nil)
  485. scanButton.isEnabled = false
  486. case .error(let message):
  487. statusLabel.stringValue = message
  488. progressIndicator.stopAnimation(nil)
  489. scanButton.isEnabled = false
  490. }
  491. }
  492. private func startScan() {
  493. guard scanner.selectedDevice != nil else { return }
  494. editorView.isHidden = true
  495. placeholderView.isHidden = false
  496. setPreviewActionsVisible(false)
  497. if case .ready = scanner.state {
  498. scanner.scan()
  499. } else {
  500. pendingScan = true
  501. }
  502. }
  503. private func setPreviewActionsVisible(_ visible: Bool) {
  504. previewActionRow.isHidden = !visible
  505. previewActionRowHeight.constant = visible ? 40 : 0
  506. previewImageAreaBottomToContainer.isActive = !visible
  507. previewImageAreaBottomToActions.isActive = visible
  508. previewContainer.layoutSubtreeIfNeeded()
  509. }
  510. private func processScannedImage(_ image: NSImage) {
  511. let displayImage = image.normalizedForDisplay()
  512. hasResult = true
  513. scannedPages.append(displayImage)
  514. placeholderView.isHidden = true
  515. editorView.isHidden = false
  516. setPreviewActionsVisible(true)
  517. if scannedPages.count == 1 {
  518. editorView.loadDocument(pages: [(displayImage, "")])
  519. } else {
  520. editorView.addPage(displayImage)
  521. }
  522. updateScanUI()
  523. previewContainer.layoutSubtreeIfNeeded()
  524. }
  525. private func updateScanUI() {
  526. let count = scannedPages.count
  527. scanButton.title = count > 0 ? "Scan Next Document" : "Scan"
  528. if count > 0 {
  529. documentsScannedLabel.stringValue = count == 1
  530. ? "1 document scanned"
  531. : "\(count) documents scanned"
  532. documentsScannedLabel.isHidden = false
  533. } else {
  534. documentsScannedLabel.isHidden = true
  535. }
  536. if count > 1 {
  537. statusLabel.stringValue = "Select pages to print below, or tap Scan Next Document to add more"
  538. } else if count == 1 {
  539. statusLabel.stringValue = "Tap Scan Next Document to add another, or print this one"
  540. }
  541. }
  542. private func handleScanResult(_ result: Result<NSImage, Error>) {
  543. progressIndicator.stopAnimation(nil)
  544. switch result {
  545. case .success(let image):
  546. if let onScanComplete {
  547. onScanComplete(image)
  548. } else {
  549. processScannedImage(image)
  550. }
  551. updateStateUI(scanner.state)
  552. case .failure(let error):
  553. if case ScanError.cancelled = error { return }
  554. statusLabel.stringValue = error.localizedDescription
  555. scanButton.isEnabled = scanner.selectedDevice != nil
  556. }
  557. }
  558. private func activeEditor() -> EditableScanContentView {
  559. editorView.isHidden ? resultEditorView : editorView
  560. }
  561. private func updateScanStatusMessage() -> String {
  562. let count = scannedPages.count
  563. if count > 1 {
  564. return "Select pages to print below, or tap Scan Next Document to add more"
  565. }
  566. return "Tap Scan Next Document to add another, or print this one"
  567. }
  568. private func saveAsPDF() {
  569. let pages = pagesForExport()
  570. guard !pages.isEmpty else {
  571. showNoPagesSelectedAlert()
  572. return
  573. }
  574. PrintService.saveParsedScansAsPDF(pages: pages, defaultName: "Scan", from: window)
  575. }
  576. private func printScan() {
  577. let pages = pagesForExport()
  578. guard !pages.isEmpty else {
  579. showNoPagesSelectedAlert()
  580. return
  581. }
  582. PrintService.printParsedScans(pages: pages, title: "Scan", from: window)
  583. }
  584. private func pagesForExport() -> [ParsedScanPage] {
  585. let editor = activeEditor()
  586. if editor.pageCount > 0 {
  587. return editor.exportSelectedPagesForPrint()
  588. }
  589. if !scannedPages.isEmpty {
  590. return scannedPages.map {
  591. ParsedScanPage(picture: $0, caption: nil, body: .single(NSAttributedString()), showsPicture: false)
  592. }
  593. }
  594. return []
  595. }
  596. private func showNoPagesSelectedAlert() {
  597. let alert = NSAlert()
  598. alert.messageText = "No Documents Selected"
  599. alert.informativeText = "Select at least one scanned document using the checkboxes below the preview."
  600. alert.alertStyle = .informational
  601. alert.addButton(withTitle: "OK")
  602. if let window {
  603. alert.beginSheetModal(for: window)
  604. } else {
  605. alert.runModal()
  606. }
  607. }
  608. @objc private func paperSizeChanged() {
  609. guard let title = paperSizePopup.selectedItem?.title,
  610. let size = PaperSize(rawValue: title) else { return }
  611. scanner.paperSize = size
  612. }
  613. }
  614. // MARK: - Controls
  615. private final class ScannerToolbarButton: NSControl, AppearanceRefreshable {
  616. var onClick: (() -> Void)?
  617. private let iconView = NSImageView()
  618. init(symbolName: String, accessibilityLabel: String) {
  619. super.init(frame: .zero)
  620. toolTip = accessibilityLabel
  621. wantsLayer = true
  622. layer?.cornerRadius = 12
  623. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  624. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  625. iconView.image = image.withSymbolConfiguration(config)
  626. }
  627. iconView.translatesAutoresizingMaskIntoConstraints = false
  628. addSubview(iconView)
  629. NSLayoutConstraint.activate([
  630. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  631. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  632. ])
  633. refreshAppearance()
  634. }
  635. @available(*, unavailable)
  636. required init?(coder: NSCoder) { nil }
  637. func refreshAppearance() {
  638. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  639. layer?.borderColor = AppTheme.paywallBorder.cgColor
  640. layer?.borderWidth = 1.5
  641. iconView.contentTintColor = AppTheme.textPrimary
  642. }
  643. override func mouseUp(with event: NSEvent) {
  644. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  645. onClick?()
  646. }
  647. override func resetCursorRects() {
  648. addCursorRect(bounds, cursor: .pointingHand)
  649. }
  650. }
  651. private final class FlippedSidebarContentView: NSView {
  652. override var isFlipped: Bool { true }
  653. }
  654. private final class ScannerSectionHeader: NSView, AppearanceRefreshable {
  655. var count: Int = 0 {
  656. didSet { countLabel.stringValue = "\(count)" }
  657. }
  658. private let titleLabel = NSTextField(labelWithString: "")
  659. private let countLabel = NSTextField(labelWithString: "0")
  660. init(title: String) {
  661. super.init(frame: .zero)
  662. translatesAutoresizingMaskIntoConstraints = false
  663. titleLabel.stringValue = title
  664. titleLabel.font = AppTheme.semiboldFont(size: 11)
  665. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  666. countLabel.font = AppTheme.regularFont(size: 11)
  667. countLabel.alignment = .right
  668. countLabel.translatesAutoresizingMaskIntoConstraints = false
  669. addSubview(titleLabel)
  670. addSubview(countLabel)
  671. NSLayoutConstraint.activate([
  672. heightAnchor.constraint(equalToConstant: 20),
  673. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
  674. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  675. countLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  676. countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  677. ])
  678. refreshAppearance()
  679. }
  680. @available(*, unavailable)
  681. required init?(coder: NSCoder) { nil }
  682. func refreshAppearance() {
  683. titleLabel.textColor = AppTheme.textSecondary
  684. countLabel.textColor = AppTheme.textSecondary
  685. }
  686. }
  687. private final class ScannerDeviceRow: NSControl, AppearanceRefreshable {
  688. var onClick: (() -> Void)?
  689. private let iconView = NSImageView()
  690. private let titleLabel = NSTextField(labelWithString: "")
  691. private var isSelected = false
  692. init(title: String, isSelected: Bool) {
  693. self.isSelected = isSelected
  694. super.init(frame: .zero)
  695. translatesAutoresizingMaskIntoConstraints = false
  696. wantsLayer = true
  697. layer?.cornerRadius = 6
  698. titleLabel.stringValue = title
  699. titleLabel.font = AppTheme.regularFont(size: 13)
  700. titleLabel.lineBreakMode = .byTruncatingTail
  701. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  702. if let image = NSImage(systemSymbolName: "scanner", accessibilityDescription: "Scanner") {
  703. iconView.image = image.withSymbolConfiguration(
  704. NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
  705. )
  706. }
  707. iconView.translatesAutoresizingMaskIntoConstraints = false
  708. addSubview(iconView)
  709. addSubview(titleLabel)
  710. NSLayoutConstraint.activate([
  711. heightAnchor.constraint(equalToConstant: 32),
  712. iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
  713. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  714. iconView.widthAnchor.constraint(equalToConstant: 18),
  715. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6),
  716. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
  717. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  718. ])
  719. refreshAppearance()
  720. }
  721. @available(*, unavailable)
  722. required init?(coder: NSCoder) { nil }
  723. func refreshAppearance() {
  724. if isSelected {
  725. layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.2).cgColor
  726. titleLabel.textColor = AppTheme.blue
  727. iconView.contentTintColor = AppTheme.blue
  728. } else {
  729. layer?.backgroundColor = .clear
  730. titleLabel.textColor = AppTheme.textPrimary
  731. iconView.contentTintColor = AppTheme.textPrimary
  732. }
  733. }
  734. override func mouseUp(with event: NSEvent) {
  735. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  736. onClick?()
  737. }
  738. override func resetCursorRects() {
  739. addCursorRect(bounds, cursor: .pointingHand)
  740. }
  741. }
  742. private final class ScannerPlaceholderView: NSView, AppearanceRefreshable {
  743. override var isOpaque: Bool { false }
  744. func refreshAppearance() {
  745. needsDisplay = true
  746. }
  747. override func draw(_ dirtyRect: NSRect) {
  748. let rect = bounds.insetBy(dx: 1, dy: 1)
  749. let path = NSBezierPath(roundedRect: rect, xRadius: 16, yRadius: 16)
  750. path.setLineDash([6, 4], count: 2, phase: 0)
  751. AppTheme.border.setStroke()
  752. path.lineWidth = 1.5
  753. path.stroke()
  754. }
  755. }
  756. private final class ScannerPrimaryButton: NSControl, AppearanceRefreshable {
  757. var onClick: (() -> Void)?
  758. private let titleLabel = NSTextField(labelWithString: "")
  759. private var isEnabledState = true
  760. var title: String {
  761. get { titleLabel.stringValue }
  762. set { titleLabel.stringValue = newValue }
  763. }
  764. override var isEnabled: Bool {
  765. get { isEnabledState }
  766. set {
  767. isEnabledState = newValue
  768. refreshAppearance()
  769. }
  770. }
  771. init(title: String) {
  772. super.init(frame: .zero)
  773. titleLabel.stringValue = title
  774. titleLabel.font = AppTheme.semiboldFont(size: 13)
  775. titleLabel.alignment = .center
  776. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  777. wantsLayer = true
  778. layer?.cornerRadius = 6
  779. addSubview(titleLabel)
  780. NSLayoutConstraint.activate([
  781. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  782. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  783. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  784. ])
  785. refreshAppearance()
  786. }
  787. @available(*, unavailable)
  788. required init?(coder: NSCoder) { nil }
  789. func refreshAppearance() {
  790. if isEnabledState {
  791. layer?.backgroundColor = NSColor.controlAccentColor.cgColor
  792. titleLabel.textColor = .white
  793. } else {
  794. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  795. titleLabel.textColor = AppTheme.textSecondary
  796. }
  797. }
  798. override func mouseUp(with event: NSEvent) {
  799. guard isEnabledState, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  800. onClick?()
  801. }
  802. override func resetCursorRects() {
  803. guard isEnabledState else { return }
  804. addCursorRect(bounds, cursor: .pointingHand)
  805. }
  806. }
  807. private final class ScannerSecondaryButton: NSControl, AppearanceRefreshable {
  808. var onClick: (() -> Void)?
  809. private let titleLabel = NSTextField(labelWithString: "")
  810. private let iconView = NSImageView()
  811. private var hoverTracker: HoverTracker?
  812. private var isHovered = false
  813. init(title: String, symbolName: String) {
  814. super.init(frame: .zero)
  815. wantsLayer = true
  816. layer?.cornerRadius = 10
  817. layer?.borderWidth = 1.5
  818. titleLabel.stringValue = title
  819. titleLabel.font = AppTheme.semiboldFont(size: 14)
  820. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  821. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
  822. iconView.image = image.withSymbolConfiguration(
  823. NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  824. )
  825. }
  826. iconView.translatesAutoresizingMaskIntoConstraints = false
  827. addSubview(iconView)
  828. addSubview(titleLabel)
  829. NSLayoutConstraint.activate([
  830. iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  831. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  832. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
  833. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  834. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
  835. ])
  836. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  837. self?.setHovered(hovering)
  838. }
  839. refreshAppearance()
  840. }
  841. @available(*, unavailable)
  842. required init?(coder: NSCoder) { nil }
  843. func refreshAppearance() {
  844. layer?.backgroundColor = (isHovered
  845. ? AppTheme.elevatedBackground.highlight(withLevel: 0.05) ?? AppTheme.elevatedBackground
  846. : AppTheme.elevatedBackground).cgColor
  847. layer?.borderColor = AppTheme.paywallBorder.cgColor
  848. titleLabel.textColor = AppTheme.textPrimary
  849. iconView.contentTintColor = AppTheme.textPrimary
  850. }
  851. private func setHovered(_ hovering: Bool) {
  852. isHovered = hovering
  853. refreshAppearance()
  854. }
  855. override func mouseUp(with event: NSEvent) {
  856. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  857. onClick?()
  858. }
  859. override func resetCursorRects() {
  860. addCursorRect(bounds, cursor: .pointingHand)
  861. }
  862. }