//! Galaxy UI: parameter slider panel (left) and selected-system //! info panel (right). //! //! Bevy 0.16 does not ship a native Slider widget, so each parameter is a //! row of `Label Value [-] [+]` buttons. Each `+/-` button carries a //! [`ParamButton`] marker identifying which field to mutate. //! //! Rows are grouped under section headers (Galaxy, Core, Disk, Top Beam, //! Bottom Beam) for visual clarity now that the panel spans five conceptual //! layers. //! //! ## Multi-disk UI //! //! Disk layers use a **tab selector** pattern: a row of numbered buttons //! (1 2 3 …) picks which disk the parameter rows below edit. An "Add Disk" //! button appears when below [`MAX_DISKS`]; a "Remove" button appears for //! non-primary disks. This keeps the UI at constant height regardless of //! how many disks are active. use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; use super::GalaxySpawned; use crate::gameplay::galaxy::params::*; // ── Markers ───────────────────────────────────────────────────────────────── #[derive(Component)] pub struct GalaxyControlPanel; /// Marker for the scrollable inner container of the control panel. #[derive(Component)] pub struct GalaxyScrollContent; #[derive(Component)] pub struct GalaxyInfoPanel; /// Identifies which parameter a `+/-` button mutates and which direction. #[derive(Component, Clone, Copy)] pub enum ParamButton { SeedDecr, SeedIncr, SizeDecr, SizeIncr, // Core CoreCountDecr, CoreCountIncr, CoreRadiusDecr, CoreRadiusIncr, // Disk — tab selection + add/remove DiskTab(usize), DiskAdd, DiskRemove, // Disk — per-field controls (target the currently-selected disk) DiskCountDecr, DiskCountIncr, DiskArmsDecr, DiskArmsIncr, DiskTwistDecr, DiskTwistIncr, DiskTiltDecr, DiskTiltIncr, DiskRotationDecr, DiskRotationIncr, DiskInnerRadiusDecr, DiskInnerRadiusIncr, DiskOuterRadiusDecr, DiskOuterRadiusIncr, // Top beam (+Y) BeamTopEnabled, BeamTopThicknessDecr, BeamTopThicknessIncr, BeamTopLengthDecr, BeamTopLengthIncr, BeamTopDensityDecr, BeamTopDensityIncr, BeamTopTaperDecr, BeamTopTaperIncr, BeamTopGravityDecr, BeamTopGravityIncr, // Bottom beam (-Y) BeamBottomEnabled, BeamBottomThicknessDecr, BeamBottomThicknessIncr, BeamBottomLengthDecr, BeamBottomLengthIncr, BeamBottomDensityDecr, BeamBottomDensityIncr, BeamBottomTaperDecr, BeamBottomTaperIncr, BeamBottomGravityDecr, BeamBottomGravityIncr, /// Randomize all params to random valid values within their bounds. RandomSettings, /// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button). Regenerate, /// Reset orbit camera to default orientation. CenterView, } /// Resource tracking which disk layer is currently selected in the tab bar. /// Always clamped to a valid index when the panel is rebuilt. #[derive(Resource, Debug, Clone, Copy)] pub struct SelectedDisk(pub usize); impl Default for SelectedDisk { fn default() -> Self { Self(0) } } /// Flag resource inserted when the disk count changes (add/remove). The /// [`rebuild_scroll_content`] system reads and removes it, re-spawning /// the scroll area with the correct number of tab buttons. #[derive(Resource)] pub(super) struct RebuildScrollContent; // ── Styling constants ─────────────────────────────────────────────────────── const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12); const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62); const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0); const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82); const SECTION_TEXT: Color = Color::srgb(0.42, 0.62, 0.86); const BUTTON_BG: Color = Color::srgb(0.10, 0.14, 0.22); const TAB_ACTIVE_BG: Color = Color::srgb(0.18, 0.28, 0.46); const TAB_INACTIVE_BG: Color = Color::srgb(0.08, 0.11, 0.18); const PANEL_WIDTH: f32 = 300.0; const PANEL_PADDING: f32 = 14.0; const TITLE_FONT_SIZE: f32 = 20.0; const SECTION_FONT_SIZE: f32 = 12.0; const LABEL_FONT_SIZE: f32 = 15.0; const VALUE_FONT_SIZE: f32 = 15.0; const BUTTON_FONT_SIZE: f32 = 16.0; const HELP_FONT_SIZE: f32 = 12.0; const TAB_FONT_SIZE: f32 = 14.0; // ── Setup ─────────────────────────────────────────────────────────────────── pub fn setup_galaxy_ui(mut commands: Commands, params: Res) { commands.init_resource::(); spawn_control_panel(&mut commands, ¶ms); spawn_info_panel_empty(&mut commands); } fn spawn_control_panel(commands: &mut Commands, params: &GalaxyParams) { commands .spawn(( Node { position_type: PositionType::Absolute, left: Val::Px(12.0), top: Val::Px(12.0), bottom: Val::Px(12.0), width: Val::Px(PANEL_WIDTH), flex_direction: FlexDirection::Column, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(PANEL_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(8.0)), GalaxyControlPanel, GalaxySpawned, )) .with_children(|parent| { // Sticky title — pinned above the scroll area. parent.spawn(( Text::new("Galaxy Parameters"), TextFont { font_size: TITLE_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), Node { padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, 4.0), ..default() }, )); // Randomize button — pinned above the scroll area. parent .spawn(( Button, Node { height: Val::Px(32.0), margin: UiRect::px(PANEL_PADDING, PANEL_PADDING, 4.0, PANEL_PADDING), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(BUTTON_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(6.0)), ParamButton::RandomSettings, )) .with_children(|btn| { btn.spawn(( Text::new("Randomize Settings"), TextFont { font_size: BUTTON_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), )); }); // Scrollable content area. parent .spawn(( Node { flex_grow: 1.0, flex_direction: FlexDirection::Column, row_gap: Val::Px(6.0), padding: UiRect::px( PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, ), overflow: Overflow::scroll_y(), ..default() }, ScrollPosition::default(), GalaxyScrollContent, )) .with_children(|scroll| { spawn_scroll_contents_with_tabs(scroll, params, 0); }); }); } /// Build all rows inside the scroll area. This is the version used by /// [`spawn_control_panel`] for the initial build. The full rebuild variant /// that knows the current params / active tab is [`spawn_scroll_contents_with_tabs`]. fn spawn_scroll_contents(scroll: &mut ChildSpawnerCommands) { // Delegate to the tab-aware builder with default params context. // The initial build uses a single default disk. let default_params = GalaxyParams::default(); spawn_scroll_contents_with_tabs(scroll, &default_params, 0); } /// Marker for the disk "Remove" button so we can toggle its visibility. #[derive(Component)] struct DiskRemoveButton; /// Marker for disk tab buttons so we can restyle the active tab. #[derive(Component)] struct DiskTabButton(usize); // ── Section / row helpers ─────────────────────────────────────────────────── /// Section header row — small dim label with a top margin to separate groups. fn spawn_section(parent: &mut ChildSpawnerCommands, label: &str) { parent.spawn(( Text::new(label.to_uppercase()), TextFont { font_size: SECTION_FONT_SIZE, ..default() }, TextColor(SECTION_TEXT), Node { margin: UiRect::top(Val::Px(4.0)), ..default() }, )); } /// Build a `[Label ........ value [-] [+]]` row. The `value_key` is stored in /// the marker as a label for the value text child; the actual value is read /// from `GalaxyParams` at button-press time and updated by /// `refresh_control_panel_values` each frame. fn spawn_param_row( parent: &mut ChildSpawnerCommands, label: &str, value_key: &str, decr: ParamButton, incr: ParamButton, ) { parent .spawn(Node { width: Val::Percent(100.0), flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: Val::Px(6.0), ..default() }) .with_children(|row| { // Label — fixed width so values align. row.spawn(( Text::new(label), TextFont { font_size: LABEL_FONT_SIZE, ..default() }, TextColor(TEXT_DIM), Node { width: Val::Px(100.0), ..default() }, )); // Value — flexible width, right-aligned textually by spacer below. row.spawn(( Text::new("—"), TextFont { font_size: VALUE_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), Node { flex_grow: 1.0, ..default() }, ParamValue(value_key.to_string()), )); // [-] button. spawn_icon_button(row, "−", decr); // [+] button. spawn_icon_button(row, "+", incr); }); } /// `[Label ........ value [ø]]` — a single-button row for boolean toggles /// (Enabled / Disabled). The button cycles the field on each press; the value /// text displays "On"/"Off" via [`refresh_control_panel_values`]. fn spawn_toggle_row( parent: &mut ChildSpawnerCommands, label: &str, value_key: &str, toggle: ParamButton, ) { parent .spawn(Node { width: Val::Percent(100.0), flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: Val::Px(6.0), ..default() }) .with_children(|row| { row.spawn(( Text::new(label), TextFont { font_size: LABEL_FONT_SIZE, ..default() }, TextColor(TEXT_DIM), Node { width: Val::Px(100.0), ..default() }, )); row.spawn(( Text::new("—"), TextFont { font_size: VALUE_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), Node { flex_grow: 1.0, ..default() }, ParamValue(value_key.to_string()), )); // Wider button so the toggle label "ø" / "✓" reads clearly. parent_button(row, toggle, "ø", 34.0); }); } /// Marker on the value Text node so `refresh_control_panel_values` can find /// and update it. The `String` is the param field name (e.g. "seed", "twist"). #[derive(Component)] pub(crate) struct ParamValue(pub(crate) String); fn spawn_icon_button(parent: &mut ChildSpawnerCommands, label: &str, marker: ParamButton) { parent_button(parent, marker, label, 26.0); } fn parent_button( parent: &mut ChildSpawnerCommands, marker: ParamButton, label: &str, width: f32, ) { parent .spawn(( Button, Node { width: Val::Px(width), height: Val::Px(26.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(BUTTON_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(4.0)), marker, )) .with_children(|btn| { btn.spawn(( Text::new(label), TextFont { font_size: BUTTON_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), )); }); } /// Spawn the info panel in its "no selection" state. The selection module /// replaces its contents whenever [`super::SelectedStar`] changes. fn spawn_info_panel_empty(commands: &mut Commands) { commands .spawn(( Node { position_type: PositionType::Absolute, right: Val::Px(12.0), top: Val::Px(12.0), width: Val::Px(PANEL_WIDTH), padding: UiRect::all(Val::Px(PANEL_PADDING)), flex_direction: FlexDirection::Column, row_gap: Val::Px(4.0), border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(PANEL_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(8.0)), GalaxyInfoPanel, GalaxySpawned, )) .with_children(|parent| { parent.spawn(( Text::new("Selected System"), TextFont { font_size: TITLE_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), Node { margin: UiRect::bottom(Val::Px(8.0)), ..default() }, )); parent.spawn(( Text::new("Click a star to inspect."), TextFont { font_size: LABEL_FONT_SIZE, ..default() }, TextColor(TEXT_DIM), )); }); } // ── Button handler ────────────────────────────────────────────────────────── pub fn param_button_handler( mut commands: Commands, mut params: ResMut, mut selected_disk: ResMut, query: Query<(&Interaction, &ParamButton), Changed>, ) { for (interaction, button) in &query { let &Interaction::Pressed = interaction else { continue; }; match button { // ── Galaxy ──────────────────────────────────────────────────── ParamButton::SeedDecr => { params.seed = params.seed.wrapping_sub(SEED_STEP); params.bump_generation(); } ParamButton::SeedIncr => { params.seed = params.seed.wrapping_add(SEED_STEP); params.bump_generation(); } ParamButton::SizeDecr => { params.size = (params.size - SIZE_STEP).max(SIZE_MIN); params.bump_generation(); } ParamButton::SizeIncr => { params.size = (params.size + SIZE_STEP).min(SIZE_MAX); params.bump_generation(); } // ── Core ────────────────────────────────────────────────────── ParamButton::CoreCountDecr => { params.core.count = params .core .count .saturating_sub(CORE_COUNT_STEP) .max(CORE_COUNT_MIN); params.bump_generation(); } ParamButton::CoreCountIncr => { params.core.count = (params.core.count + CORE_COUNT_STEP).min(CORE_COUNT_MAX); params.bump_generation(); } ParamButton::CoreRadiusDecr => { params.core.radius = (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN); params.bump_generation(); } ParamButton::CoreRadiusIncr => { params.core.radius = (params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX); params.bump_generation(); } // ── Disk tab management ─────────────────────────────────────── ParamButton::DiskTab(n) => { selected_disk.0 = *n; } ParamButton::DiskAdd => { if params.disks.len() < MAX_DISKS { params.disks.push(DiskParams::default()); selected_disk.0 = params.disks.len() - 1; params.bump_generation(); commands.insert_resource(RebuildScrollContent); } } ParamButton::DiskRemove => { if params.disks.len() > 1 && selected_disk.0 < params.disks.len() { params.disks.remove(selected_disk.0); selected_disk.0 = (selected_disk.0).min(params.disks.len() - 1); params.bump_generation(); commands.insert_resource(RebuildScrollContent); } } // ── Disk params (target selected_disk) ──────────────────────── ParamButton::DiskCountDecr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.count = disk.count.saturating_sub(DISK_COUNT_STEP).max(DISK_COUNT_MIN); params.bump_generation(); } ParamButton::DiskCountIncr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.count = (disk.count + DISK_COUNT_STEP).min(DISK_COUNT_MAX); params.bump_generation(); } ParamButton::DiskArmsDecr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.arms = disk.arms.saturating_sub(1).max(DISK_ARMS_MIN); params.bump_generation(); } ParamButton::DiskArmsIncr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.arms = disk.arms.saturating_add(1).min(DISK_ARMS_MAX); params.bump_generation(); } ParamButton::DiskTwistDecr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.twist = (disk.twist - DISK_TWIST_STEP).max(DISK_TWIST_MIN); params.bump_generation(); } ParamButton::DiskTwistIncr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.twist = (disk.twist + DISK_TWIST_STEP).min(DISK_TWIST_MAX); params.bump_generation(); } ParamButton::DiskTiltDecr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.tilt = (disk.tilt - DISK_TILT_STEP).max(DISK_TILT_MIN); params.bump_generation(); } ParamButton::DiskTiltIncr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.tilt = (disk.tilt + DISK_TILT_STEP).min(DISK_TILT_MAX); params.bump_generation(); } ParamButton::DiskRotationDecr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.rotation_offset = (disk.rotation_offset - DISK_ROTATION_STEP) .max(DISK_ROTATION_MIN); params.bump_generation(); } ParamButton::DiskRotationIncr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.rotation_offset = (disk.rotation_offset + DISK_ROTATION_STEP) .min(DISK_ROTATION_MAX); params.bump_generation(); } ParamButton::DiskInnerRadiusDecr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.inner_radius = (disk.inner_radius - DISK_INNER_RADIUS_STEP).max(DISK_INNER_RADIUS_MIN); params.bump_generation(); } ParamButton::DiskInnerRadiusIncr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.inner_radius = (disk.inner_radius + DISK_INNER_RADIUS_STEP).min(DISK_INNER_RADIUS_MAX); params.bump_generation(); } ParamButton::DiskOuterRadiusDecr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.outer_radius = (disk.outer_radius - DISK_OUTER_RADIUS_STEP).max(DISK_OUTER_RADIUS_MIN); params.bump_generation(); } ParamButton::DiskOuterRadiusIncr => { let disk = selected_disk_mut(&mut params, selected_disk.0); disk.outer_radius = (disk.outer_radius + DISK_OUTER_RADIUS_STEP).min(DISK_OUTER_RADIUS_MAX); params.bump_generation(); } // ── Top Beam (+Y) ───────────────────────────────────────────── ParamButton::BeamTopEnabled => { params.beam_top.enabled = !params.beam_top.enabled; params.bump_generation(); } ParamButton::BeamTopThicknessDecr => { params.beam_top.thickness = (params.beam_top.thickness - BEAM_THICKNESS_STEP).max(BEAM_THICKNESS_MIN); params.bump_generation(); } ParamButton::BeamTopThicknessIncr => { params.beam_top.thickness = (params.beam_top.thickness + BEAM_THICKNESS_STEP).min(BEAM_THICKNESS_MAX); params.bump_generation(); } ParamButton::BeamTopLengthDecr => { params.beam_top.length = (params.beam_top.length - BEAM_LENGTH_STEP).max(BEAM_LENGTH_MIN); params.bump_generation(); } ParamButton::BeamTopLengthIncr => { params.beam_top.length = (params.beam_top.length + BEAM_LENGTH_STEP).min(BEAM_LENGTH_MAX); params.bump_generation(); } ParamButton::BeamTopDensityDecr => { params.beam_top.count = params .beam_top .count .saturating_sub(BEAM_COUNT_STEP) .max(BEAM_COUNT_MIN); params.bump_generation(); } ParamButton::BeamTopDensityIncr => { params.beam_top.count = (params.beam_top.count + BEAM_COUNT_STEP).min(BEAM_COUNT_MAX); params.bump_generation(); } ParamButton::BeamTopTaperDecr => { params.beam_top.taper = (params.beam_top.taper - BEAM_TAPER_STEP).max(BEAM_TAPER_MIN); params.bump_generation(); } ParamButton::BeamTopTaperIncr => { params.beam_top.taper = (params.beam_top.taper + BEAM_TAPER_STEP).min(BEAM_TAPER_MAX); params.bump_generation(); } ParamButton::BeamTopGravityDecr => { params.beam_top.gravity = (params.beam_top.gravity - BEAM_GRAVITY_STEP).max(BEAM_GRAVITY_MIN); params.bump_generation(); } ParamButton::BeamTopGravityIncr => { params.beam_top.gravity = (params.beam_top.gravity + BEAM_GRAVITY_STEP).min(BEAM_GRAVITY_MAX); params.bump_generation(); } // ── Bottom Beam (-Y) ────────────────────────────────────────── ParamButton::BeamBottomEnabled => { params.beam_bottom.enabled = !params.beam_bottom.enabled; params.bump_generation(); } ParamButton::BeamBottomThicknessDecr => { params.beam_bottom.thickness = (params.beam_bottom.thickness - BEAM_THICKNESS_STEP) .max(BEAM_THICKNESS_MIN); params.bump_generation(); } ParamButton::BeamBottomThicknessIncr => { params.beam_bottom.thickness = (params.beam_bottom.thickness + BEAM_THICKNESS_STEP) .min(BEAM_THICKNESS_MAX); params.bump_generation(); } ParamButton::BeamBottomLengthDecr => { params.beam_bottom.length = (params.beam_bottom.length - BEAM_LENGTH_STEP).max(BEAM_LENGTH_MIN); params.bump_generation(); } ParamButton::BeamBottomLengthIncr => { params.beam_bottom.length = (params.beam_bottom.length + BEAM_LENGTH_STEP).min(BEAM_LENGTH_MAX); params.bump_generation(); } ParamButton::BeamBottomDensityDecr => { params.beam_bottom.count = params .beam_bottom .count .saturating_sub(BEAM_COUNT_STEP) .max(BEAM_COUNT_MIN); params.bump_generation(); } ParamButton::BeamBottomDensityIncr => { params.beam_bottom.count = (params.beam_bottom.count + BEAM_COUNT_STEP).min(BEAM_COUNT_MAX); params.bump_generation(); } ParamButton::BeamBottomTaperDecr => { params.beam_bottom.taper = (params.beam_bottom.taper - BEAM_TAPER_STEP).max(BEAM_TAPER_MIN); params.bump_generation(); } ParamButton::BeamBottomTaperIncr => { params.beam_bottom.taper = (params.beam_bottom.taper + BEAM_TAPER_STEP).min(BEAM_TAPER_MAX); params.bump_generation(); } ParamButton::BeamBottomGravityDecr => { params.beam_bottom.gravity = (params.beam_bottom.gravity - BEAM_GRAVITY_STEP) .max(BEAM_GRAVITY_MIN); params.bump_generation(); } ParamButton::BeamBottomGravityIncr => { params.beam_bottom.gravity = (params.beam_bottom.gravity + BEAM_GRAVITY_STEP) .min(BEAM_GRAVITY_MAX); params.bump_generation(); } ParamButton::RandomSettings => { use rand::Rng; let mut rng = rand::thread_rng(); params.seed = rng.gen(); params.size = rng.gen_range(SIZE_MIN..=SIZE_MAX); // Round size to step. params.size = (params.size / SIZE_STEP).round() * SIZE_STEP; params.core.count = rng.gen_range(CORE_COUNT_MIN..=CORE_COUNT_MAX); params.core.radius = rng.gen_range(CORE_RADIUS_MIN..=CORE_RADIUS_MAX); params.core.radius = (params.core.radius / CORE_RADIUS_STEP).round() * CORE_RADIUS_STEP; // Randomize disks: 1–3 layers. let disk_count = rng.gen_range(1..=3.min(MAX_DISKS)); params.disks.clear(); for _ in 0..disk_count { params.disks.push(randomize_disk(&mut rng)); } selected_disk.0 = 0; randomize_beam(&mut params.beam_top, &mut rng); randomize_beam(&mut params.beam_bottom, &mut rng); params.bump_generation(); commands.insert_resource(RebuildScrollContent); } ParamButton::Regenerate => params.reseed_and_bump(), // CenterView is handled by `reset_view_button_handler` (needs Commands). ParamButton::CenterView => {} } } } /// Helper: get a mutable reference to the currently-selected disk. /// Clamps the index to the valid range as a safety measure. fn selected_disk_mut(params: &mut GalaxyParams, index: usize) -> &mut DiskParams { let idx = index.min(params.disks.len() - 1); &mut params.disks[idx] } /// Handle the CenterView button: insert [`ResetOrbitCamera`] so /// [`crate::camera::apply_orbit_reset`] can snap the orbit camera back to /// its default orientation next frame. Kept in a separate system because it /// needs `Commands` (not just `ResMut`). pub fn reset_view_button_handler( mut commands: Commands, reset_flag: Option>, query: Query<(&Interaction, &ParamButton), Changed>, ) { for (interaction, button) in &query { if *interaction != Interaction::Pressed { continue; } if matches!(button, ParamButton::CenterView) && reset_flag.is_none() { commands.insert_resource(crate::camera::ResetOrbitCamera); } } } /// Refresh the displayed parameter values every frame so the UI stays in sync. /// Bevy UI has no native data binding, so we manually patch the `Text` children /// — cheap (~30 nodes) and avoids re-spawning the whole panel. /// /// Disk-related values read from the currently-selected disk /// ([`SelectedDisk`]) so the tab selector works correctly. pub fn refresh_control_panel_values( params: Res, selected_disk: Res, mut values: Query<(&ParamValue, &mut Text)>, ) { // Resolve the selected disk, clamped to a valid index. let disk_idx = selected_disk.0.min(params.disks.len() - 1); let disk = ¶ms.disks[disk_idx]; for (marker, mut text) in &mut values { let new = match marker.0.as_str() { // Galaxy "seed" => format!("{}", params.seed), "size" => format!("{:.0}", params.size), // Core "core_count" => format!("{}", params.core.count), "core_radius" => format!("{:.0}", params.core.radius), // Disk — reads from selected disk "disk_count" => format!("{}", disk.count), "disk_arms" => format!("{}", disk.arms), "disk_twist" => format!("{:.1}", disk.twist), "disk_tilt" => format!("{:.0}°", disk.tilt.to_degrees()), "disk_rotation" => format!("{:.0}°", disk.rotation_offset.to_degrees()), "disk_inner_radius" => { if disk.inner_radius > 0.0 { format!("{:.0}", disk.inner_radius) } else { "0".to_string() } } "disk_outer_radius" => { if disk.outer_radius > 0.0 { format!("{:.0}", disk.outer_radius) } else { "auto".to_string() } } // Top beam "beam_top_enabled" => { format!( "{}", if params.beam_top.enabled { "On" } else { "Off" } ) } "beam_top_thickness" => format!("{:.0}", params.beam_top.thickness), "beam_top_length" => format!("{:.0}", params.beam_top.length), "beam_top_density" => format!("{}", params.beam_top.count), "beam_top_taper" => format!("{:.1}", params.beam_top.taper), "beam_top_gravity" => format!("{:.1}", params.beam_top.gravity), // Bottom beam "beam_bottom_enabled" => { format!( "{}", if params.beam_bottom.enabled { "On" } else { "Off" } ) } "beam_bottom_thickness" => format!("{:.0}", params.beam_bottom.thickness), "beam_bottom_length" => format!("{:.0}", params.beam_bottom.length), "beam_bottom_density" => format!("{}", params.beam_bottom.count), "beam_bottom_taper" => format!("{:.1}", params.beam_bottom.taper), "beam_bottom_gravity" => format!("{:.1}", params.beam_bottom.gravity), _ => continue, }; if text.0 != new { text.0 = new; } } } // ── Scroll content rebuild ────────────────────────────────────────────────── /// When the disk count changes (add/remove/randomize), the tab bar and the /// remove button need to be rebuilt. This system detects the flag, despawns /// the old scroll content entity, and re-spawns it with the correct tab count. pub fn rebuild_scroll_content( mut commands: Commands, flag: Option>, scroll_content: Query>, control_panel: Query>, params: Res, selected_disk: Res, ) { let Some(_) = flag else { return; }; // Consume the flag. commands.remove_resource::(); let Ok(scroll_entity) = scroll_content.single() else { return; }; let Ok(panel_entity) = control_panel.single() else { return; }; // Despawn the old scroll content entity (and all its children). commands.entity(scroll_entity).despawn(); // Re-spawn a fresh scroll content as a child of the panel. commands .entity(panel_entity) .with_children(|parent| { parent .spawn(( Node { flex_grow: 1.0, flex_direction: FlexDirection::Column, row_gap: Val::Px(6.0), padding: UiRect::px( PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, ), overflow: Overflow::scroll_y(), ..default() }, ScrollPosition::default(), GalaxyScrollContent, )) .with_children(|scroll| { spawn_scroll_contents_with_tabs(scroll, ¶ms, selected_disk.0); }); }); } /// Full scroll content builder that includes the actual tab buttons for the /// current disk count. This is the authoritative spawn function — the simpler /// [`spawn_scroll_contents`] delegates to this when we need dynamic tabs. fn spawn_scroll_contents_with_tabs( scroll: &mut ChildSpawnerCommands, params: &GalaxyParams, active_disk: usize, ) { // ── Galaxy (seed + size) ──────────────────────────────────────── spawn_section(scroll, "Galaxy"); spawn_param_row( scroll, "Seed", "seed", ParamButton::SeedDecr, ParamButton::SeedIncr, ); spawn_param_row( scroll, "Size", "size", ParamButton::SizeDecr, ParamButton::SizeIncr, ); // ── Core ──────────────────────────────────────────────────────── spawn_section(scroll, "Core"); spawn_param_row( scroll, "Core Count", "core_count", ParamButton::CoreCountDecr, ParamButton::CoreCountIncr, ); spawn_param_row( scroll, "Core Radius", "core_radius", ParamButton::CoreRadiusDecr, ParamButton::CoreRadiusIncr, ); // ── Disk (tab bar + shared parameter rows) ────────────────────── spawn_section(scroll, "Disk"); // Tab bar: [1] [2] ... [+] let tab_bar_active = active_disk.min(params.disks.len() - 1); scroll .spawn(Node { width: Val::Percent(100.0), flex_direction: FlexDirection::Row, column_gap: Val::Px(4.0), align_items: AlignItems::Center, ..default() }) .with_children(|row| { for i in 0..params.disks.len() { let is_active = i == tab_bar_active; let bg = if is_active { TAB_ACTIVE_BG } else { TAB_INACTIVE_BG }; let border = if is_active { Color::srgb(0.40, 0.60, 0.90) } else { PANEL_BORDER }; let text_color = if is_active { TEXT_BRIGHT } else { TEXT_DIM }; row.spawn(( Button, Node { width: Val::Px(30.0), height: Val::Px(24.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(bg), BorderColor(border), BorderRadius::all(Val::Px(4.0)), ParamButton::DiskTab(i), DiskTabButton(i), )) .with_children(|btn| { btn.spawn(( Text::new(format!("{}", i + 1)), TextFont { font_size: TAB_FONT_SIZE, ..default() }, TextColor(text_color), )); }); } // [+ Add Disk] button — only if below max. if params.disks.len() < MAX_DISKS { row.spawn(( Button, Node { width: Val::Px(26.0), height: Val::Px(24.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(BUTTON_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(4.0)), ParamButton::DiskAdd, )) .with_children(|btn| { btn.spawn(( Text::new("+"), TextFont { font_size: TAB_FONT_SIZE, ..default() }, TextColor(TEXT_DIM), )); }); } }); // Shared disk parameter rows. spawn_param_row( scroll, "Disk Count", "disk_count", ParamButton::DiskCountDecr, ParamButton::DiskCountIncr, ); spawn_param_row( scroll, "Disk Arms", "disk_arms", ParamButton::DiskArmsDecr, ParamButton::DiskArmsIncr, ); spawn_param_row( scroll, "Disk Twist", "disk_twist", ParamButton::DiskTwistDecr, ParamButton::DiskTwistIncr, ); spawn_param_row( scroll, "Tilt", "disk_tilt", ParamButton::DiskTiltDecr, ParamButton::DiskTiltIncr, ); spawn_param_row( scroll, "Rotation", "disk_rotation", ParamButton::DiskRotationDecr, ParamButton::DiskRotationIncr, ); spawn_param_row( scroll, "Inner Rad", "disk_inner_radius", ParamButton::DiskInnerRadiusDecr, ParamButton::DiskInnerRadiusIncr, ); spawn_param_row( scroll, "Outer Rad", "disk_outer_radius", ParamButton::DiskOuterRadiusDecr, ParamButton::DiskOuterRadiusIncr, ); // Remove button — only show if more than one disk. if params.disks.len() > 1 { scroll .spawn(( Button, Node { width: Val::Percent(100.0), height: Val::Px(24.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(Color::srgb(0.25, 0.10, 0.10)), BorderColor(Color::srgb(0.50, 0.20, 0.20)), BorderRadius::all(Val::Px(4.0)), ParamButton::DiskRemove, DiskRemoveButton, )) .with_children(|btn| { btn.spawn(( Text::new("Remove This Disk"), TextFont { font_size: BUTTON_FONT_SIZE, ..default() }, TextColor(Color::srgb(0.90, 0.60, 0.60)), )); }); } // ── Top Beam (+Y) ─────────────────────────────────────────────── spawn_section(scroll, "Top Beam (+Y)"); spawn_toggle_row( scroll, "Enabled", "beam_top_enabled", ParamButton::BeamTopEnabled, ); spawn_param_row( scroll, "Thickness", "beam_top_thickness", ParamButton::BeamTopThicknessDecr, ParamButton::BeamTopThicknessIncr, ); spawn_param_row( scroll, "Length", "beam_top_length", ParamButton::BeamTopLengthDecr, ParamButton::BeamTopLengthIncr, ); spawn_param_row( scroll, "Density", "beam_top_density", ParamButton::BeamTopDensityDecr, ParamButton::BeamTopDensityIncr, ); spawn_param_row( scroll, "Taper", "beam_top_taper", ParamButton::BeamTopTaperDecr, ParamButton::BeamTopTaperIncr, ); spawn_param_row( scroll, "Gravity", "beam_top_gravity", ParamButton::BeamTopGravityDecr, ParamButton::BeamTopGravityIncr, ); // ── Bottom Beam (-Y) ──────────────────────────────────────────── spawn_section(scroll, "Bottom Beam (-Y)"); spawn_toggle_row( scroll, "Enabled", "beam_bottom_enabled", ParamButton::BeamBottomEnabled, ); spawn_param_row( scroll, "Thickness", "beam_bottom_thickness", ParamButton::BeamBottomThicknessDecr, ParamButton::BeamBottomThicknessIncr, ); spawn_param_row( scroll, "Length", "beam_bottom_length", ParamButton::BeamBottomLengthDecr, ParamButton::BeamBottomLengthIncr, ); spawn_param_row( scroll, "Density", "beam_bottom_density", ParamButton::BeamBottomDensityDecr, ParamButton::BeamBottomDensityIncr, ); spawn_param_row( scroll, "Taper", "beam_bottom_taper", ParamButton::BeamBottomTaperDecr, ParamButton::BeamBottomTaperIncr, ); spawn_param_row( scroll, "Gravity", "beam_bottom_gravity", ParamButton::BeamBottomGravityDecr, ParamButton::BeamBottomGravityIncr, ); // ── Action buttons ────────────────────────────────────────────── scroll .spawn(( Button, Node { width: Val::Percent(100.0), height: Val::Px(32.0), margin: UiRect::top(Val::Px(8.0)), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(BUTTON_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(6.0)), ParamButton::Regenerate, )) .with_children(|btn| { btn.spawn(( Text::new("Regenerate"), TextFont { font_size: BUTTON_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), )); }); scroll .spawn(( Button, Node { width: Val::Percent(100.0), height: Val::Px(32.0), margin: UiRect::top(Val::Px(4.0)), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), ..default() }, BackgroundColor(BUTTON_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(6.0)), ParamButton::CenterView, )) .with_children(|btn| { btn.spawn(( Text::new("Center View"), TextFont { font_size: BUTTON_FONT_SIZE, ..default() }, TextColor(TEXT_BRIGHT), )); }); scroll.spawn(( Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"), TextFont { font_size: HELP_FONT_SIZE, ..default() }, TextColor(TEXT_DIM), Node { margin: UiRect::top(Val::Px(10.0)), ..default() }, )); } // ── Helpers ───────────────────────────────────────────────────────────────── /// Randomize all fields of a [`DiskParams`] to random valid values within /// their respective bounds, rounded to the UI step. fn randomize_disk(rng: &mut impl rand::Rng) -> DiskParams { let mut disk = DiskParams::default(); disk.count = rng.gen_range(DISK_COUNT_MIN..=DISK_COUNT_MAX); disk.count = (disk.count / DISK_COUNT_STEP) * DISK_COUNT_STEP; disk.arms = rng.gen_range(DISK_ARMS_MIN..=DISK_ARMS_MAX); disk.twist = rng.gen_range(DISK_TWIST_MIN..=DISK_TWIST_MAX); disk.twist = (disk.twist / DISK_TWIST_STEP).round() * DISK_TWIST_STEP; disk.tilt = rng.gen_range(DISK_TILT_MIN..=DISK_TILT_MAX); disk.tilt = (disk.tilt / DISK_TILT_STEP).round() * DISK_TILT_STEP; disk.rotation_offset = rng.gen_range(DISK_ROTATION_MIN..=DISK_ROTATION_MAX); disk.rotation_offset = (disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP; disk.inner_radius = rng.gen_range(DISK_INNER_RADIUS_MIN..=DISK_INNER_RADIUS_MAX); disk.inner_radius = (disk.inner_radius / DISK_INNER_RADIUS_STEP).round() * DISK_INNER_RADIUS_STEP; disk.outer_radius = rng.gen_range(DISK_OUTER_RADIUS_MIN..=DISK_OUTER_RADIUS_MAX); disk.outer_radius = (disk.outer_radius / DISK_OUTER_RADIUS_STEP).round() * DISK_OUTER_RADIUS_STEP; // Allow 0 (auto) ~50% of the time. if rng.gen_bool(0.5) { disk.outer_radius = 0.0; } disk } /// Randomize all fields of a [`BeamParams`] to random valid values within /// their respective bounds, rounded to the UI step. fn randomize_beam(beam: &mut BeamParams, rng: &mut impl rand::Rng) { beam.enabled = rng.gen(); beam.count = rng.gen_range(BEAM_COUNT_MIN..=BEAM_COUNT_MAX); beam.count = (beam.count / BEAM_COUNT_STEP) * BEAM_COUNT_STEP; beam.thickness = rng.gen_range(BEAM_THICKNESS_MIN..=BEAM_THICKNESS_MAX); beam.thickness = (beam.thickness / BEAM_THICKNESS_STEP).round() * BEAM_THICKNESS_STEP; beam.length = rng.gen_range(BEAM_LENGTH_MIN..=BEAM_LENGTH_MAX); beam.length = (beam.length / BEAM_LENGTH_STEP).round() * BEAM_LENGTH_STEP; beam.taper = rng.gen_range(BEAM_TAPER_MIN..=BEAM_TAPER_MAX); beam.taper = (beam.taper / BEAM_TAPER_STEP).round() * BEAM_TAPER_STEP; beam.gravity = rng.gen_range(BEAM_GRAVITY_MIN..=BEAM_GRAVITY_MAX); beam.gravity = (beam.gravity / BEAM_GRAVITY_STEP).round() * BEAM_GRAVITY_STEP; } /// Route mouse-wheel events to the control panel's scroll container when the /// cursor is over the panel. Bevy 0.16 does not wire `MouseWheel` to /// `ScrollPosition` automatically — this system bridges the gap. pub fn scroll_control_panel( mut scroll_events: EventReader, primary_window: Query<&Window, With>, panel_nodes: Query<(&ComputedNode, &GlobalTransform), With>, mut scroll_content: Query<&mut ScrollPosition, With>, ) { let Ok(window) = primary_window.single() else { scroll_events.clear(); return; }; let Some(cursor_pos) = window.cursor_position() else { scroll_events.clear(); return; }; // Check if cursor is within any control panel node's rect. let over_panel = panel_nodes.iter().any(|(node, transform)| { let pos = transform.translation().truncate(); let size = node.size(); let min = pos - size * 0.5; let max = pos + size * 0.5; cursor_pos.x >= min.x && cursor_pos.x <= max.x && cursor_pos.y >= min.y && cursor_pos.y <= max.y }); if !over_panel { return; } let Ok(mut scroll_pos) = scroll_content.single_mut() else { return; }; for event in scroll_events.read() { scroll_pos.offset_y -= event.y * 20.0; } }