//! Top menu bar. use bevy::prelude::*; use bevy_egui::egui; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_dock::DockState; use crate::history::{apply_command_redo, apply_command_undo, EditorHistory}; use crate::project::samples::{SampleCatalog, SampleCatalogEntry}; use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel}; use crate::state::{EditorMode, PlayPaused}; use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel}; use super::dock_tabs::{open_and_focus_tab, tab_is_open, tab_label, PanelNodes, PANEL_TABS}; use super::helpers::{ delete_selection, duplicate_selection, toggle_play_mode, toggle_play_paused, toggle_possession, }; use super::layout::reset_and_save_layout; use super::widgets::menu_item; use super::EditorTab; #[derive(Debug, Clone, PartialEq, Eq)] enum SampleMenuModel { Available(Vec), Unavailable(String), } fn sample_menu_model(catalog: &SampleCatalog) -> SampleMenuModel { match catalog.unavailable_reason() { Some(reason) => SampleMenuModel::Unavailable(reason.to_string()), None => SampleMenuModel::Available(catalog.entries().to_vec()), } } pub fn top_menu_bar( world: &mut World, root_ui: &mut egui::Ui, selected: &SelectedEntities, dock_state: &mut DockState, panel_nodes: &mut PanelNodes, ) { egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if menu_item(ui, "New Scene", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::New); ui.close(); } if menu_item(ui, "Open Scene...", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::Open); ui.close(); } let sample_menu = sample_menu_model(world.resource::()); ui.menu_button("Open Sample", |ui| match sample_menu { SampleMenuModel::Available(entries) => { for entry in entries { if menu_item(ui, &entry.label, None, true) .on_hover_text(entry.hover_text()) .clicked() { world.resource_mut::().request = Some(SceneIoRequest::OpenSample(entry.id)); ui.close(); } } } SampleMenuModel::Unavailable(reason) => { menu_item(ui, "Sample pack unavailable", None, false) .on_disabled_hover_text(reason); } }); if menu_item(ui, "Save", Some("Ctrl+S"), true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::Save); ui.close(); } if menu_item(ui, "Save Scene As...", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::SaveAs); ui.close(); } let recovery_available = world.resource::().recovery_snapshot.is_some(); if recovery_available { ui.separator(); if menu_item(ui, "Restore Recovery Snapshot", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::RestoreRecovery); ui.close(); } if menu_item(ui, "Save Recovery Copy As...", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::SaveRecoveryCopyAs); ui.close(); } if menu_item(ui, "Discard Recovery Snapshot", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::DiscardRecovery); ui.close(); } } ui.separator(); if menu_item(ui, "Import Assets...", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::ImportAssets); ui.close(); } if menu_item(ui, "Export Selection...", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::ExportSelection); ui.close(); } if menu_item(ui, "Save Selection As Prefab...", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::SaveSelectionAsPrefab); ui.close(); } ui.separator(); if menu_item(ui, "Switch Project...", None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::SwitchProject); ui.close(); } if menu_item(ui, "Reveal Project Folder", None, true).clicked() { let root = world .resource::() .root .clone(); let status = match crate::launcher::reveal_project_root(std::path::Path::new(&root)) { Ok(()) => format!("Revealed project folder {root}"), Err(error) => format!("Reveal project folder failed: {error}"), }; world.resource_mut::().set_status(status); ui.close(); } let recent: Vec<(usize, String)> = world .resource::() .recent_paths .iter() .enumerate() .map(|(index, path)| (index, path.display().to_string())) .collect(); if !recent.is_empty() { ui.separator(); ui.menu_button("Recent Scenes", |ui| { for (index, label) in recent { if menu_item(ui, &label, None, true).clicked() { world.resource_mut::().request = Some(SceneIoRequest::OpenRecent(index)); ui.close(); } } }); } }); ui.menu_button("Edit", |ui| { if menu_item(ui, "Project Settings...", None, true).clicked() { open_project_settings_panel(&mut world.resource_mut::()); ui.close(); } ui.separator(); let can_undo = world.resource::().can_undo(); let can_redo = world.resource::().can_redo(); if menu_item(ui, "Undo", Some("Ctrl+Z"), can_undo).clicked() { apply_command_undo(world); ui.close(); } if menu_item(ui, "Redo", Some("Ctrl+Y"), can_redo).clicked() { apply_command_redo(world); ui.close(); } ui.separator(); let has_selection = !selected.as_slice().is_empty(); if menu_item(ui, "Duplicate", Some("Ctrl+D"), has_selection).clicked() { duplicate_selection(world, selected); ui.close(); } if menu_item(ui, "Delete", Some("Del"), has_selection).clicked() { delete_selection(world, selected); ui.close(); } }); ui.menu_button("Scene", |ui| { if menu_item(ui, "Lighting → Use project sun", None, true).clicked() { super::helpers::use_project_sun(world); ui.close(); } if menu_item(ui, "Lighting → Bake scene sun from project", None, true).clicked() { super::helpers::create_scene_sun_override_from_project_settings(world); ui.close(); } if menu_item(ui, "Create → Post Process Volume", None, true).clicked() { crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world); ui.close(); } ui.separator(); ui.menu_button("Navigation", |ui| { use super::navigation_inspector::{ spawn_navigation_actor, NavigationActorType, }; for (label, actor_type) in [ ("Create Bounds", NavigationActorType::Bounds), ("Create Obstacle", NavigationActorType::Obstacle), ("Create Area", NavigationActorType::Area), ("Create Link", NavigationActorType::Link), ] { if menu_item(ui, label, None, true).clicked() { spawn_navigation_actor(world, actor_type); ui.close(); } } ui.separator(); let selected_bounds = selected.as_slice().iter().copied().find(|entity| { world.get::(*entity).is_some() }); if menu_item(ui, "Bake Selected Bounds", None, selected_bounds.is_some()) .clicked() { if let Some(entity) = selected_bounds { super::navigation_inspector::bake_bounds(world, entity); } ui.close(); } if menu_item(ui, "Test Selected Path", None, selected_bounds.is_some()) .clicked() { if let Some(entity) = selected_bounds { super::navigation_inspector::query_preview(world, entity); } ui.close(); } }); }); ui.menu_button("Build", |ui| { let running = world.resource::().is_running(); if menu_item(ui, "Build Package", None, !running).clicked() { open_and_focus_tab(dock_state, EditorTab::Build, panel_nodes); super::build::request_build(world, false); ui.close(); } if menu_item(ui, "Build and Run", None, !running).clicked() { open_and_focus_tab(dock_state, EditorTab::Build, panel_nodes); super::build::request_build(world, true); ui.close(); } if menu_item(ui, "Cancel Build", None, running).clicked() { super::build::cancel_build(world); ui.close(); } ui.separator(); let has_output = world.resource::().has_output(); if menu_item(ui, "Reveal Output", None, has_output).clicked() { super::build::reveal_last_output(world); ui.close(); } }); ui.menu_button("View", |ui| { if menu_item(ui, "Reset Layout", None, true).clicked() { let layout = reset_and_save_layout(world); *dock_state = layout.dock_state; if let Some(nodes) = layout.panel_nodes { *panel_nodes = nodes; } ui.close(); } }); ui.menu_button("Window", |ui| { for tab in PANEL_TABS { let open = tab_is_open(dock_state, &tab); if ui.selectable_label(open, tab_label(&tab)).clicked() { open_and_focus_tab(dock_state, tab, panel_nodes); ui.close(); } } ui.separator(); let mut diagnostics = world.resource_mut::(); if ui.checkbox(&mut diagnostics.open, "Diagnostics").clicked() { ui.close(); } let mut brush_diagnostics = world.resource_mut::(); if ui .checkbox(&mut brush_diagnostics.open, "Brush Diagnostics") .clicked() { ui.close(); } let mut rendering = world.resource_mut::(); if ui.checkbox(&mut rendering.open, "Rendering").clicked() { ui.close(); } }); ui.menu_button("Play", |ui| { let playing = *world.resource::>().get() == EditorMode::Playing; let paused = world.resource::().0; if menu_item(ui, "Toggle Play/Edit", Some("F5"), true).clicked() { toggle_play_mode(world); ui.close(); } if menu_item(ui, "Pause / Resume", Some("F6"), playing).clicked() { toggle_play_paused(world); ui.close(); } let possess_label = if playing { "Possess / Eject" } else { "Possess / Eject (while playing)" }; if menu_item(ui, possess_label, Some("F8"), playing).clicked() { toggle_possession(world); ui.close(); } if playing && paused { ui.label("Simulation paused"); } }); }); }); } #[cfg(test)] mod tests { use std::path::PathBuf; use super::*; fn entry(id: &str, label: &str) -> SampleCatalogEntry { SampleCatalogEntry { id: id.into(), label: label.into(), scene_path: PathBuf::from(format!("assets/levels/samples/{id}.scn.ron")), project_relative_scene: format!("assets/levels/samples/{id}.scn.ron"), summary: format!("{label} summary"), checks: vec![format!("{label} check")], } } #[test] fn sample_menu_preserves_catalog_order_and_hover_details() { let catalog = SampleCatalog::available_entries(vec![ entry("material", "Material Lab"), entry("terrain", "Terrain Sculpt"), ]); let SampleMenuModel::Available(rows) = sample_menu_model(&catalog) else { panic!("valid catalog must produce actionable menu rows"); }; assert_eq!( rows.iter().map(|row| row.id.as_str()).collect::>(), vec!["material", "terrain"] ); assert!(rows[0].hover_text().contains("Material Lab summary")); assert!(rows[0].hover_text().contains("Material Lab check")); } #[test] fn unavailable_catalog_produces_one_disabled_explanation() { let catalog = SampleCatalog::default(); assert_eq!( sample_menu_model(&catalog), SampleMenuModel::Unavailable("Sample catalog is loading".into()) ); } }