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
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): theHStack { Text(label); if !help.isEmpty { ⓘ } }with@State showHelp, tap→.popover(presentationCompactAdaptation(.popover), ~260pt), macOS.help(help),.accessibilityHint(help). RefactorParamSliderto useHelpLabelfor its label (DRY; identical behaviour).- Extend
ParamHelpwith non-scalar entries ported fromsrc/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 aVStack): 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.textfor 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/moveCustomoperate 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).