Zum Inhalt

apple/PhysarumApp/Sources/Model/AppState.swift

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
import SwiftUI
import PhysarumEngine

@Observable
final class AppState {
    private let store: PresetStore
    private let audioStore: AudioPresetStore
    /// The fixed-slot preset model (save-game style): the filled slots ARE the preset list, a
    /// slot's `Preset.id` is its slot id (`slot-NN`), and pins drive the ATV rotation. Persists +
    /// syncs via its backend (iCloud-KVS on iOS/tvOS, a local file on macOS / in tests).
    let slotStore: SlotStore
    /// Read-only access to the underlying preset store (e.g. for thumbnail resolution).
    var presetStore: PresetStore { store }
    /// Saved, switchable audio-reactivity configs (routes + gain + auto-gain). Persisted via
    /// `audioStore`, ordered by `_order.json`.
    var audioPresets: [AudioConfig]
    var selectedID: String
    var edited: PresetParams          // live-edited working copy (drives the engine)
    var baseline: PresetParams        // selected preset's original — for dirty + per-param reset
    var autoCycle: Bool = false
    var locked: Set<ParamID> = []
    /// Bumped when a thumbnail PNG is (re)written, so PresetCards reload.
    var thumbnailRevision = 0
    /// Set by RootView so the save flow can snapshot the live image.
    weak var snapshotProvider: LiveSnapshotProvider?
    var undoStack: [PresetParams] = []
    var redoStack: [PresetParams] = []
    let audio = AudioInput()
    /// Global, preset-independent audio-reactivity config — persisted, survives preset
    /// switch / undo / randomize. (Mic setup is a property of "how I drive it", not the look.)
    var audioReactive: AudioReactive {
        didSet { store.saveAudio(audioReactive) }
    }
    /// Loudness-response calibration constants (device-tuning). Global like `audioReactive`,
    /// persisted to its own file, never part of the preset/undo history. Defaults reproduce
    /// the shipped curve; only the DEBUG tuning rig mutates these.
    var audioTuning: AudioTuning {
        didSet { store.saveTuning(audioTuning) }
    }

    /// The preset list = the filled slots, in slot order. (No built-in/custom split — every slot
    /// is an equal, editable preset; some ship factory-filled, all overwritable.)
    var presets: [Preset] { slotStore.slots.filled }
    /// Never empty-crashes: prefer the selected slot, else the first filled slot, else the fallback.
    var selectedPreset: Preset {
        presets.first { $0.id == selectedID } ?? presets.first ?? PresetCatalog.fallbackPreset
    }
    /// The slot index of the current selection, or nil when the selection isn't a *filled* slot
    /// (parses `slot-NN` AND confirms the slot is non-empty — so SaveDialog never labels an empty
    /// slot "overwrite" after a remote clear).
    var selectedSlotIndex: Int? {
        PresetSlots.index(of: selectedID).flatMap { slotStore.slots.preset(at: $0) != nil ? $0 : nil }
    }
    /// The lowest empty slot, or nil when all 24 slots are filled.
    var firstEmptySlotIndex: Int? { slotStore.slots.firstEmptyIndex }
    var isDirty: Bool { !paramsEqual(edited, baseline) }

    // MARK: - pinning (drives the ATV lean-back rotation)

    var pinned: Set<String> { slotStore.slots.pinned }
    func isPinned(_ id: String) -> Bool { slotStore.slots.isPinned(id) }
    func togglePin(_ id: String) { slotStore.togglePin(id) }

