Zum Inhalt

ios/Sources/GradientEditor.swift

import SwiftUI

/// Edits the working preset's gradient stops: a row per stop with a colour well
/// and a position slider, plus add/remove. Writing any stop updates
/// `model.preset.gradient`, which the live preview + export consume.
struct GradientEditor: View {
    @ObservedObject var model: EditorModel
    @State private var paletteText = ""

    var body: some View {
        let stops = model.preset.gradient ?? []
        ForEach(stops.indices, id: \.self) { i in
            HStack {
                ColorPicker("", selection: colorBinding(i), supportsOpacity: false).labelsHidden()
                HexField(color: stops[i].color) { setColor(i, $0) }
                Slider(value: posBinding(i), in: 0...1) { editing in
                    // Re-sort on release (not per tick) so the gradient lookup
                    // gets a monotonic list and rows reorder if positions
                    // crossed — the web editor's behaviour.
                    if !editing { model.preset.gradient?.sort { $0.pos < $1.pos } }
                }
                if stops.count > 2 {
                    Button(role: .destructive) { remove(i) } label: { Image(systemName: "minus.circle") }
                        .buttonStyle(.borderless)
                }
            }
        }
        Button { add() } label: { Label("Add stop", systemImage: "plus") }
        HStack {
            TextField("Paste palette (hex list)", text: $paletteText)
                .font(.system(.caption, design: .monospaced)).autocorrectionDisabled().textInputAutocapitalization(.never)
            Button("Apply") { applyPalette() }.disabled(Hex.parseList(paletteText).count < 2)
        }
    }

    /// Pasted hex list → evenly-spread gradient (≥2, capped at 16).
    private func applyPalette() {
        let colors = Array(Hex.parseList(paletteText).prefix(16))
        guard colors.count >= 2 else { return }
        let n = colors.count
        model.preset.gradient = colors.enumerated().map { idx, c in
            GradientStop(pos: Double(idx) / Double(n - 1), color: c)
        }
        paletteText = ""
    }

    // The bindings guard the live array, not the body's copy: SwiftUI can still
    // read/write a row's bindings after remove()/applyPalette() shrank the stops.
    private func colorBinding(_ i: Int) -> Binding<Color> {
        Binding(get: {
            guard let g = model.preset.gradient, g.indices.contains(i) else { return .white }
            let c = g[i].color
            return Color(red: Double(c[0]) / 255, green: Double(c[1]) / 255, blue: Double(c[2]) / 255)
        }, set: { newColor in
            setColor(i, Self.rgb255(newColor))
        })
    }
    private func posBinding(_ i: Int) -> Binding<Double> {
        Binding(get: {
            guard let g = model.preset.gradient, g.indices.contains(i) else { return 0 }
            return g[i].pos
        }, set: {
            guard model.preset.gradient?.indices.contains(i) == true else { return }
            model.preset.gradient?[i].pos = $0
        })
    }
    private func setColor(_ i: Int, _ rgb: [Int]) {
        guard model.preset.gradient?.indices.contains(i) == true else { return }
        model.preset.gradient?[i].color = rgb
    }
    private func add() {
        var g = model.preset.gradient ?? []
        g.append(GradientStop(pos: 1.0, color: [255, 255, 255]))
        model.preset.gradient = g.sorted { $0.pos < $1.pos }
    }
    private func remove(_ i: Int) {
        guard var g = model.preset.gradient, g.indices.contains(i) else { return }
        g.remove(at: i); model.preset.gradient = g
    }
    /// SwiftUI Color → [r,g,b] in 0...255 via its resolved sRGB components.
    /// getRed returns *extended* sRGB, so wide-gamut (P3) picks can fall outside
    /// 0...1 — clamp, or the hex formatter and the shader get garbage.
    private static func rgb255(_ color: Color) -> [Int] {
        let ui = UIColor(color)
        var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
        _ = ui.getRed(&r, green: &g, blue: &b, alpha: &a)
        func channel(_ v: CGFloat) -> Int { Int(min(255, max(0, (v * 255).rounded()))) }
        return [channel(r), channel(g), channel(b)]
    }
}

/// Hex text field for one stop. Owns its text while focused so the model's
/// canonical "#rrggbb" echo never interrupts typing (a computed binding would
/// hijack 6-digit entry as soon as the first 3 digits parse as shorthand).
/// Valid input commits live; blur re-syncs to the canonical form — the web
/// editor's input/blur split.
private struct HexField: View {
    let color: [Int]
    let onColor: ([Int]) -> Void
    @State private var text = ""
    @FocusState private var focused: Bool

    var body: some View {
        TextField("#rrggbb", text: $text)
            .font(.system(.caption, design: .monospaced))
            .autocorrectionDisabled().textInputAutocapitalization(.never)
            .frame(width: 90)
            .focused($focused)
            .onAppear { text = Hex.string(color) }
            .onChange(of: color) { if !focused { text = Hex.string(color) } }
            .onChange(of: text) { if focused, let rgb = Hex.parse(text) { onColor(rgb) } }
            .onChange(of: focused) { if !focused { text = Hex.string(color) } }
    }
}