#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