477 lines
16 KiB
Rust
477 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(crate) use asset_browser::{
|
|
adopt_material_conflict_save_as, reload_material_after_file_conflict,
|
|
validate_material_conflict_destination,
|
|
};
|
|
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);
|
|
});
|
|
|
|
crate::collaboration::file_conflict_modal(world, ctx);
|
|
|
|
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]);
|
|
}
|
|
}
|