    init(slotStore: SlotStore? = nil,
         store: PresetStore = PresetStore(),
         audioStore: AudioPresetStore = AudioPresetStore()) {
        self.store = store
        self.audioStore = audioStore
        // Default to an isolated in-memory store (tests + previews never touch iCloud / disk); the
        // app's scenes inject the real persisted store via `makeProductionSlotStore()`.
        self.slotStore = slotStore ?? SlotStore(backend: InMemorySlotBackend(), factory: FactorySlots.load())
        audioPresets = audioStore.loadAll()
        audioReactive = store.loadAudio() ?? AudioReactive(enabled: false, gain: 1, autoGain: true, routes: [])
        audioTuning = store.loadTuning() ?? AudioTuning()
        let filled = self.slotStore.slots.filled
        var first = filled.first ?? PresetCatalog.fallbackPreset
        #if DEBUG
        // Capture hook: boot a specific filled-slot preset via PHYSARUM_BOOT_PRESET=<index>
        // for deterministic App Store media capture. DEBUG-only — excluded from release builds.
        if let idx = AppState.bootPresetIndex(from: ProcessInfo.processInfo.environment, filledCount: filled.count) {
            first = filled[idx]
        }
        #endif
        selectedID = first.id
        edited = first.params
        baseline = first.params
        // Reconcile the selection/working copy + refresh thumbnails when another device changes the
        // store (iCloud sync) or the initial load lands.
        self.slotStore.onReload = { [weak self] in self?.handleSlotReload() }
    }

    #if DEBUG
    /// Parse `PHYSARUM_BOOT_PRESET` into a valid filled-slot index, or nil. Capture hook only.
    static func bootPresetIndex(from env: [String: String], filledCount: Int) -> Int? {
        guard let raw = env["PHYSARUM_BOOT_PRESET"], let i = Int(raw), i >= 0, i < filledCount else { return nil }
        return i
    }
    #endif

    /// Run after the slot store reloads (external iCloud change or initial load): reconcile the
    /// selection, then refresh thumbnails — bump the revision so content-addressed cards re-resolve,
    /// and backfill renders any newly-arrived / changed-content slots that lack a local PNG.
    private func handleSlotReload() {
        reconcileSelectionAfterReload()
        thumbnailRevision += 1
        backfillThumbnails()
    }

    /// After an external (iCloud) change reloaded the slots: if the selected slot vanished (cleared
    /// on another device), fall back to the first filled slot; if it still exists but its content
    /// changed and there are no pending local edits, refresh the working copy to the new content.
    /// Dirty local edits are kept (last-writer-wins per the spec — don't clobber an in-progress edit).
    private func reconcileSelectionAfterReload() {
        guard let current = presets.first(where: { $0.id == selectedID }) else {
            if let firstFilled = presets.first { select(id: firstFilled.id) }
            return
        }
        if !isDirty, !paramsEqual(current.params, baseline) {
            baseline = current.params
            edited = current.params
            undoStack.removeAll()   // the undo history referenced the pre-refresh params — drop it
            redoStack.removeAll()
        }
    }

    /// The production slot store: iCloud-KVS on iOS/tvOS (paid team + ubiquity-kvstore entitlement),
    /// a local JSON file on macOS — the Developer-ID/notarized Mac build has no embedded iCloud
    /// provisioning profile yet, so KVS is deferred there (spec-sanctioned fast-follow).
    static func makeProductionSlotStore() -> SlotStore {
        let factory = FactorySlots.load()
        #if os(macOS)
        let backend: SlotBackend = FileSlotBackend(url: slotsFileURL)
        #else
        let backend: SlotBackend = KVSSlotBackend()
        #endif
        return SlotStore(backend: backend, factory: factory)
    }

