diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bcae84..55ded76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- 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 + setup the current settings match. Kept as .oqsetup files in OpenQuad's setups folder. - Millimetres: OpenQuad > Settings… (⌘,) shows and takes every length in the app (safe edge, black outline, set sizes, print sizes, the summary) in inches or millimetres, remembered between sessions. Files, settings and the command line stay in inches. diff --git a/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift b/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift index 2b5a2d7..f9fe9f7 100644 --- a/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift +++ b/swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift @@ -2,7 +2,7 @@ // // OpenQuad the app // OpenQuad --snapshot out.png [--photo PHOTO] [--negative] [--printer CUPS_QUEUE] [--max-size WxH] -// [--outline INCHES] [--units in|mm] +// [--outline INCHES] [--units in|mm] [--setups FOLDER] [--setup NAME] // OpenQuad --snapshot out.png --wizard calibration|process|blocking|guide [--scan SCAN] [--run RUN] // [--mixtures] (blocking: the ink-mixture chart) // draw the window to a PNG and quit (for checks) @@ -79,10 +79,13 @@ enum Snapshot { let settings = FileManager.default.temporaryDirectory.appendingPathComponent("openquad-snapshot-settings.json") try? FileManager.default.removeItem(at: settings) // The real calibrations, so the snapshot shows what this Mac has. - let model = AppModel(settingsURL: settings, libraryFolder: AppSettings.dataDirectory().appendingPathComponent("calibrations")) + let model = AppModel(settingsURL: settings, libraryFolder: AppSettings.dataDirectory().appendingPathComponent("calibrations"), + setupsFolder: value("--setups").map { URL(fileURLWithPath: $0) } + ?? AppSettings.dataDirectory().appendingPathComponent("setups")) 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])) } diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift index b700c7d..3281905 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift @@ -31,13 +31,19 @@ public final class AppModel { /// Saved calibrations to choose from: the newest of each printer and paper. public private(set) var calibrations: [LibraryEntry] = [] public let library: CalibrationLibrary + /// Saved negative setups (process curve, colour, mirror, edges, printer, paper). + public let setupLibrary: SetupLibrary + public private(set) var setups: [NegativeSetup] = [] let settingsURL: URL private var previewToken = 0 - public init(settingsURL: URL = AppSettings.defaultURL(), libraryFolder: URL? = nil) { + public init(settingsURL: URL = AppSettings.defaultURL(), libraryFolder: URL? = nil, setupsFolder: URL? = nil) { self.settingsURL = settingsURL library = CalibrationLibrary(folder: libraryFolder ?? settingsURL.deletingLastPathComponent().appendingPathComponent("calibrations")) + setupLibrary = SetupLibrary(folder: setupsFolder + ?? settingsURL.deletingLastPathComponent().appendingPathComponent("setups")) + setups = setupLibrary.all() settings = AppSettings.load(settingsURL) restore() refreshLibrary() @@ -208,6 +214,66 @@ public final class AppModel { refreshPreview() } + // MARK: saved negative setups + + /// The saved setup the negative settings match right now, if any. + public var currentSetup: NegativeSetup? { + setups.first { setup in + let curve = setupLibrary.processURL(setup)?.resolvingSymlinksInPath() + return setup.colour == session.negativeColour && setup.mirror == session.mirror + && abs(setup.safeEdge - session.safeEdge) < 1e-9 && abs(setup.outline - session.outline) < 1e-9 + && curve == session.processPath?.resolvingSymlinksInPath() + && setup.printer == session.printer && setup.paper == session.paper + } + } + + /// Switch to a saved setup: its curve, colour, mirror, edges, printer and paper. + public func applySetup(_ setup: NegativeSetup) { + setMode(.negative) + setPaper(setup.paper) + setPrinter(setup.printer) + if let curve = setupLibrary.processURL(setup) { + setProcess(curve) + } else { + setProcess(nil) + setNegativeColour(setup.colour) + } + session.mirror = setup.mirror + setSafeEdge(setup.safeEdge) + if setup.outline > 0 { outlineSize = setup.outline } + setOutline(setup.outline > 0) + } + + /// A name to offer when saving: "Carbon, green, Letter". + public var suggestedSetupName: String { + let process = session.process.map(\.process).flatMap { $0.isEmpty ? nil : $0.capitalized } ?? "Negative" + return "\(process), \(session.negativeColour), \(session.paper)" + } + + /// Save the negative settings as a setup (replacing one of the same name). + public func saveSetup(named name: String) { + attempt { + let setup = NegativeSetup(name: name, colour: session.negativeColour, mirror: session.mirror, + safeEdge: session.safeEdge, outline: session.outline, + printer: session.printer, paper: session.paper) + try setupLibrary.save(setup, process: session.processPath) + setups = setupLibrary.all() + // Point at the setup's own copy of the curve, so the setup shows as chosen. + if let saved = setups.first(where: { $0.name == name.trimmingCharacters(in: .whitespacesAndNewlines) }), + let curve = setupLibrary.processURL(saved) { + try session.setProcess(curve) + remember() + } + } + } + + public func deleteSetup(_ setup: NegativeSetup) { + attempt { + try setupLibrary.delete(setup) + setups = setupLibrary.all() + } + } + public func setPaper(_ paper: String) { attempt { try session.setPaper(paper) diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift index eaf71f4..2268343 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift @@ -10,6 +10,9 @@ 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 let guideOnLaunch: Bool @@ -28,6 +31,23 @@ public struct ContentView: View { } .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) } + .disabled(setupName.trimmingCharacters(in: .whitespaces).isEmpty) + Button("Cancel", role: .cancel) {} + } message: { + Text("Remembers the process curve, ink colour, mirror, safe edge, black outline, printer and paper. " + + "A setup with the same name is replaced.") + } + .confirmationDialog("Delete the setup “\(deletingSetup?.name ?? "")”?", isPresented: Binding( + get: { deletingSetup != nil }, set: { if !$0 { deletingSetup = nil } })) { + Button("Delete", role: .destructive) { + if let setup = deletingSetup { model.deleteSetup(setup) } + } + } 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") } @@ -182,6 +202,21 @@ public struct ContentView: View { } @ViewBuilder private var negativeControls: some View { + Picker("Setup", selection: Binding(get: { model.currentSetup?.name ?? "" }, set: { name in + if let setup = model.setups.first(where: { $0.name == name }) { model.applySetup(setup) } + })) { + Text(model.setups.isEmpty ? "None saved" : "Custom").tag("") + ForEach(model.setups) { Text($0.name).tag($0.name) } + } + HStack { + Button("Save Setup…") { + setupName = model.currentSetup?.name ?? model.suggestedSetupName + savingSetup = true + } + if let current = model.currentSetup { + Button("Delete Setup…") { deletingSetup = current } + } + } Picker("Ink colour", selection: Binding(get: { model.session.negativeColour }, set: { model.setNegativeColour($0) })) { ForEach(Negative.colourOrder, id: \.self) { Text($0.capitalized).tag($0) } } diff --git a/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift b/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift index 4852ce6..f32f3ec 100644 --- a/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift +++ b/swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift @@ -5,7 +5,7 @@ import SwiftUI public struct GuideView: View { /// Raise when the guide changes enough to show it again on launch. - public static let version = 9 + public static let version = 10 public static let showNotification = Notification.Name("OpenQuadShowGuide") struct Page { @@ -52,7 +52,9 @@ public struct GuideView: View { 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.", - steps: ["Ink colour: the colour to print the negative in (the blocking test finds the best one).", + 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 " diff --git a/swift/OpenQuadApp/Tests/OpenQuadUITests/AppModelTests.swift b/swift/OpenQuadApp/Tests/OpenQuadUITests/AppModelTests.swift index c6ce751..85d1b3b 100644 --- a/swift/OpenQuadApp/Tests/OpenQuadUITests/AppModelTests.swift +++ b/swift/OpenQuadApp/Tests/OpenQuadUITests/AppModelTests.swift @@ -102,6 +102,46 @@ struct AppModelTests { #expect(m.summary.joined().contains("with a 0.19685 in safe edge") && model().units == .inches) } + @Test func negativeSetupsAreSavedAndSwitchedBetween() throws { + let m = model() + #expect(m.setups.isEmpty && m.currentSetup == nil) + let curve = folder.appendingPathComponent("cyanotype-curve.oqproc") + try ProcessCurve.identity(colour: "orange").encode().write(to: curve) + m.openPhoto(photo) + m.setMode(.negative) + m.setProcess(curve) + m.setMirror(true) + m.setSafeEdge(0.3) + m.setOutlineSize(0.05) + m.setOutline(true) + m.setPaper("A4") + m.setPrinter("Epson_ET_2900") + #expect(m.suggestedSetupName == "Carbon, orange, A4") + m.saveSetup(named: "Cyanotype") + #expect(m.error == nil && m.setups.map(\.name) == ["Cyanotype"] && m.currentSetup?.name == "Cyanotype") + #expect(m.session.processPath?.lastPathComponent == "cyanotype.oqproc") // the setup's own copy + + m.setProcess(nil) + m.setNegativeColour("black") + m.setMirror(false) + m.setSafeEdge(0.25) + m.setOutline(false) + m.setPaper("Letter") + m.setPrinter(nil) + #expect(m.currentSetup == nil) + m.saveSetup(named: "Carbon black") + #expect(m.setups.map(\.name) == ["Carbon black", "Cyanotype"] && m.currentSetup?.name == "Carbon black") + + m.applySetup(m.setups[1]) + #expect(m.currentSetup?.name == "Cyanotype") + #expect(m.session.negativeColour == "orange" && m.session.mirror && m.session.safeEdge == 0.3) + #expect(m.session.outline == 0.05 && m.session.paper == "A4" && m.session.printer == "Epson_ET_2900") + let again = model() // a new window sees the same setups + #expect(again.setups.count == 2) + again.deleteSetup(again.setups[1]) + #expect(again.setups.map(\.name) == ["Carbon black"]) + } + @Test func negativesAndErrors() throws { let m = model() m.openPhoto(photo) diff --git a/swift/OpenQuadCore/Sources/OpenQuadCore/NegativeSetups.swift b/swift/OpenQuadCore/Sources/OpenQuadCore/NegativeSetups.swift new file mode 100644 index 0000000..e2c8e2e --- /dev/null +++ b/swift/OpenQuadCore/Sources/OpenQuadCore/NegativeSetups.swift @@ -0,0 +1,135 @@ +// Saved digital-negative setups ("Cyanotype on Pictorico"): the process curve, ink +// colour, mirror, safe edge, black outline, printer and paper that go together, so the +// app can switch between calibrated processes in one step. Each setup is an .oqsetup +// file in the app's setups folder, with a copy of its process curve beside it, so it +// keeps working if the original curve file is moved or deleted. + +import Foundation + +public struct NegativeSetup: Sendable, Equatable, Identifiable { + public var name: String + public var colour: String + /// The process curve's file name in the setups folder, or nil for none. + public var process: String? + public var mirror: Bool + /// Inches. + public var safeEdge: Double + /// Inches of black outline (0 for none). + public var outline: Double + /// The CUPS queue, or nil to choose in the macOS print panel. + public var printer: String? + public var paper: String + public var created: String + + public var id: String { name } + + public init(name: String, colour: String, process: String? = nil, mirror: Bool = false, safeEdge: Double = 0.25, + outline: Double = 0, printer: String? = nil, paper: String = "Letter", created: String = "") { + self.name = name + self.colour = colour + self.process = process + self.mirror = mirror + self.safeEdge = safeEdge + self.outline = outline + self.printer = printer + self.paper = paper + self.created = created + } + + struct File: Codable { + var format: String + var version: Int + var name: String + var colour: String + var process: String? + var mirror: Bool + var safe_edge: Double + var outline: Double + var printer: String? + var paper: String + var created: String? + } + + public static func decode(_ data: Data) throws -> NegativeSetup { + let file: File + do { file = try JSONDecoder().decode(File.self, from: data) } catch { + throw FileFormatError(description: "not valid JSON: \(error)") + } + guard file.format == "openquad-setup" else { throw FileFormatError(description: "not an OpenQuad negative setup") } + guard file.version == 1 else { throw FileFormatError(description: "unsupported setup version \(file.version)") } + guard Negative.colours[file.colour] != nil else { throw FileFormatError(description: "unknown negative colour \(file.colour)") } + guard Paper.sizes[file.paper] != nil else { throw FileFormatError(description: "unknown paper \(file.paper)") } + return NegativeSetup(name: file.name, colour: file.colour, process: file.process, mirror: file.mirror, + safeEdge: min(max(file.safe_edge, 0), 2), outline: min(max(file.outline, 0), 1), + printer: file.printer, paper: file.paper, created: file.created ?? "") + } + + public func encode() throws -> Data { + let file = File(format: "openquad-setup", version: 1, name: name, colour: colour, process: process, mirror: mirror, + safe_edge: safeEdge, outline: outline, printer: printer, paper: paper, created: created) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(file) + } +} + +public struct SetupLibrary: Sendable { + public let folder: URL + + public init(folder: URL = AppSettings.dataDirectory().appendingPathComponent("setups")) { + self.folder = folder.resolvingSymlinksInPath() + } + + /// "cyanotype-on-pictorico" from "Cyanotype on Pictorico". + static func slug(_ name: String) -> String { + let words = name.lowercased().split { !$0.isLetter && !$0.isNumber } + return words.isEmpty ? "setup" : words.joined(separator: "-") + } + + func url(_ name: String) -> URL { folder.appendingPathComponent("\(Self.slug(name)).oqsetup") } + + /// The setup's own copy of its process curve, if it has one. + public func processURL(_ setup: NegativeSetup) -> URL? { + setup.process.map { folder.appendingPathComponent($0) } + } + + /// Every saved setup, by name. + public func all() -> [NegativeSetup] { + let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? [] + return files.filter { $0.pathExtension == "oqsetup" } + .compactMap { try? NegativeSetup.decode(Data(contentsOf: $0)) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + /// Save `setup` (replacing one of the same name), copying the process curve at + /// `process` in beside it. Returns the setup as saved. + @discardableResult + public func save(_ setup: NegativeSetup, process: URL?) throws -> NegativeSetup { + let name = setup.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw FileFormatError(description: "the setup needs a name") } + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + var saved = setup + saved.name = name + saved.process = nil + if let process { + let data = try Data(contentsOf: process) + _ = try ProcessCurve.decode(data) + let file = "\(Self.slug(name)).oqproc" + if process.resolvingSymlinksInPath() != folder.appendingPathComponent(file) { + try data.write(to: folder.appendingPathComponent(file), options: .atomic) + } + saved.process = file + } else { + try? FileManager.default.removeItem(at: folder.appendingPathComponent("\(Self.slug(name)).oqproc")) + } + if saved.created.isEmpty { saved.created = ISO8601DateFormatter().string(from: Date()) } + try saved.encode().write(to: url(name), options: .atomic) + return saved + } + + /// Remove a setup and its copy of the process curve. + public func delete(_ setup: NegativeSetup) throws { + try FileManager.default.removeItem(at: url(setup.name)) + if let curve = processURL(setup) { try? FileManager.default.removeItem(at: curve) } + } +} diff --git a/swift/OpenQuadCore/Tests/OpenQuadCoreTests/SetupTests.swift b/swift/OpenQuadCore/Tests/OpenQuadCoreTests/SetupTests.swift new file mode 100644 index 0000000..a4b37a5 --- /dev/null +++ b/swift/OpenQuadCore/Tests/OpenQuadCoreTests/SetupTests.swift @@ -0,0 +1,47 @@ +// Saved negative setups: saving with a copy of the curve, listing, replacing, deleting. + +import Foundation +import Testing +@testable import OpenQuadCore + +@Suite("Negative setups") +struct SetupTests { + @Test func savesWithItsOwnCopyOfTheCurve() throws { + let folder = try temporaryFolder() + let library = SetupLibrary(folder: folder.appendingPathComponent("setups")) + #expect(library.all().isEmpty) + let curveFile = folder.appendingPathComponent("cyanotype.oqproc") + try ProcessCurve.identity(colour: "orange").encode().write(to: curveFile) + let setup = NegativeSetup(name: " Cyanotype on Pictorico ", colour: "orange", mirror: true, safeEdge: 0.3, + outline: 0.08, printer: "Epson_ET_2900", paper: "A4") + let saved = try library.save(setup, process: curveFile) + #expect(saved.name == "Cyanotype on Pictorico" && saved.process == "cyanotype-on-pictorico.oqproc" && !saved.created.isEmpty) + try FileManager.default.removeItem(at: curveFile) // the setup keeps working without the original + let listed = try #require(library.all().first) + #expect(listed == saved) + let copy = try #require(library.processURL(listed)) + #expect(try ProcessCurve.decode(Data(contentsOf: copy)).colour == "orange") + + var black = NegativeSetup(name: "Carbon", colour: "black") + black = try library.save(black, process: nil) + #expect(library.all().map(\.name) == ["Carbon", "Cyanotype on Pictorico"] && black.process == nil) + var changed = listed + changed.mirror = false + try library.save(changed, process: copy) // re-saving with its own curve + #expect(library.all().count == 2 && library.all()[1].mirror == false) + #expect(FileManager.default.fileExists(atPath: copy.path)) + try library.delete(library.all()[1]) + #expect(library.all().map(\.name) == ["Carbon"] && !FileManager.default.fileExists(atPath: copy.path)) + } + + @Test func badSetupsAreRefused() throws { + let library = SetupLibrary(folder: try temporaryFolder()) + #expect(throws: FileFormatError.self) { try library.save(NegativeSetup(name: " ", colour: "green"), process: nil) } + let good = try NegativeSetup(name: "x", colour: "green").encode() + let text = String(decoding: good, as: UTF8.self) + for (from, to) in [("\"green\"", "\"purple\""), ("\"Letter\"", "\"Tabloid\""), ("openquad-setup", "other")] { + #expect(throws: FileFormatError.self) { try NegativeSetup.decode(Data(text.replacingOccurrences(of: from, with: to).utf8)) } + } + #expect(SetupLibrary.slug("B&W / 8×10!") == "b-w-8-10") + } +}