//! Top-level authored scene tabs and composition controls. use std::path::{Path, PathBuf}; use bevy::prelude::*; use bevy_egui::egui; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_phosphor_icons::icons; use shared::{SceneComposition, SubsceneReference}; use crate::native_dialog::NativeDialogBroker; use crate::scene_io::{ComposedSceneMember, SceneIo, SceneIoRequest}; use crate::selection::SelectedEntity; use super::selection_ops::focus_editor_camera_on_selection; use super::theme::{BORDER, SELECTION, SELECTION_BG, TEXT, TEXT_DIM, TEXT_MUTED, WIDGET_BG}; use super::widgets::phosphor_icon; pub fn scene_tabs_chrome( world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedEntities, editing: bool, ) { let (tabs, active_tab) = { let io = world.resource::(); (io.tabs.clone(), io.active_tab) }; let composition_editing = editing && !world .resource::() .active_path .as_deref() .is_some_and(crate::scene_io::is_prefab_document); let composition_count = world.resource::().subscenes.len(); let mut request = None; ui.spacing_mut().item_spacing.x = 3.0; ui.menu_button( super::tab_title(icons::STACK, &format!("Composition {composition_count}")), |ui| composition_menu(world, ui, selected, composition_editing), ) .response .on_hover_text(if composition_editing { "Scene composition" } else { "Composition is unavailable for prefab documents" }); ui.separator(); if ui .add_enabled( editing, egui::Button::new(phosphor_icon(icons::PLUS, 13.0)) .frame(false) .min_size(egui::vec2(22.0, 20.0)), ) .on_hover_text("New scene tab") .clicked() { request = Some(SceneIoRequest::New); } for (index, tab) in tabs.iter().enumerate().rev() { if ui .add_enabled( editing, egui::Button::new(phosphor_icon(icons::X, 11.0)) .frame(false) .min_size(egui::vec2(18.0, 20.0)), ) .on_hover_text("Close scene tab") .clicked() { request = Some(SceneIoRequest::CloseTab(index)); } let active = index == active_tab; let label = if tab.dirty { format!("{} *", tab.label()) } else { tab.label() }; let text = egui::RichText::new(label) .color(if active { TEXT } else { TEXT_DIM }) .strong(); let response = ui.add_enabled( editing, egui::Button::new(text) .fill(if active { SELECTION_BG } else { WIDGET_BG }) .stroke(egui::Stroke::new( 1.0, if active { SELECTION } else { BORDER }, )) .min_size(egui::vec2(128.0, 20.0)), ); if response.clicked() && !active { request = Some(SceneIoRequest::SwitchTab(index)); } if response.hovered() { let path = tab .path .as_deref() .map(Path::display) .map(|path| path.to_string()) .unwrap_or_else(|| "Unsaved scene".to_string()); response.on_hover_text(path); } } if !editing { ui.label(egui::RichText::new("Play mode").color(TEXT_MUTED)); } if let Some(request) = request { world.resource_mut::().request = Some(request); } } fn composition_menu( world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedEntities, editing: bool, ) { ui.set_min_width(480.0); let mut composition = world.resource::().clone(); ui.label( egui::RichText::new(format!("Scene ID {}", composition.scene_id)) .monospace() .size(11.0) .color(TEXT_MUTED), ); ui.separator(); let mut changed = false; let mut remove = None; let mut focus = None; if composition.subscenes.is_empty() { ui.label(egui::RichText::new("No composed subscenes").color(TEXT_DIM)); } for (index, reference) in composition.subscenes.iter_mut().enumerate() { ui.horizontal(|ui| { let file_name = Path::new(&reference.path) .file_name() .and_then(|name| name.to_str()) .unwrap_or(&reference.path); ui.label(egui::RichText::new(file_name).strong().color(TEXT)); ui.label( egui::RichText::new(&reference.path) .monospace() .size(10.0) .color(TEXT_MUTED), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add_enabled( editing, egui::Button::new(phosphor_icon(icons::TRASH, 14.0)).frame(false), ) .on_hover_text("Remove subscene reference") .clicked() { remove = Some(index); } if ui .add_enabled(reference.visible, egui::Button::new("Focus")) .clicked() { focus = Some(reference.id.clone()); ui.close(); } if ui .add_enabled( editing, egui::Checkbox::new(&mut reference.locked, "Locked"), ) .changed() { changed = true; } if ui .add_enabled( editing, egui::Checkbox::new(&mut reference.visible, "Loaded"), ) .changed() { changed = true; } }); }); ui.add_space(4.0); } if let Some(index) = remove { composition.subscenes.remove(index); changed = true; } ui.separator(); if ui .add_enabled( editing && !world.resource::().is_pending(), egui::Button::new("Add Subscene..."), ) .clicked() { let project_root = PathBuf::from(&world.resource::().root); let directory = project_root.join("assets/levels"); let result = world.resource::().request( move || { rfd::FileDialog::new() .set_directory(directory) .add_filter("Bevy scene", &["scn.ron", "ron"]) .pick_file() }, move |world, path| { let Some(path) = path else { world .resource_mut::() .set_status("Add subscene cancelled"); return; }; let current = world.resource::().clone(); match validate_subscene_choice(&project_root, ¤t, path) { Ok(reference) => { let mut updated = current; updated.subscenes.push(reference); crate::history::set_scene_composition_with_history(world, updated); world.resource_mut::().set_status("Subscene added"); } Err(error) => world .resource_mut::() .set_status(format!("Add subscene failed: {error}")), } }, ); world.resource_mut::().set_status(match result { Ok(()) => "Choose a scene to compose".into(), Err(error) => format!("Add subscene unavailable: {error}"), }); } if changed { crate::history::set_scene_composition_with_history(world, composition); } if let Some(reference_id) = focus { focus_subscene(world, selected, &reference_id); } } fn validate_subscene_choice( project_root: &Path, composition: &SceneComposition, path: PathBuf, ) -> Result { let project_root = project_root .canonicalize() .map_err(|error| format!("could not resolve project root: {error}"))?; let path = path .canonicalize() .map_err(|error| format!("could not resolve {}: {error}", path.display()))?; let relative = path.strip_prefix(&project_root).map_err(|_| { format!( "{} is outside project root {}", path.display(), project_root.display() ) })?; scene::validate_level_file(&path)?; let relative = relative.to_string_lossy().replace('\\', "/"); if composition .subscenes .iter() .any(|reference| reference.path == relative) { return Err(format!("{relative} is already composed")); } Ok(SubsceneReference { id: uuid::Uuid::new_v4().to_string(), path: relative, visible: true, locked: true, }) } fn focus_subscene(world: &mut World, selected: &mut SelectedEntities, reference_id: &str) { let mut query = world.query::<(Entity, &ComposedSceneMember)>(); let entity = query.iter(world).find_map(|(entity, member)| { (member.reference_id == reference_id || member.reference_id.starts_with(&format!("{reference_id}/"))) .then_some(entity) }); let Some(entity) = entity else { world .resource_mut::() .set_status("Subscene is not loaded or contains no authored actors"); return; }; selected.clear(); selected.select_replace(entity); world.resource_mut::().0 = Some(entity); focus_editor_camera_on_selection(world); world .resource_mut::() .set_status(format!("Focused composed subscene {reference_id}")); }