- AppSettings: snake_case Codable model — argyll_binary_dir, default_instrument (stored, never applied to argv), log_level (nil -> Debug debug / Info release), delta_e thresholds (2.0/5.0), custom_presets, enable_i1pro2_leds, calibration_stale_days 30, default_install_location user, ask_before_overwrite_profile, open_color_panel_after_install - Validation with the exact contract strings; save() refuses invalid settings; corrupt/missing JSON -> defaults; settingsDidChange notification posted on save (for #20) - LogSink: rolling file at ~/Library/Logs/com.gronod.iccery2/ iccery.log, 5 MiB x 5 segments, runtime setLevel applied at startup and on save (#158); AppLogger gates os_log+file through it - SettingsView sheet: Argyll dir picker, instrument (display-only caveat), i1Pro2 LEDs, ΔE fields + inline errors, stale days, install location, overwrite + ColorSync toggles, log level, open-log-folder / copy-path / copy-excerpt - v1 settings path never read; writes atomic via AtomicFileWriter - 13 new tests; 60/60 green Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
170 lines
6.3 KiB
Swift
170 lines
6.3 KiB
Swift
import SwiftUI
|
|
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()
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private static let instruments: [(code: String, label: String)] = [
|
|
("i1", "X-Rite i1Pro / i1Pro 2"),
|
|
("p3", "X-Rite i1Pro 3 / 3 Plus"),
|
|
("CM", "ColorMunki"),
|
|
("SS", "Specbos / Spectraval"),
|
|
("20", "Gretag i1Display 2"),
|
|
("22", "X-Rite i1Display Pro / ColorMunki Display"),
|
|
("41", "Datacolor Spyder 4/5"),
|
|
("51", "Spyder X"),
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Form {
|
|
Section("Argyll") {
|
|
HStack {
|
|
TextField(
|
|
"Bundled sidecars",
|
|
text: Binding(
|
|
get: { model.settings.argyllBinaryDir ?? "" },
|
|
set: {
|
|
model.settings.argyllBinaryDir =
|
|
$0.isEmpty ? nil : $0
|
|
}
|
|
)
|
|
)
|
|
Button("Browse…") {
|
|
if let dir = FileDialogService.shared.selectDirectory() {
|
|
model.settings.argyllBinaryDir = dir.path
|
|
}
|
|
}
|
|
}
|
|
Text("Leave empty to use the bundled Argyll tools.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Picker(
|
|
"Default instrument",
|
|
selection: Binding(
|
|
get: { model.settings.defaultInstrument ?? "" },
|
|
set: {
|
|
model.settings.defaultInstrument =
|
|
$0.isEmpty ? nil : $0
|
|
}
|
|
)
|
|
) {
|
|
Text("None").tag("")
|
|
ForEach(Self.instruments, id: \.code) {
|
|
Text($0.label).tag($0.code)
|
|
}
|
|
}
|
|
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Toggle(
|
|
"Enable i1Pro 2 LEDs",
|
|
isOn: $model.settings.enableI1Pro2Leds
|
|
)
|
|
}
|
|
|
|
Section("Verification") {
|
|
HStack {
|
|
Text("Good ΔE ≤")
|
|
TextField(
|
|
"2.0",
|
|
value: $model.settings.deltaEGoodMax,
|
|
format: .number
|
|
)
|
|
.frame(width: 60)
|
|
Text("Warning ΔE ≤")
|
|
TextField(
|
|
"5.0",
|
|
value: $model.settings.deltaEWarningMax,
|
|
format: .number
|
|
)
|
|
.frame(width: 60)
|
|
}
|
|
ForEach(model.validationErrors, id: \.self) { error in
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
Section("Calibration") {
|
|
HStack {
|
|
Text("Stale after")
|
|
TextField(
|
|
"30",
|
|
value: $model.settings.calibrationStaleDays,
|
|
format: .number
|
|
)
|
|
.frame(width: 60)
|
|
Text("days")
|
|
}
|
|
}
|
|
|
|
Section("Profile install") {
|
|
Picker(
|
|
"Install location",
|
|
selection: $model.settings.defaultInstallLocation
|
|
) {
|
|
Text("User library").tag(InstallLocation.user)
|
|
Text("System library").tag(InstallLocation.system)
|
|
}
|
|
Toggle(
|
|
"Ask before overwriting a profile",
|
|
isOn: $model.settings.askBeforeOverwriteProfile
|
|
)
|
|
Toggle(
|
|
"Open ColorSync after install",
|
|
isOn: $model.settings.openColorPanelAfterInstall
|
|
)
|
|
}
|
|
|
|
Section("Logging") {
|
|
Picker(
|
|
"Log level",
|
|
selection: Binding(
|
|
get: { model.settings.logLevel },
|
|
set: { model.settings.logLevel = $0 }
|
|
)
|
|
) {
|
|
Text("Default").tag(LogLevel?.none)
|
|
ForEach(LogLevel.allCases, id: \.self) {
|
|
Text($0.rawValue.capitalized).tag(LogLevel?.some($0))
|
|
}
|
|
}
|
|
HStack {
|
|
Button("Open log folder") { model.openLogFolder() }
|
|
Button("Copy path") { model.copyLogPath() }
|
|
Button("Copy excerpt") { model.copyLogExcerpt() }
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
|
|
Divider()
|
|
|
|
HStack {
|
|
if model.savedFlash {
|
|
Text("Saved")
|
|
.foregroundStyle(.green)
|
|
.font(.callout)
|
|
}
|
|
Spacer()
|
|
Button("Cancel") { dismiss() }
|
|
.keyboardShortcut(.cancelAction)
|
|
Button("Save") {
|
|
if model.save() { dismiss() }
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
}
|
|
.padding(12)
|
|
}
|
|
.frame(width: 560, height: 620)
|
|
.background(Theme.background)
|
|
}
|
|
}
|