From 32d2184d2e47c06653c390d14e17af882d9fffbd Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 11 Sep 2026 21:51:18 +0100 Subject: [PATCH] feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/ICCery/CalibrationView.swift | 7 +- Sources/ICCery/CalibrationViewModel.swift | 25 ++-- Sources/ICCery/GamutView.swift | 37 ++++-- Sources/ICCery/GamutViewModel.swift | 13 +- Sources/ICCery/ICCeryApp.swift | 36 ++++-- .../ICCery/MeasurementWorkflowViewModel.swift | 45 ++++--- Sources/ICCery/PresetDialogs.swift | 4 +- .../ICCery/Print/PrintSessionViewModel.swift | 23 ++-- Sources/ICCery/ProfileWorkflowViewModel.swift | 61 +++++---- Sources/ICCery/RootView.swift | 16 ++- Sources/ICCery/SettingsView.swift | 3 +- Sources/ICCery/SettingsViewModel.swift | 10 +- Sources/ICCery/SidebarView.swift | 20 ++- Sources/ICCery/Stage1View.swift | 2 +- Sources/ICCery/Stage2View.swift | 42 ++++++- Sources/ICCery/Stage3View.swift | 10 +- Sources/ICCery/Stage4View.swift | 10 +- Sources/ICCery/Stage5View.swift | 10 +- Sources/ICCery/TargetWorkflowViewModel.swift | 117 +++++++++--------- Sources/ICCery/WizardViewModel.swift | 27 ++-- .../CupsOptionsFilterTests.swift | 10 +- 21 files changed, 319 insertions(+), 209 deletions(-) diff --git a/Sources/ICCery/CalibrationView.swift b/Sources/ICCery/CalibrationView.swift index b4eb94c..f0178f4 100644 --- a/Sources/ICCery/CalibrationView.swift +++ b/Sources/ICCery/CalibrationView.swift @@ -3,8 +3,8 @@ import ICCeryCore /// Stage 0 calibration dashboard (issue #29, docs/07). struct CalibrationView: View { - @Bindable var model: CalibrationViewModel - @Bindable var wizard: WizardViewModel + @ObservedObject var model: CalibrationViewModel + @ObservedObject var wizard: WizardViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -73,7 +73,7 @@ struct CalibrationView: View { if let url = model.computedCalURL { Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) - .onChange(of: model.applyToProfile) { model.updateApplyToProfile() } + .onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() } .accessibilityIdentifier("calApplyToggle") Text("Loaded: \(url.lastPathComponent)") @@ -96,7 +96,6 @@ struct CalibrationView: View { } } } - .formStyle(.grouped) HStack { Spacer() diff --git a/Sources/ICCery/CalibrationViewModel.swift b/Sources/ICCery/CalibrationViewModel.swift index d19db7f..5736609 100644 --- a/Sources/ICCery/CalibrationViewModel.swift +++ b/Sources/ICCery/CalibrationViewModel.swift @@ -1,13 +1,12 @@ +import Combine import Foundation -import Observation import SwiftUI import ICCeryCore /// Stage 0 calibration workflow: generate wedge, print, measure, and /// compute `.cal` curves. @MainActor -@Observable -final class CalibrationViewModel { +final class CalibrationViewModel: ObservableObject { let workflow: TargetWorkflowViewModel let profile: ProfileWorkflowViewModel @@ -15,16 +14,16 @@ final class CalibrationViewModel { // MARK: - Form state - var colourSpace: ColourSpace = .cmyk - var steps: Int = 21 - var whitePatches: Int = 4 - var includeNeutralEmphasis: Bool = false - var inkLimit: String = "320" - var applyToProfile: Bool = false - var computedCalURL: URL? - var calibrationLog: [String] = [] - var isGenerating = false - var isComputing = false + @Published var colourSpace: ColourSpace = .cmyk + @Published var steps: Int = 21 + @Published var whitePatches: Int = 4 + @Published var includeNeutralEmphasis: Bool = false + @Published var inkLimit: String = "320" + @Published var applyToProfile: Bool = false + @Published var computedCalURL: URL? + @Published var calibrationLog: [String] = [] + @Published var isGenerating = false + @Published var isComputing = false init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { self.workflow = workflow diff --git a/Sources/ICCery/GamutView.swift b/Sources/ICCery/GamutView.swift index 96cbe43..aa3debb 100644 --- a/Sources/ICCery/GamutView.swift +++ b/Sources/ICCery/GamutView.swift @@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder { /// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* /// (blue-yellow) is depth. struct GamutView: View { - @State private var viewModel: GamutViewModel + @StateObject private var viewModel: GamutViewModel @State private var pause: () -> Void = {} @FocusState private var isFocused: Bool init(profileGamURL: URL? = nil) { - _viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) + _viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) } var body: some View { @@ -87,11 +87,6 @@ struct GamutView: View { ) .focusable() .focused($isFocused) - .focusEffectDisabled() - .onKeyPress(.init("R"), action: { - viewModel.resetCamera() - return .handled - }) .onAppear { isFocused = true } VStack { @@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable { context.coordinator.scnView = scnView context.coordinator.scene = scene context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + context.coordinator.installKeyMonitor() return scnView } @@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable { } static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { + coordinator.removeKeyMonitor() nsView.isPlaying = false } @@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable { final class Coordinator: NSObject { weak var scnView: SCNView? weak var scene: SCNScene? + private var keyMonitor: Any? private let profileNode = SCNNode() private let referenceGroup = SCNNode() @@ -431,6 +429,31 @@ private struct GamutSceneView: NSViewRepresentable { scnView?.isPlaying = false } + /// Local key-down monitor for the R camera-reset shortcut (the + /// SwiftUI key-press modifier is unavailable on macOS 12). Only + /// events aimed at this view's window are handled; everything + /// else passes through untouched. + func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard let self, + let scnView = self.scnView, + event.window === scnView.window, + event.charactersIgnoringModifiers?.uppercased() == "R" + else { return event } + self.resetCamera() + return nil + } + } + + func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + } + func resetCamera() { guard let scnView else { return } diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift index 27dda7f..6e99565 100644 --- a/Sources/ICCery/GamutViewModel.swift +++ b/Sources/ICCery/GamutViewModel.swift @@ -1,26 +1,25 @@ +import Combine import Foundation import ICCeryCore -import Observation /// View model for the native SceneKit gamut viewer. /// /// Loads the bundled `sRGB.gam` reference immediately and, optionally, a /// printer/profile `.gam` from the current working directory. @MainActor -@Observable -final class GamutViewModel { +final class GamutViewModel: ObservableObject { /// Parsed reference sRGB gamut mesh. - var sRGBMesh: GamutMesh? + @Published var sRGBMesh: GamutMesh? /// Parsed printer/profile gamut mesh. - var profileMesh: GamutMesh? + @Published var profileMesh: GamutMesh? /// User-facing status line. - var status = "Loading gamut…" + @Published var status = "Loading gamut…" /// Closure injected into the SceneKit view to request a camera reset. - var resetCamera: () -> Void = {} + @Published var resetCamera: () -> Void = {} private let profileGamURL: URL? diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift index d5dd14c..d0bec78 100644 --- a/Sources/ICCery/ICCeryApp.swift +++ b/Sources/ICCery/ICCeryApp.swift @@ -5,11 +5,11 @@ import SwiftUI @main struct ICCeryApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @State private var workflow: TargetWorkflowViewModel + @StateObject private var workflow: TargetWorkflowViewModel init() { let environment = AppEnvironment.live() - _workflow = State(initialValue: TargetWorkflowViewModel(environment: environment)) + _workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment)) try? AppPaths.ensureDirectories() // Log level is runtime state — apply persisted settings at // startup (#158); the Settings sheet re-applies on save. @@ -17,15 +17,17 @@ struct ICCeryApp: App { } var body: some Scene { - // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). - Window("ICCery", id: "main") { + // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700); + // metrics are applied by AppDelegate once the window exists. + WindowGroup("ICCery") { RootView(workflow: workflow) .frame(minWidth: 1100, minHeight: 700) .preferredColorScheme(.dark) } - .defaultSize(width: 1280, height: 800) - .windowResizability(.contentMinSize) - .defaultPosition(.center) + .commands { + // Single-window app: no File > New window. + CommandGroup(replacing: .newItem) {} + } } } @@ -37,16 +39,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var terminationRequested = false func applicationDidFinishLaunching(_ notification: Notification) { - // SwiftUI `Window` scenes launched by XCTest stay - // `.runningBackground` unless the app takes regular activation - // and orders the window front (CI run 29804). + // SwiftUI scenes launched by XCTest stay `.runningBackground` + // unless the app takes regular activation and orders the window + // front (CI run 29804). NSApp.setActivationPolicy(.regular) for window in NSApp.windows { + configureMainWindow(window) window.makeKeyAndOrderFront(nil) } NSApp.activate(ignoringOtherApps: true) } + /// docs/21 §Shell: 1280×800 content, min 1100×700, centred. + private func configureMainWindow(_ window: NSWindow) { + window.setContentSize(NSSize(width: 1280, height: 800)) + window.contentMinSize = NSSize(width: 1100, height: 700) + window.center() + } + + /// Dock-click reopen: let the WindowGroup re-show or recreate the + /// main window when none are visible. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + true + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } diff --git a/Sources/ICCery/MeasurementWorkflowViewModel.swift b/Sources/ICCery/MeasurementWorkflowViewModel.swift index 6ec4bba..59656d5 100644 --- a/Sources/ICCery/MeasurementWorkflowViewModel.swift +++ b/Sources/ICCery/MeasurementWorkflowViewModel.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import SwiftUI import ICCeryCore @@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable { /// Stage 3 workflow state and interaction (issues #18–#22). @MainActor -@Observable -final class MeasurementWorkflowViewModel { +final class MeasurementWorkflowViewModel: ObservableObject { // MARK: - Authorities @@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel { // MARK: - Settings-driven thresholds - private(set) var goodMax: Double = 2.0 - private(set) var warningMax: Double = 5.0 - private(set) var enableLEDs: Bool = false + @Published private(set) var goodMax: Double = 2.0 + @Published private(set) var warningMax: Double = 5.0 + @Published private(set) var enableLEDs: Bool = false // MARK: - Instrument detection - var instruments: [InstrumentDevice] = [] - var selectedInstrument: InstrumentSelection = .auto - var isDetecting = false - var detectionError: String? + @Published var instruments: [InstrumentDevice] = [] + @Published var selectedInstrument: InstrumentSelection = .auto + @Published var isDetecting = false + @Published var detectionError: String? // MARK: - Chartread session - var isChartreadRunning = false - var chartreadState: ChartreadState = .idle - var currentPrompt: String? - var requestedWarningKey: String? - var chartreadLog: [String] = [] - var rows: [ChartreadRow] = [] - var swatchRows: [SwatchRow] = [] - var showRemoveSheetNotice = false + @Published var isChartreadRunning = false + @Published var chartreadState: ChartreadState = .idle + @Published var currentPrompt: String? + @Published var requestedWarningKey: String? + @Published var chartreadLog: [String] = [] + @Published var rows: [ChartreadRow] = [] + @Published var swatchRows: [SwatchRow] = [] + @Published var showRemoveSheetNotice = false /// Stage-local chartread error notice (`#chartreadLastError`, #80). - var chartreadNotice: Notice? + @Published var chartreadNotice: Notice? private var chartreadTask: Task? // MARK: - Averaging - var passSnapshots: [URL] = [] - var isFinishing = false - var finishNotice: Notice? - var resumedFromTi2 = false + @Published var passSnapshots: [URL] = [] + @Published var isFinishing = false + @Published var finishNotice: Notice? + @Published var resumedFromTi2 = false init(wizard: WizardViewModel, environment: AppEnvironment) { self.wizard = wizard diff --git a/Sources/ICCery/PresetDialogs.swift b/Sources/ICCery/PresetDialogs.swift index e9781a9..40a072c 100644 --- a/Sources/ICCery/PresetDialogs.swift +++ b/Sources/ICCery/PresetDialogs.swift @@ -4,7 +4,7 @@ import ICCeryCore /// `#savePresetDialog` — save the live Stage 1/2 form as a custom /// preset (issue #11). Names/descriptions render via `Text` only (#114). struct SavePresetDialog: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -35,7 +35,7 @@ struct SavePresetDialog: View { /// `#managePresetsDialog` — list, delete (custom only), import, export. struct ManagePresetsDialog: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { VStack(alignment: .leading, spacing: 12) { diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index b35cf99..f1f59f7 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -1,23 +1,22 @@ +import Combine import Foundation -import Observation import ICCeryCore /// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85). @MainActor -@Observable -final class PrintSessionViewModel { +final class PrintSessionViewModel: ObservableObject { let wizard: WizardViewModel let environment: AppEnvironment - var printers: [Printer] = [] - var selectedPrinter = "" - var printerCaps = PrinterCapabilities() - var selectedTray: Int? - var selectedMediaType: String? - var printOrientation = "portrait" - var capturedCupsOptions: [String: String] = [:] - var printNotice: Notice? - var isPrinting = false + @Published var printers: [Printer] = [] + @Published var selectedPrinter = "" + @Published var printerCaps = PrinterCapabilities() + @Published var selectedTray: Int? + @Published var selectedMediaType: String? + @Published var printOrientation = "portrait" + @Published var capturedCupsOptions: [String: String] = [:] + @Published var printNotice: Notice? + @Published var isPrinting = false private var printTask: Task? init(wizard: WizardViewModel, environment: AppEnvironment) { diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift index 8426e61..5903606 100644 --- a/Sources/ICCery/ProfileWorkflowViewModel.swift +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -1,12 +1,11 @@ +import Combine import Foundation -import Observation import SwiftUI import ICCeryCore /// Stage 4/5 workflow: build a profile, verify it, track drift, and install. @MainActor -@Observable -final class ProfileWorkflowViewModel { +final class ProfileWorkflowViewModel: ObservableObject { let wizard: WizardViewModel let environment: AppEnvironment @@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel { // MARK: - Stage 4 form - var algorithm: String = "l" // l | x | X | m - var quality: String = "m" // l | m | h | u - var intent: String = "" // usually empty at Stage 4 - var fwaSelection: ColprofFwaSelection = .none - var fwaCustomPath: String = "" - var illuminant: String = "" - var observer: String = "" - var inputViewingCond: String = "" - var outputViewingCond: String = "" - var profileDescription: String = "" - var copyright: String = "" + @Published var algorithm: String = "l" // l | x | X | m + @Published var quality: String = "m" // l | m | h | u + @Published var intent: String = "" // usually empty at Stage 4 + @Published var fwaSelection: ColprofFwaSelection = .none + @Published var fwaCustomPath: String = "" + @Published var illuminant: String = "" + @Published var observer: String = "" + @Published var inputViewingCond: String = "" + @Published var outputViewingCond: String = "" + @Published var profileDescription: String = "" + @Published var copyright: String = "" // MARK: - Run state - var isColprofRunning = false - var colprofLog: [String] = [] - var colprofProgress: String? - var createdProfileURL: URL? + @Published var isColprofRunning = false + @Published var colprofLog: [String] = [] + @Published var colprofProgress: String? + @Published var createdProfileURL: URL? /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). - var createdGamutURL: URL? + @Published var createdGamutURL: URL? // MARK: - Stage 4/5 calibration (issue #24) - var applyCalibration = false - var calibrationFile: String = "" + @Published var applyCalibration = false + @Published var calibrationFile: String = "" // MARK: - Stage 5 verification (issue #25) - var profcheckReport: ProfcheckReport? - var profcheckWarning: String? - var isProfcheckRunning = false + @Published var profcheckReport: ProfcheckReport? + @Published var profcheckWarning: String? + @Published var isProfcheckRunning = false // MARK: - History / drift (issue #26) - var verificationHistory: [VerificationRecord] = [] - var driftPrinterFilter: String? = nil - var driftAlert: String? - var isHistoryStoreError: String? + @Published var verificationHistory: [VerificationRecord] = [] + @Published var driftPrinterFilter: String? = nil + @Published var driftAlert: String? + @Published var isHistoryStoreError: String? // MARK: - Install (issue #27) - var installResult: InstallProfileResult? - var showingInstallCollision = false - var installCollisionMessage: String = "" + @Published var installResult: InstallProfileResult? + @Published var showingInstallCollision = false + @Published var installCollisionMessage: String = "" var pendingInstallOptions: InstallProfileOptions? init(wizard: WizardViewModel, environment: AppEnvironment) { diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index cfedcb1..aa67a55 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -5,12 +5,18 @@ import ICCeryCore /// Root layout: 270 pt sidebar + main stage area with the notification /// banner pinned to the top (docs/21 §Shell). struct RootView: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var model: WizardViewModel @State private var showingSettings = false @State private var showingAbout = false @State private var showingAllHelp = false - private var model: WizardViewModel { workflow.wizard } + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._model = ObservedObject(wrappedValue: workflow.wizard) + } var body: some View { HStack(spacing: 0) { @@ -64,11 +70,11 @@ struct RootView: View { } /// Content for the active wizard stage. Isolated into its own view so that -/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's +/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's /// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations. private struct WizardStageContent: View { - @Bindable var model: WizardViewModel - var workflow: TargetWorkflowViewModel + @ObservedObject var model: WizardViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { switch model.stage { diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift index 085e7ff..f55a0b7 100644 --- a/Sources/ICCery/SettingsView.swift +++ b/Sources/ICCery/SettingsView.swift @@ -4,7 +4,7 @@ import ICCeryCore /// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with /// the full v1 field set; ΔE validation shows inline under the fields. struct SettingsView: View { - @State var model = SettingsViewModel() + @StateObject var model = SettingsViewModel() @Environment(\.dismiss) private var dismiss private static let instruments: [(code: String, label: String)] = [ @@ -143,7 +143,6 @@ struct SettingsView: View { } } } - .formStyle(.grouped) Divider() diff --git a/Sources/ICCery/SettingsViewModel.swift b/Sources/ICCery/SettingsViewModel.swift index d231309..ed480fa 100644 --- a/Sources/ICCery/SettingsViewModel.swift +++ b/Sources/ICCery/SettingsViewModel.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Foundation import ICCeryCore @@ -6,12 +7,11 @@ import ICCeryCore /// validation; the log level is applied live via `LogSink` (#158) and a /// `settingsDidChange` notification fans out to #20. @MainActor -@Observable -final class SettingsViewModel { +final class SettingsViewModel: ObservableObject { - var settings: AppSettings - var validationErrors: [String] = [] - var savedFlash = false + @Published var settings: AppSettings + @Published var validationErrors: [String] = [] + @Published var savedFlash = false private let store: SettingsStore private let sink: LogSink diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index bb7301d..0c4d3c1 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -4,12 +4,28 @@ import ICCeryCore /// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset /// select, Calibrate Printer + status chip, and the 1–5 stepper. struct SidebarView: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var model: WizardViewModel + @ObservedObject private var profile: ProfileWorkflowViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void @Binding var showingAllHelp: Bool - private var model: WizardViewModel { workflow.wizard } + init( + workflow: TargetWorkflowViewModel, + onOpenSettings: @escaping () -> Void, + onOpenAbout: @escaping () -> Void, + showingAllHelp: Binding + ) { + self.workflow = workflow + self._model = ObservedObject(wrappedValue: workflow.wizard) + self._profile = ObservedObject(wrappedValue: workflow.profile) + self.onOpenSettings = onOpenSettings + self.onOpenAbout = onOpenAbout + self._showingAllHelp = showingAllHelp + } var body: some View { VStack(alignment: .leading, spacing: 0) { diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift index 349b100..9b40307 100644 --- a/Sources/ICCery/Stage1View.swift +++ b/Sources/ICCery/Stage1View.swift @@ -5,7 +5,7 @@ import ICCeryCore /// docs/08). All documented element ids are wired as accessibility /// identifiers so the UI-test contract stays stable. struct Stage1View: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel var body: some View { ScrollView { diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 9d2c784..e04b162 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -5,7 +5,19 @@ import ICCeryCore /// issues #9/#10, docs/09). Print controls are visible but inert — /// real spooling lands in M3. struct Stage2View: View { - @Bindable var workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var printSession: PrintSessionViewModel + @ObservedObject private var wizard: WizardViewModel + + @State private var printGenerationTask: Task? + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._printSession = ObservedObject(wrappedValue: workflow.print) + self._wizard = ObservedObject(wrappedValue: workflow.wizard) + } var body: some View { ScrollView { @@ -240,7 +252,7 @@ struct Stage2View: View { } .frame(maxWidth: 320) .accessibilityIdentifier("printerSelect") - .onChange(of: workflow.print.selectedPrinter) { _, _ in + .onChange(of: workflow.print.selectedPrinter) { _ in workflow.print.selectedTray = nil workflow.print.selectedMediaType = nil Task { @MainActor in await workflow.print.reloadSelectedCapabilities() } @@ -328,9 +340,18 @@ struct Stage2View: View { .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .accessibilityElement(children: .contain) .accessibilityIdentifier("rawPrintPanel") - .task(id: workflow.printtargResult?.pages.count) { - // Auto-enumerate once a manifest exists and whenever it - // changes (e.g. resume from .ti2). + .onAppear { schedulePrinterRefresh() } + .onChange(of: workflow.printtargResult?.pages.count) { _ in + schedulePrinterRefresh() + } + } + + /// Auto-enumerates printers once a manifest exists and whenever it + /// changes (e.g. resume from .ti2). The explicit task handle keeps + /// a superseded run from racing the next one. + private func schedulePrinterRefresh() { + printGenerationTask?.cancel() + printGenerationTask = Task { @MainActor in if workflow.print.printers.isEmpty, workflow.printtargResult != nil { workflow.print.refreshPrinters() } @@ -341,7 +362,16 @@ struct Stage2View: View { /// One gallery cell: PNG preview + per-page Print button. private struct GalleryPageView: View { let page: GalleryPage - let workflow: TargetWorkflowViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: `print` is a nested ObservableObject and its + /// `isPrinting`/`selectedPrinter` changes drive this cell's button. + @ObservedObject private var printSession: PrintSessionViewModel + + init(page: GalleryPage, workflow: TargetWorkflowViewModel) { + self.page = page + self.workflow = workflow + self._printSession = ObservedObject(wrappedValue: workflow.print) + } var body: some View { VStack(spacing: 6) { diff --git a/Sources/ICCery/Stage3View.swift b/Sources/ICCery/Stage3View.swift index 4ee7a65..8e1b1d8 100644 --- a/Sources/ICCery/Stage3View.swift +++ b/Sources/ICCery/Stage3View.swift @@ -4,7 +4,15 @@ import ICCeryCore /// Stage 3 — measurement, live swatches, and multi-pass averaging. struct Stage3View: View { - @Bindable var model: MeasurementWorkflowViewModel + @ObservedObject var model: MeasurementWorkflowViewModel + /// `model.basename`/`model.workingDirectory` delegate to `wizard`; + /// observe it directly so header updates propagate. + @ObservedObject private var wizard: WizardViewModel + + init(model: MeasurementWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } var body: some View { VStack(spacing: 0) { diff --git a/Sources/ICCery/Stage4View.swift b/Sources/ICCery/Stage4View.swift index 21333a6..2a400ac 100644 --- a/Sources/ICCery/Stage4View.swift +++ b/Sources/ICCery/Stage4View.swift @@ -3,7 +3,15 @@ import ICCeryCore /// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`. struct Stage4View: View { - @Bindable var model: ProfileWorkflowViewModel + @ObservedObject var model: ProfileWorkflowViewModel + /// Header reads `model.wizard.basename`; observe the nested + /// ObservableObject directly. + @ObservedObject private var wizard: WizardViewModel + + init(model: ProfileWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } var body: some View { VStack(spacing: 0) { diff --git a/Sources/ICCery/Stage5View.swift b/Sources/ICCery/Stage5View.swift index 9ef62c3..c38eaf7 100644 --- a/Sources/ICCery/Stage5View.swift +++ b/Sources/ICCery/Stage5View.swift @@ -3,7 +3,15 @@ import ICCeryCore /// Stage 5 — verify the generated profile, track drift, and install. struct Stage5View: View { - @Bindable var model: ProfileWorkflowViewModel + @ObservedObject var model: ProfileWorkflowViewModel + /// Header/buttons read `model.wizard.*`; observe the nested + /// ObservableObject directly. + @ObservedObject private var wizard: WizardViewModel + + init(model: ProfileWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } var body: some View { VStack(spacing: 0) { diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 3afa2e7..f89d37e 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import ICCeryCore /// Stage 1/2 form state, runner orchestration, resume flow, and preset @@ -10,8 +10,7 @@ import ICCeryCore /// All process work runs through `ArgyllRunner` off `@MainActor`; only /// coalesced log batches and completion hop back. @MainActor -@Observable -final class TargetWorkflowViewModel { +final class TargetWorkflowViewModel: ObservableObject { let wizard: WizardViewModel let environment: AppEnvironment @@ -19,95 +18,95 @@ final class TargetWorkflowViewModel { // MARK: - Stage 1 form (targen) - var colourSpace: ColourSpace = .rgb { + @Published var colourSpace: ColourSpace = .rgb { didSet { guard colourSpace != oldValue else { return } // CMYK black patches default to 0, RGB to 4 (docs/08). blackPatches = colourSpace == .cmyk ? 0 : 4 } } - var patchPreset: PatchCountPreset = .standard800 + @Published var patchPreset: PatchCountPreset = .standard800 /// `#patchCountCustom` — used when `patchPreset == .custom`. - var customPatchCount = 2500 - var whitePatches = 4 - var blackPatches = 4 + @Published var customPatchCount = 2500 + @Published var whitePatches = 4 + @Published var blackPatches = 4 // Advanced — each optional flag is enabled + value, so an untouched // control emits nothing (#advanced fields are opt-in). - var greyStepsEnabled = false - var greySteps = 5 - var singleChannelEnabled = false - var singleChannelSteps = 5 - var neutralStepsEnabled = false - var neutralSteps = 3 - var neutralConcEnabled = false - var neutralConcentration = 0.50 - var preconditioningProfile: String? - var highQuality = false - var adaptationEnabled = false - var adaptation = 0.10 - var algorithm: FullSpreadAlgorithm = .ofps - var inkLimitEnabled = false - var totalInkLimit = 320 - var darkEmphasisEnabled = false - var darkEmphasis = 1.0 - var devicePowerEnabled = false - var devicePower = 1.0 + @Published var greyStepsEnabled = false + @Published var greySteps = 5 + @Published var singleChannelEnabled = false + @Published var singleChannelSteps = 5 + @Published var neutralStepsEnabled = false + @Published var neutralSteps = 3 + @Published var neutralConcEnabled = false + @Published var neutralConcentration = 0.50 + @Published var preconditioningProfile: String? + @Published var highQuality = false + @Published var adaptationEnabled = false + @Published var adaptation = 0.10 + @Published var algorithm: FullSpreadAlgorithm = .ofps + @Published var inkLimitEnabled = false + @Published var totalInkLimit = 320 + @Published var darkEmphasisEnabled = false + @Published var darkEmphasis = 1.0 + @Published var devicePowerEnabled = false + @Published var devicePower = 1.0 /// `#targetBasename` — no placeholder is ever invented (#60). - var targetBasename = "" + @Published var targetBasename = "" /// `#selectedPathDisplay` / resolved cwd. - var targetDirectory: URL? + @Published var targetDirectory: URL? // MARK: - Stage 2 form (printtarg) - var instrument: PrintInstrument = .i1 - var pageSize: PageSize = .a4 - var customPageW = 210.0 - var customPageH = 297.0 - var bitDepth: TiffBitDepth = .eight + @Published var instrument: PrintInstrument = .i1 + @Published var pageSize: PageSize = .a4 + @Published var customPageW = 210.0 + @Published var customPageH = 297.0 + @Published var bitDepth: TiffBitDepth = .eight /// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft /// regression must be visible here). - var tiffDpi = 300 - var layoutOrder: LayoutOrder = .deterministic - var customSeed = 1 - var labelIsCustom = false - var customLabel = "" - var metaPrinter = "" - var metaInkSet = "" - var metaDriverPaper = "" - var metaActualPaper = "" + @Published var tiffDpi = 300 + @Published var layoutOrder: LayoutOrder = .deterministic + @Published var customSeed = 1 + @Published var labelIsCustom = false + @Published var customLabel = "" + @Published var metaPrinter = "" + @Published var metaInkSet = "" + @Published var metaDriverPaper = "" + @Published var metaActualPaper = "" // MARK: - Run state - var targenRunning = false - var targenLog: [String] = [] - var printtargRunning = false - var printtargLog: [String] = [] - var printtargResult: PrinttargResult? + @Published var targenRunning = false + @Published var targenLog: [String] = [] + @Published var printtargRunning = false + @Published var printtargLog: [String] = [] + @Published var printtargResult: PrinttargResult? /// Sticky until the target changes: `.ti2` resume landed us on /// Stage 3 (`#stage3LoadedTargetBanner` data). - var resumedFromTi2 = false + @Published var resumedFromTi2 = false // MARK: - Presets - var presets: [ProfilingPreset] = [] - var selectedPresetID = "none" - var showingSavePreset = false - var showingManagePresets = false - var savePresetName = "" - var savePresetDesc = "" + @Published var presets: [ProfilingPreset] = [] + @Published var selectedPresetID = "none" + @Published var showingSavePreset = false + @Published var showingManagePresets = false + @Published var savePresetName = "" + @Published var savePresetDesc = "" /// Stage 3 measurement workflow, owned at the app level so it persists /// across stage switches and can observe settings changes. - var measurement: MeasurementWorkflowViewModel + @Published var measurement: MeasurementWorkflowViewModel /// Stage 4/5 profile workflow, owned at the app level so it persists /// across stage switches and can observe preset values. - var profile: ProfileWorkflowViewModel + @Published var profile: ProfileWorkflowViewModel /// Stage 0 calibration workflow. - var calibration: CalibrationViewModel! + @Published var calibration: CalibrationViewModel! /// Stage 2 unmanaged print session. - var print: PrintSessionViewModel! + @Published var print: PrintSessionViewModel! init(environment: AppEnvironment = .live()) { self.environment = environment diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 55adc72..bbd0aa1 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import ICCeryCore /// Wizard state machine + artefact gating (issue #4, docs/06). @@ -10,47 +10,46 @@ import ICCeryCore /// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` — /// navigation is disk, not buttons. @MainActor -@Observable -final class WizardViewModel { +final class WizardViewModel: ObservableObject { // MARK: - wizardState fields (persisted) - var stage: WizardStage { + @Published var stage: WizardStage { didSet { if stage != oldValue { persist() } } } /// `wizardState.basename` — empty until a real artefact names it (#60). - var basename: String { + @Published var basename: String { didSet { if basename != oldValue { refreshGating(); persist() } } } /// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59). - var workingDirectory: URL? { + @Published var workingDirectory: URL? { didSet { if workingDirectory != oldValue { refreshGating(); persist() } } } - var printerName: String? { + @Published var printerName: String? { didSet { if printerName != oldValue { persist() } } } - var sessionMode: SessionMode { + @Published var sessionMode: SessionMode { didSet { if sessionMode != oldValue { persist() } } } /// `profileBasename` may differ after a `.ti3` import (#94). - var profileBasename: String? { + @Published var profileBasename: String? { didSet { if profileBasename != oldValue { persist() } } } /// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29). - var calibrationOriginalBasename: String { + @Published var calibrationOriginalBasename: String { didSet { if calibrationOriginalBasename != oldValue { persist() } } } // MARK: - Ephemeral /// Banner notice currently displayed (`#wizardNotification`). - var notice: Notice? + @Published var notice: Notice? /// Current artefact probe result; recomputed on `refreshGating()`. - private(set) var artefacts = StageArtefacts() + @Published private(set) var artefacts = StageArtefacts() /// Whether the 3D gamut viewer sheet is open (issue #28). - var showingGamutViewer = false + @Published var showingGamutViewer = false /// Optional `.gam` URL to show alongside the sRGB reference. - var gamutProfileURL: URL? + @Published var gamutProfileURL: URL? private let stateStore: WizardStateStore private var noticeDismissTask: Task? diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift index 0f035da..b4fe1ba 100644 --- a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -71,10 +71,14 @@ final class ColorSyncSuppressorTests: XCTestCase { s.modeResolver = { name in if Self.missing.contains(name) { return nil } Self.currentSymbol = name + // `Self` inside a @convention(c) closure is a dynamic-Self + // capture — spell the (final) class name instead. return { _, modeArg in - Self.recorded.append((Self.currentSymbol, modeArg as String)) - if let ok = Self.succeeding, - Self.currentSymbol == ok.0, (modeArg as String) == ok.1 { + ColorSyncSuppressorTests.recorded.append( + (ColorSyncSuppressorTests.currentSymbol, modeArg as String)) + if let ok = ColorSyncSuppressorTests.succeeding, + ColorSyncSuppressorTests.currentSymbol == ok.0, + (modeArg as String) == ok.1 { return 0 } return 1 -- 2.39.5