Blacksite/crates/editor/src/ui/menu.rs

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())
);
}
}