Blacksite/crates/editor/src/ui/scene_tabs.rs
Rbanh ba59f57a5a
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Finish non-blocking native dialog workflows
2026-07-12 18:06:57 -04:00

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, &current, 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}"));
}