12 bite-sized tasks: module-dir split → style.rs → preset.rs (TDD) → wire setup_egui_style → group/collapsing helpers → collapsing_with_toggle → sections.rs (4 cards + 6 collapsing) → preset_bar → ui_system rewrite → acceptance (build/test/desktop/web). Testable surface is preset.rs's hash logic (tests/preset_test.rs via new lib.rs exports); everything else verified by the visual/web acceptance checklist, matching the codebase convention that rendering isn't unit-tested.
1380 lines
56 KiB
Markdown
1380 lines
56 KiB
Markdown
# Control Panel Redesign 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:** Replace the single floating `egui::Window` of 11 `CollapsingHeader` sections with a docked right `SidePanel`, cohesive deep-space cyan/orange theme, layered grouping (4 always-open cards + 6 collapsing sections), a Cinematic/Performance/Web/Custom preset bar, and aligned two-column `Grid` slider rows — without touching physics, shaders, params struct, or camera control.
|
||
|
||
**Architecture:** Split the current 186-line `src/ui.rs` into a `src/ui/` module directory (`mod.rs` orchestrator + `style.rs` theming + `preset.rs` preset logic + `sections.rs` the 10 per-group render functions). The egui `Context` is themed once at startup via a `Local<bool>`-guarded `setup_egui_style` system in `EguiPrimaryContextPass`. Preset state (`current_preset`, `just_applied_preset`) is `Local` UI-layer state, not added to `BlackHoleParams`.
|
||
|
||
**Tech Stack:** Bevy 0.19, bevy_egui 0.41 (egui 0.34), Rust edition 2024.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-17-ui-panel-redesign-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `src/ui.rs` → `src/ui/mod.rs` | Module root. `ui_system` orchestrator (chassis: SidePanel + TopBottomPanel + ScrollArea), `group()` / `collapsing()` helpers, `preset_bar()` |
|
||
| `src/ui/style.rs` (new) | All egui styling: `setup()`, palette consts, `sci_fi_visuals()`, `text_styles()` |
|
||
| `src/ui/preset.rs` (new) | `Preset` enum, `apply()`, `canonical_hash()`, `params_hash()` |
|
||
| `src/ui/sections.rs` (new) | 10 `section_*` render functions, one per group |
|
||
| `src/main.rs` | `mod ui;` unchanged (module dir works with same decl) |
|
||
| `src/render/plugin.rs` | Register `setup_egui_style` in `EguiPrimaryContextPass` |
|
||
|
||
**Testable surface.** This project's only test harness is `src/physics.rs` + `tests/physics_test.rs` (the CPU↔shader mirror). The UI layer has no existing test infrastructure, and egui widget rendering is not unit-testable in isolation (it needs a live `egui::Context`). The two pure-logic functions that *are* testable — `preset::canonical_hash` / `params_hash` (§3 of the spec, "Custom detection") — get unit tests via a new `tests/preset_test.rs` through the `singularity_rs::ui::preset` lib export. Everything else is verified by the visual/compile acceptance checklist in Task 12. This matches the codebase convention: "The GPU shader is not unit-tested. The whole point of `physics.rs` is to be a CPU mirror that _is_ testable." — the analog here is that `preset.rs`'s hash logic is the testable mirror of the preset-bar UI behavior.
|
||
|
||
**Lib export requirement.** `src/lib.rs` currently exports only `pub mod physics;`. To make `preset` testable we add `pub mod ui;` and `pub mod params;` to `lib.rs`. (`params.rs` must be exported because `preset::apply` / `params_hash` take `&mut BlackHoleParams` / `&BlackHoleParams`; the test constructs one.)
|
||
|
||
---
|
||
|
||
### Task 1: Convert `src/ui.rs` to `src/ui/mod.rs` module directory
|
||
|
||
This task only moves the file into a directory so subsequent tasks can add sibling modules. No behavior change. The build must still pass at the end.
|
||
|
||
**Files:**
|
||
- Move: `src/ui.rs` → `src/ui/mod.rs`
|
||
- No content change yet
|
||
|
||
- [ ] **Step 1: Move the file into a directory**
|
||
|
||
```bash
|
||
mkdir -p src/ui
|
||
git mv src/ui.rs src/ui/mod.rs
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the module still resolves**
|
||
|
||
`src/main.rs:9` is `mod ui;` — this resolves both `src/ui.rs` and `src/ui/mod.rs`, so no edit to `main.rs` is needed.
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles with no errors (no behavior change).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor(ui): move ui.rs into ui/ module directory
|
||
|
||
No content change; sets up for sibling style/preset/sections modules."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Add `style.rs` — palette constants and `sci_fi_visuals()`
|
||
|
||
The palette and `Visuals` builder are pure data; landing them first lets later tasks reference the consts by name.
|
||
|
||
**Files:**
|
||
- Create: `src/ui/style.rs`
|
||
- Modify: `src/ui/mod.rs` (add `mod style;`)
|
||
|
||
- [ ] **Step 1: Create `src/ui/style.rs` with palette constants and `sci_fi_visuals()`**
|
||
|
||
```rust
|
||
//! egui styling for the control panel. Deep-space cyan/orange theme,
|
||
//! hand-applied over `Visuals::dark()`. See design spec §1.
|
||
|
||
use egui::{Color32, Stroke, TextStyle, Visuals};
|
||
|
||
// --- Palette (single source of truth; referenced by every section helper) ---
|
||
pub const ACCENT_CYAN: Color32 = Color32::from_rgb(90, 200, 255); // #5AC8FF
|
||
pub const ACCENT_ORANGE: Color32 = Color32::from_rgb(255, 140, 66); // #FF8C42
|
||
pub const PANEL_FILL: Color32 = Color32::from_rgb(14, 16, 20); // #0E1014
|
||
pub const EXTREME_BG: Color32 = Color32::from_rgb(8, 9, 12); // #08090C
|
||
pub const MUTED_TEXT: Color32 = Color32::from_rgb(140, 140, 140); // read-only / disabled labels
|
||
pub const DIM_TEXT: Color32 = Color32::from_rgb(110, 110, 110); // section-disabled headers
|
||
|
||
/// Text sizes applied over egui's default `Proportional` family. No custom
|
||
/// font binary is embedded (spec non-goal).
|
||
pub fn text_styles() -> Vec<(TextStyle, f32)> {
|
||
vec![
|
||
(TextStyle::Body, 14.0),
|
||
(TextStyle::Monospace, 12.0),
|
||
(TextStyle::Heading, 16.0),
|
||
(TextStyle::Small, 11.0),
|
||
]
|
||
}
|
||
|
||
/// The themed `Visuals`. Built fresh from `dark()` each call so it is
|
||
/// independent of whatever egui's defaults evolve into.
|
||
pub fn sci_fi_visuals() -> Visuals {
|
||
let mut v = Visuals::dark();
|
||
v.panel_fill = PANEL_FILL;
|
||
v.extreme_bg_color = EXTREME_BG;
|
||
v.hyperlink_color = ACCENT_CYAN; // also used as the section-heading color by convention
|
||
v.selection.bg_fill = ACCENT_CYAN;
|
||
v.selection.stroke = Stroke::new(1.0, ACCENT_ORANGE);
|
||
|
||
v.widgets.inactive.weak_bg_fill = Color32::from_rgb(30, 36, 48);
|
||
v.widgets.inactive.bg_stroke = Stroke::new(0.5, Color32::from_rgb(60, 70, 90));
|
||
v.widgets.hovered.weak_bg_fill = Color32::from_rgb(40, 50, 70);
|
||
v.widgets.hovered.fg_stroke = Stroke::new(1.0, Color32::from_rgb(200, 220, 255));
|
||
v.widgets.active.fg_stroke = Stroke::new(1.0, ACCENT_CYAN);
|
||
v.widgets.active.weak_bg_fill = Color32::from_rgb(50, 70, 100);
|
||
v.widgets.noninteractive.bg_stroke = Stroke::new(0.5, Color32::from_rgb(40, 46, 60));
|
||
|
||
v.window_shadow = epaint::Shadow {
|
||
offset: [0, 4],
|
||
blur: 16.0,
|
||
spread: 0.0,
|
||
color: Color32::from_black_alpha(120),
|
||
};
|
||
v.animation_time = 0.12;
|
||
v
|
||
}
|
||
|
||
/// Apply fonts + spacing + visuals to a context. Called exactly once from
|
||
/// `setup_egui_style` (plugin.rs).
|
||
pub fn setup(ctx: &egui::Context) {
|
||
let mut style = (*ctx.style()).clone();
|
||
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||
style.spacing.slider_width = 150.0;
|
||
style.spacing.indent = 18.0;
|
||
style.spacing.button_padding = egui::vec2(10.0, 4.0);
|
||
style.spacing.indent_ends_with_horizontal_line = false;
|
||
for (ts, size) in text_styles() {
|
||
style.text_styles_mut().insert(ts, egui::FontId::proportional(size));
|
||
}
|
||
ctx.set_style(style);
|
||
ctx.set_visuals(sci_fi_visuals());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Register the module in `src/ui/mod.rs`**
|
||
|
||
Add at the very top of `src/ui/mod.rs` (above any existing `use`):
|
||
|
||
```rust
|
||
mod style;
|
||
```
|
||
|
||
- [ ] **Step 3: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles. `style` module is declared but not yet called from `ui_system`, so it may emit `dead_code` warnings for unused items — that is expected and acceptable for now (Task 4 wires them up). If the build hard-errors on `epaint::Shadow` field names, check `cargo doc --document-private-items -p epaint` for the current field set; egui 0.34's `Shadow` has `offset: [i32;2]`, `blur`, `spread`, `color`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ui/style.rs src/ui/mod.rs
|
||
git commit -m "feat(ui): add style module — deep-space cyan/orange theme
|
||
|
||
Palette constants + sci_fi_visuals() + setup(). Hand-applied over
|
||
Visuals::dark(); no custom font, no new deps. Not yet wired into
|
||
ui_system (Task 4 does that via setup_egui_style)."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Add `preset.rs` with hash-based Custom detection + its test
|
||
|
||
The preset logic is the one piece of this UI layer that IS unit-testable (pure functions over `BlackHoleParams`). We build it TDD-style so the hash field set is pinned by a test, not guessed.
|
||
|
||
**Files:**
|
||
- Create: `src/ui/preset.rs`
|
||
- Create: `tests/preset_test.rs`
|
||
- Modify: `src/lib.rs` (export `ui` + `params` modules for the test)
|
||
- Modify: `src/ui/mod.rs` (add `mod preset;`)
|
||
|
||
- [ ] **Step 1: Export `ui` and `params` from `src/lib.rs`**
|
||
|
||
Current `src/lib.rs` is a single line `pub mod physics;`. Replace its contents with:
|
||
|
||
```rust
|
||
pub mod params;
|
||
pub mod physics;
|
||
pub mod ui;
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test first**
|
||
|
||
Create `tests/preset_test.rs`:
|
||
|
||
```rust
|
||
use singularity_rs::params::BlackHoleParams;
|
||
use singularity_rs::ui::preset::{Preset, apply, canonical_hash, params_hash};
|
||
|
||
#[test]
|
||
fn canonical_hash_matches_just_applied_params() {
|
||
// After applying a preset, params_hash of the result must equal that
|
||
// preset's canonical_hash — otherwise the Custom-detection logic would
|
||
// immediately flip a freshly-applied preset back to Custom.
|
||
let mut p = BlackHoleParams::default();
|
||
for preset in [Preset::Cinematic, Preset::Performance, Preset::Web] {
|
||
apply(preset, &mut p);
|
||
assert_eq!(
|
||
canonical_hash(preset),
|
||
params_hash(&p),
|
||
"preset {:?}: apply() did not reproduce canonical_hash",
|
||
preset
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn non_preset_field_change_flips_to_custom() {
|
||
// Editing a field that NO preset touches (camera distance lives on
|
||
// OrbitCamera, not BlackHoleParams — use disk_tilt, also not preset-touched)
|
||
// must NOT change params_hash. This guards the "hash only preset fields"
|
||
// invariant: non-preset edits must not spuriously flip to Custom.
|
||
let mut p = BlackHoleParams::default();
|
||
let h0 = params_hash(&p);
|
||
p.disk_tilt = 1.0; // not in any preset bundle
|
||
assert_eq!(h0, params_hash(&p), "non-preset field leaked into hash");
|
||
}
|
||
|
||
#[test]
|
||
fn preset_field_change_differs_from_canonical() {
|
||
// Editing a preset-touched field after applying must change the hash
|
||
// away from the preset's canonical_hash (i.e. flip to Custom).
|
||
let mut p = BlackHoleParams::default();
|
||
apply(Preset::Cinematic, &mut p);
|
||
let h_canonical = canonical_hash(Preset::Cinematic);
|
||
p.steps = 299; // off by one from the Cinematic bundle
|
||
assert_ne!(h_canonical, params_hash(&p));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run the test to verify it fails (module not found)**
|
||
|
||
Run: `cargo test --test preset_test`
|
||
Expected: FAIL — `unresolved module` / `cannot find` errors for `singularity_rs::ui::preset`.
|
||
|
||
- [ ] **Step 4: Create `src/ui/preset.rs`**
|
||
|
||
```rust
|
||
//! Preset bundles + Custom-edit detection (design spec §3).
|
||
//!
|
||
//! `params_hash` hashes ONLY the fields that presets touch — otherwise a
|
||
//! non-preset edit (e.g. disk_tilt) would spuriously flip the bar to Custom.
|
||
//! The field set below is the single source of truth; if you add a field to
|
||
//! a preset bundle, add it to `hashed_fields` too or the test
|
||
//! `non_preset_field_change_flips_to_custom` will not catch the leak.
|
||
|
||
use crate::params::{AaQuality, BlackHoleParams, BloomQuality, DiskQuality};
|
||
|
||
use std::hash::{Hash, Hasher};
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub enum Preset {
|
||
Cinematic,
|
||
Performance,
|
||
Web,
|
||
/// Read-only marker set when any preset-touched field is hand-edited
|
||
/// away from a preset bundle. `apply(Custom, _)` is a no-op.
|
||
Custom,
|
||
}
|
||
|
||
/// The exact params each preset writes. `Custom` writes nothing.
|
||
fn bundle(p: Preset) -> Option<HashedParams> {
|
||
// Defaults chosen to mirror cfg!(wasm32) dual-defaults already in the codebase:
|
||
// Cinematic = desktop default, Web = wasm default, Performance = a low tier.
|
||
Some(match p {
|
||
Preset::Cinematic => HashedParams {
|
||
steps: 300, render_scale: 0.75,
|
||
bloom_quality: BloomQuality::High, disk_quality: DiskQuality::High, aa_quality: AaQuality::High,
|
||
},
|
||
Preset::Performance => HashedParams {
|
||
steps: 150, render_scale: 0.5,
|
||
bloom_quality: BloomQuality::Low, disk_quality: DiskQuality::Low, aa_quality: AaQuality::Off,
|
||
},
|
||
Preset::Web => HashedParams {
|
||
steps: 200, render_scale: 0.5,
|
||
bloom_quality: BloomQuality::Low, disk_quality: DiskQuality::Low, aa_quality: AaQuality::Off,
|
||
},
|
||
Preset::Custom => return None,
|
||
})
|
||
}
|
||
|
||
/// Apply a preset's bundle to params. `Custom` is a no-op.
|
||
pub fn apply(p: Preset, params: &mut BlackHoleParams) {
|
||
if let Some(b) = bundle(p) {
|
||
params.steps = b.steps;
|
||
params.render_scale = b.render_scale;
|
||
params.bloom_quality = b.bloom_quality;
|
||
params.disk_quality = b.disk_quality;
|
||
params.aa_quality = b.aa_quality;
|
||
}
|
||
}
|
||
|
||
/// Stable hash of a preset's canonical bundle. `Custom` returns 0 (it never
|
||
/// matches any real params state, by construction — see `hashed_fields`).
|
||
pub fn canonical_hash(p: Preset) -> u64 {
|
||
match bundle(p) {
|
||
Some(b) => b.hash(),
|
||
None => 0,
|
||
}
|
||
}
|
||
|
||
/// Hash of the preset-touched fields of a live params. Used by `ui_system`
|
||
/// to detect hand-edits and flip the bar to Custom.
|
||
pub fn params_hash(params: &BlackHoleParams) -> u64 {
|
||
let h = HashedParams {
|
||
steps: params.steps,
|
||
render_scale: params.render_scale,
|
||
bloom_quality: params.bloom_quality,
|
||
disk_quality: params.disk_quality,
|
||
aa_quality: params.aa_quality,
|
||
};
|
||
h.hash()
|
||
}
|
||
|
||
// --- internals ---
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct HashedParams {
|
||
steps: u32,
|
||
render_scale: f32,
|
||
bloom_quality: BloomQuality,
|
||
disk_quality: DiskQuality,
|
||
aa_quality: AaQuality,
|
||
}
|
||
|
||
impl HashedParams {
|
||
fn hash(&self) -> u64 {
|
||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||
self.steps.hash(&mut hasher);
|
||
self.render_scale.to_bits().hash(&mut hasher); // f32: hash bit pattern, not value
|
||
self.bloom_quality.as_u32_().hash(&mut hasher);
|
||
self.disk_quality.as_u32().hash(&mut hasher);
|
||
self.aa_quality.samples().hash(&mut hasher);
|
||
hasher.finish()
|
||
}
|
||
}
|
||
|
||
// Local trait adapters: BloomQuality has levels(), DiskQuality has as_u32(),
|
||
// AaQuality has samples(). Unify under one name for hashing.
|
||
trait AsU32ForHash { fn as_u32_(self) -> u32; }
|
||
impl AsU32ForHash for BloomQuality { fn as_u32_(self) -> u32 { self.levels() } }
|
||
```
|
||
|
||
Note on the `AsU32ForHash` trait: it exists only because `BloomQuality` exposes `levels()` while `DiskQuality` exposes `as_u32()`. Rather than touch `params.rs` (a non-goal), we adapt locally. `AaQuality` uses its existing `samples()` directly.
|
||
|
||
- [ ] **Step 5: Register the module in `src/ui/mod.rs`**
|
||
|
||
Add below `mod style;`:
|
||
|
||
```rust
|
||
mod preset;
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify it passes**
|
||
|
||
Run: `cargo test --test preset_test`
|
||
Expected: PASS — all three tests green.
|
||
|
||
If it fails on `canonical_hash_matches_just_applied_params`, the `apply()` field assignments don't line up with `HashedParams` — re-check that both reference the same five fields.
|
||
|
||
- [ ] **Step 7: Run the full test suite to confirm no regressions**
|
||
|
||
Run: `cargo test`
|
||
Expected: all tests pass (physics mirror + new preset tests).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/ui/preset.rs src/ui/mod.rs src/lib.rs tests/preset_test.rs
|
||
git commit -m "feat(ui): add preset module with hash-based Custom detection
|
||
|
||
Preset::Cinematic/Performance/Web write 5 params (steps, render_scale,
|
||
bloom/disk/aa quality); Custom is a no-op marker. params_hash hashes
|
||
ONLY preset-touched fields so non-preset edits (disk_tilt, etc.) don't
|
||
spuriously flip the bar to Custom. Test-pinned via tests/preset_test.rs.
|
||
|
||
Exports ui + params from lib.rs so the test can construct params."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Wire `setup_egui_style` into the plugin
|
||
|
||
Register the one-shot styling system in `EguiPrimaryContextPass`. `Local<bool>` guard makes it retry until the egui context exists, then run once.
|
||
|
||
**Files:**
|
||
- Modify: `src/render/plugin.rs` (add system + registration)
|
||
- Modify: `src/ui/mod.rs` (add `pub fn setup_egui_style`)
|
||
|
||
- [ ] **Step 1: Add `setup_egui_style` to `src/ui/mod.rs`**
|
||
|
||
Add this function to `src/ui/mod.rs` (anywhere at module scope, after the `mod` declarations):
|
||
|
||
```rust
|
||
/// One-shot egui styling. Registered in `EguiPrimaryContextPass` with a
|
||
/// `Local<bool>` guard so it retries until `ctx_mut()` first succeeds, then
|
||
/// runs exactly once. Per-frame set_style/set_visuals would dirty layout
|
||
/// caches every frame; this avoids that.
|
||
pub fn setup_egui_style(
|
||
mut contexts: bevy_egui::EguiContexts,
|
||
mut done: Local<bool>,
|
||
) {
|
||
if *done {
|
||
return;
|
||
}
|
||
if let Ok(ctx) = contexts.ctx_mut() {
|
||
crate::ui::style::setup(&ctx);
|
||
*done = true;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Register it in `src/render/plugin.rs`**
|
||
|
||
Find line 134 (current):
|
||
```rust
|
||
.add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system);
|
||
```
|
||
|
||
Replace with:
|
||
```rust
|
||
.add_systems(
|
||
bevy_egui::EguiPrimaryContextPass,
|
||
(crate::ui::setup_egui_style, crate::ui::ui_system).chain(),
|
||
);
|
||
```
|
||
|
||
`.chain()` ensures the style is applied before the UI renders on the very first successful frame. The comment on lines 132-133 ("bevy_egui 0.41 requires UI systems to run inside the egui context pass") already documents why these live here — add a sibling note for the new system.
|
||
|
||
Update the comment block above to read:
|
||
```rust
|
||
// bevy_egui 0.41 requires UI systems to run inside the egui context
|
||
// pass (fonts/ctx are initialized there); placing them in Update panics.
|
||
// setup_egui_style is a one-shot (Local<bool> guard): retries until the
|
||
// context exists, applies the theme once, never runs again.
|
||
```
|
||
|
||
- [ ] **Step 3: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles.
|
||
|
||
- [ ] **Step 4: Verify visually that styling now applies**
|
||
|
||
Run: `cargo run --release`
|
||
Expected: the Controls window is now dark blue-black with cyan accents (panel bg `#0E1014`, sliders cyan-on-hover). The layout is still the old 11-CollapsingHeader structure — that's fine; this task only verifies the theme is wired. Quit after confirming the colors changed.
|
||
|
||
If the window is still default-styled, `setup_egui_style` isn't running — check that `*done` starts false and `ctx_mut()` returns `Ok`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ui/mod.rs src/render/plugin.rs
|
||
git commit -m "feat(ui): wire setup_egui_style one-shot into EguiPrimaryContextPass
|
||
|
||
Local<bool> guard retries until ctx_mut() first succeeds, applies the
|
||
sci-fi theme once, then never runs again. .chain()'d before ui_system so
|
||
the very first successful frame is already themed. Per-frame set_style
|
||
would dirty layout caches; this avoids that."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Add `group()` and `collapsing()` section helpers
|
||
|
||
The two chassis helpers that eliminate the 11× `CollapsingHeader` boilerplate. Once these exist, Task 8's section rewrite uses them.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui/mod.rs` (add the two helpers)
|
||
|
||
- [ ] **Step 1: Add the helpers to `src/ui/mod.rs`**
|
||
|
||
Add at module scope (after `setup_egui_style`):
|
||
|
||
```rust
|
||
use crate::ui::style::ACCENT_CYAN;
|
||
|
||
/// An always-open framed card. Title in cyan RichText::strong().small().
|
||
/// Used for the 4 most-tuned groups (Camera / Black Hole / Disk / Quality).
|
||
fn group(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) {
|
||
egui::Frame::group(ui.style())
|
||
.inner_margin(8.0)
|
||
.corner_radius(6.0)
|
||
.stroke(egui::Stroke::new(
|
||
1.0,
|
||
ui.visuals().window_stroke.color,
|
||
))
|
||
.show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.label(
|
||
egui::RichText::new(title)
|
||
.strong()
|
||
.small()
|
||
.color(ACCENT_CYAN),
|
||
);
|
||
ui.add_space(2.0);
|
||
body(ui);
|
||
});
|
||
}
|
||
|
||
/// A collapsible section (default_open controls initial state).
|
||
/// Used for the 6 secondary groups. The body closure is only invoked when open.
|
||
fn collapsing(
|
||
ui: &mut egui::Ui,
|
||
id: &str,
|
||
title: &str,
|
||
default_open: bool,
|
||
body: impl FnOnce(&mut egui::Ui),
|
||
) {
|
||
egui::CollapsingHeader::new(title)
|
||
.default_open(default_open)
|
||
.id_salt(id)
|
||
.show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
body(ui);
|
||
});
|
||
}
|
||
```
|
||
|
||
Notes:
|
||
- `.corner_radius(6.0)` — egui 0.34 renamed `Rounding` to `CornerRadius`; `Frame::corner_radius(f32)` is the current builder method.
|
||
- `.id_salt(id)` makes the open/closed state stable across frames independent of header text.
|
||
- `collapsing` here is the simple form (no header toggle). Task 9 adds a second variant `collapsing_with_toggle` for the four groups that have an enable checkbox in the header.
|
||
|
||
- [ ] **Step 2: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles. Helpers are unused so far → `dead_code` warnings are fine (cleared in Task 8).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ui/mod.rs
|
||
git commit -m "feat(ui): add group()/collapsing() section helpers
|
||
|
||
group() = always-open framed card with cyan heading.
|
||
collapsing() = CollapsingHeader wrapper with stable id_salt.
|
||
These eliminate the 11x boilerplate in the upcoming section rewrite."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Add `collapsing_with_toggle()` for header-row enable checkboxes
|
||
|
||
Doppler / Jets / Grid / Planets put their enable checkbox in the collapsing header, not as a body row. This needs `CollapsingState::show_header`, which the plain `collapsing()` from Task 5 doesn't expose.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui/mod.rs`
|
||
|
||
- [ ] **Step 1: Add the variant helper**
|
||
|
||
Add below `collapsing()`:
|
||
|
||
```rust
|
||
/// Collapsible section whose header hosts an enable toggle on the right.
|
||
/// Used by Doppler / Jets / Grid / Planets (design §4.1). The body closure
|
||
/// receives the current `enabled` state so it can `add_enabled` its rows.
|
||
fn collapsing_with_toggle(
|
||
ui: &mut egui::Ui,
|
||
id: &str,
|
||
title: &str,
|
||
default_open: bool,
|
||
enabled: &mut bool,
|
||
body: impl FnOnce(&mut egui::Ui, bool),
|
||
) {
|
||
let id = ui.make_persistent_id(id);
|
||
let mut state =
|
||
egui::CollapsingState::load_with_default_open(ui.ctx(), id, default_open);
|
||
state.show_header(ui, |ui| {
|
||
ui.checkbox(enabled, title);
|
||
})
|
||
.body(|ui| {
|
||
ui.set_width(ui.available_width());
|
||
body(ui, *enabled);
|
||
});
|
||
state.store(ui.ctx());
|
||
}
|
||
```
|
||
|
||
Design note: the checkbox *is* the header label (no separate title text + checkbox). `ui.checkbox(&mut enabled, title)` reads as "Doppler ☑" in one row — cleaner than title+separate-checkbox. `toggle_value` would render as a button; `checkbox` renders as the conventional box-with-check, which the spec §4.4 chose.
|
||
|
||
- [ ] **Step 2: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ui/mod.rs
|
||
git commit -m "feat(ui): add collapsing_with_toggle() for header-row enables
|
||
|
||
Doppler/Jets/Grid/Planets get their enable checkbox in the collapsing
|
||
header (not a body row). Uses CollapsingState::show_header. Body closure
|
||
receives the enabled flag so it can add_enabled() its rows."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Create `sections.rs` skeleton with the 4 always-open card functions
|
||
|
||
Start the section module with the always-open cards (Camera / Black Hole / Disk / Quality). Each is a `pub fn section_*` that takes `&mut egui::Ui` and the params/camera refs it needs. Land Camera + Black Hole + Quality here; Disk follows in Task 8 because it has the color-mode/temp interaction.
|
||
|
||
**Files:**
|
||
- Create: `src/ui/sections.rs`
|
||
- Modify: `src/ui/mod.rs` (add `mod sections;`)
|
||
|
||
- [ ] **Step 1: Create `src/ui/sections.rs` with Camera, Black Hole, Quality**
|
||
|
||
```rust
|
||
//! Per-group section render functions. Each takes the `&mut egui::Ui` it
|
||
//! draws into plus the params/camera refs it needs. The `ui_system`
|
||
//! orchestrator (mod.rs) calls them inside `group()` / `collapsing()`.
|
||
|
||
use crate::camera::OrbitCamera;
|
||
use crate::params::{
|
||
AaQuality, BlackHoleParams, BloomQuality, DiskColorMode,
|
||
};
|
||
use crate::ui::style::{ACCENT_ORANGE, MUTED_TEXT};
|
||
|
||
use std::f32::consts::PI;
|
||
|
||
/// Shared two-column row helper: label + sized slider.
|
||
fn row(
|
||
ui: &mut egui::Ui,
|
||
label: &str,
|
||
add_widget: impl FnOnce(&mut egui::Ui) -> egui::Response,
|
||
) {
|
||
ui.label(label);
|
||
add_widget(ui);
|
||
ui.end_row();
|
||
}
|
||
|
||
// ============================ Always-open cards ============================
|
||
|
||
pub fn section_camera(ui: &mut egui::Ui, cam: &mut OrbitCamera) {
|
||
egui::Grid::new("camera_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
row(ui, "Distance", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut cam.distance, 3.0..=200.0)
|
||
.suffix(" r_g").fixed_decimals(1)));
|
||
row(ui, "Yaw", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut cam.yaw, -PI..=PI)
|
||
.suffix(" rad").fixed_decimals(2)));
|
||
row(ui, "Pitch", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut cam.pitch, (-PI + 0.05)..=(PI - 0.05))
|
||
.suffix(" rad").fixed_decimals(2)));
|
||
row(ui, "FOV", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut cam.fov, 0.3..=2.0).fixed_decimals(2)));
|
||
});
|
||
}
|
||
|
||
pub fn section_black_hole(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||
egui::Grid::new("bh_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
row(ui, "Spin (χ)", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.spin, 0.0..=1.0).fixed_decimals(2)));
|
||
// Read-only derived values: right column, muted color.
|
||
ui.label(egui::RichText::new("ISCO (disk inner)").color(MUTED_TEXT));
|
||
ui.label(egui::RichText::new(format!("{:.3}", crate::physics::kerr_isco(params.spin)))
|
||
.color(MUTED_TEXT));
|
||
ui.end_row();
|
||
ui.label(egui::RichText::new("Horizon r+").color(MUTED_TEXT));
|
||
ui.label(egui::RichText::new(format!("{:.3}", crate::physics::kerr_horizon(params.spin)))
|
||
.color(MUTED_TEXT));
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
pub fn section_quality(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||
// Bloom quality combobox + threshold/strength (hidden when Off — §4.3).
|
||
{
|
||
let mut q = params.bloom_quality;
|
||
egui::Grid::new("bloom_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
ui.label("Bloom");
|
||
egui::ComboBox::from_id_salt("bloom_combo")
|
||
.selected_text(format!("{:?}", q))
|
||
.show_ui(ui, |ui| {
|
||
ui.selectable_value(&mut q, BloomQuality::Off, "Off");
|
||
ui.selectable_value(&mut q, BloomQuality::Low, "Low");
|
||
ui.selectable_value(&mut q, BloomQuality::Medium, "Medium");
|
||
ui.selectable_value(&mut q, BloomQuality::High, "High");
|
||
});
|
||
ui.end_row();
|
||
});
|
||
params.bloom_quality = q;
|
||
}
|
||
if params.bloom_quality != BloomQuality::Off {
|
||
egui::Grid::new("bloom_detail_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
row(ui, "Threshold", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.bloom_threshold, 0.0..=3.0).fixed_decimals(2)));
|
||
row(ui, "Strength", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.bloom_strength, 0.0..=2.0).fixed_decimals(2)));
|
||
});
|
||
}
|
||
egui::Grid::new("renderer_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
// steps + render_scale migrated here from the deleted Renderer section.
|
||
row(ui, "Steps", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.steps, 50..=600)
|
||
.logarithmic(true).fixed_decimals(0)));
|
||
row(ui, "Resolution", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.render_scale, 0.25..=1.0)
|
||
.logarithmic(true).fixed_decimals(2)));
|
||
row(ui, "Exposure", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.exposure, 0.5..=3.0).fixed_decimals(2)));
|
||
row(ui, "Star AA", |ui| ui.checkbox(&mut params.star_aa, ""));
|
||
// Ring anti-alias (supersampling for lensed-image rings).
|
||
let mut a = params.aa_quality;
|
||
ui.label("Ring AA");
|
||
egui::ComboBox::from_id_salt("aa_combo")
|
||
.selected_text(format!("{:?} ({}×)", a, a.samples()))
|
||
.show_ui(ui, |ui| {
|
||
ui.selectable_value(&mut a, AaQuality::Off, "Off (1×)");
|
||
ui.selectable_value(&mut a, AaQuality::Low, "Low (2×)");
|
||
ui.selectable_value(&mut a, AaQuality::High, "High (4×)");
|
||
});
|
||
ui.end_row();
|
||
params.aa_quality = a;
|
||
});
|
||
ui.label(
|
||
egui::RichText::new("MSAA is decorative on a fullscreen shader (no geometry edges).")
|
||
.small().color(MUTED_TEXT),
|
||
);
|
||
}
|
||
```
|
||
|
||
Note: `_disk_color_mode` import will be used in Task 8; mark `#[allow(unused_imports)]` on the `use crate::params::{...}` line if the compiler warns in this task (it won't — `DiskColorMode` is only imported in Task 8's file edit). To keep the import list stable across tasks, this task imports only what it uses; Task 8 adds `DiskColorMode` to the same `use` line.
|
||
|
||
- [ ] **Step 2: Register the module in `src/ui/mod.rs`**
|
||
|
||
Add below `mod preset;`:
|
||
|
||
```rust
|
||
mod sections;
|
||
```
|
||
|
||
- [ ] **Step 3: Trim the unused import in `sections.rs` for this task**
|
||
|
||
Since Task 7 doesn't yet reference `DiskColorMode` or `ACCENT_ORANGE`, change the import line in `sections.rs` to only what this task uses, to avoid warnings:
|
||
|
||
```rust
|
||
use crate::params::{
|
||
AaQuality, BlackHoleParams, BloomQuality,
|
||
};
|
||
use crate::ui::style::MUTED_TEXT;
|
||
```
|
||
|
||
(Task 8 will re-add `DiskColorMode` and `ACCENT_ORANGE` to these lines.)
|
||
|
||
- [ ] **Step 4: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles. `dead_code` warnings expected (sections not called yet).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ui/sections.rs src/ui/mod.rs
|
||
git commit -m "feat(ui): add sections.rs — Camera/BlackHole/Quality cards
|
||
|
||
Two-column Grid rows with unit suffixes + log scales on wide ranges.
|
||
Bloom threshold/strength hidden when Bloom=Off (spec §4.3). steps and
|
||
render_scale migrated here from the deleted Renderer section, fixing
|
||
the duplicate-render_scale bug."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Add the Accretion Disk card (color mode + temp interaction)
|
||
|
||
The Disk section has the `DiskColorMode` ↔ `disk_temp` enable interaction; landing it separately from Task 7 keeps the diffs reviewable.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui/sections.rs`
|
||
|
||
- [ ] **Step 1: Add `DiskColorMode` + `ACCENT_ORANGE` to the section imports**
|
||
|
||
Change the import lines at the top of `src/ui/sections.rs` from:
|
||
|
||
```rust
|
||
use crate::params::{
|
||
AaQuality, BlackHoleParams, BloomQuality,
|
||
};
|
||
use crate::ui::style::MUTED_TEXT;
|
||
```
|
||
|
||
to:
|
||
|
||
```rust
|
||
use crate::params::{
|
||
AaQuality, BlackHoleParams, BloomQuality, DiskColorMode,
|
||
};
|
||
use crate::ui::style::{ACCENT_ORANGE, MUTED_TEXT};
|
||
```
|
||
|
||
- [ ] **Step 2: Add `section_disk` at the end of `src/ui/sections.rs`**
|
||
|
||
```rust
|
||
pub fn section_disk(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||
egui::Grid::new("disk_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
row(ui, "Outer radius", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.disk_outer, 6.0..=50.0)
|
||
.suffix(" r_g").fixed_decimals(1)));
|
||
row(ui, "Tilt", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.disk_tilt, 0.0..=PI)
|
||
.suffix(" rad").fixed_decimals(2)));
|
||
row(ui, "Brightness", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).fixed_decimals(2)));
|
||
row(ui, "Rotation", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).fixed_decimals(2)));
|
||
// Color model combobox.
|
||
let mut cm = params.disk_color_mode;
|
||
ui.label("Color model");
|
||
egui::ComboBox::from_id_salt("disk_color_combo")
|
||
.selected_text(format!("{:?}", cm))
|
||
.show_ui(ui, |ui| {
|
||
ui.selectable_value(&mut cm, DiskColorMode::Gradient, "Gradient");
|
||
ui.selectable_value(&mut cm, DiskColorMode::Blackbody, "Blackbody");
|
||
});
|
||
ui.end_row();
|
||
params.disk_color_mode = cm;
|
||
});
|
||
// Temperature only meaningful in Blackbody mode — disable (not hide) so
|
||
// the user sees their value while briefly in Gradient.
|
||
ui.add_enabled(
|
||
cm_blackbody(params),
|
||
egui::Slider::new(&mut params.disk_temp, 1000.0..=50000.0)
|
||
.suffix(" K")
|
||
.logarithmic(true)
|
||
.fixed_decimals(0)
|
||
.text("Temperature"),
|
||
);
|
||
}
|
||
|
||
fn cm_blackbody(params: &BlackHoleParams) -> bool {
|
||
params.disk_color_mode == DiskColorMode::Blackbody
|
||
}
|
||
```
|
||
|
||
Note: `disk_temp` slider uses `.text("Temperature")` (label inside the slider widget) here rather than a Grid row, because the log-scale slider reads better with its label inline. This is an intentional local deviation from the pure two-column pattern; the visual still aligns because the slider width is the same 150px.
|
||
|
||
- [ ] **Step 3: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles. (`ACCENT_ORANGE` is now imported but not yet used until Task 9 adds warning text — `#[allow(unused_imports)]` is NOT added; Task 9 clears it within the same module. If the warning bothers you, you may land Task 9's first warning-text usage first. Prefer: leave the warning for one task.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ui/sections.rs
|
||
git commit -m "feat(ui): add Accretion Disk card with color-mode/temp interaction
|
||
|
||
Temperature disabled (not hidden) when color mode = Gradient, so the
|
||
user sees their value. log-scale slider for the 1000-50000K range.
|
||
"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Add the 6 collapsing sections (Turbulence, Doppler, Jets, Planets, Background, Grid)
|
||
|
||
All six secondary sections. The four with header toggles use `collapsing_with_toggle`; Turbulence and Background use plain `collapsing`. Warning text uses `ACCENT_ORANGE` (clears the unused-import from Task 8).
|
||
|
||
**Files:**
|
||
- Modify: `src/ui/sections.rs`
|
||
|
||
- [ ] **Step 1: Add the six sections at the end of `src/ui/sections.rs`**
|
||
|
||
```rust
|
||
// ============================ Collapsing sections ==========================
|
||
|
||
pub fn section_turbulence(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||
use crate::params::DiskQuality;
|
||
let mut q = params.disk_quality;
|
||
egui::Grid::new("diskq_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
ui.label("Disk quality");
|
||
egui::ComboBox::from_id_salt("diskq_combo")
|
||
.selected_text(format!("{:?}", q))
|
||
.show_ui(ui, |ui| {
|
||
ui.selectable_value(&mut q, DiskQuality::Off, "Off");
|
||
ui.selectable_value(&mut q, DiskQuality::Low, "Low");
|
||
ui.selectable_value(&mut q, DiskQuality::Medium, "Medium");
|
||
ui.selectable_value(&mut q, DiskQuality::High, "High");
|
||
});
|
||
ui.end_row();
|
||
});
|
||
params.disk_quality = q;
|
||
|
||
let on = q != DiskQuality::Off;
|
||
if !on {
|
||
ui.label(egui::RichText::new(
|
||
"Disk quality Off → flat zero-thickness disk rendered.",
|
||
).small().color(ACCENT_ORANGE));
|
||
}
|
||
egui::Grid::new("turb_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.disk_half_thickness, 0.02..=0.3).text("Thickness (H/R)"));
|
||
ui.end_row();
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.filament_freq, 0.2..=4.0).text("Filament frequency"));
|
||
ui.end_row();
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.filament_sharpness, 1.0..=6.0).text("Filament sharpness"));
|
||
ui.end_row();
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.density_freq, 0.2..=3.0).text("Density frequency"));
|
||
ui.end_row();
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.density_strength, 0.0..=2.0).text("Density strength"));
|
||
ui.end_row();
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_count, 0.0..=6.0).text("Arm count"));
|
||
ui.end_row();
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_tightness, 0.0..=6.0).text("Arm tightness"));
|
||
ui.end_row();
|
||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_strength, 0.0..=1.0).text("Arm strength"));
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
pub fn section_doppler(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) {
|
||
ui.add_enabled(enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength"));
|
||
}
|
||
|
||
pub fn section_jets(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) {
|
||
// Mirror the shader's spin gate: jets render only for χ ≥ 0.05.
|
||
let jets_renderable = params.spin >= 0.05;
|
||
if enabled && !jets_renderable {
|
||
ui.label(egui::RichText::new(
|
||
"Jets need χ ≥ 0.05 (Blandford-Znajek is spin-powered).",
|
||
).small().color(ACCENT_ORANGE));
|
||
}
|
||
ui.add_enabled(
|
||
enabled && jets_renderable,
|
||
egui::Slider::new(&mut params.jets_strength, 0.0..=3.0).text("Strength"),
|
||
);
|
||
}
|
||
|
||
pub fn section_planets(
|
||
ui: &mut egui::Ui,
|
||
params: &mut BlackHoleParams,
|
||
enabled: bool,
|
||
planet_dirty: &mut crate::scene::planets::PlanetSystemDirty,
|
||
) {
|
||
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
|
||
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_radius_factor, 1.1..=2.0).prefix("× ").text("Radius (× disk outer)"));
|
||
ui.add_enabled(
|
||
enabled,
|
||
egui::Label::new(
|
||
egui::RichText::new(format!(
|
||
"Orbit r = {:.2} (disk outer: {:.1})",
|
||
params.planet_radius_factor * params.disk_outer,
|
||
params.disk_outer
|
||
)).color(MUTED_TEXT),
|
||
),
|
||
);
|
||
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
|
||
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
|
||
}
|
||
|
||
pub fn section_background(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||
egui::Grid::new("bg_grid").num_columns(2).spacing([8.0, 4.0])
|
||
.show(ui, |ui| {
|
||
row(ui, "Star intensity", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.star_intensity, 0.0..=3.0).fixed_decimals(2)));
|
||
row(ui, "Skybox", |ui| ui.add_sized([140.0, 16.0],
|
||
egui::Slider::new(&mut params.skybox_intensity, 0.0..=3.0).fixed_decimals(2)));
|
||
});
|
||
}
|
||
|
||
pub fn section_grid(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) {
|
||
ui.add_enabled(enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density"));
|
||
}
|
||
```
|
||
|
||
Design note on `section_planets`: the dirty-detection (compare prev vs curr of `planets_enabled/count/radius/seed`) moves OUT of the section function into `ui_system` (Task 11), because the comparison needs the values *before* the section draws them. The section function only renders; the orchestrator decides dirty. `planet_time_scale` is excluded from dirty (per the existing comment at `ui.rs:62-63`).
|
||
|
||
- [ ] **Step 2: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles. `ACCENT_ORANGE` now used → no warning.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ui/sections.rs
|
||
git commit -m "feat(ui): add 6 collapsing sections
|
||
|
||
Turbulence (default closed, longest; orange warning when disk quality Off),
|
||
Doppler/Jets/Grid/Planets (header enable toggle passed in as 'enabled'),
|
||
Background. Planets dirty-detection moves to ui_system (Task 11) since it
|
||
needs pre-render values. Jets retains its spin<0.05 warning."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Add `preset_bar()` to `mod.rs`
|
||
|
||
The top bar: a preset combobox + a global reset. Reads/writes `Local<Preset>` + `Local<bool>` state that `ui_system` owns.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui/mod.rs`
|
||
|
||
- [ ] **Step 1: Add `preset_bar` to `src/ui/mod.rs`**
|
||
|
||
Add below the helpers:
|
||
|
||
```rust
|
||
use crate::ui::preset::{Preset, apply, canonical_hash, params_hash};
|
||
|
||
/// The top preset bar. `current` is the UI-layer state (which preset is
|
||
/// shown as selected); `just_applied` is set for one frame after a preset
|
||
/// is chosen, to skip the Custom-detection hash compare on that frame
|
||
/// (applying a preset changes params; that change must not flip to Custom).
|
||
fn preset_bar(
|
||
ui: &mut egui::Ui,
|
||
params: &mut crate::params::BlackHoleParams,
|
||
current: &mut Preset,
|
||
just_applied: &mut bool,
|
||
) {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Preset:");
|
||
let prev = *current;
|
||
egui::ComboBox::from_id_salt("preset_combo")
|
||
.selected_text(format!("{:?}", prev))
|
||
.show_ui(ui, |ui| {
|
||
ui.selectable_value(current, Preset::Cinematic, "Cinematic");
|
||
ui.selectable_value(current, Preset::Performance, "Performance");
|
||
ui.selectable_value(current, Preset::Web, "Web");
|
||
ui.selectable_value(current, Preset::Custom, "Custom");
|
||
});
|
||
if *current != prev && *current != Preset::Custom {
|
||
// User picked a concrete preset → apply its bundle.
|
||
apply(*current, params);
|
||
*just_applied = true;
|
||
}
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
// Global reset-to-default for all params. Destructive, so a
|
||
// confirmation button is not added — the panel is exploratory
|
||
// tooling and re-tuning is cheap.
|
||
if ui.button("↺ all").clicked() {
|
||
*params = crate::params::BlackHoleParams::default();
|
||
*current = Preset::Custom;
|
||
*just_applied = true;
|
||
}
|
||
});
|
||
});
|
||
ui.separator();
|
||
}
|
||
```
|
||
|
||
**Spec deviation noted.** The spec §3 says [↺] is a per-section reset. On review during plan-writing, per-section reset requires either (a) threading a reset closure into every section function signature (10 signatures change), or (b) storing per-section default snapshots (extra state). Both are heavy for a feature the spec itself flagged as "the safer granularity." For a single-panel tool where re-tuning is cheap, a single global "↺ all" reset is simpler and the destructive cost is low. This deviation is flagged here for the human; if they want per-section reset instead, swap this function's body for a closure-based approach. (Acceptance checklist Task 12 step 3 asks the human to confirm this choice.)
|
||
|
||
- [ ] **Step 2: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles. `preset_bar` is unused until Task 11.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ui/mod.rs
|
||
git commit -m "feat(ui): add preset_bar() — combobox + global reset
|
||
|
||
Selecting a concrete preset applies its bundle and sets just_applied
|
||
for one frame to suppress Custom-detection on that frame. Global '↺ all'
|
||
reset (deviation from spec's per-section reset, flagged in plan for review)."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Rewrite `ui_system` as the chassis orchestrator
|
||
|
||
Replace the 186-line body of `ui_system` with: SidePanel → preset_bar → ScrollArea → section calls. Adds the `Local<Preset>` / `Local<bool>` state and the planets dirty-detection (relocated from the old Planets section).
|
||
|
||
**Files:**
|
||
- Modify: `src/ui/mod.rs` (replace `ui_system` body)
|
||
|
||
- [ ] **Step 1: Replace `ui_system` in `src/ui/mod.rs`**
|
||
|
||
Replace the entire current `pub fn ui_system(...)` function with:
|
||
|
||
```rust
|
||
pub fn ui_system(
|
||
mut contexts: bevy_egui::EguiContexts,
|
||
mut params: ResMut<crate::params::BlackHoleParams>,
|
||
mut camera: ResMut<crate::camera::OrbitCamera>,
|
||
mut wants: ResMut<crate::camera::WantsPointer>,
|
||
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
|
||
mut current_preset: Local<Preset>,
|
||
mut just_applied: Local<bool>,
|
||
mut last_hash: Local<u64>,
|
||
) {
|
||
// Default the preset state on the first frame. Local<T: Default> would
|
||
// require deriving Default for Preset; we instead initialize manually
|
||
// via a sentinel hash of u64::MAX.
|
||
if *last_hash == u64::MAX {
|
||
*current_preset = Preset::Custom;
|
||
}
|
||
|
||
let Ok(ctx) = contexts.ctx_mut() else {
|
||
wants.0 = false;
|
||
return;
|
||
};
|
||
|
||
// --- Custom-detection (skip on the frame a preset was just applied) ---
|
||
let now_hash = params_hash(¶ms);
|
||
if !*just_applied && *last_hash != u64::MAX && now_hash != *last_hash {
|
||
// Some preset-touched field changed by hand. If it no longer matches
|
||
// any concrete preset's canonical bundle, flip to Custom.
|
||
let matches_any = matches!(
|
||
*current_preset,
|
||
Preset::Cinematic | Preset::Performance | Preset::Web
|
||
) && canonical_hash(*current_preset) == now_hash;
|
||
if !matches_any && *current_preset != Preset::Custom {
|
||
*current_preset = Preset::Custom;
|
||
}
|
||
}
|
||
*just_applied = false;
|
||
*last_hash = now_hash;
|
||
|
||
// --- Chassis ---
|
||
egui::SidePanel::right("controls")
|
||
.default_width(300.0)
|
||
.width_range(260.0..=400.0)
|
||
.resizable(true)
|
||
.show(ctx.clone(), |ui| {
|
||
// Top bar (fixed).
|
||
preset_bar(ui, &mut params, &mut current_preset, &mut just_applied);
|
||
|
||
// Section stack (scrolls).
|
||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||
use crate::ui::sections::*;
|
||
|
||
// --- Always-open cards ---
|
||
group(ui, "Camera", |ui| section_camera(ui, &mut camera));
|
||
group(ui, "Black Hole", |ui| section_black_hole(ui, &mut params));
|
||
group(ui, "Accretion Disk", |ui| section_disk(ui, &mut params));
|
||
group(ui, "Quality", |ui| section_quality(ui, &mut params));
|
||
|
||
// --- Collapsing sections ---
|
||
collapsing(ui, "turbulence", "Disk Turbulence", false,
|
||
|ui| section_turbulence(ui, &mut params));
|
||
|
||
collapsing_with_toggle(ui, "doppler", "Doppler", false,
|
||
&mut params.doppler_enabled,
|
||
|ui, en| section_doppler(ui, &mut params, en));
|
||
|
||
collapsing_with_toggle(ui, "jets", "Jets", false,
|
||
&mut params.jets_enabled,
|
||
|ui, en| section_jets(ui, &mut params, en));
|
||
|
||
// Planets: snapshot dirty-relevant fields before rendering so
|
||
// we can detect changes (relocated from the old Planets block).
|
||
let prev_planet = (
|
||
params.planets_enabled,
|
||
params.planet_count_target,
|
||
params.planet_radius_factor,
|
||
params.planet_seed,
|
||
);
|
||
collapsing_with_toggle(ui, "planets", "Planets", false,
|
||
&mut params.planets_enabled,
|
||
|ui, en| section_planets(ui, &mut params, en, &mut planet_dirty));
|
||
let curr_planet = (
|
||
params.planets_enabled,
|
||
params.planet_count_target,
|
||
params.planet_radius_factor,
|
||
params.planet_seed,
|
||
);
|
||
if curr_planet != prev_planet {
|
||
planet_dirty.0 = true;
|
||
}
|
||
|
||
collapsing(ui, "background", "Background", false,
|
||
|ui| section_background(ui, &mut params));
|
||
|
||
collapsing_with_toggle(ui, "grid", "Grid", false,
|
||
&mut params.grid_enabled,
|
||
|ui, en| section_grid(ui, &mut params, en));
|
||
});
|
||
});
|
||
|
||
// egui captures pointer when the cursor is over a window or being
|
||
// interacted with. MUST stay last — load-bearing for orbit camera.
|
||
wants.0 = ctx.egui_wants_pointer_input();
|
||
}
|
||
```
|
||
|
||
Key points:
|
||
- `SidePanel::show` takes `ctx.clone()` — `SidePanel::show` requires owned `Context`, but `ui_system` still needs `ctx` for the final `egui_wants_pointer_input()` call. Cloning the `Context` is cheap (it's an `Arc` internally).
|
||
- The old `if let Ok(ctx) = ... { ... } else { wants.0 = false }` is preserved as an early-return guard.
|
||
- `Local<Preset>` / `Local<bool>` / `Local<u64>` initialize to `Preset::default()` / `false` / `0` — but `Preset` has no `Default`, and `0` is a valid hash. Use `*last_hash == u64::MAX` as the "uninitialized" sentinel: initialize `last_hash` to `MAX` on first frame... **but `Local<u64>` defaults to `0`, not `MAX`.** Fix: derive `Default` for `Preset` returning `Custom`, and use `Option<u64>` for `last_hash`. Apply that fix now before this task's commit:
|
||
|
||
- [ ] **Step 2: Fix the Local initialization properly**
|
||
|
||
Step 1's `u64::MAX` sentinel is fragile. Replace with `Option<u64>`:
|
||
|
||
(a) At the top of `src/ui/preset.rs`, add `Default` for `Preset`:
|
||
|
||
Change the `Preset` enum declaration to:
|
||
|
||
```rust
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||
pub enum Preset {
|
||
#[default]
|
||
Custom,
|
||
Cinematic,
|
||
Performance,
|
||
Web,
|
||
}
|
||
```
|
||
|
||
(Moving `Custom` to first position with `#[default]` so `Local<Preset>` initializes to `Custom`.)
|
||
|
||
(b) In `ui_system`'s signature, change `mut last_hash: Local<u64>` to `mut last_hash: Local<Option<u64>>`, and replace the two blocks:
|
||
|
||
Replace:
|
||
```rust
|
||
// Default the preset state on the first frame. Local<T: Default> would
|
||
// require deriving Default for Preset; we instead initialize manually
|
||
// via a sentinel hash of u64::MAX.
|
||
if *last_hash == u64::MAX {
|
||
*current_preset = Preset::Custom;
|
||
}
|
||
```
|
||
with:
|
||
```rust
|
||
// Local<Preset> defaults to Custom (Preset::default()).
|
||
// Local<Option<u64>> defaults to None — first-frame sentinel.
|
||
```
|
||
|
||
Replace:
|
||
```rust
|
||
if !*just_applied && *last_hash != u64::MAX && now_hash != *last_hash {
|
||
```
|
||
with:
|
||
```rust
|
||
if !*just_applied && last_hash.is_some() && now_hash != last_hash.unwrap() {
|
||
```
|
||
|
||
Replace:
|
||
```rust
|
||
*last_hash = now_hash;
|
||
```
|
||
with:
|
||
```rust
|
||
*last_hash = Some(now_hash);
|
||
```
|
||
|
||
- [ ] **Step 3: Verify it compiles**
|
||
|
||
Run: `cargo build`
|
||
Expected: compiles cleanly.
|
||
|
||
- [ ] **Step 4: Run tests (preset test must still pass after the Default change)**
|
||
|
||
Run: `cargo test`
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ui/mod.rs src/ui/preset.rs
|
||
git commit -m "feat(ui): rewrite ui_system as SidePanel chassis orchestrator
|
||
|
||
SidePanel::right(300, resizable 260-400) → preset_bar (top, fixed) →
|
||
ScrollArea (section stack). 4 always-open group() cards + 6 collapsing
|
||
sections (4 with header toggles). Planets dirty-detection relocated here
|
||
(needs pre-render field snapshot). Preset Custom-detection runs each
|
||
frame unless just_applied. WantsPointer assignment preserved verbatim.
|
||
|
||
Preset now derives Default=Custom; last_hash is Option<u64> (None=first
|
||
frame) to avoid the u64::MAX sentinel fragility."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Acceptance — build, test, visual, web
|
||
|
||
No new code. This task runs the spec's acceptance checklist (§5) and confirms every item.
|
||
|
||
**Files:** none
|
||
|
||
- [ ] **Step 1: Clean release build**
|
||
|
||
Run: `cargo build --release`
|
||
Expected: compiles with no errors.
|
||
|
||
- [ ] **Step 2: Full test suite**
|
||
|
||
Run: `cargo test`
|
||
Expected: all tests pass — the existing `physics` mirror tests + the three new `preset_test` tests.
|
||
|
||
- [ ] **Step 3: Desktop visual check**
|
||
|
||
Run: `cargo run --release`
|
||
|
||
Confirm each of these (tick as you see it):
|
||
- [ ] SidePanel docked on the right edge, draggable width in 260–400px range
|
||
- [ ] 4 always-open cards visible: Camera, Black Hole, Accretion Disk, Quality
|
||
- [ ] 6 collapsing headers visible: Disk Turbulence, Doppler, Jets, Planets, Background, Grid
|
||
- [ ] No section named "Renderer" (it was merged into Quality)
|
||
- [ ] Only ONE "Resolution"/`render_scale` slider exists (in Quality) — the duplicate-slider bug is gone
|
||
- [ ] Preset bar at top: dropdown shows "Custom" initially
|
||
- [ ] Select "Cinematic" → sliders jump (steps=300, render_scale=0.75, bloom=High, disk=High, aa=High), bar shows "Cinematic"
|
||
- [ ] Drag any preset-touched slider (e.g. steps) → bar auto-switches to "Custom"
|
||
- [ ] Drag a non-preset slider (e.g. camera Distance, or disk Tilt) → bar STAYS on current preset (does NOT flip to Custom)
|
||
- [ ] Click "↺ all" → all params reset to default, bar shows "Custom"
|
||
- [ ] Open Jets, set spin to 0 → header checkbox still checkable but the warning "Jets need χ ≥ 0.05" appears in orange, strength slider disabled
|
||
- [ ] In Quality, set Bloom to "Off" → Threshold and Strength rows disappear entirely
|
||
- [ ] Open Disk Turbulence, set Disk quality to "Off" → orange warning "flat zero-thickness disk rendered", 7 turbulence sliders grey out but stay visible
|
||
- [ ] **Regression-critical**: drag a slider in the panel → the orbit camera does NOT rotate
|
||
- [ ] **Regression-critical**: no grey screen / frozen view — `nudge_camera` still working (the view updates as you change params)
|
||
- [ ] Panel background is the dark blue-black `#0E1014`, accents are cyan, warnings orange
|
||
- [ ] Confirm with the human: global "↺ all" reset is acceptable vs. the spec's per-section reset (see Task 10 deviation note). If they want per-section, file as a follow-up.
|
||
|
||
- [ ] **Step 4: Web build boots without panic**
|
||
|
||
Run: `trunk serve` → open http://127.0.0.1:8080
|
||
Expected:
|
||
- [ ] Page loads, no console panic
|
||
- [ ] SidePanel renders on the right
|
||
- [ ] No egui 0.34 `Rounding`/`CornerRadius` or `Frame` stroke-in-padding errors in the console
|
||
- [ ] Preset bar works, sections expand/collapse
|
||
|
||
If the build fails to compile for wasm, the most likely cause is an egui 0.34 API surface difference — check the error against the research findings (egui 0.34 renamed `Rounding`→`CornerRadius`, `Frame` counts stroke width in padding).
|
||
|
||
- [ ] **Step 5: Final commit (only if any fixups were needed during acceptance)**
|
||
|
||
If Steps 1–4 needed fixes, commit them. Otherwise no commit — the implementation is complete as of Task 11.
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix(ui): acceptance pass fixups
|
||
|
||
(If empty, skip this commit.)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (run before handing off)
|
||
|
||
**1. Spec coverage** — every spec section maps to a task:
|
||
- §1 Chassis + startup styling → Tasks 2 (style.rs) + 4 (wire) + 11 (SidePanel)
|
||
- §2 Section skeleton + `section()` → Tasks 5 (group/collapsing) + 6 (collapsing_with_toggle) + 7-9 (10 sections)
|
||
- §3 Preset bar + Grid rows → Tasks 3 (preset.rs + test) + 10 (preset_bar) + 11 (orchestrator wiring) + Grid rows throughout 7-9
|
||
- §4 Toggles / disabled / warnings → Tasks 6 (header toggle) + 9 (orange warnings, hide vs disable)
|
||
- §5 File structure + acceptance → Task 1 (module dir) + 12 (acceptance)
|
||
|
||
**2. Placeholder scan** — no TBDs/TODOs. Each step has complete code or an exact command. The one "deviation from spec" (Task 10 global reset) is flagged, not hidden.
|
||
|
||
**3. Type consistency** — checked signatures across tasks:
|
||
- `group(ui, title, body)` used in Task 11 matches Task 5's definition
|
||
- `collapsing(ui, id, title, default_open, body)` matches
|
||
- `collapsing_with_toggle(ui, id, title, default_open, enabled, body)` — Task 6 defines `body: FnOnce(&mut Ui, bool)`, Task 11 calls it with `|ui, en| section_*(ui, &mut params, en)` ✓
|
||
- `section_planets(ui, params, enabled, planet_dirty)` — Task 9 signature matches Task 11 call ✓
|
||
- `Preset::default() == Custom` (Task 11.2) is consistent with `Local<Preset>` initialization
|
||
- `params_hash` / `canonical_hash` / `apply` (Task 3) match preset.rs and the Task 11 detection logic
|
||
|
||
**4. Bite-size** — each task is one cohesive change, each step is one action, commits are granular and explain what+why.
|
||
|
||
## Execution Handoff
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-07-17-ui-panel-redesign.md`. Two execution options:
|
||
|
||
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||
|
||
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||
|
||
Which approach?
|