Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]))
}
Expand Down
68 changes: 67 additions & 1 deletion swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions swift/OpenQuadApp/Sources/OpenQuadUI/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") }
Expand Down Expand Up @@ -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) }
}
Expand Down
6 changes: 4 additions & 2 deletions swift/OpenQuadApp/Sources/OpenQuadUI/Guide.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 "
Expand Down
40 changes: 40 additions & 0 deletions swift/OpenQuadApp/Tests/OpenQuadUITests/AppModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
135 changes: 135 additions & 0 deletions swift/OpenQuadCore/Sources/OpenQuadCore/NegativeSetups.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Loading
Loading