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
15 changes: 12 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/openquad/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 56 additions & 27 deletions swift/OpenQuadApp/Sources/OpenQuad/OpenQuadApp.swift
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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("?")
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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))
Expand Down
62 changes: 44 additions & 18 deletions swift/OpenQuadApp/Sources/OpenQuadUI/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,25 @@ 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?
public private(set) var busy = false
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.
Expand All @@ -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"))
Expand All @@ -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).
Expand Down Expand Up @@ -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))
}
Expand All @@ -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
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
}

Expand All @@ -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()
}

Expand All @@ -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)."
Expand All @@ -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)
}

Expand Down
9 changes: 5 additions & 4 deletions swift/OpenQuadApp/Sources/OpenQuadUI/BlockingTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading