Zum Inhalt

apple/PhysarumApp/Sources/Editor/Sections.swift

#if !os(tvOS)
import SwiftUI
import PhysarumEngine

// MARK: - shared helpers

/// A ColorPicker bound to an engine RGB (SIMD3<Float>, sRGB 0…1).
struct RGBColorPicker: View {
    let title: String
    @Binding var rgb: RGB
    @Environment(\.self) private var env

    var body: some View {
        ColorPicker(title, selection: Binding(
            get: { Color(red: Double(rgb.x), green: Double(rgb.y), blue: Double(rgb.z)) },
            set: { c in
                let r = c.resolve(in: env)
                rgb = RGB(r.red, r.green, r.blue)
            }
        ))
    }
}

/// Trailing ↩ reset affordance for a non-scalar control, matching ParamSlider's
/// per-row reset button (plain, 11pt, fgTertiary). Shown only by the caller when modified.
@ViewBuilder
func resetButton(_ name: String, action: @escaping () -> Void) -> some View {
    Button(action: action) {
        Image(systemName: "arrow.uturn.backward").font(.system(size: 11))
            .foregroundStyle(Theme.Colors.fgTertiary)
    }
    .buttonStyle(.plain)
    .accessibilityLabel("Reset \(name) to preset")
}

// MARK: - species selector helpers

/// Species selector mapping. `nil` == "All" (broadcast). Concrete 0..2 focus one species.
func speciesFocus(_ selected: Int?) -> Int { selected ?? 0 }
func speciesIsBroadcast(_ selected: Int?) -> Bool { selected == nil }
/// Pheromone-channel identity label for a species index (deposit shader maps 0→R,1→G,2→B).
func speciesChannelLabel(_ i: Int) -> String { ["R", "G", "B"][i] }

extension Color {
    /// Initialise from an engine RGB (SIMD3<Float>, sRGB 0…1).
    init(rgb c: RGB) { self = Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z)) }
}

/// A `#RRGGBB` string for an engine RGB (sRGB 0…1), for VoiceOver to announce a
/// colour control's current value.
func rgbHex(_ c: RGB) -> String {
    func chan(_ v: Float) -> Int { Int((min(max(v, 0), 1) * 255).rounded()) }
    return String(format: "#%02X%02X%02X", chan(c.x), chan(c.y), chan(c.z))
}

/// The species index a ParamID refers to, or nil for non-species params.
func speciesIndex(of id: ParamID) -> Int? {
    switch id {
    case let .populationShare(i), let .sensorAngle(i), let .sensorDistance(i),
         let .turnSpeed(i), let .moveSpeed(i), let .depositAmount(i),
         let .speciesColor(i):
        return i
    default:
        return nil
    }
}

/// A compact, best-practice dropdown label for a non-scalar enum control: the current
/// value on one truncating line + a menu chevron, full-width with a subtle background.
/// Replaces the default `.menu` Picker whose value label rendered oversized and wrapped
/// char-by-char in a narrow panel.
func enumDropdownLabel(_ text: String) -> some View {
    HStack(spacing: Theme.Space.s2) {
        Text(text)
            .font(Theme.Fonts.body)
            .foregroundStyle(Theme.Colors.fgPrimary)
            .lineLimit(1).truncationMode(.tail)
        Spacer(minLength: 0)
        Image(systemName: "chevron.up.chevron.down")
            .font(.system(size: 11))
            .foregroundStyle(Theme.Colors.fgTertiary)
    }
    .padding(.horizontal, Theme.Space.s3)
    .padding(.vertical, Theme.Space.s2)
    .background(Theme.Colors.overlay, in: RoundedRectangle(cornerRadius: Theme.Radius.md))
    .contentShape(RoundedRectangle(cornerRadius: Theme.Radius.md))
}

// MARK: - sections

/// Pure registry-driven slider list for a section.
struct ScalarSection: View {
    @Bindable var state: AppState
    let section: EditorSection
    var body: some View {
        ForEach(ParamRegistry.descriptors(in: section)) { d in
            ParamSlider(state: state, descriptor: d)
        }
    }
}

struct GlobalSection: View {
    @Bindable var state: AppState
    var body: some View {
        ForEach(ParamRegistry.descriptors(in: .global)) { d in
            ParamSlider(state: state, descriptor: d)
        }
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HStack {
                HelpLabel("Spawn", help: ParamHelp.text(for: "Spawn pattern"))
                if state.spawnModified { resetButton("spawn pattern") { state.resetSpawnPattern() } }
            }
            HStack {
                #if os(macOS)
                // Native macOS pop-up button (HIG). The custom chrome below is only for
                // narrow iOS/iPad panels, where a native .menu label char-wraps.
                Picker("", selection: Binding(
                    get: { state.edited.spawnPattern },
                    set: { state.edited.spawnPattern = $0 })) {
                    ForEach(SpawnPattern.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
                }
                .labelsHidden().pickerStyle(.menu).frame(maxWidth: .infinity)
                .accessibilityLabel("Spawn pattern")
                .accessibilityHint("How agents are placed when seeding")
                .accessibilityValue(state.edited.spawnPattern.rawValue.capitalized)
                #else
                Menu {
                    Picker("", selection: Binding(
                        get: { state.edited.spawnPattern },
                        set: { state.edited.spawnPattern = $0 })) {
                        ForEach(SpawnPattern.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
                    }
                } label: {
                    enumDropdownLabel(state.edited.spawnPattern.rawValue.capitalized)
                }
                .accessibilityLabel("Spawn pattern")
                .accessibilityHint("How agents are placed when seeding")
                .accessibilityValue(state.edited.spawnPattern.rawValue.capitalized)
                #endif
                LockButton(state: state, id: .spawnPattern)
                    .accessibilityLabel("Lock spawn pattern")
                    .accessibilityHint("Keep randomize/mutate from changing this")
                    .accessibilityValue(A11y.locked(state.isLocked(.spawnPattern)))
            }
        }
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HStack {
                HelpLabel("Blend", help: ParamHelp.text(for: "Blend mode"))
                if state.blendModified { resetButton("blend mode") { state.resetBlendMode() } }
            }
            HStack {
                #if os(macOS)
                Picker("", selection: Binding(
                    get: { state.edited.blendMode },
                    set: { state.edited.blendMode = $0 })) {
                    ForEach(BlendMode.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
                }
                .labelsHidden().pickerStyle(.menu).frame(maxWidth: .infinity)
                .accessibilityLabel("Blend mode")
                .accessibilityHint("How agents composite over the background")
                .accessibilityValue(state.edited.blendMode.rawValue.capitalized)
                #else
                Menu {
                    Picker("", selection: Binding(
                        get: { state.edited.blendMode },
                        set: { state.edited.blendMode = $0 })) {
                        ForEach(BlendMode.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
                    }
                } label: {
                    enumDropdownLabel(state.edited.blendMode.rawValue.capitalized)
                }
                .accessibilityLabel("Blend mode")
                .accessibilityHint("How agents composite over the background")
                .accessibilityValue(state.edited.blendMode.rawValue.capitalized)
                #endif
                LockButton(state: state, id: .blendMode)
                    .accessibilityLabel("Lock blend mode")
                    .accessibilityHint("Keep randomize/mutate from changing this")
                    .accessibilityValue(A11y.locked(state.isLocked(.blendMode)))
            }
        }
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HStack {
                HelpLabel("Background", help: ParamHelp.text(for: "Background color"))
                if state.backgroundModified { resetButton("background color") { state.resetBackgroundColor() } }
            }
            HStack {
                RGBColorPicker(title: "",
                               rgb: Binding(get: { state.edited.backgroundColor },
                                            set: { state.edited.backgroundColor = $0 }))
                    .labelsHidden()
                    .accessibilityLabel("Background color")
                    .accessibilityHint("Colour of the empty void")
                    .accessibilityValue(rgbHex(state.edited.backgroundColor))
                LockButton(state: state, id: .backgroundColor)
                    .accessibilityLabel("Lock background color")
                    .accessibilityHint("Keep randomize/mutate from changing this")
                    .accessibilityValue(A11y.locked(state.isLocked(.backgroundColor)))
            }
        }
    }
}

struct SpeciesSection: View {
    @Bindable var state: AppState
    /// nil = "All" (broadcast), 0/1/2 = focused species index (R/G/B).
    @State private var selected: Int? = nil

    var body: some View {
        Picker("Species", selection: $selected) {
            Text("All").tag(Int?.none)
            speciesTab(0); speciesTab(1); speciesTab(2)
        }
        .pickerStyle(.segmented)
        .accessibilityLabel("Species")
        .accessibilityHint("Pick which species to edit (All/R/G/B)")
        .accessibilityValue(selected.map(speciesChannelLabel) ?? "All")

        let focus = speciesFocus(selected)
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HStack {
                HelpLabel("Color", help: ParamHelp.text(for: "Species color"))
                if speciesIsBroadcast(selected) {
                    // In broadcast mode show reset if any species color is modified.
                    if (0..<3).contains(where: { state.speciesColorModified($0) }) {
                        resetButton("species color") { for i in 0..<3 { state.resetSpeciesColor(i) } }
                    }
                } else {
                    if state.speciesColorModified(focus) {
                        resetButton("species color") { state.resetSpeciesColor(focus) }
                    }
                }
            }
            HStack {
                RGBColorPicker(title: "",
                               rgb: Binding(get: { state.edited.species[focus].color },
                                            set: { newC in
                                                if speciesIsBroadcast(selected) {
                                                    for i in 0..<3 { state.edited.species[i].color = newC }
                                                } else {
                                                    state.edited.species[focus].color = newC
                                                }
                                            }))
                    .labelsHidden()
                    .accessibilityLabel("Species color")
                    .accessibilityHint("Deposit colour for this species")
                    .accessibilityValue(rgbHex(state.edited.species[focus].color))
                LockButton(state: state, id: .speciesColor(focus))
                    .accessibilityLabel("Lock species color")
                    .accessibilityHint("Keep randomize/mutate from changing this")
                    .accessibilityValue(A11y.locked(state.isLocked(.speciesColor(focus))))
            }
        }
        ForEach(speciesDescriptors(focus)) { d in
            ParamSlider(state: state, descriptor: speciesIsBroadcast(selected) ? broadcast(d) : d)
        }
    }

    @ViewBuilder
    private func speciesTab(_ i: Int) -> some View {
        Label {
            Text(speciesChannelLabel(i))
        } icon: {
            Circle()
                .fill(Color(rgb: state.edited.species[i].color))
                .frame(width: 9, height: 9)
        }
        .labelStyle(.titleAndIcon)
        .tag(Int?.some(i))
    }

    private func speciesDescriptors(_ i: Int) -> [ParamDescriptor] {
        ParamRegistry.descriptors(in: .species).filter { speciesIndex(of: $0.id) == i }
    }

    /// A descriptor whose set writes the value to the same param on all 3 species
    /// (matched by label). get stays the focused species (for display).
    private func broadcast(_ d: ParamDescriptor) -> ParamDescriptor {
        let analogs = ParamRegistry.descriptors(in: .species).filter { $0.label == d.label }
        return ParamDescriptor(
            id: d.id, label: d.label, section: d.section, range: d.range,
            isInt: d.isInt, isRespawn: d.isRespawn,
            get: d.get,
            set: { p, v in for a in analogs { a.set(&p, v) } },
            tooltip: d.tooltip
        )
    }
}

struct MatrixSection: View {
    @Bindable var state: AppState
    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s3) {
            ForEach(0..<3, id: \.self) { r in
                VStack(alignment: .leading, spacing: Theme.Space.s1) {
                    HelpLabel("\(speciesChannelLabel(r)) senses…", help: ParamHelp.matrix)
                    ForEach(0..<3, id: \.self) { c in
                        if let d = ParamRegistry.all.first(where: { $0.id == .matrix(r, c) }) {
                            ParamSlider(state: state, descriptor: d,
                                        displayLabel: "→ \(speciesChannelLabel(c))")
                        }
                    }
                }
            }
        }
    }
}
#endif