diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d44d83..2e3cfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,20 @@ versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added -- Contact sheets: File > New Contact Sheet… (⇧⌘N, or Make a Contact Sheet… under Photo) arranges up +- Tabs: the Mac app is one window with five tabs along the toolbar (⌘1–⌘5): Print, Negative, + Contact Sheet, Printer Calibration and Process Calibration (blocking colour, then process curve). + Each tab keeps its own work, so one printer can be calibrating while another prints; the Print + and Negative tabs each remember their own photo, printer and paper. A finished calibration, + blocking colour or process curve offers to go straight to the tab that uses it, and a contact + sheet goes to Print It or Make a Negative. On quitting, OpenQuad asks whether to keep the open + photos and contact sheet for next time; it reopens on the last tab used. +- Contact sheets show a progress bar while photos open, with each photo's name and an estimate of + the time left. +- Contact sheets: the Contact Sheet tab (⇧⌘N) arranges up to 16 photos as thumbnails on one sheet the size of the page (inside its margins, safe edge and outline), portrait or landscape, on black or white, each whole or cropped to fill its cell, with - optional file names and a 21-step greyscale wedge. Use Sheet makes it the photo to print, as an - inkjet print or a digital negative. Command line: `openquad contact-sheet PHOTOS... -o sheet.tif`. + optional file names and a 21-step greyscale wedge. Print It or Make a Negative sends it to that + tab. Command line: `openquad contact-sheet PHOTOS... -o sheet.tif`. - Saved negative setups: Save Setup… in negative mode remembers the process curve (a copy is kept with the setup), ink colour, mirror, safe edge, black outline, printer and paper under a name such as "Cyanotype on Pictorico"; the Setup menu switches all of them at once, and shows which diff --git a/README.md b/README.md index ea1598e..07b500b 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,10 @@ opens on first launch (and later from **Help** or the **?** button). did the goals get met? - **Digital negatives:** make a negative and try the blocking test and process calibration, if you contact print (carbon, or another UV process). -- **Contact sheets:** File > New Contact Sheet… (⇧⌘N) puts up to 16 photos on one sheet, to print - or to make a negative of. +- **Contact sheets:** the Contact Sheet tab (⌘3) puts up to 16 photos on one sheet, to print or + to make a negative of. +- **Tabs:** Print, Negative, Contact Sheet, Printer Calibration and Process Calibration each keep + their own work. Does moving between them while working feel natural? - **The guide:** is anything unclear or missing? ### Reporting problems diff --git a/src/openquad/settings.py b/src/openquad/settings.py index 014d1de..70f91ab 100644 --- a/src/openquad/settings.py +++ b/src/openquad/settings.py @@ -38,6 +38,9 @@ class Settings: negative_colour: str = "green" max_size: list[float] | None = None # fit within (width, height) inches, e.g. the tissue units: str = "in" # lengths shown in the app: "in" or "mm" + # The Mac app's Negative tab keeps its own printer and paper (None: as above). + negative_printer: str | None = None + negative_paper: str | None = None @classmethod def load(cls, path: Path | None = None) -> Settings: diff --git a/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift b/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift index 45dd83a..175167e 100644 --- a/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift +++ b/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift @@ -1,7 +1,8 @@ // OpenQuad for macOS. // // OpenQuad the app -// OpenQuad --snapshot out.png [--photo PHOTO] [--negative] [--printer CUPS_QUEUE] [--max-size WxH] +// OpenQuad --snapshot out.png [--tab print|negative|contact-sheet|printer-calibration|process-calibration] +// [--photo PHOTO] [--negative] [--printer CUPS_QUEUE] [--max-size WxH] [--notice TEXT] // [--outline INCHES] [--units in|mm] [--setups FOLDER] [--setup NAME] // OpenQuad --snapshot out.png --wizard calibration|process|blocking|guide|contact-sheet [--scan SCAN] [--run RUN] // [--photos P1,P2,...] [--landscape] [--white] [--wedge] [--labels] [--crop] @@ -34,32 +35,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + /// Offer to keep the open photos for next time. + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + MainActor.assumeIsolated { (OpenQuadApp.root?.confirmQuit() ?? true) ? .terminateNow : .terminateCancel } + } } @main struct OpenQuadApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate - @State private var model = Snapshot.model() + @State private var root = Snapshot.root() + /// For the app delegate's quit question. + @MainActor static var root: AppRoot? init() { // Options like "--negative --max-size 4x5" would otherwise leave "4x5" looking like a // file to open, and then the print window doesn't open at launch. UserDefaults.standard.register(defaults: ["NSTreatUnknownArgumentsAsOpen": "NO"]) + Self.root = root } var body: some Scene { Window("OpenQuad (Beta)", id: "print") { - Snapshot.content(model) + Snapshot.content(root) } .defaultSize(width: 1100, height: 720) Settings { - SettingsView(model: model) + SettingsView(root: root) } .commands { CommandGroup(replacing: .newItem) { - Button("New Contact Sheet…") { NotificationCenter.default.post(name: ContentView.contactSheetNotification, object: nil) } + Button("New Contact Sheet…") { root.show(.contactSheet) } .keyboardShortcut("n", modifiers: [.command, .shift]) } + CommandGroup(before: .toolbar) { + ForEach(AppTab.allCases) { tab in + Button(tab.title) { root.show(tab) } + .keyboardShortcut(tab.shortcut, modifiers: .command) + } + Divider() + } CommandGroup(replacing: .help) { Button("How to Use OpenQuad") { NotificationCenter.default.post(name: GuideView.showNotification, object: nil) } .keyboardShortcut("?") @@ -78,33 +94,51 @@ enum Snapshot { return arguments[i + 1] } - static func model() -> AppModel { - guard value("--snapshot") != nil else { return AppModel() } + static func root() -> AppRoot { + guard value("--snapshot") != nil else { return AppRoot() } // A throwaway settings file, so snapshots never change the user's settings. - let settings = FileManager.default.temporaryDirectory.appendingPathComponent("openquad-snapshot-settings.json") - try? FileManager.default.removeItem(at: settings) + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("openquad-snapshot") + try? FileManager.default.removeItem(at: folder) + let negative = arguments.contains("--negative") + let tab = value("--tab").flatMap(AppTab.init(rawValue:)) ?? (negative ? .negative : .print) // The real calibrations, so the snapshot shows what this Mac has. - let model = AppModel(settingsURL: settings, libraryFolder: AppSettings.dataDirectory().appendingPathComponent("calibrations"), - setupsFolder: value("--setups").map { URL(fileURLWithPath: $0) } - ?? AppSettings.dataDirectory().appendingPathComponent("setups")) + let root = AppRoot(settingsURL: folder.appendingPathComponent("settings.json"), + libraryFolder: AppSettings.dataDirectory().appendingPathComponent("calibrations"), + setupsFolder: value("--setups").map { URL(fileURLWithPath: $0) } + ?? AppSettings.dataDirectory().appendingPathComponent("setups"), + tab: tab, restoreWorkspace: false) + root.askOnQuit = false + let model = tab == .negative ? root.negativeModel : root.printModel + if let units = value("--units").flatMap(LengthUnit.init(rawValue:)) { root.setUnits(units) } if let photo = value("--photo") { model.openPhoto(URL(fileURLWithPath: photo)) } - if arguments.contains("--negative") { model.setMode(.negative) } if let printer = value("--printer") { model.setPrinter(printer) } if let name = value("--setup"), let setup = model.setups.first(where: { $0.name == name }) { model.applySetup(setup) } if let size = value("--max-size")?.lowercased().split(separator: "x").compactMap({ Double($0) }), size.count == 2 { model.setMaxSize((size[0], size[1])) } - if let units = value("--units").flatMap(LengthUnit.init(rawValue:)) { model.setUnits(units) } if let outline = value("--outline").flatMap(Double.init) { model.setOutlineSize(outline) model.setOutline(true) } - return model + if let notice = value("--notice") { root.notice = notice } + if tab == .contactSheet { fillSheet(root.contactSheet) } + return root + } + + static func fillSheet(_ sheet: ContactSheetModel) { + if arguments.contains("--landscape") { sheet.setLandscape(true) } + sheet.update { + $0.background = arguments.contains("--white") ? .white : .black + $0.wedge = arguments.contains("--wedge") + $0.labels = arguments.contains("--labels") + $0.crop = arguments.contains("--crop") + } + sheet.add((value("--photos") ?? "").split(separator: ",").map { URL(fileURLWithPath: String($0)) }) } /// The print window, or (for a snapshot) one of the wizards, filled in from a scan. - static func content(_ model: AppModel) -> AnyView { - wizard ?? AnyView(ContentView(model: model, guideOnLaunch: value("--snapshot") == nil)) + static func content(_ root: AppRoot) -> AnyView { + wizard ?? AnyView(RootView(root: root, guideOnLaunch: value("--snapshot") == nil)) } /// Built once: the scene's body is evaluated again on every change. @@ -141,15 +175,8 @@ enum Snapshot { return AnyView(GuideView()) case "contact-sheet": let sheet = ContactSheetModel(room: (7.5, 10), folder: folder) - if arguments.contains("--landscape") { sheet.setLandscape(true) } - sheet.update { - $0.background = arguments.contains("--white") ? .white : .black - $0.wedge = arguments.contains("--wedge") - $0.labels = arguments.contains("--labels") - $0.crop = arguments.contains("--crop") - } - sheet.add((value("--photos") ?? "").split(separator: ",").map { URL(fileURLWithPath: String($0)) }) - return AnyView(ContactSheetView(model: sheet) { _ in }) + fillSheet(sheet) + return AnyView(ContactSheetView(model: sheet) { _, _ in }) default: return nil } @@ -159,7 +186,9 @@ enum Snapshot { guard let output = value("--snapshot") else { return } let wait = Double(value("--wait") ?? "") ?? (value("--wizard") == nil ? 2.5 : 8.0) // a wizard reads its scan DispatchQueue.main.asyncAfter(deadline: .now() + wait) { - guard let view = NSApp.windows.first(where: { $0.isVisible })?.contentView, + // The frame view, so the toolbar (the tab bar) is in the picture too. + guard let content = NSApp.windows.first(where: { $0.isVisible })?.contentView, + let view = Optional(content.superview ?? content), let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { let windows = NSApp.windows.map { "\($0.title) visible=\($0.isVisible) frame=\($0.frame)" } FileHandle.standardError.write(Data("no window to snapshot; windows: \(windows)\n".utf8)) diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift index e2a12b2..8da0e71 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift @@ -16,9 +16,16 @@ public let presetLabels: [(String, String)] = [ public let paperOrder = ["Letter", "Legal", "A4", "A5", "4x6", "5x7", "8x10"] +/// Which tab a model serves: the Print tab or the Negative tab. Each keeps its own photo, +/// printer and paper; the rest of the settings are shared. +public enum TabRole: Sendable { + case print, negative +} + @MainActor @Observable public final class AppModel { + public let role: TabRole public private(set) var session = PrintSession() public private(set) var settings: AppSettings public private(set) var preview: CGImage? @@ -26,6 +33,8 @@ public final class AppModel { public var error: String? /// The last thing that happened (e.g. a print job sent), for the status line. public var status: String? + /// A new process curve is in use: offer to save it as a setup (Negative tab). + public var setupOffer: String? /// CUPS printers on this Mac, for printing without the print panel. public private(set) var printers: [String] = [] /// Saved calibrations to choose from: the newest of each printer and paper. @@ -37,7 +46,9 @@ public final class AppModel { let settingsURL: URL private var previewToken = 0 - public init(settingsURL: URL = AppSettings.defaultURL(), libraryFolder: URL? = nil, setupsFolder: URL? = nil) { + public init(settingsURL: URL = AppSettings.defaultURL(), libraryFolder: URL? = nil, setupsFolder: URL? = nil, + role: TabRole = .print) { + self.role = role self.settingsURL = settingsURL library = CalibrationLibrary(folder: libraryFolder ?? settingsURL.deletingLastPathComponent().appendingPathComponent("calibrations")) @@ -47,6 +58,14 @@ public final class AppModel { settings = AppSettings.load(settingsURL) restore() refreshLibrary() + session.setMode(role == .negative ? .negative : .print) + } + + /// Change the settings file: re-read it first, as the other tab may have changed it. + private func saveSettings(_ change: (inout AppSettings) -> Void) { + settings = AppSettings.load(settingsURL) + change(&settings) + try? settings.save(settingsURL) } /// Re-read the calibrations folder (after a wizard or an import). @@ -104,7 +123,7 @@ public final class AppModel { } private func restore() { - try? session.setPaper(settings.paper) + try? session.setPaper(role == .negative ? settings.negativePaper ?? settings.paper : settings.paper) if let path = settings.calibration, FileManager.default.fileExists(atPath: path) { try? session.setCalibration(URL(fileURLWithPath: path)) } @@ -113,7 +132,7 @@ public final class AppModel { } else { try? session.setNegativeColour(settings.negativeColour) } - session.printer = settings.printer + session.printer = role == .negative ? settings.negativePrinter ?? settings.printer : settings.printer if let size = settings.maxSize, size.count == 2 { session.maxSize = (size[0], size[1]) } session.units = settings.units } @@ -122,18 +141,26 @@ public final class AppModel { public var units: LengthUnit { settings.units } public func setUnits(_ units: LengthUnit) { - settings.units = units session.units = units - try? settings.save(settingsURL) + saveSettings { $0.units = units } } + /// Take up the unit the other tab chose. + public func followUnits() { session.units = AppSettings.load(settingsURL).units } + private func remember() { - settings.paper = session.paper - settings.calibration = session.calibrationPath?.path - settings.process = session.processPath?.path - settings.negativeColour = session.negativeColour - settings.lastFolder = session.source?.deletingLastPathComponent().path ?? settings.lastFolder - try? settings.save(settingsURL) + let session = session + saveSettings { settings in + if session.negative { + settings.negativePaper = session.paper + settings.process = session.processPath?.path + settings.negativeColour = session.negativeColour + } else { + settings.paper = session.paper + settings.calibration = session.calibrationPath?.path + } + settings.lastFolder = session.source?.deletingLastPathComponent().path ?? settings.lastFolder + } } private func attempt(_ work: () throws -> Void) { @@ -314,8 +341,8 @@ public final class AppModel { /// A CUPS printer to print to directly, or nil to choose in the macOS print panel. public func setPrinter(_ name: String?) { session.printer = name - settings.printer = name - try? settings.save(settingsURL) + let negative = role == .negative + saveSettings { if negative { $0.negativePrinter = name } else { $0.printer = name } } matchCalibration() } @@ -329,8 +356,7 @@ public final class AppModel { public func setMaxSize(_ size: (Double, Double)?) { if let size, !(size.0 > 0 && size.1 > 0) { return } session.maxSize = size - settings.maxSize = size.map { [$0.0, $0.1] } - try? settings.save(settingsURL) + saveSettings { $0.maxSize = size.map { [$0.0, $0.1] } } refreshPreview() } @@ -354,7 +380,7 @@ public final class AppModel { /// Make negatives in the colour the blocking test found best. public func useBlockingColour(_ colour: String, scannerHost: String) { - if !scannerHost.isEmpty { settings.scannerHost = scannerHost } + if !scannerHost.isEmpty { saveSettings { $0.scannerHost = scannerHost } } session.setMode(.negative) if let process = session.process, process.colour != colour { error = "The process curve is for \(process.colour) negatives; clear it to use \(colour)." @@ -379,13 +405,13 @@ public final class AppModel { } public func finishCalibration(_ path: URL, scannerHost: String) { - if !scannerHost.isEmpty { settings.scannerHost = scannerHost } + if !scannerHost.isEmpty { saveSettings { $0.scannerHost = scannerHost } } setCalibration(path) if let entry = currentCalibration { status = "Using \(entry.label)." } } public func finishProcess(_ path: URL, scannerHost: String) { - if !scannerHost.isEmpty { settings.scannerHost = scannerHost } + if !scannerHost.isEmpty { saveSettings { $0.scannerHost = scannerHost } } setProcess(path) } diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/BlockingTest.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/BlockingTest.swift index d27cea6..a2fee36 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/BlockingTest.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/BlockingTest.swift @@ -105,10 +105,11 @@ struct BlockingChart: View { public struct BlockingTestView: View { @Bindable var model: BlockingTestModel let use: (String) -> Void - let close: () -> Void + /// Closes the window it is in; nil inside a tab (no Close button). + let close: (() -> Void)? @State private var resultView = 0 - public init(model: BlockingTestModel, use: @escaping (String) -> Void, close: @escaping () -> Void = {}) { + public init(model: BlockingTestModel, use: @escaping (String) -> Void, close: (() -> Void)? = nil) { self.model = model self.use = use self.close = close @@ -173,10 +174,10 @@ public struct BlockingTestView: View { if let error = model.error { Text(error).foregroundStyle(.red) } HStack { Spacer() - Button("Close", action: close) + if let close { Button("Close", action: close) } Button(model.best.map { "Use \($0.capitalized) for Negatives" } ?? "Use the Best Colour") { if let best = model.best { use(best) } - close() + close?() } .buttonStyle(.borderedProminent) .disabled(model.best == nil) diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/ContactSheetView.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/ContactSheetView.swift index 7e63f31..0e92c5e 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/ContactSheetView.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/ContactSheetView.swift @@ -25,21 +25,40 @@ public final class ContactSheetModel { public var busy = false public var error: String? public private(set) var preview: CGImage? - /// The printable room (width, height) in inches, portrait, from the print window. - public let room: (Double, Double) - public let units: LengthUnit + /// The printable room (width, height) in inches, portrait: the Print tab's page. + public private(set) var room: (Double, Double) + /// The Negative tab's room (inside its safe edge and outline), if different. + public private(set) var negativeRoom: (Double, Double)? let folder: URL private var previewToken = 0 + public var units: LengthUnit + + static func portrait(_ room: (Double, Double)) -> (Double, Double) { (min(room.0, room.1), max(room.0, room.1)) } + public init(room: (Double, Double), units: LengthUnit = .inches, folder: URL) { - self.room = (min(room.0, room.1), max(room.0, room.1)) + self.room = Self.portrait(room) self.units = units self.folder = folder applySize() } /// The sheet's size in inches, turned for landscape. - public var size: (Double, Double) { landscape ? (room.1, room.0) : room } + public var size: (Double, Double) { size(for: .print) } + + /// The sheet's size for the Print or Negative tab's page. + public func size(for role: TabRole) -> (Double, Double) { + let room = role == .negative ? negativeRoom ?? room : room + return landscape ? (room.1, room.0) : room + } + + /// The Print and Negative tabs' pages, as they are now. + public func setRooms(print: (Double, Double), negative: (Double, Double)) { + let (print, negative) = (Self.portrait(print), Self.portrait(negative)) + guard print != room || negative != negativeRoom ?? room else { return } + (room, negativeRoom) = (print, negative) + applySize() + } func applySize() { (layout.width, layout.height) = size @@ -56,27 +75,64 @@ public final class ContactSheetModel { refresh() } - public var canAdd: Bool { entries.count < ContactSheet.maxPhotos } + public var canAdd: Bool { entries.count + queue.count < ContactSheet.maxPhotos } + + /// Photos being opened: how many are done, of how many, since when. + public struct Loading: Equatable { + public var done: Int + public var total: Int + public let started: Date + /// The file being opened now. + public var current: String + + /// Seconds left, from the time per photo so far (nil until one is done). + public func secondsLeft(now: Date = Date()) -> Double? { + guard done > 0 else { return nil } + return now.timeIntervalSince(started) / Double(done) * Double(total - done) + } + + /// "Opening frame-03.tif (3 of 12) · about 8 s left". + public func description(now: Date = Date()) -> String { + var text = "Opening \(current) (\(done + 1) of \(total))" + if let left = secondsLeft(now: now) { + text += left < 60 ? " · about \(max(1, Int(left.rounded()))) s left" + : " · about \(Int((left / 60).rounded())) min left" + } + return text + } + } + + public private(set) var loading: Loading? + private var queue: [URL] = [] - /// Add photos (up to 16 in all), in the background. + /// Add photos (up to 16 in all), opened one at a time in the background; each shows on + /// the sheet as soon as it is ready. public func add(_ urls: [URL]) { - let room = ContactSheet.maxPhotos - entries.count - let chosen = Array(urls.prefix(room)) + let room = ContactSheet.maxPhotos - entries.count - queue.count + let chosen = Array(urls.prefix(max(0, room))) if urls.count > room { error = "A contact sheet takes up to \(ContactSheet.maxPhotos) photos; the rest were left out." } guard !chosen.isEmpty else { return } + queue += chosen + if loading != nil { // already opening: these join the queue + loading?.total += chosen.count + return + } + loading = Loading(done: 0, total: queue.count, started: Date(), current: queue[0].lastPathComponent) busy = true Task { - let loaded = await Task.detached(priority: .userInitiated) { - chosen.map { url in Result { (url, try Self.shrunk(Photos.load(url))) } } - }.value - busy = false - for result in loaded { + while !queue.isEmpty { + let url = queue.removeFirst() + loading?.current = url.lastPathComponent + let result = await Task.detached(priority: .userInitiated) { Result { try Self.shrunk(Photos.load(url)) } }.value switch result { - case .success(let (url, photo)): entries.append(Entry(url: url, photo: photo)) - case .failure(let failure): error = "Couldn't open a photo: \(failure)" + case .success(let photo): entries.append(Entry(url: url, photo: photo)) + case .failure(let failure): error = "Couldn't open \(url.lastPathComponent): \(failure)" } + loading?.done += 1 + refresh() } - refresh() + loading = nil + busy = false } } @@ -130,10 +186,12 @@ public final class ContactSheetModel { provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) } - /// Make the full sheet at 300 ppi, save it as a 16-bit TIFF in OpenQuad's contact - /// sheets folder, and hand its file to `done`. - public func make(dpi: Double = 300, done: @escaping (URL) -> Void) { - let photos = photos, names = names, layout = layout, landscape = landscape + /// Make the full sheet at 300 ppi for the Print or Negative tab's page, save it as a + /// 16-bit TIFF in OpenQuad's contact sheets folder, and hand its file to `done`. + public func make(dpi: Double = 300, for role: TabRole = .print, done: @escaping (URL) -> Void) { + var sized = layout + (sized.width, sized.height) = size(for: role) + let photos = photos, names = names, landscape = landscape, layout = sized let format = DateFormatter() format.dateFormat = "yyyy-MM-dd-HHmmss" let url = folder.appendingPathComponent("contact-sheet-\(format.string(from: Date())).tif") @@ -164,10 +222,12 @@ public final class ContactSheetModel { public struct ContactSheetView: View { @Bindable var model: ContactSheetModel - let use: (URL) -> Void - let close: () -> Void + /// Hands the made sheet to the Print or Negative tab. + let use: (URL, TabRole) -> Void + /// Closes the window it is in; nil inside a tab (no Close button). + let close: (() -> Void)? - public init(model: ContactSheetModel, use: @escaping (URL) -> Void, close: @escaping () -> Void = {}) { + public init(model: ContactSheetModel, use: @escaping (URL, TabRole) -> Void, close: (() -> Void)? = nil) { self.model = model self.use = use self.close = close @@ -205,7 +265,7 @@ public struct ContactSheetView: View { Form { Section("Photos (\(model.entries.count) of \(ContactSheet.maxPhotos))") { Button("Add Photos…", action: addPhotos) - .disabled(!model.canAdd || model.busy) + .disabled(!model.canAdd) List { ForEach(model.entries) { entry in HStack { @@ -248,8 +308,8 @@ public struct ContactSheetView: View { set: { value in model.update { $0.wedge = value } })) LengthStepper(title: "Space between", inches: model.layout.gap, range: 0...1, inchStep: 0.05, mmStep: 1, units: model.units) { value in model.update { $0.gap = value } } - Text("The sheet fills the print window's page, inside its margins (and the safe edge and outline, for " - + "a negative); a landscape sheet is turned to fit. Use it there as a print or a digital negative.") + Text("The sheet fills the Print or Negative tab's page, inside its margins (and the safe edge and " + + "outline, for a negative); a landscape sheet is turned to fit.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -260,23 +320,42 @@ public struct ContactSheetView: View { } .formStyle(.grouped) Divider() - HStack { - if model.busy { ProgressView().controlSize(.small) } - Spacer() - Button("Cancel", action: close) - Button("Use Sheet") { - model.make { url in - use(url) - close() + if let loading = model.loading { + VStack(alignment: .leading, spacing: 4) { + ProgressView(value: Double(loading.done), total: Double(max(loading.total, 1))) + TimelineView(.periodic(from: .now, by: 1)) { context in + Text(loading.description(now: context.date)) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) } } - .keyboardShortcut(.defaultAction) - .disabled(model.entries.isEmpty || model.busy) + .padding([.horizontal, .top]) + } + HStack { + if model.busy && model.loading == nil { ProgressView().controlSize(.small) } + Spacer() + if let close { Button("Cancel", action: close) } + Button("Make a Negative") { make(for: .negative) } + .disabled(model.entries.isEmpty || model.busy) + .help("Make the sheet for the Negative tab's page and open it there") + Button("Print It") { make(for: .print) } + .keyboardShortcut(.defaultAction) + .disabled(model.entries.isEmpty || model.busy) + .help("Make the sheet for the Print tab's page and open it there") } .padding() } } + private func make(for role: TabRole) { + model.make(for: role) { url in + use(url, role) + close?() + } + } + private func addPhotos() { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift index 9d61d31..9f19bd1 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift @@ -1,5 +1,5 @@ -// The print window: open a photo, choose toning or a digital negative, set the page, -// preview, and print through the macOS print panel. +// The Print and Negative tabs: open a photo, choose toning (Print) or the negative's +// settings (Negative), set the page, preview, and print. import AppKit import OpenQuadCore @@ -9,17 +9,15 @@ import UniformTypeIdentifiers public struct ContentView: View { @Bindable var model: AppModel @State private var ppiText = "" - @State private var showGuide = false @State private var savingSetup = false @State private var setupName = "" @State private var deletingSetup: NegativeSetup? - @AppStorage("guideSeen") private var guideSeen = 0 + /// Switch to another tab (e.g. to calibrate). + let goTo: (AppTab) -> Void - let guideOnLaunch: Bool - - public init(model: AppModel, guideOnLaunch: Bool = true) { + public init(model: AppModel, goTo: @escaping (AppTab) -> Void = { _ in }) { self.model = model - self.guideOnLaunch = guideOnLaunch + self.goTo = goTo } public var body: some View { @@ -30,7 +28,6 @@ public struct ContentView: View { .frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity) } .frame(minWidth: 820, minHeight: 600) - .sheet(isPresented: $showGuide) { GuideView { guideSeen = GuideView.version } } .alert("Save Negative Setup", isPresented: $savingSetup) { TextField("Name", text: $setupName) Button("Save") { model.saveSetup(named: setupName) } @@ -48,18 +45,7 @@ public struct ContentView: View { } message: { Text("Its copy of the process curve goes too. Your own curve files are not touched.") } - .toolbar { - ToolbarItem { - Button { showGuide = true } label: { Label("How to Use OpenQuad", systemImage: "questionmark.circle") } - .help("How to use OpenQuad") - } - } - .onReceive(NotificationCenter.default.publisher(for: GuideView.showNotification)) { _ in showGuide = true } - .onReceive(NotificationCenter.default.publisher(for: ContentView.contactSheetNotification)) { _ in openContactSheet() } - .onAppear { - model.loadPrinters() - if guideOnLaunch && guideSeen < GuideView.version { showGuide = true } // first launch: walk through the app - } + .onAppear { model.loadPrinters() } } // MARK: the controls @@ -68,22 +54,14 @@ public struct ContentView: View { VStack(spacing: 0) { Form { Section("Photo") { - HStack { - Button("Open Photo…", action: openPhoto) - .keyboardShortcut("o") - Button("Make a Contact Sheet…", action: openContactSheet) - } + Button("Open Photo…", action: openPhoto) + .keyboardShortcut("o") Text(model.photoDescription) .font(.callout) .foregroundStyle(.secondary) .textSelection(.enabled) } - Section("Output") { - Picker("Output", selection: Binding(get: { model.session.mode }, set: { model.setMode($0) })) { - Text("Print").tag(OutputMode.print) - Text("Digital negative").tag(OutputMode.negative) - } - .pickerStyle(.segmented) + Section(model.session.negative ? "Negative" : "Toning and calibration") { if model.session.negative { negativeControls } else { printControls } } Section("Page") { @@ -195,7 +173,7 @@ public struct ContentView: View { Button("Import…") { if let url = Self.askOpen(types: [UTType(filenameExtension: "oqcal")].compactMap { $0 }) { model.importCalibration(url) } } - Button("Calibrate This Printer…", action: openCalibrationWizard) + Button("Calibrate This Printer…") { goTo(.printerCalibration) } } } @@ -206,6 +184,20 @@ public struct ContentView: View { } @ViewBuilder private var negativeControls: some View { + if let offer = model.setupOffer { + HStack { + Text(offer).font(.callout) + Spacer() + Button("Save Setup…") { + setupName = model.suggestedSetupName + savingSetup = true + model.setupOffer = nil + } + Button("Not Now") { model.setupOffer = nil } + } + .padding(8) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + } Picker("Setup", selection: Binding(get: { model.currentSetup?.name ?? "" }, set: { name in if let setup = model.setups.first(where: { $0.name == name }) { model.applySetup(setup) } })) { @@ -227,8 +219,7 @@ public struct ContentView: View { .disabled(model.session.process != nil) // the process curve decides the colour fileRow("Process curve", model.session.processPath, types: ["oqproc"], choose: { model.setProcess($0) }, clear: { model.setProcess(nil) }) - Button("Calibrate a Process…", action: openProcessWizard) - Button("Find the Best Blocking Colour…", action: openBlockingTest) + Button("Calibrate a Process…") { goTo(.processCalibration) } Toggle("Mirror (flip left to right)", isOn: Binding(get: { model.session.mirror }, set: { model.setMirror($0) })) LengthStepper(title: "Safe edge", inches: model.session.safeEdge, range: 0...2, inchStep: 0.05, mmStep: 1, units: model.units) { model.setSafeEdge($0) } @@ -276,41 +267,6 @@ public struct ContentView: View { // MARK: actions - /// Posted by File > New Contact Sheet…. - public static let contactSheetNotification = Notification.Name("OpenQuadContactSheet") - - private func openContactSheet() { - let sheet = model.contactSheet(), app = model - WizardWindows.show("contact-sheet", title: "Contact Sheet", size: CGSize(width: 1000, height: 760), - minSize: CGSize(width: 760, height: 520)) { close in - ContactSheetView(model: sheet, use: { app.useContactSheet($0) }, close: close) - } - } - - private func openCalibrationWizard() { - let wizard = model.calibrationWizard(), app = model - WizardWindows.show("calibrate-printer", title: "Calibrate a Printer", size: CGSize(width: 980, height: 820), - minSize: CGSize(width: 760, height: 560)) { close in - CalibrationWizardView(model: wizard, use: { app.finishCalibration($0, scannerHost: wizard.scannerHost) }, close: close) - } - } - - private func openProcessWizard() { - let wizard = model.processWizard(), app = model - WizardWindows.show("calibrate-process", title: "Calibrate a Process", size: CGSize(width: 980, height: 860), - minSize: CGSize(width: 760, height: 600)) { close in - ProcessWizardView(model: wizard, use: { app.finishProcess($0, scannerHost: wizard.scannerHost) }, close: close) - } - } - - private func openBlockingTest() { - let test = model.blockingTest(), app = model - WizardWindows.show("blocking-test", title: "Blocking Test", size: CGSize(width: 900, height: 820), - minSize: CGSize(width: 700, height: 560)) { close in - BlockingTestView(model: test, use: { app.useBlockingColour($0, scannerHost: test.scannerHost) }, close: close) - } - } - private func openPhoto() { let types: [UTType] = [.tiff, .png, .jpeg] if let url = Self.askOpen(types: types, folder: model.settings.lastFolder) { model.openPhoto(url) } diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift index 38e4897..e7df8c5 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift @@ -1,11 +1,11 @@ -// "How to Use OpenQuad": a short walk through the app, shown on first launch and from -// the Help menu or the toolbar. +// "How to Use OpenQuad": a short walk through the app's tabs, shown on first launch +// (and when it changes) and from the Help menu or the toolbar. import SwiftUI public struct GuideView: View { /// Raise when the guide changes enough to show it again on launch. - public static let version = 11 + public static let version = 12 public static let showNotification = Notification.Name("OpenQuadShowGuide") struct Page { @@ -18,16 +18,18 @@ public struct GuideView: View { static let pages: [Page] = [ Page(symbol: "printer.fill", title: "Welcome to OpenQuad", text: "OpenQuad prints black-and-white photos with accurate, neutral tones on an ordinary inkjet printer, " - + "and makes digital negatives for contact printing (carbon transfer).", - steps: ["Open a photo, choose how it should print, and print.", - "Calibrate your printer once, with its scanner, so greys print neutral and evenly spaced.", - "For carbon: find the best negative colour, then calibrate the process.", + + "and makes digital negatives for contact printing (carbon, cyanotype and other UV processes).", + steps: ["The tabs at the top (⌘1 to ⌘5) are the five parts of the app: Print, Negative, Contact Sheet, " + + "Printer Calibration and Process Calibration.", + "Each tab keeps its own work while you use the others: calibrate one printer, print on another " + + "and refine a process at the same time.", + "When you quit, OpenQuad asks whether to keep your open photos for next time.", "This is a beta. Please report anything that goes wrong, or reads unclearly, at " + "github.com/akendrick/openquad/issues."]), - Page(symbol: "photo", title: "Print a photo", - text: "Everything is on the left; the preview on the right shows how the print should look.", + Page(symbol: "printer", title: "Print a photo", + text: "The Print tab: everything is on the left; the preview on the right shows how the print should look.", steps: ["Open Photo… (⌘O): greyscale or colour, any size. Colour photos are converted to grey.", - "Output: Print. Toning stays Neutral unless you choose a duotone; it's never remembered.", + "Toning stays Neutral unless you choose a duotone; it's never remembered.", "Calibration: pick one of your saved calibrations (Import… adds a file). Choosing a printer " + "under Page picks its newest calibration for that paper.", "Page: paper, Fit to page, or turn Fit off and give a resolution. Print size shows the result.", @@ -35,8 +37,8 @@ public struct GuideView: View { + "same quality settings every time. Or pick a printer to print straight to it.", "Save Print File… writes the finished 16-bit file to print from another app at 100%."]), Page(symbol: "slider.horizontal.3", title: "Calibrate your printer", - text: "Calibrate This Printer… opens its own window. Calibration goes in rounds; each round is Print, Scan, " - + "Result, and the blue card always says which step you're on and highlights the button for it.", + text: "The Printer Calibration tab (or Calibrate This Printer… in Print). Calibration goes in rounds; each " + + "round is Print, Scan, Result, and the blue card always says which step you're on.", steps: ["Choose the printer, paper (and type), how you'll print and the scanner, then Start. Print the same " + "way every time, or the calibration won't fit.", "Print: the comparison page has two wedges, the top one uncorrected (one square), the bottom one " @@ -45,20 +47,17 @@ public struct GuideView: View { + "macOS asks to let OpenQuad find devices on your network: choose Allow.", "Result: the card says what to do. Usually Improve Grey Balance first (colour casts), then Even " + "Out Tone (lightness), printing and scanning again after each to check it.", - "The rounds table shows every round's numbers, so you can see progress. When both goals are met, " - + "Use This Calibration saves it under the printer's name.", - "Stopped halfway? Continue a Previous Run picks up exactly where you left off. Show Log opens " - + "the log at the side."]), + "When both goals are met, Use This Calibration saves it and the Print tab uses it (if it prints " + + "on that printer). New Calibration in the toolbar starts again.", + "Stopped halfway? Continue a Previous Run picks up exactly where you left off."]), Page(symbol: "film", title: "Digital negatives", - text: "Output: Digital negative turns the photo into a negative for contact printing, in black or a " - + "colour that blocks UV.", + text: "The Negative tab turns a photo into a negative for contact printing, in black or a colour that " + + "blocks UV. It has its own photo, printer and paper.", steps: ["Setup: switch between saved setups (curve, colour, mirror, edges, printer and paper) in one step. " + "Once a process is calibrated, Save Setup… keeps it under a name, e.g. Cyanotype on Pictorico.", - "Ink colour: the colour to print the negative in (the blocking test finds the best one).", - "Process curve: shapes the tones for your process (made by Calibrate a Process…).", - "Mirror if the film's printed side must face the tissue.", - "The safe edge frames the photo; the F cut into it reads correctly in the print when the " - + "mirror setting was right.", + "Ink colour and Process curve: both come from the Process Calibration tab.", + "Mirror if the film's printed side must face the tissue. The F cut into the safe edge reads " + + "correctly in the print when the mirror setting was right.", "Outline in black adds a black border of the width you set between the safe edge and the " + "photo: a natural frame for photos with white or light edges.", "Prefer millimetres? OpenQuad > Settings… (⌘,) switches every length in the app to mm.", @@ -66,34 +65,34 @@ public struct GuideView: View { + "that becomes the negative's outer edge, safe edge included, and the photo shrinks to fit.", "Print on transparency film at 100% with your usual film settings."]), Page(symbol: "square.grid.3x3", title: "Contact sheets", - text: "File > New Contact Sheet… (⇧⌘N) puts up to 16 photos on one sheet, to proof them or to make one " + text: "The Contact Sheet tab (⇧⌘N) puts up to 16 photos on one sheet, to proof them or to make one " + "negative of them all.", - steps: ["Add Photos… (or drop them on the sheet); drag the list to change the order.", + steps: ["Add Photos… (or drop them on the sheet); a bar shows each photo opening and the time left. Drag " + + "the list to change the order.", "Choose portrait or landscape, a black or white background, whole photos or cropped to fill, " + "file names, and a greyscale step wedge to check the print's tones.", - "The sheet fills the page you chose in the print window (for a negative, inside its safe edge " - + "and outline). Set the page and output first.", - "Use Sheet makes it the photo in the print window: print it, or print it as a negative."]), + "Print It makes the sheet for the Print tab's page and opens it there; Make a Negative makes it " + + "for the Negative tab's page (inside its safe edge and outline)."]), Page(symbol: "square.grid.3x3.square", title: "Find the best blocking colour", - text: "Find the Best Blocking Colour… (in negative mode) prints a sheet of every negative colour.", + text: "Process Calibration, step 1: a sheet of every negative colour, to find the one that blocks UV best.", steps: ["Choose the chart: Standard (six colours) or Ink mixtures (ten colours your printer makes " + "from single inks and simple mixes). Print it on film and expose it at your usual time.", "Develop it, let it dry, and scan the print (or read a scan file).", "OpenQuad reads the print any way up, tilted or mirrored, and reports which colours block " + "fully and how many distinct steps each gives.", - "Use the best colour for your negatives."]), + "Use the best colour: the Negative tab prints in it, and step 2 calibrates in it."]), Page(symbol: "chart.line.uptrend.xyaxis", title: "Calibrate the process", - text: "Calibrate a Process… prints process charts (101 tones each) on one page, one chart per colour.", + text: "Process Calibration, step 2: process charts (101 tones each) on one page, one chart per colour.", steps: ["Start, then print the chart page on film (or print it from another app at 100% and say so).", "Contact print it at your usual exposure, develop, dry, and scan the print.", "Each colour's measured tones are drawn against the target; Refine the Curves and repeat " + "until every patch is within 3 L*.", - "Use This Colour's Curve: your negatives then print with tones on target."]), + "Use This Colour's Curve: the Negative tab uses it and offers to save it as a setup."]), Page(symbol: "lightbulb", title: "Good to know", text: "A few things that make results repeatable.", steps: ["Scans are made in Document mode with fixed settings so days compare; Photo mode alters colour.", - "Calibration runs, scans and measurements are kept in ~/Library/Application Support/OpenQuad, " - + "shared with the OpenQuad command-line tools.", + "Calibration runs, scans, setups and contact sheets are kept in ~/Library/Application Support/" + + "OpenQuad, shared with the OpenQuad command-line tools.", "Nothing is printed or scanned without you confirming it first.", "Open this guide again any time from Help ▸ How to Use OpenQuad or the ? button."]), ] diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/Lengths.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/Lengths.swift index 48a0c78..cc0d953 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/Lengths.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/Lengths.swift @@ -27,13 +27,13 @@ struct LengthStepper: View { /// The app's settings window (OpenQuad > Settings…). public struct SettingsView: View { - @Bindable var model: AppModel + @Bindable var root: AppRoot - public init(model: AppModel) { self.model = model } + public init(root: AppRoot) { self.root = root } public var body: some View { Form { - Picker("Lengths in", selection: Binding(get: { model.units }, set: { model.setUnits($0) })) { + Picker("Lengths in", selection: Binding(get: { root.printModel.units }, set: { root.setUnits($0) })) { Text("Inches").tag(LengthUnit.inches) Text("Millimetres").tag(LengthUnit.millimetres) } diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/Tabs.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/Tabs.swift new file mode 100644 index 0000000..1a94e61 --- /dev/null +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/Tabs.swift @@ -0,0 +1,368 @@ +// The window's five tabs: Print, Negative, Contact Sheet, Printer Calibration and +// Process Calibration. Each keeps its own work while you use the others (calibrate one +// printer, print on another, refine a process), and results are handed between them. + +import AppKit +import OpenQuadCore +import SwiftUI + +public enum AppTab: String, CaseIterable, Identifiable, Sendable { + case print + case negative + case contactSheet = "contact-sheet" + case printerCalibration = "printer-calibration" + case processCalibration = "process-calibration" + + public var id: String { rawValue } + + public var title: String { + switch self { + case .print: "Print" + case .negative: "Negative" + case .contactSheet: "Contact Sheet" + case .printerCalibration: "Printer Calibration" + case .processCalibration: "Process Calibration" + } + } + + public var symbol: String { + switch self { + case .print: "printer" + case .negative: "film" + case .contactSheet: "square.grid.3x3" + case .printerCalibration: "slider.horizontal.3" + case .processCalibration: "chart.line.uptrend.xyaxis" + } + } + + /// ⌘1 to ⌘5. + public var shortcut: KeyEquivalent { KeyEquivalent(Character(String((Self.allCases.firstIndex(of: self) ?? 0) + 1))) } +} + +/// What can be kept for next time: each tab's photo and the contact sheet. +public struct Workspace: Codable, Equatable, Sendable { + public var printPhoto: String? + public var negativePhoto: String? + public var sheetPhotos: [String] = [] + public var sheetLandscape = false + public var sheetBackground = "black" + public var sheetCrop = false + public var sheetWedge = false + public var sheetLabels = false + public var sheetGap = 0.15 + + public var isEmpty: Bool { printPhoto == nil && negativePhoto == nil && sheetPhotos.isEmpty } +} + +@MainActor +@Observable +public final class AppRoot { + public var tab: AppTab { + didSet { + UserDefaults.standard.set(tab.rawValue, forKey: Self.tabKey) + arrived(at: tab) + } + } + public let printModel: AppModel + public let negativeModel: AppModel + public let contactSheet: ContactSheetModel + /// The calibration runs, made when their tab is first opened. + public private(set) var calibration: CalibrationWizardModel? + public private(set) var process: ProcessWizardModel? + public private(set) var blocking: BlockingTestModel? + /// Process Calibration's two steps: the blocking colour, then the curve. + public enum ProcessStep: String, CaseIterable, Sendable { + case blocking = "1. Blocking colour" + case curve = "2. Process curve" + } + public var processStep = ProcessStep.blocking + /// What a hand-off between tabs just changed, shown under the tab bar. + public var notice: String? + /// Ask on quit whether to keep the photos for next time (off for snapshots and tests). + public var askOnQuit = true + + static let tabKey = "selectedTab" + let workspaceURL: URL + + public init(settingsURL: URL = AppSettings.defaultURL(), libraryFolder: URL? = nil, setupsFolder: URL? = nil, + tab: AppTab? = nil, restoreWorkspace: Bool = true) { + printModel = AppModel(settingsURL: settingsURL, libraryFolder: libraryFolder, setupsFolder: setupsFolder, role: .print) + negativeModel = AppModel(settingsURL: settingsURL, libraryFolder: libraryFolder, setupsFolder: setupsFolder, + role: .negative) + contactSheet = ContactSheetModel(room: printModel.printableRoom, units: printModel.units, + folder: printModel.contactSheetFolder) + workspaceURL = settingsURL.deletingLastPathComponent().appendingPathComponent("workspace.json") + self.tab = tab ?? UserDefaults.standard.string(forKey: Self.tabKey).flatMap(AppTab.init(rawValue:)) ?? .print + if restoreWorkspace { restore() } + arrived(at: self.tab) + } + + public func show(_ tab: AppTab) { self.tab = tab } + + /// Lengths in inches or millimetres, everywhere. + public func setUnits(_ units: LengthUnit) { + printModel.setUnits(units) + negativeModel.followUnits() + contactSheet.units = units + blocking?.units = units + } + + /// Bring a tab up to date with the others when it is shown. + func arrived(at tab: AppTab) { + switch tab { + case .print: printModel.followUnits() + case .negative: negativeModel.followUnits() + case .contactSheet: + contactSheet.units = printModel.units + contactSheet.setRooms(print: printModel.printableRoom, negative: negativeModel.printableRoom) + case .printerCalibration: + if calibration == nil { calibration = printModel.calibrationWizard() } + case .processCalibration: + if blocking == nil { blocking = negativeModel.blockingTest() } + if process == nil { process = negativeModel.processWizard() } + } + } + + /// Start a fresh printer calibration (for the Print tab's printer and paper). + public func newCalibration() { calibration = printModel.calibrationWizard() } + + /// Start a fresh process calibration and blocking test. + public func newProcess() { + blocking = negativeModel.blockingTest() + process = negativeModel.processWizard() + processStep = .blocking + } + + // MARK: hand-offs between tabs + + /// A finished printer calibration: used in the Print tab when it prints on that printer + /// (negatives are shaped by their process curve instead). + public func useCalibration(_ path: URL, scannerHost: String) { + let entry = CalibrationLibrary.entry(path) + let label = entry?.label ?? path.lastPathComponent + if let printer = entry?.printer, let current = printModel.session.printer, current != printer { + printModel.refreshLibrary() + notice = "Saved \(label). The Print tab prints on \(LibraryEntry.printerName(current)); " + + "choose \(LibraryEntry.printerName(printer)) there to use it." + } else { + printModel.finishCalibration(path, scannerHost: scannerHost) + notice = "The Print tab now uses \(label)." + } + tab = .print + } + + /// The blocking test's best colour: used for negatives, and the colour the process + /// curve is calibrated in. + public func useBlockingColour(_ colour: String, scannerHost: String) { + negativeModel.useBlockingColour(colour, scannerHost: scannerHost) + process?.colours = [colour] + process?.useColour = colour + processStep = .curve + notice = "Negatives now print in \(colour). Next: calibrate the process curve in \(colour)." + } + + /// A finished process curve: used in the Negative tab, with an offer to save a setup. + public func useProcess(_ path: URL, scannerHost: String) { + negativeModel.finishProcess(path, scannerHost: scannerHost) + negativeModel.setupOffer = "Using the new curve \(path.lastPathComponent). Save it as a setup to switch back to it later?" + notice = "The Negative tab now uses \(path.lastPathComponent)." + tab = .negative + } + + /// A made contact sheet, opened in the Print or Negative tab. + public func useContactSheet(_ url: URL, for role: TabRole) { + (role == .negative ? negativeModel : printModel).useContactSheet(url) + notice = "The contact sheet is open in the \(role == .negative ? "Negative" : "Print") tab." + tab = role == .negative ? .negative : .print + } + + // MARK: keeping work for next time + + /// What would be kept now. + public var workspace: Workspace { + var kept = Workspace() + kept.printPhoto = printModel.session.source?.path + kept.negativePhoto = negativeModel.session.source?.path + kept.sheetPhotos = contactSheet.entries.map(\.url.path) + kept.sheetLandscape = contactSheet.landscape + kept.sheetBackground = contactSheet.layout.background.rawValue + kept.sheetCrop = contactSheet.layout.crop + kept.sheetWedge = contactSheet.layout.wedge + kept.sheetLabels = contactSheet.layout.labels + kept.sheetGap = contactSheet.layout.gap + return kept + } + + public func keepWorkspace() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try FileManager.default.createDirectory(at: workspaceURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try encoder.encode(workspace).write(to: workspaceURL, options: .atomic) + } + + public func forgetWorkspace() { try? FileManager.default.removeItem(at: workspaceURL) } + + /// Reopen kept work (once: it is asked about again at the next quit). + func restore() { + guard let data = try? Data(contentsOf: workspaceURL), + let kept = try? JSONDecoder().decode(Workspace.self, from: data) else { return } + forgetWorkspace() + let exists = { (path: String) in FileManager.default.fileExists(atPath: path) } + if let path = kept.printPhoto, exists(path) { printModel.openPhoto(URL(fileURLWithPath: path)) } + if let path = kept.negativePhoto, exists(path) { negativeModel.openPhoto(URL(fileURLWithPath: path)) } + contactSheet.setLandscape(kept.sheetLandscape) + contactSheet.update { + $0.background = SheetLayout.Background(rawValue: kept.sheetBackground) ?? .black + $0.crop = kept.sheetCrop + $0.wedge = kept.sheetWedge + $0.labels = kept.sheetLabels + $0.gap = min(max(kept.sheetGap, 0), 1) + } + contactSheet.add(kept.sheetPhotos.filter(exists).map { URL(fileURLWithPath: $0) }) + } + + /// On quit: if there is work open, ask whether to keep it. False cancels the quit. + public func confirmQuit() -> Bool { + guard askOnQuit, !workspace.isEmpty else { + forgetWorkspace() + return true + } + let alert = NSAlert() + alert.messageText = "Keep your photos for next time?" + alert.informativeText = "OpenQuad can reopen the photos in Print and Negative and the contact sheet's photos " + + "the next time it starts, so you can carry on where you left off." + alert.addButton(withTitle: "Keep for Next Time") + alert.addButton(withTitle: "Start Fresh") + alert.addButton(withTitle: "Cancel") + switch alert.runModal() { + case .alertFirstButtonReturn: + try? keepWorkspace() + return true + case .alertSecondButtonReturn: + forgetWorkspace() + return true + default: + return false + } + } +} + +/// The window: the tab bar in the toolbar and the chosen tab's page. +public struct RootView: View { + @Bindable var root: AppRoot + @State private var showGuide = false + @AppStorage("guideSeen") private var guideSeen = 0 + let guideOnLaunch: Bool + + public init(root: AppRoot, guideOnLaunch: Bool = true) { + self.root = root + self.guideOnLaunch = guideOnLaunch + } + + public var body: some View { + VStack(spacing: 0) { + if let notice = root.notice { + HStack { + Image(systemName: "checkmark.circle").foregroundStyle(.green) + Text(notice).font(.callout) + Spacer() + Button { root.notice = nil } label: { Image(systemName: "xmark") } + .buttonStyle(.borderless) + .help("Dismiss") + } + .padding(.horizontal) + .padding(.vertical, 6) + .background(Color.green.opacity(0.08)) + Divider() + } + page + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 900, minHeight: 620) + .toolbar { + ToolbarItem(placement: .principal) { TabBar(selection: $root.tab) } + ToolbarItem(placement: .primaryAction) { + Button { showGuide = true } label: { Label("How to Use OpenQuad", systemImage: "questionmark.circle") } + .help("How to use OpenQuad") + } + } + .navigationTitle("") + .sheet(isPresented: $showGuide) { GuideView { guideSeen = GuideView.version } } + .onReceive(NotificationCenter.default.publisher(for: GuideView.showNotification)) { _ in showGuide = true } + .onAppear { + if guideOnLaunch && guideSeen < GuideView.version { showGuide = true } // first launch: walk through the app + } + } + + @ViewBuilder private var page: some View { + switch root.tab { + case .print: + ContentView(model: root.printModel, goTo: root.show).id(AppTab.print) + case .negative: + ContentView(model: root.negativeModel, goTo: root.show).id(AppTab.negative) + case .contactSheet: + ContactSheetView(model: root.contactSheet) { url, role in root.useContactSheet(url, for: role) } + case .printerCalibration: + if let wizard = root.calibration { + CalibrationWizardView(model: wizard) { root.useCalibration($0, scannerHost: wizard.scannerHost) } + .id(ObjectIdentifier(wizard)) + .toolbar { + ToolbarItem { Button("New Calibration", action: root.newCalibration).help("Start again for the Print tab's printer and paper") } + } + } + case .processCalibration: + VStack(spacing: 0) { + Picker("Step", selection: $root.processStep) { + ForEach(AppRoot.ProcessStep.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 420) + .padding(10) + Divider() + switch root.processStep { + case .blocking: + if let test = root.blocking { + BlockingTestView(model: test) { root.useBlockingColour($0, scannerHost: test.scannerHost) } + .id(ObjectIdentifier(test)) + } + case .curve: + if let wizard = root.process { + ProcessWizardView(model: wizard) { root.useProcess($0, scannerHost: wizard.scannerHost) } + .id(ObjectIdentifier(wizard)) + } + } + } + .toolbar { + ToolbarItem { Button("New Process", action: root.newProcess).help("Start the blocking test and curve again") } + } + } + } +} + +/// Five labelled icons, the chosen one highlighted. +struct TabBar: View { + @Binding var selection: AppTab + + var body: some View { + HStack(spacing: 6) { + ForEach(AppTab.allCases) { tab in + Button { selection = tab } label: { + VStack(spacing: 2) { + Image(systemName: tab.symbol).font(.system(size: 15)) + Text(tab.title).font(.caption) + } + .frame(minWidth: 112) + .padding(.vertical, 3) + .contentShape(Rectangle()) + .foregroundStyle(selection == tab ? Color.accentColor : .secondary) + .background(selection == tab ? Color.accentColor.opacity(0.14) : .clear, + in: RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .help("\(tab.title) (⌘\(AppTab.allCases.firstIndex(of: tab)! + 1))") + .accessibilityAddTraits(selection == tab ? .isSelected : []) + } + } + } +} diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/Wizards.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/Wizards.swift index 573ae4f..effe00d 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/Wizards.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/Wizards.swift @@ -431,10 +431,11 @@ func confirm(_ title: String, _ question: String) -> Bool { public struct CalibrationWizardView: View { @Bindable var model: CalibrationWizardModel let use: (URL) -> Void - let close: () -> Void + /// Closes the window it is in; nil inside a tab (no Close button). + let close: (() -> Void)? @State private var editingSetup = false - public init(model: CalibrationWizardModel, use: @escaping (URL) -> Void, close: @escaping () -> Void = {}) { + public init(model: CalibrationWizardModel, use: @escaping (URL) -> Void, close: (() -> Void)? = nil) { self.model = model self.use = use self.close = close @@ -459,7 +460,7 @@ public struct CalibrationWizardView: View { HStack { if model.busy { ProgressView().controlSize(.small) } Spacer() - Button("Close", action: close) + if let close { Button("Close", action: close) } } } } @@ -549,7 +550,7 @@ public struct CalibrationWizardView: View { improve: { model.improve($0) }, use: { if let path = model.calibrationPath { use(path) } - close() + close?() }) } @@ -783,9 +784,10 @@ struct RoundsTable: View { public struct ProcessWizardView: View { @Bindable var model: ProcessWizardModel let use: (URL) -> Void - let close: () -> Void + /// Closes the window it is in; nil inside a tab (no Close button). + let close: (() -> Void)? - public init(model: ProcessWizardModel, use: @escaping (URL) -> Void, close: @escaping () -> Void = {}) { + public init(model: ProcessWizardModel, use: @escaping (URL) -> Void, close: (() -> Void)? = nil) { self.model = model self.use = use self.close = close @@ -836,7 +838,7 @@ public struct ProcessWizardView: View { if let error = model.error { Text(error).foregroundStyle(.red) } HStack { Spacer() - Button("Close", action: close) + if let close { Button("Close", action: close) } Picker("Use", selection: $model.useColour) { ForEach(model.run?.colours ?? [], id: \.self) { Text($0.capitalized).tag($0) } } @@ -844,7 +846,7 @@ public struct ProcessWizardView: View { .disabled(!model.started) Button("Use This Colour's Curve") { if let path = model.curvePath { use(path) } - close() + close?() } .buttonStyle(.borderedProminent) .disabled(!model.started) diff --git a/swift/OpenQuadApp/Tests/OpenQuadUITests/GapsTests.swift b/swift/OpenQuadApp/Tests/OpenQuadUITests/GapsTests.swift index 389006c..f1d0206 100644 --- a/swift/OpenQuadApp/Tests/OpenQuadUITests/GapsTests.swift +++ b/swift/OpenQuadApp/Tests/OpenQuadUITests/GapsTests.swift @@ -68,5 +68,8 @@ struct GapsTests { #expect(titles.contains(part)) } #expect(GuideView.pages.allSatisfy { !$0.steps.isEmpty }) + for tab in AppTab.allCases where tab != .print { // every tab is explained + #expect(GuideView.pages.contains { $0.text.contains(tab.title) || $0.steps.joined().contains(tab.title) }) + } } } diff --git a/swift/OpenQuadApp/Tests/OpenQuadUITests/TabsTests.swift b/swift/OpenQuadApp/Tests/OpenQuadUITests/TabsTests.swift new file mode 100644 index 0000000..22083e8 --- /dev/null +++ b/swift/OpenQuadApp/Tests/OpenQuadUITests/TabsTests.swift @@ -0,0 +1,149 @@ +// The tabbed window's model: each tab's own work, hand-offs between tabs, and keeping +// the open photos for next time. + +import Foundation +import OpenQuadCore +import Testing +@testable import OpenQuadUI + +@MainActor +@Suite("Tabs") +struct TabsTests { + let folder: URL + let photo: URL + + init() throws { + folder = FileManager.default.temporaryDirectory.appendingPathComponent("openquad-tabs-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + photo = folder.appendingPathComponent("ramp.tif") + try ImageFiles.saveGrey(photo, GreyImage(width: 60, height: 40, pixels: (0..<2400).map { UInt16(($0 % 60) * 1110) }, + dpi: (300, 300))) + } + + func root(tab: AppTab = .print, restore: Bool = true) -> AppRoot { + let root = AppRoot(settingsURL: folder.appendingPathComponent("settings.json"), tab: tab, restoreWorkspace: restore) + root.askOnQuit = false + return root + } + + @Test func eachTabKeepsItsOwnPhotoPrinterAndPaper() { + let r = root() + #expect(r.printModel.role == .print && !r.printModel.session.negative) + #expect(r.negativeModel.role == .negative && r.negativeModel.session.negative) + r.printModel.openPhoto(photo) + r.printModel.setPrinter("Canon_MG3600_series") + r.negativeModel.setPrinter("Epson_ET_2900") + r.negativeModel.setPaper("A4") + r.printModel.setPaper("Letter") + #expect(r.negativeModel.session.photo == nil && r.printModel.session.photo != nil) + let again = root() // both remembered, neither overwriting the other + #expect(again.printModel.session.printer == "Canon_MG3600_series" && again.printModel.session.paper == "Letter") + #expect(again.negativeModel.session.printer == "Epson_ET_2900" && again.negativeModel.session.paper == "A4") + } + + @Test func tabsAreRememberedAndSetUpWhenShown() { + let r = root(tab: .print) + #expect(r.calibration == nil && r.process == nil && r.blocking == nil) + r.show(.processCalibration) + #expect(r.process != nil && r.blocking != nil && r.processStep == .blocking) + r.show(.printerCalibration) + #expect(r.calibration != nil) + #expect(UserDefaults.standard.string(forKey: AppRoot.tabKey) == "printer-calibration") + let first = r.calibration + r.newCalibration() + #expect(r.calibration !== first) + #expect(AppTab.allCases.map(\.title) == ["Print", "Negative", "Contact Sheet", "Printer Calibration", "Process Calibration"]) + #expect(AppTab.processCalibration.shortcut == "5") + } + + @Test func unitsReachEveryTab() { + let r = root() + r.show(.processCalibration) + r.setUnits(.millimetres) + #expect(r.printModel.units == .millimetres && r.negativeModel.session.units == .millimetres) + #expect(r.contactSheet.units == .millimetres && r.blocking?.units == .millimetres) + r.setUnits(.inches) + } + + @Test func blockingColourAndProcessCurveHandOff() throws { + let r = root(tab: .processCalibration) + r.useBlockingColour("orange", scannerHost: "") + #expect(r.negativeModel.session.negativeColour == "orange") + #expect(r.process?.colours == ["orange"] && r.process?.useColour == "orange" && r.processStep == .curve) + #expect(r.notice?.contains("orange") == true) + let curve = folder.appendingPathComponent("carbon.oqproc") + try ProcessCurve.identity(colour: "orange").encode().write(to: curve) + r.useProcess(curve, scannerHost: "") + #expect(r.tab == .negative && r.negativeModel.session.processPath == curve) + #expect(r.negativeModel.setupOffer != nil && r.notice?.contains("carbon.oqproc") == true) + #expect(r.printModel.session.processPath == nil) // the Print tab doesn't make negatives + } + + @Test func printerCalibrationGoesToThePrintTabOnThatPrinter() throws { + let r = root(tab: .printerCalibration) + let levels: [UInt16] = [0, 32768, 65535] + let calibration = try Calibration(levels: levels, curves: [[0, 30000, 65535], [0, 32768, 65535], [0, 34000, 65535]], + printer: "Canon_MG3600_series", paper: "Letter plain") + let path = folder.appendingPathComponent("calibrations/canon.oqcal") + try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try calibration.encode().write(to: path) + r.printModel.setPrinter("Epson_ET_2900") + r.useCalibration(path, scannerHost: "") + #expect(r.tab == .print && r.printModel.session.calibrationPath == nil) + #expect(r.notice?.contains("choose Canon MG3600") == true) + r.printModel.setPrinter("Canon_MG3600_series") + r.useCalibration(path, scannerHost: "") + #expect(r.printModel.session.calibrationPath?.lastPathComponent == "canon.oqcal") + #expect(r.notice?.hasPrefix("The Print tab now uses") == true) + } + + @Test func contactSheetsOpenInTheChosenTab() async throws { + let r = root(tab: .contactSheet) + r.negativeModel.setSafeEdge(0.5) + r.show(.contactSheet) + #expect(r.contactSheet.size(for: .print) == (7.5, 10) && r.contactSheet.size(for: .negative) == (6.5, 9)) + r.contactSheet.add([photo, photo]) + for _ in 0..<300 where r.contactSheet.busy { try await Task.sleep(for: .milliseconds(10)) } + var made: URL? + r.contactSheet.make(dpi: 20, for: .negative) { made = $0 } + for _ in 0..<300 where made == nil { try await Task.sleep(for: .milliseconds(10)) } + r.useContactSheet(try #require(made), for: .negative) + #expect(r.tab == .negative && r.negativeModel.session.photo?.width == 130 && r.printModel.session.photo == nil) + } + + @Test func workIsKeptForNextTimeOnlyWhenAsked() async throws { + let r = root() + #expect(r.workspace.isEmpty) + r.printModel.openPhoto(photo) + r.negativeModel.openPhoto(photo) + r.contactSheet.setLandscape(true) + r.contactSheet.update { $0.background = .white; $0.wedge = true } + r.contactSheet.add([photo]) + for _ in 0..<300 where r.contactSheet.busy { try await Task.sleep(for: .milliseconds(10)) } + try r.keepWorkspace() + + let next = root() + #expect(next.printModel.session.source == photo && next.negativeModel.session.source == photo) + for _ in 0..<300 where next.contactSheet.busy { try await Task.sleep(for: .milliseconds(10)) } + #expect(next.contactSheet.entries.count == 1 && next.contactSheet.landscape) + #expect(next.contactSheet.layout.background == .white && next.contactSheet.layout.wedge) + #expect(root().printModel.session.photo == nil) // reopened once; asked again at the next quit + + try r.keepWorkspace() + r.forgetWorkspace() // Start Fresh + #expect(root().printModel.session.photo == nil) + #expect(r.confirmQuit()) // not asking (tests): quits, keeping nothing + } + + @Test func loadingEstimatesTheTimeLeft() { + let start = Date(timeIntervalSince1970: 0) + var loading = ContactSheetModel.Loading(done: 0, total: 12, started: start, current: "frame-01.tif") + #expect(loading.description(now: start.addingTimeInterval(1)) == "Opening frame-01.tif (1 of 12)") + loading.done = 3 + loading.current = "frame-04.tif" + #expect(loading.secondsLeft(now: start.addingTimeInterval(6)) == 18) + #expect(loading.description(now: start.addingTimeInterval(6)) == "Opening frame-04.tif (4 of 12) · about 18 s left") + loading.done = 1 + #expect(loading.description(now: start.addingTimeInterval(30)).hasSuffix("about 6 min left")) + } +} diff --git a/swift/OpenQuadCore/Sources/OpenQuadCore/Session.swift b/swift/OpenQuadCore/Sources/OpenQuadCore/Session.swift index fb5cf1a..4068779 100644 --- a/swift/OpenQuadCore/Sources/OpenQuadCore/Session.swift +++ b/swift/OpenQuadCore/Sources/OpenQuadCore/Session.swift @@ -269,6 +269,9 @@ public struct AppSettings: Sendable, Equatable { public var maxSize: [Double]? /// Lengths shown in the app. public var units = LengthUnit.inches + /// The Negative tab's own printer and paper (nil: the ones above). + public var negativePrinter: String? + public var negativePaper: String? public init() {} @@ -296,6 +299,8 @@ public struct AppSettings: Sendable, Equatable { settings.negativeColour = object["negative_colour"] as? String ?? "green" if let size = object["max_size"] as? [NSNumber], size.count == 2 { settings.maxSize = size.map(\.doubleValue) } settings.units = (object["units"] as? String).flatMap(LengthUnit.init(rawValue:)) ?? .inches + settings.negativePrinter = object["negative_printer"] as? String + settings.negativePaper = (object["negative_paper"] as? String).flatMap { Paper.sizes[$0] != nil ? $0 : nil } return settings } @@ -306,6 +311,7 @@ public struct AppSettings: Sendable, Equatable { "calibration": calibration ?? NSNull(), "last_folder": lastFolder ?? NSNull(), "process": process ?? NSNull(), "negative_colour": negativeColour, "max_size": maxSize ?? NSNull(), "units": units.rawValue, + "negative_printer": negativePrinter ?? NSNull(), "negative_paper": negativePaper ?? NSNull(), ] var data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) data.append(0x0a) diff --git a/swift/OpenQuadCore/Tests/OpenQuadCoreTests/SessionParityTests.swift b/swift/OpenQuadCore/Tests/OpenQuadCoreTests/SessionParityTests.swift index 1849db8..53d002e 100644 --- a/swift/OpenQuadCore/Tests/OpenQuadCoreTests/SessionParityTests.swift +++ b/swift/OpenQuadCore/Tests/OpenQuadCoreTests/SessionParityTests.swift @@ -99,6 +99,8 @@ struct SessionParity { settings.scannerHost = "876298000000.local:80" settings.negativeColour = "orange" settings.units = .millimetres + settings.negativePrinter = "Epson_ET_2900" + settings.negativePaper = "A4" try settings.save(url) #expect(AppSettings.load(url) == settings) let text = try String(contentsOf: url, encoding: .utf8) diff --git a/tests/test_settings.py b/tests/test_settings.py index ad94f80..74b17e0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -57,5 +57,6 @@ def test_max_size_is_remembered(tmp_path: Path) -> None: def test_units_are_remembered(tmp_path: Path) -> None: path = tmp_path / "settings.json" assert Settings.load(path).units == "in" - Settings(units="mm").save(path) - assert Settings.load(path).units == "mm" + Settings(units="mm", negative_printer="Epson", negative_paper="A4").save(path) + loaded = Settings.load(path) + assert (loaded.units, loaded.negative_printer, loaded.negative_paper) == ("mm", "Epson", "A4")