Zum Inhalt

Physarum Native — Finishing Touches Design Spec

Status: Approved · 2026-06-05 · author: Johannes (brainstormed with Claude) Builds on: the native authoring app. Three small, independent polish items, ported from web precedent, bundled into one increment (one spec, one plan, sequenced).

1. Kontext & Ziel

Drei Feinschliff-Reste aus der Roadmap, alle mit Web-Präzedenz und großteils schon vorhandener nativer Infrastruktur: 1. Slider Shift/Alt fine/coarse (macOS) — präzises Nudging per Scroll-Wheel + Pfeiltasten. 2. Rename/Reorder für Custom-Presets — Verwaltung in der Library. 3. paramHelp für Non-Scalar-Controls — Tooltips auf Pickern/Toggles/Color, nicht nur Slidern.

2. Festgezurrte Entscheidungen (Brainstorming 2026-06-05)

Achse Entscheidung
Slider-Modifier (#1) macOS-only (iOS hat keine Modifier). Scroll-Wheel (hover→scroll nudged) UND Pfeiltasten (fokussierter Slider, ←/→, Home/End). Shift=fein (×0.1), Option/Alt=grob (×10). Drag bleibt unangetastet (nativer SwiftUI-Slider). tap-to-type + Doppeltap-Reset bleiben.
Custom-Preset-Mgmt (#2) Context-Menü auf der PresetCard (Rename / Move Up / Move Down / Delete) statt Overlay-Buttons — idiomatischer, kein Thumbnail-Clutter, nutzt die bestehende Delete-Context-Menü-Naht. Backend ist schon da (AppState.rename(id:to:), PresetStore.reorder(ids:), Order in _order.json).
Rename-UI (#2) Modal RenameDialog (analog SaveDialog), ein Name-Feld + Cancel/Rename.
Non-Scalar-Help (#3) Wiederverwendbares HelpLabel (Label + inline-ⓘ + Popover + macOS-.help + .accessibilityHint), aus ParamSliders ⓘ-Muster extrahiert; ParamSlider auf HelpLabel refactored (DRY). Non-Scalar-Hilfetexte aus src/ui/paramHelp.ts portiert.
Preset-Mgmt vs Undo Reorder/Rename sind Store-Ops, keine Param-Edits → kein pushUndo (der Undo-Stack ist nur für edited-Params).

3. #1 — Slider-Präzision (macOS)

Pure, testbare Kernfunktion — Sources/Editor/SliderPrecision.swift:

/// The value delta for one fine/coarse precision step. base = baseStep ?? range/100.
/// fine → ×0.1, coarse → ×10 (web wheel/arrow parity). Neither → ×1.
func precisionStep(range: ClosedRange<Double>, baseStep: Double?, fine: Bool, coarse: Bool) -> Double
View-Modifier (macOS only; iOS = no-op passthrough) attached to ParamSlider: - A ScrollWheelCatcher (NSViewRepresentable) overlaid on the slider row that reads scrollWheel events + NSEvent.modifierFlags and calls back with the scroll direction + fine/coarse flags. It consumes the event over the slider (so the editor list doesn't also scroll — web-parity hover capture). - .focusable() + .onKeyPress for ←/→ (±step), Home/End (min/max), reading KeyPress.modifiers for .shift/.option. - Each interaction clamps to range, calls onEditStart once at gesture/keypress-sequence start (→ state.pushUndo()), then commits via the existing value binding. - Applied to ParamSlider (the main tuning surface). The audio gain/depth sliders may opt in later.

Test: precisionStep — base = range/100 when baseStep nil; fine = base×0.1; coarse = base×10; neither = base. (The NSView/keypress wiring is build-verified + Johannes-manual on macOS.)

4. #2 — Custom-Preset Rename + Reorder

Backend exists; this is UI wiring + one small store helper. - AppState.moveCustom(id:up:) — compute the new custom-ID order from customs (swap with the up/down neighbour, no-op at the boundary), call store.reorder(newIDs), reload customs. Pure reorder logic factored as a testable helper reorderedIDs(_ ids:[String], moving id:String, up:Bool) -> [String]. - RenameDialog.swift — a sheet (mirroring SaveDialog): a single name TextField (trim, max 64 chars, non-empty), Cancel + Rename. Commits via the existing AppState.rename(id:to:). - LibraryView context menu — extend the existing custom-preset .contextMenu (currently Delete only) with: Rename (sets presetToRename → .sheet(item:) presenting RenameDialog), Move Up (disabled at top), Move Down (disabled at bottom), then Delete. Move actions call state.moveCustom(id:up:). - Test: reorderedIDs — move up/down swaps correctly; top-move-up and bottom-move-down are no-ops; unknown id is a no-op.

5. #3 — paramHelp for non-scalar controls

  • Sources/UI/HelpLabel.swift — HelpLabel(_ label: String, help: String, font: Font = Theme.Fonts.label): the HStack { Text(label); if !help.isEmpty { ⓘ } } with @State showHelp, tap→.popover (presentationCompactAdaptation(.popover), ~260pt), macOS .help(help), .accessibilityHint(help). Refactor ParamSlider to use HelpLabel for its label (DRY; identical behaviour).
  • Extend ParamHelp with non-scalar entries ported from src/ui/paramHelp.ts: "Spawn pattern", "Blend mode", "Background color", "Species color", "Audio reactive", "Auto-gain", "Input gain" (verbatim web texts).
  • Wrap the non-scalar control rows with HelpLabel (label above the control in a VStack): GlobalSection (Spawn, Blend, Background), SpeciesSection (Species color), AudioReactiveSection (Enabled→"Audio reactive", Auto-gain, Gain→"Input gain"). The Format picker keeps its existing inline .help (out of scope to reroute). Route source/target/depth controls are native-only with no web text → no help (keep their .accessibilityLabels from the audio feature).
  • Test: ParamHelp.text(for:) returns the new non-scalar texts (non-empty, exact for a couple).

6. Increments (Scope in)

  • Inc 5a — Slider precision: precisionStep (pure, tested) + the macOS scroll/keypress modifier on ParamSlider.
  • Inc 5b — Rename/Reorder: reorderedIDs (pure, tested) + AppState.moveCustom + RenameDialog
  • LibraryView context-menu (Rename/Move Up/Move Down/Delete).
  • Inc 5c — Non-scalar help: HelpLabel (ParamSlider refactor) + ParamHelp non-scalar texts + wrap the non-scalar rows.

7. Testing & Verifikation

  • Unit (App-Test-Target): precisionStep (multipliers + base), reorderedIDs (swap/boundary/unknown), ParamHelp.text for the new non-scalar keys.
  • Build: macOS + iOS-sim BUILD SUCCEEDED; app suite green.
  • Manual (Johannes, macOS): scroll/arrow fine-coarse on a slider (Shift/Alt), undo captures the pre-value; rename + move up/down in the Library persist across relaunch; ⓘ popovers + hover on the non-scalar controls.
  • Gate: app suite green; macOS + iOS-sim builds; sim screenshot; deployed to iPhone.

8. Out-of-scope / Risks

  • #1 is macOS-only (no iOS modifiers) — the modifier is #if os(macOS); iOS keeps plain Slider + tap-to-type. Risk: scroll capture vs editor-list scroll — the catcher consumes scroll only while the cursor is over the slider (web-parity); verify it doesn't make the editor list feel sticky.
  • Drag fine/coarse (web's 0.1/4 incremental drag) is out of scope — we don't replace the native Slider; wheel+arrows deliver the precision, tap-to-type the exact value.
  • Route-control help, Format-picker ParamHelp reroute, and reorder-by-drag are out of scope.
  • reorderedIDs/moveCustom operate on the persisted order manifest; ensure a reorder doesn't race the debounced respawn or a concurrent save (Library is modal, so contention is unlikely).