import SwiftUI
/// The editor's full control panel. Every row binds to a `Preset` field on the
/// shared model; colour-mode-specific sections appear for the active mode.
struct ControlsView: View {
@ObservedObject var model: EditorModel
private let presetNames = ["default","lava","aurora","clouds","terrain","circuit","marble","composite","nebula","flow"]
/// True once the user explicitly picked "Custom" — keeps the editor open
/// even while the stops still equal a named gradient.
@State private var customPicked = false
var body: some View {
Form {
presetSection
colourSection
shapeSection
warpSection
layer2Section
Section("Motion") { slider("Speed", $model.preset.scaleT, 0.0...0.6) }
exportSection
MaskSection(model: model)
LooksSection(model: model)
}
}
// MARK: – Section helpers (split to avoid Swift type-checker timeout on a single large body)
private var presetSection: some View {
Section("Preset") {
Picker("Preset", selection: presetBinding) {
ForEach(presetNames, id: \.self) { Text($0.capitalized).tag($0) }
}
}
}
private var colourSection: some View {
Section("Colour") {
Picker("Mode", selection: $model.preset.colorMode) {
Text("Gradient").tag("gradient"); Text("Posterize").tag("posterize")
Text("HSV").tag("hsv"); Text("Flow").tag("flow"); Text("RGB").tag("rgb")
}.pickerStyle(.menu)
if model.preset.colorMode == "gradient" || model.preset.colorMode == "posterize" {
Picker("Gradient", selection: gradientBinding) {
ForEach(Gradients.all, id: \.name) { Text($0.name.capitalized).tag($0.name) }
Text("Custom").tag("custom")
}
if gradientSelection == "custom" { GradientEditor(model: model) }
}
if model.preset.colorMode == "posterize" {
slider("Steps", intB(\.posterizeSteps, 5).double, 2...16)
}
if model.preset.colorMode == "hsv" {
slider("Hue shift", dbl(\.hsvHueShift, 0), 0...1)
slider("Hue range", dbl(\.hsvHueRange, 0.5), 0...1)
slider("Saturation", dbl(\.hsvSat, 0.8), 0...1)
slider("Value", dbl(\.hsvVal, 1), 0...1)
}
if model.preset.colorMode == "flow" {
slider("Hue shift", dbl(\.flowHueShift, 0), 0...1)
slider("Saturation", dbl(\.flowSat, 0.8), 0...1)
slider("Gain", dbl(\.flowGain, 4), 0.1...20)
slider("Floor", dbl(\.flowFloor, 0.05), 0...0.6)
slider("Eps", dbl(\.flowEps, 0.6), 0.1...3)
}
if model.preset.colorMode == "rgb" {
Toggle("Per-channel", isOn: boolB(\.rgbMultiChannel, false))
slider("R scale", dbl(\.rScale, 0), 0...2)
slider("G scale", dbl(\.gScale, 1), 0...2)
slider("B scale", dbl(\.bScale, 0), 0...2)
Toggle("Invert R", isOn: boolB(\.rInvert, false))
Toggle("Invert G", isOn: boolB(\.gInvert, false))
Toggle("Invert B", isOn: boolB(\.bInvert, false))
}
}
}
private var shapeSection: some View {
Section("Shape") {
slider("Zoom", zoomBinding, 0.003...0.05)
slider("Detail (octaves)", octavesBinding, 1...8)
slider("Persistence", $model.preset.persistence, 0.1...0.9)
slider("Lacunarity", $model.preset.lacunarity, 1.5...3.0)
Stepper("Seed: \(model.preset.base)", value: $model.preset.base, in: 0...255)
}
}
private var warpSection: some View {
Section("Warp") {
slider("Strength", dbl(\.warpStrength, 0), 0...3)
slider("Scale", dbl(\.warpScale, 1), 0.2...3)
}
}
private var layer2Section: some View {
Section("Layer 2") {
Toggle("On", isOn: boolB(\.layer2On, false))
if model.preset.layer2On == true {
slider("Scale X", dbl(\.layer2ScaleX, 0.02), 0.003...0.08)
slider("Scale Y", dbl(\.layer2ScaleY, 0.02), 0.003...0.08)
slider("Octaves", intB(\.layer2Octaves, 3).double, 1...8)
slider("Mix", dbl(\.layer2Mix, 0.5), 0...1)
Picker("Blend", selection: blendBinding) {
ForEach(["multiply","add","screen","darken","lighten","difference"], id: \.self) { Text($0.capitalized).tag($0) }
}
Stepper("L2 seed: \(model.preset.layer2Base ?? 0)", value: intB(\.layer2Base, 0), in: 0...255)
}
}
}
private var exportSection: some View {
Section("Export") {
Picker("Resolution", selection: resolutionBinding) {
ForEach(LivePhotoExportOptions.resolutionPresets.indices, id: \.self) { i in
Text(LivePhotoExportOptions.resolutionPresets[i].label).tag(i)
}
}
slider("Duration (\(String(format: "%.1f", model.export.duration)) s)", $model.export.duration, 1...6)
Picker("FPS", selection: $model.export.fps) {
Text("24").tag(24); Text("30").tag(30); Text("60").tag(60)
}.pickerStyle(.segmented)
}
}
// MARK: – Bindings
private var presetBinding: Binding<String> {
Binding(get: { model.preset.name ?? "default" },
set: { customPicked = false; model.load(presetNamed: $0) })
}
/// The picker selection is *derived* from the active stops (instead of a
/// parallel @State name) so loading a look or preset can never desync the
/// label from what actually renders.
private var gradientSelection: String {
if customPicked { return "custom" }
return Gradients.all.first(where: { $0.stops == model.preset.gradient })?.name ?? "custom"
}
private var gradientBinding: Binding<String> {
Binding(get: { gradientSelection },
set: { customPicked = ($0 == "custom")
if $0 != "custom" { model.preset.gradient = Gradients.stops(named: $0) } })
}
// One "zoom" master drives both scale axes equally.
private var zoomBinding: Binding<Double> {
Binding(get: { model.preset.scaleX }, set: { model.preset.scaleX = $0; model.preset.scaleY = $0 })
}
private var octavesBinding: Binding<Double> {
Binding(get: { Double(model.preset.octaves) }, set: { model.preset.octaves = Int($0.rounded()) })
}
private var blendBinding: Binding<String> {
Binding(get: { model.preset.layer2BlendMode ?? "multiply" }, set: { model.preset.layer2BlendMode = $0 })
}
/// Maps the resolution-preset index ⇄ the export width/height.
private var resolutionBinding: Binding<Int> {
Binding(get: {
LivePhotoExportOptions.resolutionPresets.firstIndex(where: {
$0.w == model.export.width && $0.h == model.export.height
}) ?? 0
}, set: { i in
let p = LivePhotoExportOptions.resolutionPresets[i]
model.export.width = p.w; model.export.height = p.h
})
}
// MARK: – KeyPath binding helpers for optional fields
private func dbl(_ kp: WritableKeyPath<Preset, Double?>, _ def: Double) -> Binding<Double> {
Binding(get: { model.preset[keyPath: kp] ?? def }, set: { model.preset[keyPath: kp] = $0 })
}
private func intB(_ kp: WritableKeyPath<Preset, Int?>, _ def: Int) -> Binding<Int> {
Binding(get: { model.preset[keyPath: kp] ?? def }, set: { model.preset[keyPath: kp] = $0 })
}
private func boolB(_ kp: WritableKeyPath<Preset, Bool?>, _ def: Bool) -> Binding<Bool> {
Binding(get: { model.preset[keyPath: kp] ?? def }, set: { model.preset[keyPath: kp] = $0 })
}
@ViewBuilder
private func slider(_ title: String, _ value: Binding<Double>, _ range: ClosedRange<Double>) -> some View {
VStack(alignment: .leading) {
Text(title).font(.caption).foregroundStyle(.secondary)
Slider(value: value, in: range)
}
}
}
private extension Binding where Value == Int {
var double: Binding<Double> {
Binding<Double>(get: { Double(wrappedValue) }, set: { (v: Double) in wrappedValue = Int(v.rounded()) })
}
}