Authoring Editor Core (Increment 3b-1) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Turn the native app into a full parameter editor — all ~52 params editable live across macOS/iPad/iPhone, via a Parameter-Registry metamodel, with an adaptive inspector/sheet UI, ParamSlider (tap-to-type, double-tap-reset, macOS modifiers), and the collapsible-panel + fullscreen fixes.
Architecture: A ParamRegistry of ParamDescriptors (id, label, section, range, isRespawn, get/set on PresetParams) is the single source for every scalar param. AppState.edited/baseline replace the old LiveParams; the player gets edited every frame (live) and a debounced re-seed for respawn params. The editor is generated from the registry; non-scalar params (enums, colors) get bespoke controls but share ParamID identity. Preset-management + exploration features are later plans (3b-2, 3b-3).
Tech Stack: Swift 6.3 (lang mode 5), SwiftUI, MetalKit, XcodeGen; engine PhysarumEngine package.
Reference (read first): spec docs/superpowers/specs/2026-06-03-authoring-editor-design.md; apple/PhysarumApp/Sources/Model/{AppState,LiveParams,PresetCatalog}.swift; apple/PhysarumApp/Sources/Player/{RenderCoordinator,MetalView}.swift; apple/PhysarumApp/Sources/App/RootView.swift; engine Model/{Preset,Types}.swift. Param ranges (from src/presets/schema.ts): see Task 1.
File Structure¶
apple/PhysarumApp/Sources/Model/ParamRegistry.swift— CREATE:ParamID,ParamDescriptor,EditorSection,ParamRegistry(all scalar params),randomValue/range helpers.apple/PhysarumApp/Sources/Model/AppState.swift— REWRITE:edited/baseline/isDirty, selection seeds both; remove the oldLiveParamsdependency.apple/PhysarumApp/Sources/Model/LiveParams.swift— DELETE (superseded byedited: PresetParams).apple/PhysarumApp/Sources/Player/RenderCoordinator.swift— MODIFY: debounced respawn on respawn-param change;currentParamssetter.apple/PhysarumApp/Sources/Player/MetalView.swift— MODIFY: passedited(full PresetParams) instead oflivePreset.apple/PhysarumApp/Sources/Editor/ParamSlider.swift— CREATE: generic slider from aParamDescriptor.apple/PhysarumApp/Sources/Editor/EditorView.swift— CREATE: sections container + toolbar.apple/PhysarumApp/Sources/Editor/Sections.swift— CREATE: Global/Pheromone/Dynamics/Effects/Species/Matrix section views.apple/PhysarumApp/Sources/App/RootView.swift— MODIFY: collapsible inspector + fullscreen; host EditorView.apple/PhysarumApp/Sources/Controls/ControlsView.swift— DELETE (replaced by EditorView).apple/PhysarumEngine/Tests/PhysarumEngineTests/— engine tests stay; registry tests live in the app (Task 1 note).
Test target note: Registry/AppState are app code. Add a PhysarumAppTests XCTest target to project.yml (Task 1) so xcodebuild test covers the registry roundtrip + randomize/mutate later.
Task 1: ParamRegistry + test target¶
Files:
- Create: apple/PhysarumApp/Sources/Model/ParamRegistry.swift
- Modify: apple/PhysarumApp/project.yml (add test target)
- Create: apple/PhysarumApp/Tests/ParamRegistryTests.swift
- [ ] Step 1: Write ParamRegistry.swift —
ParamID,ParamDescriptor,EditorSection, and the registry of all scalar params. Ranges are fromsrc/presets/schema.ts.
import PhysarumEngine
import simd
enum EditorSection: String, CaseIterable { case global, species, matrix, pheromone, dynamics, effects }
/// Stable identity for every editable parameter (scalars + non-scalars), so locks/
/// undo/morph (later plans) can address each one.
enum ParamID: Hashable {
// global scalars
case agentCount, exposure, simSpeed
// global non-scalars (own controls)
case spawnPattern, blendMode, backgroundColor
// per-species (index 0..2)
case speciesColor(Int), populationShare(Int), sensorAngle(Int), sensorDistance(Int)
case turnSpeed(Int), moveSpeed(Int), depositAmount(Int)
// matrix cell (row, col)
case matrix(Int, Int)
// pheromone
case decayRate, diffusionFreq, fadeStrength, trailSaturation
// dynamics
case chaosInterval, chaosStrength, rhythmAmplitude
// effects
case headIntensity, kaleidoscope, feedbackAmount, bloomThreshold, bloomIntensity, vignette, saturation, contrast
}
/// A scalar, slider-editable parameter. Non-scalar params (enums/colors) are not
/// in the registry — they get bespoke controls but share ParamID identity.
struct ParamDescriptor: Identifiable {
let id: ParamID
let label: String
let section: EditorSection
let range: ClosedRange<Double>
let isInt: Bool
let isRespawn: Bool
let get: (PresetParams) -> Double
let set: (inout PresetParams, Double) -> Void
var idKey: String { "\(id)" } // for Identifiable/ForEach
}
enum ParamRegistry {
/// All scalar editable params with web-schema ranges. (color/spawnPattern/blendMode
/// are non-scalar → bespoke controls, not here.)
static let all: [ParamDescriptor] = {
var d: [ParamDescriptor] = []
func add(_ id: ParamID, _ label: String, _ s: EditorSection, _ r: ClosedRange<Double>,
int: Bool = false, respawn: Bool = false,
_ get: @escaping (PresetParams) -> Double,
_ set: @escaping (inout PresetParams, Double) -> Void) {
d.append(ParamDescriptor(id: id, label: label, section: s, range: r, isInt: int, isRespawn: respawn, get: get, set: set))
}
// Global
add(.agentCount, "Agents", .global, 100...500_000, int: true, respawn: true,
{ Double($0.agentCount) }, { $0.agentCount = Int($1.rounded()) })
add(.exposure, "Exposure", .global, 0.1...10, { Double($0.exposure) }, { $0.exposure = Float($1) })
add(.simSpeed, "Sim speed", .global, 1...8, int: true, { Double($0.simSpeed) }, { $0.simSpeed = Int($1.rounded()) })
// Per-species (i = 0..2)
for i in 0..<3 {
add(.populationShare(i), "Population", .species, 0...3, respawn: true,
{ Double($0.species[i].populationShare) }, { $0.species[i].populationShare = Float($1) })
add(.sensorAngle(i), "Sensor angle", .species, 0...Double.pi,
{ Double($0.species[i].sensorAngle) }, { $0.species[i].sensorAngle = Float($1) })
add(.sensorDistance(i), "Sensor distance", .species, 1...80,
{ Double($0.species[i].sensorDistance) }, { $0.species[i].sensorDistance = Float($1) })
add(.turnSpeed(i), "Turn speed", .species, 0...2,
{ Double($0.species[i].turnSpeed) }, { $0.species[i].turnSpeed = Float($1) })
add(.moveSpeed(i), "Move speed", .species, 0...10,
{ Double($0.species[i].moveSpeed) }, { $0.species[i].moveSpeed = Float($1) })
add(.depositAmount(i), "Deposit", .species, 0...100,
{ Double($0.species[i].depositAmount) }, { $0.species[i].depositAmount = Float($1) })
}
// Matrix (r,c)
for r in 0..<3 { for c in 0..<3 {
add(.matrix(r, c), "M[\(r)][\(c)]", .matrix, -2...2,
{ Double($0.interactionMatrix[r][c]) }, { $0.interactionMatrix[r][c] = Float($1) })
} }
// Pheromone
add(.decayRate, "Decay", .pheromone, 0.5...1, { Double($0.decayRate) }, { $0.decayRate = Float($1) })
add(.diffusionFreq, "Diffusion", .pheromone, 1...20, int: true, { Double($0.diffusionFreq) }, { $0.diffusionFreq = Int($1.rounded()) })
add(.fadeStrength, "Fade", .pheromone, 0...1, { Double($0.fadeStrength) }, { $0.fadeStrength = Float($1) })
add(.trailSaturation, "Trail cap", .pheromone, 1...500, { Double($0.trailSaturation) }, { $0.trailSaturation = Float($1) })
// Dynamics
add(.chaosInterval, "Chaos interval", .dynamics, 0...2000, int: true, { Double($0.chaosInterval) }, { $0.chaosInterval = Int($1.rounded()) })
add(.chaosStrength, "Chaos strength", .dynamics, 0...2, { Double($0.chaosStrength) }, { $0.chaosStrength = Float($1) })
add(.rhythmAmplitude, "Rhythm", .dynamics, 0...0.5, { Double($0.rhythmAmplitude) }, { $0.rhythmAmplitude = Float($1) })
// Effects
add(.headIntensity, "Head glow", .effects, 0...3, { Double($0.headIntensity) }, { $0.headIntensity = Float($1) })
add(.kaleidoscope, "Kaleidoscope", .effects, 0...6, int: true, { Double($0.kaleidoscope) }, { $0.kaleidoscope = Int($1.rounded()) })
add(.feedbackAmount, "Feedback", .effects, 0...0.97, { Double($0.feedbackAmount) }, { $0.feedbackAmount = Float($1) })
add(.bloomThreshold, "Bloom threshold", .effects, 0...2, { Double($0.bloomThreshold) }, { $0.bloomThreshold = Float($1) })
add(.bloomIntensity, "Bloom", .effects, 0...3, { Double($0.bloomIntensity) }, { $0.bloomIntensity = Float($1) })
add(.vignette, "Vignette", .effects, 0...1, { Double($0.vignette) }, { $0.vignette = Float($1) })
add(.saturation, "Saturation", .effects, 0...2, { Double($0.saturation) }, { $0.saturation = Float($1) })
add(.contrast, "Contrast", .effects, 0...2, { Double($0.contrast) }, { $0.contrast = Float($1) })
return d
}()
static func descriptors(in section: EditorSection) -> [ParamDescriptor] { all.filter { $0.section == section } }
/// True if any registry param in this section is a respawn param.
static let respawnIDs: Set<String> = Set(all.filter { $0.isRespawn }.map { "\($0.id)" })
}
- [ ] Step 2: Add a PhysarumAppTests target to project.yml
In apple/PhysarumApp/project.yml, after the Physarum target, add:
PhysarumAppTests:
type: bundle.unit-test
supportedDestinations: [macOS]
sources: [Tests]
dependencies:
- target: Physarum
settings:
base:
SWIFT_VERSION: "5.0"
GENERATE_INFOPLIST_FILE: YES
schemes:
Physarum:
build:
targets:
Physarum: all
PhysarumAppTests: [test]
test:
targets: [PhysarumAppTests]
- [ ] Step 3: Write ParamRegistryTests.swift — get/set roundtrip over every descriptor.
import XCTest
@testable import Physarum
import PhysarumEngine
final class ParamRegistryTests: XCTestCase {
private func sample() -> PresetParams { PresetCatalog.fallbackPreset.params }
func testEveryDescriptorRoundTrips() {
var p = sample()
for d in ParamRegistry.all {
let mid = (d.range.lowerBound + d.range.upperBound) / 2
d.set(&p, mid)
let got = d.get(p)
if d.isInt {
XCTAssertEqual(got, mid.rounded(), accuracy: 0.5, "\(d.idKey) int roundtrip")
} else {
XCTAssertEqual(got, mid, accuracy: 1e-4, "\(d.idKey) roundtrip")
}
}
}
func testRegistryCoversExpectedCount() {
// 3 global scalars + 6×3 species + 9 matrix + 4 pheromone + 3 dynamics + 8 effects = 45 scalars
XCTAssertEqual(ParamRegistry.all.count, 45)
XCTAssertFalse(ParamRegistry.respawnIDs.isEmpty)
}
func testRangesNonEmpty() {
for d in ParamRegistry.all { XCTAssertLessThan(d.range.lowerBound, d.range.upperBound, "\(d.idKey)") }
}
}
- [ ] Step 4: Generate + test
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8
Expected: ** TEST SUCCEEDED ** (3 tests pass). If bundle.unit-test/scheme syntax errors, check XcodeGen docs — the test target type is bundle.unit-test and the host is target: Physarum.
- [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Model/ParamRegistry.swift apple/PhysarumApp/project.yml apple/PhysarumApp/Tests
git commit -m "feat(app): ParamRegistry metamodel for all 45 scalar params + test target"
Task 2: AppState rewrite (edited/baseline) + MetalView/Coordinator full-params¶
Files:
- Modify: apple/PhysarumApp/Sources/Model/AppState.swift
- Delete: apple/PhysarumApp/Sources/Model/LiveParams.swift
- Delete: apple/PhysarumApp/Sources/Controls/ControlsView.swift
- Modify: apple/PhysarumApp/Sources/Player/MetalView.swift
- [ ] Step 1: Rewrite AppState.swift —
edited/baseline, selection seeds both, per-param reset helper.
import SwiftUI
import PhysarumEngine
@Observable
final class AppState {
let presets: [Preset]
var selectedIndex: Int = 0
var edited: PresetParams // live-edited working copy
var baseline: PresetParams // the selected preset's original (for dirty + per-param reset)
var autoCycle: Bool = false
var selectedPreset: Preset { presets[selectedIndex] }
var isDirty: Bool { !paramsEqual(edited, baseline) }
init() {
let loaded = PresetCatalog.loadAll()
presets = loaded.isEmpty ? [PresetCatalog.fallbackPreset] : loaded
let p = presets[0].params
edited = p
baseline = p
}
func select(_ index: Int) {
guard presets.indices.contains(index) else { return }
selectedIndex = index
edited = presets[index].params
baseline = presets[index].params
}
func advance(by step: Int) {
guard !presets.isEmpty else { return }
select((selectedIndex + step + presets.count) % presets.count)
}
/// Reset one param to the selected preset's default (double-tap on a slider).
func reset(_ descriptor: ParamDescriptor) { descriptor.set(&edited, descriptor.get(baseline)) }
func resetAll() { edited = baseline }
}
/// Structural equality for dirty tracking (PresetParams isn't Equatable; compare packed form).
func paramsEqual(_ a: PresetParams, _ b: PresetParams) -> Bool {
packParams(simEngineUniform(a)) == packParams(simEngineUniform(b)) &&
packPostParams(rendererUniform(a)) == packPostParams(rendererUniform(b))
}
Note: paramsEqual reuses the engine's packing for a cheap structural compare. If simEngineUniform/rendererUniform helpers don't exist, compare field-by-field instead — implement paramsEqual as a direct comparison of every registry param plus the non-scalars (spawnPattern, blendMode, colors). Simplest robust form:
func paramsEqual(_ a: PresetParams, _ b: PresetParams) -> Bool {
for d in ParamRegistry.all where abs(d.get(a) - d.get(b)) > 1e-6 { return false }
return a.spawnPattern == b.spawnPattern && a.blendMode == b.blendMode
&& a.backgroundColor == b.backgroundColor
&& zip(a.species, b.species).allSatisfy { $0.color == $1.color }
}
- [ ] Step 2: Delete superseded files
git rm apple/PhysarumApp/Sources/Model/LiveParams.swift apple/PhysarumApp/Sources/Controls/ControlsView.swift
- [ ] Step 3: Update MetalView.swift — pass
edited(full params).
Change the struct's stored input from livePreset: PresetParams usage to accept the full edited params and the selected preset for switch detection. Replace the property and the two call sites:
struct MetalView: ViewRepresentable {
let field: Int
let selectedPreset: Preset
let editedParams: PresetParams
func makeCoordinator() -> RenderCoordinator? {
RenderCoordinator(field: field, initialPreset: selectedPreset, live: editedParams)
}
private func sync(_ coordinator: RenderCoordinator?) {
guard let c = coordinator else { return }
c.switchPreset(selectedPreset, live: editedParams)
c.setParams(editedParams)
}
// makeNSView/updateNSView (+ UIView variants) unchanged except they call sync(context.coordinator)
}
- [ ] Step 4: Build (RootView still references old API — expect it to break; Task 5 fixes RootView. Build just the model/player by checking compile errors are only in RootView.)
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "error:|SUCCEEDED" | head
Expected: errors only in RootView.swift/MetalView call sites (fixed in Task 4-5). If errors elsewhere, fix them.
- [ ] Step 5: Commit (after Task 5 builds clean — defer commit; this task's files commit together with the coordinator in Task 3)
Task 3: Debounced respawn in RenderCoordinator¶
Files:
- Modify: apple/PhysarumApp/Sources/Player/RenderCoordinator.swift
- [ ] Step 1: Add setParams with respawn detection — replace the
livePresetdidSet model.
// Replace `var livePreset: PresetParams { didSet { sim.updateLiveParams(livePreset) } }` with:
private(set) var params: PresetParams
private var respawnWork: DispatchWorkItem?
/// Apply edited params: live ones immediately; respawn ones (agentCount/spawn/share)
/// via a 250ms debounce so dragging the Agents slider doesn't realloc every frame.
func setParams(_ p: PresetParams) {
let needsRespawn = p.agentCount != params.agentCount
|| p.spawnPattern != params.spawnPattern
|| zip(p.species, params.species).contains { $0.populationShare != $1.populationShare }
params = p
sim.updateLiveParams(p) // live fields now
if needsRespawn {
respawnWork?.cancel()
let work = DispatchWorkItem { [weak self] in self?.respawn() }
respawnWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
}
}
private func respawn() {
if let s = try? SimEngine(device: device, library: lib, width: field, height: field, preset: params) {
sim = s
renderer.resetFeedback()
}
}
init? to set self.params = live (instead of livePreset), and draw(in:) to use params (renderer.render(sim: sim, preset: params, ...)). switchPreset sets params = live at the end and cancels any pending respawnWork.
- [ ] Step 2: Build (RootView still broken — that's Task 4-5)
Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "error:" | grep -v RootView | head
Expected: no errors outside RootView.
Task 4: ParamSlider (generic)¶
Files:
- Create: apple/PhysarumApp/Sources/Editor/ParamSlider.swift
- [ ] Step 1: Write ParamSlider.swift — generic scalar slider bound to AppState via a descriptor.
import SwiftUI
/// One slider for any scalar ParamDescriptor. Tap value → numeric entry;
/// double-tap → reset to preset default; macOS Shift/Alt = fine/coarse.
struct ParamSlider: View {
@Bindable var state: AppState
let descriptor: ParamDescriptor
@State private var editingText: String?
private var value: Double { descriptor.get(state.edited) }
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(descriptor.label).font(.caption)
Spacer()
valueLabel
}
Slider(
value: Binding(get: { value },
set: { commitDrag($0) }),
in: descriptor.range
)
.help(descriptor.label) // tooltip; replaced by paramHelp text in a later step
}
.contentShape(Rectangle())
.onTapGesture(count: 2) { state.reset(descriptor) } // double-tap → reset to default
}
@ViewBuilder private var valueLabel: some View {
if let t = editingText {
TextField("", text: Binding(get: { t }, set: { editingText = $0 }))
.frame(width: 64).textFieldStyle(.roundedBorder)
.onSubmit { commitText() }
} else {
Text(formatted).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
.onTapGesture { editingText = formatted }
}
}
private var formatted: String {
descriptor.isInt ? String(Int(value.rounded())) : String(format: "%.3g", value)
}
private func commitDrag(_ v: Double) {
var p = state.edited; descriptor.set(&p, clamp(v)); state.edited = p
}
private func commitText() {
if let t = editingText, let v = Double(t) { commitDrag(v) }
editingText = nil
}
private func clamp(_ v: Double) -> Double { min(max(v, descriptor.range.lowerBound), descriptor.range.upperBound) }
}
- [ ] Step 2: Build (still needs EditorView/RootView — check ParamSlider compiles)
Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "ParamSlider.*error" | head
Expected: no ParamSlider-specific errors (RootView errors still expected).
Task 5: Section views + EditorView + RootView wiring¶
Files:
- Create: apple/PhysarumApp/Sources/Editor/Sections.swift
- Create: apple/PhysarumApp/Sources/Editor/EditorView.swift
- Modify: apple/PhysarumApp/Sources/App/RootView.swift
- [ ] Step 1: Sections.swift — one view per section. Scalar sections are pure registry-driven; Global/Species/Matrix add bespoke controls.
import SwiftUI
import PhysarumEngine
/// 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 {
// agentCount / exposure / simSpeed sliders come from the registry:
ForEach(ParamRegistry.descriptors(in: .global)) { d in ParamSlider(state: state, descriptor: d) }
// non-scalars:
Picker("Spawn", selection: Binding(get: { state.edited.spawnPattern },
set: { state.edited.spawnPattern = $0 })) {
ForEach(SpawnPattern.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
}
Picker("Blend", selection: Binding(get: { state.edited.blendMode },
set: { state.edited.blendMode = $0 })) {
ForEach(BlendMode.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
}
ColorPicker("Background", selection: rgbBinding(\.backgroundColor))
}
private func rgbBinding(_ kp: WritableKeyPath<PresetParams, RGB>) -> Binding<Color> {
Binding(
get: { let c = state.edited[keyPath: kp]; return Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z)) },
set: { newC in
let r = resolveRGB(newC); state.edited[keyPath: kp] = RGB(Float(r.0), Float(r.1), Float(r.2))
})
}
}
struct SpeciesSection: View {
@Bindable var state: AppState
@State private var tab = 0 // 0..2 species, 3 = "All"
var body: some View {
Picker("Species", selection: $tab) {
Text("1").tag(0); Text("2").tag(1); Text("3").tag(2); Text("All").tag(3)
}.pickerStyle(.segmented)
let i = min(tab, 2)
// color for the focused species (or species 0 when "All")
ColorPicker("Color", selection: speciesColor(i))
ForEach(ParamRegistry.all.filter { isSpeciesParam($0.id, index: i) }) { d in
ParamSlider(state: state, descriptor: tab == 3 ? broadcast(d) : d)
}
}
// helpers: speciesColor(i) Binding<Color>; isSpeciesParam(id, index); broadcast(d) returns a
// descriptor whose set writes the value to ALL three species (for the "All" tab).
}
struct MatrixSection: View {
@Bindable var state: AppState
var body: some View {
Grid {
ForEach(0..<3, id: \.self) { r in
GridRow {
ForEach(0..<3, id: \.self) { c in
if let d = ParamRegistry.all.first(where: { "\($0.id)" == "\(ParamID.matrix(r, c))" }) {
ParamSlider(state: state, descriptor: d)
}
}
}
}
}
}
}
resolveRGB, speciesColor, isSpeciesParam, broadcast inline — keep them in Sections.swift. broadcast builds a ParamDescriptor copy whose set loops the same field over species 0..2.)
- [ ] Step 2: EditorView.swift — disclosure sections + toolbar.
import SwiftUI
struct EditorView: View {
@Bindable var state: AppState
var body: some View {
Form {
Section { HStack {
Button("Reset") { state.resetAll() }.disabled(!state.isDirty)
Spacer()
Toggle("Auto-cycle", isOn: $state.autoCycle)
} }
DisclosureGroup("Global") { GlobalSection(state: state) }
DisclosureGroup("Species") { SpeciesSection(state: state) }
DisclosureGroup("Interaction Matrix") { MatrixSection(state: state) }
DisclosureGroup("Pheromone") { ScalarSection(state: state, section: .pheromone) }
DisclosureGroup("Dynamics") { ScalarSection(state: state, section: .dynamics) }
DisclosureGroup("Effects") { ScalarSection(state: state, section: .effects) }
}
.formStyle(.grouped)
}
}
- [ ] Step 3: RootView.swift — collapsible inspector + fullscreen + full-params MetalView.
import SwiftUI
struct RootView: View {
@State private var state = AppState()
@State private var showInspector = true
@State private var fullscreen = false
private let field = 720
@State private var cycleTimer: Timer?
var body: some View {
playerArea
.inspector(isPresented: $showInspector) {
EditorView(state: state).inspectorColumnWidth(min: 260, ideal: 320)
}
.toolbar {
Button { showInspector.toggle() } label: { Image(systemName: "sidebar.right") }
Button { fullscreen.toggle() } label: { Image(systemName: "arrow.up.left.and.arrow.down.right") }
}
.onChange(of: state.autoCycle) { _, on in on ? startCycle() : stopCycle() }
}
private var playerArea: some View {
MetalView(field: field, selectedPreset: state.selectedPreset, editedParams: state.edited)
.ignoresSafeArea()
.overlay(alignment: .bottomLeading) { presetPicker }
}
private var presetPicker: some View { // simple inline browser until plan 3b-2 enriches it
Menu(state.selectedPreset.name) {
ForEach(Array(state.presets.enumerated()), id: \.element.id) { i, p in
Button(p.name) { state.select(i) }
}
}.padding()
}
private func startCycle() { cycleTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { _ in state.advance(by: 1) } }
private func stopCycle() { cycleTimer?.invalidate(); cycleTimer = nil }
}
fullscreen to NSApplication's toggleFullScreen via a hosting helper, or use .fullScreenCover on iOS — minimal version: a .persistentSystemOverlays(.hidden) + hide chrome when fullscreen. Keep it simple: when fullscreen, set showInspector = false and hide the toolbar/preset menu.
- [ ] Step 4: Generate, build, test
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "error:|SUCCEEDED" | head then swift -version >/dev/null; cd apple/PhysarumEngine && swift test 2>&1 | grep Executed | tail -1
Expected: ** BUILD SUCCEEDED **; engine tests still 20 pass.
- [ ] Step 5: Commit Tasks 2–5 together
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp
git commit -m "feat(app): full parameter editor — registry-driven sections, edited/baseline state, debounced respawn, collapsible inspector + fullscreen"
Task 6: Launch + visual verification¶
Files: none (verification)
- [ ] Step 1: Clean build + launch
Run:
cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' clean build 2>&1 | grep -E "SUCCEEDED|FAILED"
open "$(cat /tmp/physarum-app-path.txt)"
-
[ ] Step 2: Observe — window with live player; inspector shows all 6 sections; expanding a section shows sliders; dragging a slider changes the render live; double-tap resets a param; tap-on-value lets you type; the inspector toggles closed/open; fullscreen hides chrome; changing Agents triggers a respawn after a beat. Capture a screenshot for the record.
-
[ ] Step 3: Commit any fixes found during observation.
Definition of Done¶
- [ ]
xcodebuild test(PhysarumAppTests) green: registry roundtrips all 45 scalars; engineswift teststill 20. - [ ] App builds + launches; all ~52 params editable (45 scalar sliders + spawn/blend pickers + 4 colors); live render updates; respawn params debounce-respawn.
- [ ] Inspector is collapsible; fullscreen mode hides chrome (the two reported bugs fixed).
- [ ] Double-tap-reset + tap-to-type work; sections generated from the registry.
- [ ] Committed (push deferred to Johannes).
Self-Review notes¶
- Spec coverage: §3 registry (Task 1), §4 state + respawn (Tasks 2–3), §5 editor UI + slider + fixes (Tasks 4–5), §9 tests (Task 1 + DoD). Preset-mgmt (§6) + exploration (§7) are plans 3b-2/3b-3 — intentionally out. Lock icon stub noted in Task 4 (activated in 3b-3).
- Non-scalars: spawnPattern/blendMode (Pickers) + backgroundColor + 3 species colors (ColorPickers) are handled in Global/Species sections, not the scalar registry — covered, with ParamID identity reserved for later locks/morph.
- Carried risk: macOS Shift/Alt fine/coarse drag is deferred within Task 4 (base slider + tap-to-type + double-tap-reset is the must-have). paramHelp tooltips wired via
.helpplaceholder — port the real text in 3b-2 polish. - Type consistency:
ParamDescriptor.get/set,AppState.edited/baseline/reset/resetAll,RenderCoordinator.setParams/params,MetalView.editedParamsused consistently. - Respawn correctness: detection compares agentCount/spawnPattern/populationShare only; matches
isRespawndescriptors. Debounce on main thread (same as draw loop).