306 lines
10 KiB
Rust
306 lines
10 KiB
Rust
//! 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::<SceneIo>();
|
|
(io.tabs.clone(), io.active_tab)
|
|
};
|
|
let composition_editing = editing
|
|
&& !world
|
|
.resource::<SceneIo>()
|
|
.active_path
|
|
.as_deref()
|
|
.is_some_and(crate::scene_io::is_prefab_document);
|
|
let composition_count = world.resource::<SceneComposition>().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::<SceneIo>().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::<SceneComposition>().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::<NativeDialogBroker>().is_pending(),
|
|
egui::Button::new("Add Subscene..."),
|
|
)
|
|
.clicked()
|
|
{
|
|
let project_root =
|
|
PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().root);
|
|
let directory = project_root.join("assets/levels");
|
|
let result = world.resource::<NativeDialogBroker>().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::<SceneIo>()
|
|
.set_status("Add subscene cancelled");
|
|
return;
|
|
};
|
|
let current = world.resource::<SceneComposition>().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::<SceneIo>().set_status("Subscene added");
|
|
}
|
|
Err(error) => world
|
|
.resource_mut::<SceneIo>()
|
|
.set_status(format!("Add subscene failed: {error}")),
|
|
}
|
|
},
|
|
);
|
|
world.resource_mut::<SceneIo>().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<SubsceneReference, String> {
|
|
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::<SceneIo>()
|
|
.set_status("Subscene is not loaded or contains no authored actors");
|
|
return;
|
|
};
|
|
selected.clear();
|
|
selected.select_replace(entity);
|
|
world.resource_mut::<SelectedEntity>().0 = Some(entity);
|
|
focus_editor_camera_on_selection(world);
|
|
world
|
|
.resource_mut::<SceneIo>()
|
|
.set_status(format!("Focused composed subscene {reference_id}"));
|
|
}
|