    /// Local fallback slot file (App Support), used by the macOS backend.
    static var slotsFileURL: URL {
        let base = (try? FileManager.default.url(for: .applicationSupportDirectory,
                                                 in: .userDomainMask, appropriateFor: nil, create: true))
            ?? URL(fileURLWithPath: NSTemporaryDirectory())
        let dir = base.appendingPathComponent("Physarum", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir.appendingPathComponent("slots.json")
    }

    func select(id: String) {
        guard let p = presets.first(where: { $0.id == id }) else { return }
        selectedID = id
        edited = p.params
        baseline = p.params
        undoStack.removeAll()
        redoStack.removeAll()
    }

    /// Cycle the selection by ±step over the ATV rotation (pinned slots; nothing pinned → all
    /// filled). Pins curate the auto-cycle on every platform. When the current selection isn't in
    /// the rotation (an unpinned slot while a pinned subset exists), enter the rotation at its start
    /// (forward) or end (backward) rather than landing off-by-one.
    func advance(by step: Int) {
        let list = slotStore.slots.rotation
        guard !list.isEmpty else { return }
        let i: Int
        if let current = list.firstIndex(where: { $0.id == selectedID }) {
            i = (current + step + list.count) % list.count
        } else {
            i = step > 0 ? 0 : list.count - 1
        }
        select(id: list[i].id)
    }

    func reset(_ descriptor: ParamDescriptor) { descriptor.set(&edited, descriptor.get(baseline)) }
    func resetAll() { pushUndo(); edited = baseline }

    /// True if `descriptor` differs from the selected preset's baseline (per-param dirty).
    func isModified(_ d: ParamDescriptor) -> Bool { abs(d.get(edited) - d.get(baseline)) > 1e-6 }

    func resetSpawnPattern()    { pushUndo(); edited.spawnPattern = baseline.spawnPattern }
    func resetBlendMode()       { pushUndo(); edited.blendMode = baseline.blendMode }
    func resetBackgroundColor() { pushUndo(); edited.backgroundColor = baseline.backgroundColor }
    func resetSpeciesColor(_ i: Int) { pushUndo(); edited.species[i].color = baseline.species[i].color }

    /// Non-scalar dirty checks (mirror `isModified` for enum/colour controls).
    var spawnModified: Bool { edited.spawnPattern != baseline.spawnPattern }
    var blendModified: Bool { edited.blendMode != baseline.blendMode }
    var backgroundModified: Bool { edited.backgroundColor != baseline.backgroundColor }
    func speciesColorModified(_ i: Int) -> Bool { edited.species[i].color != baseline.species[i].color }

    // MARK: - slot operations (save = pick a slot)

    /// Save the current live edits into `index` (overwrite a filled slot or fill an empty one). The
    /// slot's preset id becomes its slot id; the slot is marked user-owned (so an update never
    /// clobbers it); a fresh thumbnail is rendered locally (thumbnails are device-local, not synced).
    func saveToSlot(index: Int, name: String, tags: [String], tagSignal: SignalName) {
        var p = selectedPreset
        p.name = name.isEmpty ? "Untitled" : name
        p.author = nil
        p.builtIn = false
        p.tags = tags
        p.tagSignal = tagSignal
        p.thumbnail = nil
        p.params = edited
        slotStore.save(p, toIndex: index)        // stamps p.id = slot id, strips thumbnail, marks owned
        selectedID = PresetSlots.slotID(index)
        baseline = edited
        generateThumbnail(params: edited)
    }

    /// Empty a slot (drops its pin). If it held the selection, fall back to the first filled slot.
    /// (Thumbnails are content-addressed and orphan-pruned by `backfillThumbnails`, so a reused slot
    /// never shows a previous occupant's render — its new content has a different key.)
    func clearSlot(slotID: String) {
        slotStore.clear(slotID: slotID)
        if selectedID == slotID, let first = presets.first { select(id: first.id) }
    }

    /// Import a preset into the lowest empty slot (slot-aware replacement for the old custom import).
    /// Re-id'd to its slot id, forced non-builtin, author/thumbnail cleared, marked user-owned.
    /// Returns false when every slot is full (caller surfaces it rather than dropping the file).
    @discardableResult
    func importIntoSlot(_ incoming: Preset) -> Bool {
        // Prefer an empty slot; if the grid is full of factory defaults, displace the lowest one
        // (recoverable via Reset). Only fail when every slot is the user's own work.
        guard let i = slotStore.slots.firstEmptyIndex ?? slotStore.slots.firstNonUserOwnedIndex else { return false }
        var p = incoming
        p.author = nil
        p.builtIn = false
        p.thumbnail = nil
        slotStore.save(p, toIndex: i)
        generateThumbnail(params: p.params)
        return true
    }

    /// Reset every slot to its factory default (destructive; clears user edits + pins, and — on the
    /// app's synced backend — propagates to every device). Re-selects the first filled slot.
    func resetAllToFactory() {
        slotStore.resetToFactory()   // factory slots resolve curated bundle art; user renders get orphan-pruned
        if let first = presets.first { select(id: first.id) }
    }

    /// Prune the store to just the pinned presets, compacted to the front (the kept set stays
    /// pinned, so the ATV rotation becomes exactly that set). No-op when nothing is pinned. Slot
    /// thumbnails are cleared first (ids are reused); factory-derived kept slots re-resolve curated
    /// art immediately, user slots re-render via backfill. Re-selects the first surviving slot.
    func keepOnlyPinned() {
        guard !pinned.isEmpty else { return }
        for i in 0..<PresetSlots.count { store.deleteThumbnail(for: PresetSlots.slotID(i)) }
        slotStore.keepOnlyPinned()
        if let first = presets.first { select(id: first.id) }
    }

    /// One-shot migration of the pre-slot custom presets (the old `PresetStore` JSONs) into the
    /// lowest empty slots (marked user-owned, so a later factory update never clobbers them). Runs
    /// once per device, guarded by a `UserDefaults` flag. Overflow (more legacy customs than free
    /// slots) is exported rather than dropped. Called by the production scenes after construction;
    /// `defaults` is injectable so tests neither read the real customs nor flip the global flag.
    func migrateLegacyCustomsIfNeeded(defaults: UserDefaults = .standard) {
        // Defer past the initial iCloud sync: migrating + persisting before the cloud has downloaded
        // would clobber the account's synced slots with this device's local customs.
        slotStore.whenLoaded { [weak self] in self?.performLegacyMigration(defaults: defaults) }
    }

    private func performLegacyMigration(defaults: UserDefaults) {
        let key = "slotMigrationDone"
        guard !defaults.bool(forKey: key) else { return }
        let legacy = store.loadCustom()
        if !legacy.isEmpty {
            let overflow = slotStore.migrate(customs: legacy)
            if !overflow.isEmpty, let data = try? JSONEncoder().encode(overflow) {
                try? data.write(to: store.legacyExportURL)
            }
        }
        defaults.set(true, forKey: key)
    }

    /// Push pending slot changes to the backend. iCloud-KVS pushes most reliably when the app
    /// backgrounds, so the scenes call this on `scenePhase` leaving `.active`.
    func flushSync() { slotStore.flush() }

    // MARK: - thumbnails

    /// Render warm-start thumbnails for filled slots that have NO resolvable thumbnail (neither a
    /// local render of the current content nor curated bundled art), one at a time (off-main,
    /// serialized) so opening the library never blocks and never floods the GPU. Thumbnails are
    /// content-addressed, so a slot whose params changed (edited OR synced from another device) has a
    /// new key with no PNG yet → it gets re-rendered here. Also prunes orphaned PNGs from prior content.
    func backfillThumbnails() {
        let store = self.store
        let filled = slotStore.slots.filled
        let keep = Set(filled.map { PresetCatalog.thumbnailKey(for: $0) })
        let missing = filled.filter { PresetCatalog.thumbnailURL(for: $0, store: store) == nil }
        Task.detached(priority: .background) {
            store.pruneThumbnails(keeping: keep)
            for p in missing {
                guard let png = try? ThumbnailRenderer.render(.warmStart(p.params)) else { continue }
                try? store.writeThumbnail(png, for: PresetCatalog.thumbnailKey(for: p))
                await MainActor.run { self.thumbnailRevision += 1 }
            }
        }
    }

    /// Render + persist a thumbnail for the given params, off-main, keyed by their content hash. Live
    /// snapshot if available, else a fresh warm-start. Bumps `thumbnailRevision` on completion.
    func generateThumbnail(params: PresetParams) {
        let key = PresetCatalog.thumbnailKey(forParams: params)
        let source: ThumbnailSource = (snapshotProvider?.takeSnapshot()).map { .liveSnapshot($0) }
            ?? .warmStart(params)
        let store = self.store
        Task.detached(priority: .utility) { [self] in
            guard let png = try? ThumbnailRenderer.render(source) else { return }
            try? store.writeThumbnail(png, for: key)
            await MainActor.run { self.thumbnailRevision += 1 }
        }
    }

    // MARK: - audio wiring

    static let defaultAudioRoutes: [AudioRoute] = [
        AudioRoute(source: .bass, target: .bloomIntensity, depth: 1.2, mode: .transient),
        AudioRoute(source: .bass, target: .moveSpeed,      depth: 0.8, mode: .transient),
        AudioRoute(source: .high, target: .exposure,       depth: 0.6, mode: .transient),
        AudioRoute(source: .mid,  target: .chaosStrength,  depth: 0.7, mode: .transient),
        AudioRoute(source: .mid,  target: .saturation,     depth: 0.4, mode: .level),
    ]

    func applyDefaultAudioWiring() {
        audioReactive = AudioReactive(enabled: true, gain: 1, autoGain: true, routes: Self.defaultAudioRoutes)
    }

    /// Restore the loudness-response constants to the shipped defaults (DEBUG tuning rig).
    func resetTuning() { audioTuning = AudioTuning() }

    /// Toggle audio. On the FIRST enable (routes empty), auto-apply the default routes
    /// + autoGain so it reacts instantly without the user hunting for the Default button.
    /// Audio config is global (not part of the preset/undo history).
    func setAudioEnabled(_ on: Bool) {
        var a = audioReactive
        if on && a.routes.isEmpty {
            a.routes = Self.defaultAudioRoutes
            a.autoGain = true
        }
        a.enabled = on
        audioReactive = a   // single assignment → one persistence write
    }

    // MARK: - audio presets (saveable, switchable configs)

    /// Snapshot the current `audioReactive` as a named, persisted config.
    func saveAudioPreset(name: String) {
        let id = "audio-\(slug(name))-\(Int(Date().timeIntervalSince1970))"
        try? audioStore.save(AudioConfig(id: id, name: name.isEmpty ? "Audio" : name, reactive: audioReactive))
        audioPresets = audioStore.loadAll()
    }

    /// Apply a saved config to the live `audioReactive` (single assignment → one
    /// persistence write via `didSet`). No-op for an unknown id.
    func applyAudioPreset(id: String) {
        guard let cfg = audioPresets.first(where: { $0.id == id }) else { return }
        audioReactive = cfg.reactive
    }

    /// Add an imported audio config as a fresh, persisted preset: always re-id'd (so
    /// re-importing the same file never collides). Persists via `audioStore` and reloads.
    func importAudioConfig(_ incoming: AudioConfig) {
        var c = incoming
        c.id = "audio-\(slug(incoming.name))-\(Int(Date().timeIntervalSince1970))"
        try? audioStore.save(c)
        audioPresets = audioStore.loadAll()
    }

    func deleteAudioPreset(id: String) {
        audioStore.delete(id)
        audioPresets = audioStore.loadAll()
    }

    func reorderAudioPreset(id: String, up: Bool) {
        audioStore.reorder(reorderedIDs(audioPresets.map { $0.id }, moving: id, up: up))
        audioPresets = audioStore.loadAll()
    }

    private func slug(_ s: String) -> String {
        let lower = s.lowercased()
        let mapped = lower.map { ch -> Character in ch.isLetter || ch.isNumber ? ch : "-" }
        let collapsed = String(mapped).split(separator: "-").joined(separator: "-")
        return collapsed.isEmpty ? "preset" : collapsed
    }
}

/// Move `id` one slot up/down within `ids`, returning the new order. No-op at the
/// boundary or for an unknown id.
func reorderedIDs(_ ids: [String], moving id: String, up: Bool) -> [String] {
    guard let i = ids.firstIndex(of: id) else { return ids }
    let j = up ? i - 1 : i + 1
    guard ids.indices.contains(j) else { return ids }
    var r = ids
    r.swapAt(i, j)
    return r
}

/// Structural equality for dirty tracking (PresetParams isn't Equatable): compare
/// every registry scalar plus the non-scalar params.
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 }
}