370 lines
16 KiB
Rust
370 lines
16 KiB
Rust
//! 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<SampleCatalogEntry>),
|
|
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<EditorTab>,
|
|
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::<SceneIo>().request = Some(SceneIoRequest::New);
|
|
ui.close();
|
|
}
|
|
if menu_item(ui, "Open Scene...", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::Open);
|
|
ui.close();
|
|
}
|
|
let sample_menu = sample_menu_model(world.resource::<SampleCatalog>());
|
|
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::<SceneIo>().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::<SceneIo>().request = Some(SceneIoRequest::Save);
|
|
ui.close();
|
|
}
|
|
if menu_item(ui, "Save Scene As...", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SaveAs);
|
|
ui.close();
|
|
}
|
|
let recovery_available = world.resource::<SceneIo>().recovery_snapshot.is_some();
|
|
if recovery_available {
|
|
ui.separator();
|
|
if menu_item(ui, "Restore Recovery Snapshot", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request =
|
|
Some(SceneIoRequest::RestoreRecovery);
|
|
ui.close();
|
|
}
|
|
if menu_item(ui, "Save Recovery Copy As...", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request =
|
|
Some(SceneIoRequest::SaveRecoveryCopyAs);
|
|
ui.close();
|
|
}
|
|
if menu_item(ui, "Discard Recovery Snapshot", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request =
|
|
Some(SceneIoRequest::DiscardRecovery);
|
|
ui.close();
|
|
}
|
|
}
|
|
ui.separator();
|
|
if menu_item(ui, "Import Assets...", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::ImportAssets);
|
|
ui.close();
|
|
}
|
|
if menu_item(ui, "Export Selection...", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::ExportSelection);
|
|
ui.close();
|
|
}
|
|
if menu_item(ui, "Save Selection As Prefab...", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request =
|
|
Some(SceneIoRequest::SaveSelectionAsPrefab);
|
|
ui.close();
|
|
}
|
|
ui.separator();
|
|
if menu_item(ui, "Switch Project...", None, true).clicked() {
|
|
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::SwitchProject);
|
|
ui.close();
|
|
}
|
|
if menu_item(ui, "Reveal Project Folder", None, true).clicked() {
|
|
let root = world
|
|
.resource::<crate::project_io::ProjectWorkspace>()
|
|
.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::<SceneIo>().set_status(status);
|
|
ui.close();
|
|
}
|
|
let recent: Vec<(usize, String)> = world
|
|
.resource::<SceneIo>()
|
|
.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::<SceneIo>().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::<ProjectSettingsPanel>());
|
|
ui.close();
|
|
}
|
|
ui.separator();
|
|
let can_undo = world.resource::<EditorHistory>().can_undo();
|
|
let can_redo = world.resource::<EditorHistory>().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::<shared::NavigationBounds>(*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::<super::build::BuildPanel>().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::<super::build::BuildPanel>().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::<DiagnosticsPanel>();
|
|
if ui.checkbox(&mut diagnostics.open, "Diagnostics").clicked() {
|
|
ui.close();
|
|
}
|
|
let mut brush_diagnostics = world.resource_mut::<BrushDiagnosticsPanel>();
|
|
if ui
|
|
.checkbox(&mut brush_diagnostics.open, "Brush Diagnostics")
|
|
.clicked()
|
|
{
|
|
ui.close();
|
|
}
|
|
let mut rendering =
|
|
world.resource_mut::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>();
|
|
if ui.checkbox(&mut rendering.open, "Rendering").clicked() {
|
|
ui.close();
|
|
}
|
|
});
|
|
|
|
ui.menu_button("Play", |ui| {
|
|
let playing = *world.resource::<State<EditorMode>>().get() == EditorMode::Playing;
|
|
let paused = world.resource::<PlayPaused>().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<_>>(),
|
|
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())
|
|
);
|
|
}
|
|
}
|