Blacksite/crates/editor/src/ui/mod.rs
Rbanh 0798aa5d57 Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
2026-07-12 00:24:06 -04:00

471 lines
16 KiB
Rust

mod actor_inspector;
mod animation_inspector;
mod asset_browser;
mod audio_inspector;
mod build;
pub mod component_registry;
mod diagnostics;
mod dock_tabs;
mod fonts;
pub(crate) mod helpers;
mod hierarchy;
pub mod hierarchy_ops;
pub mod hierarchy_state;
pub(crate) mod inspector;
mod layout;
mod menu;
pub(crate) mod navigation_inspector;
mod play_controls;
mod post_process_volume_ui;
mod scene_tabs;
pub mod selection_ops;
mod status_bar;
pub(crate) mod theme;
mod toolbar;
mod viewport_chrome;
mod widgets;
use std::collections::HashSet;
use bevy::prelude::*;
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use bevy_egui::{egui, EguiContext, EguiPrimaryContextPass, PrimaryEguiContext};
use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use egui_dock::{DockArea, DockState};
use egui_phosphor_icons::icons;
use serde::{Deserialize, Serialize};
use crate::project_io::UserPreferences;
use crate::selection::SelectedEntity;
use crate::state::EditorMode;
pub use build::BuildPanel;
pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
pub use layout::LayoutSaveTimer;
pub use viewport_chrome::ViewportUiState;
use diagnostics::diagnostics_window;
use dock_tabs::PanelNodes;
use hierarchy_state::HierarchyPanelState;
use layout::{load_dock_layout, mark_layout_dirty_if_changed, tick_layout_save};
use menu::top_menu_bar;
use status_bar::status_bar_ui;
use theme::{apply_editor_theme, editor_dock_style};
use toolbar::editor_toolbar_panel;
use viewport_chrome::viewport_tab_ui;
use crate::settings_ui::{project_settings_window, ProjectSettingsPanel};
#[derive(Resource)]
pub struct UiState {
pub dock_state: DockState<EditorTab>,
pub viewport_rect: egui::Rect,
pub pointer_in_viewport: bool,
/// Pointer position inside the viewport image (logical screen coords), for picking.
pub viewport_pointer_pos: Option<egui::Pos2>,
pub selected_entities: SelectedEntities,
pub renaming_entity: Option<Entity>,
pub rename_buffer: String,
pub(crate) inspector_collapsed_components: HashSet<String>,
pub(crate) panel_nodes: PanelNodes,
last_mode_tab: Option<EditorMode>,
}
pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool {
ctx.egui_wants_keyboard_input()
}
pub fn egui_captures_keyboard_from_world(world: &mut World) -> bool {
let Ok(egui_context) = world
.query_filtered::<&mut EguiContext, With<PrimaryEguiContext>>()
.single(world)
else {
return false;
};
let mut egui_context = egui_context.clone();
egui_captures_keyboard(egui_context.get_mut())
}
pub fn viewport_keyboard_shortcuts_active(
ui_state: &UiState,
ctx: &egui::Context,
buttons: &ButtonInput<MouseButton>,
) -> bool {
ui_state.pointer_in_viewport
&& !egui_captures_keyboard(ctx)
&& !buttons.any_pressed([MouseButton::Right, MouseButton::Middle])
}
impl UiState {
pub fn default_layout() -> Self {
Self::from_prefs(&UserPreferences::default())
}
pub fn from_prefs(prefs: &UserPreferences) -> Self {
let layout = load_dock_layout(prefs);
let fallback_nodes = layout.panel_nodes.unwrap_or(PanelNodes {
main_view: layout.main_view_node,
hierarchy: layout.main_view_node,
inspector: layout.main_view_node,
bottom: layout.main_view_node,
});
let panel_nodes = PanelNodes::discover(&layout.dock_state, fallback_nodes);
Self {
dock_state: layout.dock_state,
viewport_rect: egui::Rect::NOTHING,
pointer_in_viewport: false,
viewport_pointer_pos: None,
selected_entities: SelectedEntities::default(),
renaming_entity: None,
rename_buffer: String::new(),
inspector_collapsed_components: HashSet::new(),
panel_nodes,
last_mode_tab: None,
}
}
#[expect(
deprecated,
reason = "bevy_egui exposes a Context during EguiPrimaryContextPass, so one top-level panel must bridge to the new Ui-based panel API"
)]
fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) {
apply_editor_theme(ctx);
let selected_before_ui = world.resource::<SelectedEntity>().0;
let mode = *world.resource::<State<EditorMode>>().get();
if self.last_mode_tab != Some(mode) {
self.last_mode_tab = Some(mode);
if mode == EditorMode::Playing {
dock_tabs::ensure_viewport_for_play(&mut self.dock_state, &self.panel_nodes);
}
}
self.pointer_in_viewport = false;
self.viewport_pointer_pos = None;
let playing = mode == EditorMode::Playing;
let cursor_grabbed = world
.query_filtered::<&CursorOptions, With<PrimaryWindow>>()
.single(world)
.is_ok_and(|cursor| cursor.grab_mode != CursorGrabMode::None);
if playing && cursor_grabbed {
ctx.input_mut(|input| {
input.focused = false;
});
}
egui::CentralPanel::no_frame().show(ctx, |root_ui| {
top_menu_bar(
world,
root_ui,
&self.selected_entities,
&mut self.dock_state,
&mut self.panel_nodes,
);
editor_toolbar_panel(world, root_ui, &mut self.selected_entities);
status_bar_ui(world, root_ui, &self.selected_entities, mode);
let mut viewer = TabViewer {
world,
viewport_rect: &mut self.viewport_rect,
pointer_in_viewport: &mut self.pointer_in_viewport,
viewport_pointer_pos: &mut self.viewport_pointer_pos,
playing,
cursor_grabbed,
selected_entities: &mut self.selected_entities,
renaming_entity: &mut self.renaming_entity,
rename_buffer: &mut self.rename_buffer,
};
DockArea::new(&mut self.dock_state)
.style(editor_dock_style(ctx))
.show_inside(root_ui, &mut viewer);
});
self.panel_nodes = PanelNodes::discover(&self.dock_state, self.panel_nodes);
mark_layout_dirty_if_changed(
world,
&self.dock_state,
self.panel_nodes.main_view(),
self.panel_nodes,
);
tick_layout_save(world, dt);
let mut diagnostics_open = world.resource::<DiagnosticsPanel>().open;
diagnostics_window(world, ctx, &mut diagnostics_open);
world.resource_mut::<DiagnosticsPanel>().open = diagnostics_open;
let mut brush_diagnostics_open = world.resource::<BrushDiagnosticsPanel>().open;
diagnostics::brush_diagnostics_window(world, ctx, &mut brush_diagnostics_open);
world.resource_mut::<BrushDiagnosticsPanel>().open = brush_diagnostics_open;
let mut rendering_open = world
.resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.open;
crate::rendering_diagnostics::rendering_diagnostics_window(world, ctx, &mut rendering_open);
world
.resource_mut::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.open = rendering_open;
crate::session::session_resume_window(world, ctx);
world.resource_scope(|world, mut panel: Mut<ProjectSettingsPanel>| {
project_settings_window(world, ctx, &mut panel);
});
if ctx.input(|input| input.key_pressed(egui::Key::F1)) {
world.resource_mut::<ViewportUiState>().shortcuts_open = true;
}
let requested_selection = world.resource::<SelectedEntity>().0;
reconcile_operator_selection(
&mut self.selected_entities,
selected_before_ui,
requested_selection,
);
let selected = self.selected_entities.as_slice().first().copied();
world.resource_mut::<SelectedEntity>().0 = selected;
}
}
fn reconcile_operator_selection(
selected_entities: &mut SelectedEntities,
selected_before_ui: Option<Entity>,
requested_selection: Option<Entity>,
) {
let selected_after_ui = selected_entities.as_slice().first().copied();
if requested_selection == selected_before_ui || selected_after_ui != selected_before_ui {
return;
}
selected_entities.clear();
if let Some(entity) = requested_selection {
selected_entities.select_replace(entity);
}
}
fn tab_title(icon: egui_phosphor_icons::Icon, label: &str) -> egui::WidgetText {
let mut job = egui::text::LayoutJob::default();
job.append(
icon.as_str(),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-regular".into())),
color: theme::TEXT,
..Default::default()
},
);
job.append(
&format!(" {label}"),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional),
color: theme::TEXT,
..Default::default()
},
);
job.into()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EditorTab {
Viewport,
/// Legacy saved-layout tab. Loaded layouts are migrated to [`EditorTab::Viewport`].
GameView,
Hierarchy,
Inspector,
Toolbar,
AssetBrowser,
Build,
}
pub struct EditorUiPlugin;
impl Plugin for EditorUiPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(UiState::default_layout())
.add_plugins(fonts::EditorFontsPlugin)
.init_resource::<DiagnosticsPanel>()
.init_resource::<BuildPanel>()
.init_resource::<BrushDiagnosticsPanel>()
.init_resource::<component_registry::EditorComponentRegistry>()
.init_resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.init_resource::<asset_browser::AssetBrowserUiState>()
.init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>()
.init_resource::<animation_inspector::AnimationInspectorState>()
.init_resource::<navigation_inspector::NavigationEditorState>()
.init_resource::<ViewportUiState>()
.init_resource::<LayoutSaveTimer>()
.add_systems(
Startup,
init_hierarchy_panel_from_prefs
.after(crate::project_io::load_user_preferences_on_startup),
)
.add_systems(Startup, validate_editor_component_registry)
.add_systems(
Startup,
apply_ui_state_from_prefs
.after(crate::project_io::load_user_preferences_on_startup),
)
.add_systems(EguiPrimaryContextPass, show_ui_system)
.add_systems(
Update,
(
hierarchy_state::tick_hierarchy_prefs_save,
build::tick_build_job,
navigation_inspector::sync_navigation_state,
),
);
}
}
fn validate_editor_component_registry(world: &mut World) {
let errors = world
.resource::<component_registry::EditorComponentRegistry>()
.validate(world)
.err();
if let Some(errors) = errors {
panic!(
"invalid authoring component registry:\n{}",
errors.join("\n")
);
}
}
fn apply_ui_state_from_prefs(mut ui_state: ResMut<UiState>, prefs: Res<UserPreferences>) {
*ui_state = UiState::from_prefs(&prefs);
}
fn init_hierarchy_panel_from_prefs(mut commands: Commands, prefs: Res<UserPreferences>) {
commands.insert_resource(HierarchyPanelState::from_prefs(&prefs));
}
pub(crate) fn show_ui_system(world: &mut World) {
let Ok(egui_context) = world
.query_filtered::<&mut EguiContext, With<PrimaryEguiContext>>()
.single(world)
else {
return;
};
let mut egui_context = egui_context.clone();
let dt = world
.get_resource::<Time>()
.map(|time| time.delta_secs())
.unwrap_or(0.0);
world.resource_scope::<UiState, _>(|world, mut ui_state| {
ui_state.ui(world, egui_context.get_mut(), dt);
});
}
struct TabViewer<'a> {
world: &'a mut World,
viewport_rect: &'a mut egui::Rect,
pointer_in_viewport: &'a mut bool,
viewport_pointer_pos: &'a mut Option<egui::Pos2>,
playing: bool,
cursor_grabbed: bool,
selected_entities: &'a mut SelectedEntities,
renaming_entity: &'a mut Option<Entity>,
rename_buffer: &'a mut String,
}
impl egui_dock::TabViewer for TabViewer<'_> {
type Tab = EditorTab;
fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
match tab {
EditorTab::Viewport | EditorTab::GameView => {
viewport_tab_ui(
self.world,
ui,
self.viewport_rect,
self.pointer_in_viewport,
self.viewport_pointer_pos,
self.selected_entities,
self.playing,
self.cursor_grabbed,
);
}
EditorTab::Hierarchy => {
hierarchy::hierarchy_panel_ui(
self.world,
ui,
self.selected_entities,
self.renaming_entity,
self.rename_buffer,
);
}
EditorTab::Inspector => match self.selected_entities.as_slice() {
&[entity] if self.world.get_entity(entity).is_ok() => {
actor_inspector::draw_actor_inspector(self.world, ui, entity);
}
&[entity] => {
ui.label(format!("Entity {entity:?} is no longer in the world."));
}
entities => {
actor_inspector::draw_multi_actor_inspector(self.world, ui, entities);
}
},
EditorTab::Toolbar => {
ui.label("Toolbar moved below the menu bar (Play, spawn, save).");
}
EditorTab::AssetBrowser => {
asset_browser::asset_browser_ui(self.world, ui, self.selected_entities);
}
EditorTab::Build => build::build_ui(self.world, ui),
}
}
fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
let (icon, label) = match tab {
EditorTab::Viewport | EditorTab::GameView => (icons::FILM_SLATE, "Viewport"),
EditorTab::Hierarchy => (icons::LIST_BULLETS, "Hierarchy"),
EditorTab::Inspector => (icons::SLIDERS, "Inspector"),
EditorTab::Toolbar => (icons::WRENCH, "Toolbar"),
EditorTab::AssetBrowser => (icons::FOLDER, "Asset Browser"),
EditorTab::Build => (icons::HAMMER, "Build"),
};
tab_title(icon, label)
}
fn clear_background(&self, tab: &Self::Tab) -> bool {
match tab {
EditorTab::Viewport | EditorTab::GameView => true,
_ => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operator_selection_wins_when_ui_selection_did_not_change() {
let before = Entity::from_bits(1);
let spawned = Entity::from_bits(2);
let mut selected = SelectedEntities::default();
selected.select_replace(before);
reconcile_operator_selection(&mut selected, Some(before), Some(spawned));
assert_eq!(selected.as_slice(), &[spawned]);
}
#[test]
fn direct_ui_selection_wins_over_stale_operator_resource() {
let before = Entity::from_bits(1);
let clicked = Entity::from_bits(2);
let requested = Entity::from_bits(3);
let mut selected = SelectedEntities::default();
selected.select_replace(clicked);
reconcile_operator_selection(&mut selected, Some(before), Some(requested));
assert_eq!(selected.as_slice(), &[clicked]);
}
}