262 lines
9.1 KiB
Swift
262 lines
9.1 KiB
Swift
import AppKit
|
|
import UniformTypeIdentifiers
|
|
|
|
/// SPEC §9 — three-pane window: thumbnails | canvas | inspector.
|
|
|
|
final class PreviewWindowController: NSWindowController {
|
|
let canvas = TargetCanvasView()
|
|
let sidebar = ThumbnailSidebarView()
|
|
let inspector = InspectorView()
|
|
let dropZone = DropZoneView()
|
|
let engine = PrintEngine()
|
|
|
|
private(set) var job: TargetJob?
|
|
private(set) var modeIsJob = false
|
|
var onPagesChanged: (() -> Void)?
|
|
|
|
convenience init(standalone: Bool) {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.minSize = NSSize(width: 900, height: 560)
|
|
window.title = "TargetPrint — Untitled"
|
|
window.center()
|
|
self.init(window: window)
|
|
buildUI()
|
|
refreshPrinters()
|
|
}
|
|
|
|
private func buildUI() {
|
|
guard let window = window else { return }
|
|
let split = NSSplitView()
|
|
split.isVertical = true
|
|
split.dividerStyle = .thin
|
|
split.autosaveName = "TargetPrint.Split"
|
|
|
|
sidebar.frame = NSRect(x: 0, y: 0, width: 200, height: 800)
|
|
inspector.frame = NSRect(x: 0, y: 0, width: 280, height: 800)
|
|
|
|
let canvasHost = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 800))
|
|
canvas.frame = canvasHost.bounds
|
|
canvas.autoresizingMask = [.width, .height]
|
|
dropZone.frame = canvasHost.bounds
|
|
dropZone.autoresizingMask = [.width, .height]
|
|
canvasHost.addSubview(canvas)
|
|
canvasHost.addSubview(dropZone)
|
|
dropZone.isHidden = true
|
|
|
|
split.addSubview(sidebar)
|
|
split.addSubview(canvasHost)
|
|
split.addSubview(inspector)
|
|
split.setHoldingPriority(NSLayoutConstraint.Priority(250), forSubviewAt: 1)
|
|
window.contentView = split
|
|
window.setFrameAutosaveName("TargetPrint.Main")
|
|
DispatchQueue.main.async {
|
|
let w = split.bounds.width
|
|
if w > 500 {
|
|
split.setPosition(200, ofDividerAt: 0)
|
|
split.setPosition(w - 280, ofDividerAt: 1)
|
|
}
|
|
}
|
|
|
|
sidebar.onSelect = { [weak self] idx in
|
|
self?.canvas.selectedIndex = idx
|
|
self?.syncGeometry()
|
|
}
|
|
inspector.onChange = { [weak self] in
|
|
self?.applyInspectorToEngine()
|
|
self?.syncGeometry()
|
|
}
|
|
inspector.onPrint = { [weak self] in self?.presentPrint() }
|
|
dropZone.onFiles = { [weak self] urls in
|
|
self?.openURLs(urls)
|
|
}
|
|
canvas.registerForDraggedTypes([.fileURL])
|
|
updateDropZone()
|
|
}
|
|
|
|
func refreshPrinters() {
|
|
do {
|
|
let queues = try CUPSManager.listQueues()
|
|
inspector.loadQueues(queues, select: engine.printerName.isEmpty ? queues.first?.name : engine.printerName)
|
|
if engine.printerName.isEmpty, let first = queues.first {
|
|
engine.printerName = first.name
|
|
}
|
|
inspector.apply(engine: engine)
|
|
syncGeometry()
|
|
} catch {
|
|
TPLog.error("Failed to list CUPS queues: \(error)")
|
|
}
|
|
}
|
|
|
|
func loadJob(from url: URL) throws {
|
|
let parsed = try TargetJobParser.parse(url: url)
|
|
TPLog.info("Loaded job \(parsed.jobTitle) from \(url.path)")
|
|
// SPEC §13: do not delete the job file.
|
|
job = parsed
|
|
modeIsJob = true
|
|
engine.jobTitle = parsed.jobTitle
|
|
engine.printerName = parsed.printSettings.printerName
|
|
engine.mediaSize = parsed.printSettings.mediaSize
|
|
engine.mediaType = parsed.printSettings.mediaType
|
|
engine.paperSource = parsed.printSettings.paperSource
|
|
engine.printQuality = parsed.printSettings.printQuality
|
|
engine.resolution = parsed.printSettings.resolution
|
|
engine.scaling = parsed.printSettings.scaling
|
|
engine.centered = parsed.printSettings.centered
|
|
engine.colorMode = ColorMatchingMode.from(forceUnmanagedColor: parsed.printSettings.forceUnmanagedColor)
|
|
engine.lockColorManagement = parsed.uiPolicy.lockColorManagement
|
|
engine.allowBasicDriverChanges = parsed.uiPolicy.allowBasicDriverChanges
|
|
inspector.jobLocked = true
|
|
inspector.lockColorManagement = parsed.uiPolicy.lockColorManagement
|
|
inspector.allowBasicDriverChanges = parsed.uiPolicy.allowBasicDriverChanges
|
|
|
|
var pages: [TargetPage] = []
|
|
for path in parsed.files {
|
|
let fileURL = URL(fileURLWithPath: path)
|
|
do {
|
|
pages.append(contentsOf: try TargetPage.load(url: fileURL))
|
|
} catch {
|
|
throw AppError.unreadableTarget(path)
|
|
}
|
|
}
|
|
setPages(pages)
|
|
window?.title = "TargetPrint — \(parsed.jobTitle)"
|
|
refreshPrinters()
|
|
inspector.apply(engine: engine)
|
|
applyInspectorToEngine()
|
|
syncGeometry()
|
|
}
|
|
|
|
func openURLs(_ urls: [URL]) {
|
|
var jobURLs: [URL] = []
|
|
var tiffs: [URL] = []
|
|
for url in urls {
|
|
let ext = url.pathExtension.lowercased()
|
|
if ext == "targetjob" || ext == "json" { jobURLs.append(url) }
|
|
else { tiffs.append(url) }
|
|
}
|
|
if let jobURL = jobURLs.first {
|
|
do {
|
|
try loadJob(from: jobURL)
|
|
} catch {
|
|
PreviewWindowController.presentError(error, on: window)
|
|
}
|
|
return
|
|
}
|
|
var pages: [TargetPage] = []
|
|
for url in tiffs {
|
|
do {
|
|
pages.append(contentsOf: try TargetPage.load(url: url))
|
|
} catch {
|
|
PreviewWindowController.presentError(error, on: window)
|
|
return
|
|
}
|
|
}
|
|
if pages.isEmpty { return }
|
|
modeIsJob = false
|
|
job = nil
|
|
inspector.jobLocked = false
|
|
engine.jobTitle = pages.first.map { $0.name } ?? "Untitled"
|
|
window?.title = "TargetPrint — \(engine.jobTitle)"
|
|
setPages(canvas.pages + pages)
|
|
syncGeometry()
|
|
}
|
|
|
|
func setPages(_ pages: [TargetPage]) {
|
|
canvas.pages = pages
|
|
canvas.selectedIndex = 0
|
|
sidebar.pages = pages
|
|
sidebar.selectedIndex = 0
|
|
updateDropZone()
|
|
onPagesChanged?()
|
|
syncGeometry()
|
|
}
|
|
|
|
func presentPrint() {
|
|
applyInspectorToEngine()
|
|
guard !canvas.pages.isEmpty else { return }
|
|
guard let window = window else { return }
|
|
engine.run(view: canvas, window: window, presentPanel: true)
|
|
}
|
|
|
|
private func applyInspectorToEngine() {
|
|
inspector.push(into: engine)
|
|
canvas.centered = engine.centered
|
|
canvas.scaling = CGFloat(engine.scaling)
|
|
if let info = try? engine.makePrintInfo() {
|
|
canvas.printInfo = info
|
|
}
|
|
canvas.airPrintWarning = inspector.selectedQueue?.isAirPrint == true
|
|
? CUPSManager.airPrintWarning
|
|
: nil
|
|
inspector.applyLocks()
|
|
}
|
|
|
|
private func syncGeometry() {
|
|
if let page = canvas.currentPage {
|
|
inspector.showGeometry(canvas.geometry(for: page))
|
|
} else {
|
|
inspector.showGeometry(nil)
|
|
}
|
|
canvas.needsDisplay = true
|
|
}
|
|
|
|
private func updateDropZone() {
|
|
dropZone.isHidden = !canvas.pages.isEmpty
|
|
}
|
|
|
|
static func presentOpenPanel(completion: @escaping ([URL]) -> Void) {
|
|
let panel = NSOpenPanel()
|
|
panel.allowsMultipleSelection = true
|
|
panel.canChooseDirectories = false
|
|
if #available(macOS 12.0, *) {
|
|
var types: [UTType] = [.tiff, .json]
|
|
if let jobType = UTType(filenameExtension: "targetjob") {
|
|
types.append(jobType)
|
|
}
|
|
panel.allowedContentTypes = types
|
|
} else {
|
|
panel.allowedFileTypes = ["tif", "tiff", "targetjob", "json"]
|
|
}
|
|
panel.begin { result in
|
|
if result == .OK { completion(panel.urls) }
|
|
}
|
|
}
|
|
|
|
static func presentError(_ error: Error, on window: NSWindow?) {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .critical
|
|
alert.messageText = "TargetPrint"
|
|
alert.informativeText = (error as? AppError).map { $0.description } ?? error.localizedDescription
|
|
alert.addButton(withTitle: "OK")
|
|
TPLog.error(alert.informativeText)
|
|
if let window = window {
|
|
alert.beginSheetModal(for: window)
|
|
} else {
|
|
alert.runModal()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drag onto the canvas itself (not only the empty drop zone).
|
|
extension TargetCanvasView {
|
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
|
DropZoneView.containsSupportedFiles(sender) ? .copy : []
|
|
}
|
|
|
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
|
let urls = DropZoneView.urls(from: sender)
|
|
guard !urls.isEmpty else { return false }
|
|
NotificationCenter.default.post(name: .targetPrintOpenURLs, object: urls)
|
|
return true
|
|
}
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let targetPrintOpenURLs = Notification.Name("com.iccery.TargetPrint.openURLs")
|
|
}
|