Polish inspector add component workflow
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
19c4d017dc
commit
2fa64106c8
@ -97,7 +97,7 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
|
||||
| `Ctrl+G` | Toggle viewport grid |
|
||||
| `F` | Focus editor camera on selection |
|
||||
| `Ctrl+Shift+1` / `Ctrl+Shift+2` | Save / recall viewport camera bookmark |
|
||||
| RMB + mouse | Editor camera look |
|
||||
| RMB + mouse | Editor camera look; cursor hides while held |
|
||||
| RMB + `W` `A` `S` `D` | Editor camera fly |
|
||||
| RMB + `Q` / `E` | Editor camera down / up |
|
||||
| Mouse wheel | Dolly editor camera |
|
||||
@ -123,9 +123,11 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
|
||||
| Hierarchy context | Reparent to other selection / Unparent |
|
||||
| File menu | New, Open, Save, Save As, Import Assets, Export Selection, Save Selection As Prefab, Recent Scenes |
|
||||
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
|
||||
| Inspector footer → Add Component | Pinned search/add control for registered authoring, rendering, physics, and gameplay components with undo |
|
||||
| Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo |
|
||||
| Edit → Project Settings… | Edit `assets/project.ron` (rendering, physics, input) |
|
||||
|
||||
Viewport shortcut keys require the pointer to be in the viewport and are suspended while typing in egui text fields or actively using camera navigation. `Delete`, `Backspace`, duplicate, and undo/redo also defer to text-field focus.
|
||||
|
||||
### Play Mode
|
||||
|
||||
- Press **F5** (or Play menu) to run the **real game** in-process: same `GamePlugin`, player,
|
||||
|
||||
@ -13,7 +13,7 @@ use crate::ui::hierarchy_ops::{
|
||||
apply_sibling_change, apply_sibling_change_new, next_sibling_index,
|
||||
reorder_entities_under_parent, would_create_cycle,
|
||||
};
|
||||
use crate::ui::UiState;
|
||||
use crate::ui::{egui_captures_keyboard_from_world, UiState};
|
||||
|
||||
mod commands;
|
||||
pub use commands::{EditorCommand, EditorEntitySnapshot, SiblingChange};
|
||||
@ -1189,6 +1189,10 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit
|
||||
}
|
||||
|
||||
fn handle_history_hotkeys(world: &mut World) {
|
||||
if egui_captures_keyboard_from_world(world) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (undo, redo, delete, duplicate) = {
|
||||
let keys = world.resource::<ButtonInput<KeyCode>>();
|
||||
let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);
|
||||
|
||||
@ -79,12 +79,6 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
let footer_reserve = 52.0;
|
||||
let body_height = (ui.available_height() - footer_reserve).max(1.0);
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width(), body_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
@ -109,10 +103,9 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
world.resource_scope(|world, registry: Mut<ActorInspectorSectionRegistry>| {
|
||||
registry.draw_sections(world, ui, entity);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
inspector::add_component_footer(world, ui, entity);
|
||||
});
|
||||
}
|
||||
|
||||
fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
|
||||
|
||||
@ -5,9 +5,11 @@ use egui_phosphor_icons::icons;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EditorComponentCategory {
|
||||
Authoring,
|
||||
Rendering,
|
||||
Physics,
|
||||
Gameplay,
|
||||
Volumes,
|
||||
Editor,
|
||||
}
|
||||
|
||||
@ -19,8 +21,13 @@ pub struct EditorComponentDescriptor {
|
||||
pub addable: bool,
|
||||
pub removable: bool,
|
||||
pub reorderable: bool,
|
||||
pub hidden: bool,
|
||||
pub icon: &'static str,
|
||||
pub description: &'static str,
|
||||
pub search_terms: &'static [&'static str],
|
||||
pub recommended: &'static [&'static str],
|
||||
pub conflicts_with: &'static [&'static str],
|
||||
pub hydration_effect: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
@ -39,8 +46,14 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::CUBE.as_str(),
|
||||
search_terms: &["mesh", "renderer", "rendering", "model"],
|
||||
description: "Renders one or more normalized imported mesh slots.",
|
||||
search_terms: &["mesh", "renderer", "rendering", "model", "static"],
|
||||
recommended: &[],
|
||||
conflicts_with: &["shared::components::Primitive"],
|
||||
hydration_effect:
|
||||
"Hydrates into generated mesh children and material bindings.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::MaterialDesc",
|
||||
@ -49,8 +62,13 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::PALETTE.as_str(),
|
||||
description: "Defines an authored material directly on this actor.",
|
||||
search_terms: &["material", "shader", "texture"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into Bevy material assets for renderable actors.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::RigidBodyDesc",
|
||||
@ -59,8 +77,13 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::SPHERE.as_str(),
|
||||
description: "Controls authored rigid-body behavior for physics hydration.",
|
||||
search_terms: &["body", "physics", "rigidbody"],
|
||||
recommended: &["shared::components::ColliderDesc"],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into runtime Avian rigid-body state.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::ColliderDesc",
|
||||
@ -69,8 +92,13 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::SELECTION.as_str(),
|
||||
description: "Defines authored collision shape data.",
|
||||
search_terms: &["collision", "trigger", "physics"],
|
||||
recommended: &["shared::components::RigidBodyDesc"],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into runtime Avian collider state.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::LightDesc",
|
||||
@ -79,18 +107,29 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::LIGHTBULB.as_str(),
|
||||
search_terms: &["light", "lamp", "illumination"],
|
||||
description: "Adds authored point, spot, directional, or area lighting.",
|
||||
search_terms: &["light", "lamp", "illumination", "sun"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into runtime Bevy light components.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::Primitive",
|
||||
display_name: "Primitive",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
category: EditorComponentCategory::Authoring,
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::CUBE.as_str(),
|
||||
description: "Creates an authored built-in primitive shape.",
|
||||
search_terms: &["box", "sphere", "ramp", "primitive"],
|
||||
recommended: &[],
|
||||
conflicts_with: &["shared::components::StaticMeshRenderer"],
|
||||
hydration_effect:
|
||||
"Hydrates into generated primitive render and collision data.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::PlayerSpawn",
|
||||
@ -99,8 +138,14 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::PERSON_SIMPLE_RUN.as_str(),
|
||||
description: "Marks where Play mode should spawn the player.",
|
||||
search_terms: &["player", "spawn", "start"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect:
|
||||
"Used by PIE/session startup; no saved runtime player is created.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::WeaponSpawn",
|
||||
@ -109,18 +154,28 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::CROSSHAIR.as_str(),
|
||||
description: "Marks a gameplay weapon spawn point.",
|
||||
search_terms: &["weapon", "spawn"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into gameplay spawn metadata.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::TriggerVolume",
|
||||
display_name: "Trigger Volume",
|
||||
category: EditorComponentCategory::Gameplay,
|
||||
category: EditorComponentCategory::Volumes,
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::SELECTION.as_str(),
|
||||
description: "Adds an authored gameplay trigger volume.",
|
||||
search_terms: &["trigger", "volume", "event"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into gameplay trigger queries and visualizers.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::TeamSpawn",
|
||||
@ -129,8 +184,13 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::FLAG.as_str(),
|
||||
description: "Marks a gameplay team spawn point.",
|
||||
search_terms: &["team", "spawn"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into gameplay spawn metadata.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::ObjectiveMarker",
|
||||
@ -139,8 +199,29 @@ impl Default for EditorComponentRegistry {
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::FLAG.as_str(),
|
||||
description: "Marks an authored gameplay objective location.",
|
||||
search_terms: &["objective", "marker", "gameplay"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates into gameplay objective metadata.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::PostProcessVolumeDesc",
|
||||
display_name: "Post-Process Volume",
|
||||
category: EditorComponentCategory::Volumes,
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::APERTURE.as_str(),
|
||||
description: "Adds a local rendering profile override volume.",
|
||||
search_terms: &["post", "process", "volume", "rendering", "fx"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect:
|
||||
"Affects active camera rendering when the camera is inside the volume.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
type_name: "shared::components::InspectorOrder",
|
||||
@ -149,8 +230,14 @@ impl Default for EditorComponentRegistry {
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: false,
|
||||
hidden: true,
|
||||
icon: icons::LIST_BULLETS.as_str(),
|
||||
description: "Stores editor-only component ordering and active-state metadata.",
|
||||
search_terms: &["editor", "order"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect:
|
||||
"Controls editor presentation and authoring component active state.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@ -34,7 +34,9 @@ use crate::ui::theme::{
|
||||
};
|
||||
use crate::ui::widgets::{icon_button_small, phosphor_icon, phosphor_icon_text};
|
||||
|
||||
use super::component_registry::{EditorComponentCategory, EditorComponentRegistry};
|
||||
use super::component_registry::{
|
||||
EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry,
|
||||
};
|
||||
use super::dock_tabs::open_and_focus_tab;
|
||||
use super::helpers::create_scene_sun_override_from_project_settings;
|
||||
use super::{EditorTab, UiState};
|
||||
@ -101,6 +103,22 @@ pub(crate) struct InspectorClipboard {
|
||||
component: Option<CopiedComponent>,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default, Debug, Clone)]
|
||||
pub(crate) struct InspectorPanelState {
|
||||
add_component_search: String,
|
||||
add_component_open: bool,
|
||||
add_component_focus_search: bool,
|
||||
add_component_scroll_selected: bool,
|
||||
add_component_selected_index: usize,
|
||||
add_component_target: Option<Entity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AddComponentShelfDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum CopiedComponent {
|
||||
Primitive(Primitive),
|
||||
@ -664,90 +682,420 @@ fn draw_authoring_component_by_type(
|
||||
}
|
||||
|
||||
pub(crate) fn add_component_footer(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let descriptors = world
|
||||
.resource::<EditorComponentRegistry>()
|
||||
.descriptors
|
||||
.clone();
|
||||
ui.add_space(4.0);
|
||||
egui::Frame::new()
|
||||
let button_response = egui::Frame::new()
|
||||
.fill(WIDGET_BG)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let mut search = String::new();
|
||||
let search_width = fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut search)
|
||||
.hint_text("Search components...")
|
||||
.desired_width(search_width),
|
||||
let open_for_entity = add_component_picker_open_for(world, entity);
|
||||
let label = if open_for_entity {
|
||||
"Close Add Component"
|
||||
} else {
|
||||
"+ Add Component"
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
if let Some(mut state) = world.get_resource_mut::<InspectorPanelState>() {
|
||||
if open_for_entity {
|
||||
state.add_component_open = false;
|
||||
state.add_component_target = None;
|
||||
} else {
|
||||
state.add_component_open = true;
|
||||
state.add_component_focus_search = true;
|
||||
state.add_component_scroll_selected = true;
|
||||
state.add_component_selected_index = 0;
|
||||
state.add_component_target = Some(entity);
|
||||
state.add_component_search.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if add_component_picker_open_for(world, entity) {
|
||||
add_component_picker_shelf(world, ui, entity, button_response.response.rect);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_component_picker_open_for(world: &World, entity: Entity) -> bool {
|
||||
world
|
||||
.get_resource::<InspectorPanelState>()
|
||||
.is_some_and(|state| state.add_component_open && state.add_component_target == Some(entity))
|
||||
}
|
||||
|
||||
fn shelf_list_max_height(visible_space: f32) -> f32 {
|
||||
(visible_space - 108.0).clamp(56.0, 320.0)
|
||||
}
|
||||
|
||||
fn add_component_picker_shelf(
|
||||
world: &mut World,
|
||||
ui: &mut egui::Ui,
|
||||
target: Entity,
|
||||
anchor: egui::Rect,
|
||||
) {
|
||||
if !world.get_entity(target).is_ok() {
|
||||
if let Some(mut state) = world.get_resource_mut::<InspectorPanelState>() {
|
||||
state.add_component_open = false;
|
||||
state.add_component_target = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let descriptors = world
|
||||
.resource::<EditorComponentRegistry>()
|
||||
.descriptors
|
||||
.clone();
|
||||
|
||||
let visible = ui.clip_rect().intersect(ui.ctx().available_rect());
|
||||
let space_above = (anchor.min.y - visible.top()).max(0.0);
|
||||
let space_below = (visible.bottom() - anchor.max.y).max(0.0);
|
||||
let direction = if space_below >= space_above {
|
||||
AddComponentShelfDirection::Down
|
||||
} else {
|
||||
AddComponentShelfDirection::Up
|
||||
};
|
||||
let visible_space = match direction {
|
||||
AddComponentShelfDirection::Up => space_above,
|
||||
AddComponentShelfDirection::Down => space_below,
|
||||
};
|
||||
let list_max_height = shelf_list_max_height(visible_space);
|
||||
let shelf_height = (list_max_height + 108.0).min((visible_space - 4.0).max(96.0));
|
||||
let x = anchor
|
||||
.min
|
||||
.x
|
||||
.clamp(visible.left(), visible.right() - anchor.width());
|
||||
let y = match direction {
|
||||
AddComponentShelfDirection::Up => anchor.min.y - shelf_height - 4.0,
|
||||
AddComponentShelfDirection::Down => anchor.max.y + 4.0,
|
||||
}
|
||||
.clamp(
|
||||
visible.top(),
|
||||
(visible.bottom() - shelf_height).max(visible.top()),
|
||||
);
|
||||
ui.menu_button("Add Component", |ui| {
|
||||
for category in [
|
||||
EditorComponentCategory::Rendering,
|
||||
EditorComponentCategory::Physics,
|
||||
EditorComponentCategory::Gameplay,
|
||||
] {
|
||||
let mut wrote_category = false;
|
||||
for descriptor in descriptors.iter().filter(|descriptor| {
|
||||
descriptor.addable && descriptor.category == category
|
||||
}) {
|
||||
if !component_addable(world, entity, descriptor.type_name) {
|
||||
continue;
|
||||
}
|
||||
if !wrote_category {
|
||||
ui.label(panel_heading(match category {
|
||||
EditorComponentCategory::Rendering => "Rendering",
|
||||
EditorComponentCategory::Physics => "Physics",
|
||||
EditorComponentCategory::Gameplay => "Gameplay",
|
||||
EditorComponentCategory::Editor => "Editor",
|
||||
}));
|
||||
wrote_category = true;
|
||||
}
|
||||
let mut clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(phosphor_icon_text(descriptor.icon, 14.0).color(TEXT_DIM));
|
||||
clicked = ui
|
||||
.button(descriptor.display_name)
|
||||
.on_hover_text(format!(
|
||||
"{} | removable={} reorderable={} | {}",
|
||||
descriptor.type_name,
|
||||
descriptor.removable,
|
||||
descriptor.reorderable,
|
||||
descriptor.search_terms.join(", ")
|
||||
))
|
||||
.clicked();
|
||||
});
|
||||
if clicked {
|
||||
insert_registered_component(world, entity, descriptor.type_name);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if wrote_category {
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let shelf_width = anchor.width().max(260.0).min(visible.width());
|
||||
|
||||
egui::Area::new(egui::Id::new(("add_component_shelf", target)))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(egui::pos2(x, y))
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.set_width(shelf_width);
|
||||
add_component_picker_shelf_contents(world, ui, target, &descriptors, list_max_height);
|
||||
});
|
||||
}
|
||||
|
||||
fn component_addable(world: &World, entity: Entity, type_name: &str) -> bool {
|
||||
match type_name {
|
||||
"shared::components::Primitive" => world.get::<Primitive>(entity).is_none(),
|
||||
"shared::components::StaticMeshRenderer" => {
|
||||
world.get::<StaticMeshRenderer>(entity).is_none()
|
||||
fn add_component_picker_shelf_contents(
|
||||
world: &mut World,
|
||||
ui: &mut egui::Ui,
|
||||
target: Entity,
|
||||
descriptors: &[EditorComponentDescriptor],
|
||||
list_max_height: f32,
|
||||
) {
|
||||
egui::Frame::new()
|
||||
.fill(PANEL_BG_DARK)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(8, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(panel_heading("Add Component"));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icon_button_small(ui, icons::X, "Close Add Component").clicked() {
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
state.add_component_open = false;
|
||||
state.add_component_target = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
|
||||
let mut search_input = world
|
||||
.resource::<InspectorPanelState>()
|
||||
.add_component_search
|
||||
.clone();
|
||||
let search_response = ui.add(
|
||||
egui::TextEdit::singleline(&mut search_input)
|
||||
.hint_text("Search components...")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
if search_response.changed() {
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
state.add_component_search = search_input.clone();
|
||||
state.add_component_scroll_selected = true;
|
||||
state.add_component_selected_index = 0;
|
||||
}
|
||||
if world
|
||||
.resource::<InspectorPanelState>()
|
||||
.add_component_focus_search
|
||||
{
|
||||
search_response.request_focus();
|
||||
world
|
||||
.resource_mut::<InspectorPanelState>()
|
||||
.add_component_focus_search = false;
|
||||
}
|
||||
|
||||
let search = search_input.to_lowercase();
|
||||
let filtered = filtered_component_descriptors(&descriptors, &search);
|
||||
{
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
if !filtered.is_empty() {
|
||||
state.add_component_selected_index =
|
||||
state.add_component_selected_index.min(filtered.len() - 1);
|
||||
} else {
|
||||
state.add_component_selected_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))
|
||||
&& !filtered.is_empty()
|
||||
{
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
state.add_component_selected_index =
|
||||
(state.add_component_selected_index + 1) % filtered.len();
|
||||
state.add_component_scroll_selected = true;
|
||||
}
|
||||
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp))
|
||||
&& !filtered.is_empty()
|
||||
{
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
state.add_component_selected_index = if state.add_component_selected_index == 0 {
|
||||
filtered.len() - 1
|
||||
} else {
|
||||
state.add_component_selected_index - 1
|
||||
};
|
||||
state.add_component_scroll_selected = true;
|
||||
}
|
||||
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
state.add_component_open = false;
|
||||
state.add_component_target = None;
|
||||
}
|
||||
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
|
||||
let selected_index = world
|
||||
.resource::<InspectorPanelState>()
|
||||
.add_component_selected_index;
|
||||
if let Some(descriptor) = filtered.get(selected_index) {
|
||||
let add_state = component_add_state(world, target, descriptor, &descriptors);
|
||||
if add_state.addable {
|
||||
insert_registered_component(world, target, descriptor.type_name);
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
state.add_component_open = false;
|
||||
state.add_component_target = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
if filtered.is_empty() {
|
||||
ui.label(egui::RichText::new("No matching components").color(TEXT_DIM));
|
||||
return;
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(list_max_height)
|
||||
.show(ui, |ui| {
|
||||
let (selected_index, scroll_selected) = {
|
||||
let state = world.resource::<InspectorPanelState>();
|
||||
(
|
||||
state.add_component_selected_index,
|
||||
state.add_component_scroll_selected,
|
||||
)
|
||||
};
|
||||
let mut last_category = None;
|
||||
for (index, descriptor) in filtered.iter().enumerate() {
|
||||
if last_category != Some(descriptor.category) {
|
||||
if last_category.is_some() {
|
||||
ui.separator();
|
||||
}
|
||||
ui.label(panel_heading(component_category_label(descriptor.category)));
|
||||
last_category = Some(descriptor.category);
|
||||
}
|
||||
|
||||
let add_state =
|
||||
component_add_state(world, target, descriptor, &descriptors);
|
||||
let selected = index == selected_index;
|
||||
let row = ui
|
||||
.horizontal(|ui| {
|
||||
ui.label(phosphor_icon_text(descriptor.icon, 14.0).color(TEXT_DIM));
|
||||
ui.add_enabled(
|
||||
add_state.addable,
|
||||
egui::Button::selectable(selected, descriptor.display_name),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
.on_hover_text(component_hover_text(descriptor, &add_state));
|
||||
if selected && scroll_selected {
|
||||
row.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
if row.clicked() {
|
||||
world
|
||||
.resource_mut::<InspectorPanelState>()
|
||||
.add_component_selected_index = index;
|
||||
if add_state.addable {
|
||||
insert_registered_component(world, target, descriptor.type_name);
|
||||
let mut state = world.resource_mut::<InspectorPanelState>();
|
||||
state.add_component_open = false;
|
||||
state.add_component_target = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
world
|
||||
.resource_mut::<InspectorPanelState>()
|
||||
.add_component_scroll_selected = false;
|
||||
});
|
||||
}
|
||||
|
||||
fn filtered_component_descriptors<'a>(
|
||||
descriptors: &'a [EditorComponentDescriptor],
|
||||
search: &str,
|
||||
) -> Vec<&'a EditorComponentDescriptor> {
|
||||
[
|
||||
EditorComponentCategory::Authoring,
|
||||
EditorComponentCategory::Rendering,
|
||||
EditorComponentCategory::Physics,
|
||||
EditorComponentCategory::Gameplay,
|
||||
EditorComponentCategory::Volumes,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|category| {
|
||||
descriptors.iter().filter(move |descriptor| {
|
||||
descriptor.addable
|
||||
&& !descriptor.hidden
|
||||
&& descriptor.category == category
|
||||
&& descriptor_matches_search(descriptor, search)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn component_category_label(category: EditorComponentCategory) -> &'static str {
|
||||
match category {
|
||||
EditorComponentCategory::Authoring => "Authoring",
|
||||
EditorComponentCategory::Rendering => "Rendering",
|
||||
EditorComponentCategory::Physics => "Physics",
|
||||
EditorComponentCategory::Gameplay => "Gameplay",
|
||||
EditorComponentCategory::Volumes => "Volumes",
|
||||
EditorComponentCategory::Editor => "Editor",
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentAddState {
|
||||
addable: bool,
|
||||
reason: Option<String>,
|
||||
recommended: Vec<&'static str>,
|
||||
conflicts: Vec<&'static str>,
|
||||
}
|
||||
|
||||
fn descriptor_matches_search(descriptor: &EditorComponentDescriptor, search: &str) -> bool {
|
||||
search.trim().is_empty()
|
||||
|| descriptor.display_name.to_lowercase().contains(search)
|
||||
|| descriptor.type_name.to_lowercase().contains(search)
|
||||
|| descriptor.description.to_lowercase().contains(search)
|
||||
|| descriptor
|
||||
.search_terms
|
||||
.iter()
|
||||
.any(|term| term.to_lowercase().contains(search))
|
||||
}
|
||||
|
||||
fn component_add_state(
|
||||
world: &World,
|
||||
entity: Entity,
|
||||
descriptor: &EditorComponentDescriptor,
|
||||
descriptors: &[EditorComponentDescriptor],
|
||||
) -> ComponentAddState {
|
||||
let duplicate = component_present(world, entity, descriptor.type_name);
|
||||
let conflicts = descriptor
|
||||
.conflicts_with
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|type_name| component_present(world, entity, type_name))
|
||||
.collect::<Vec<_>>();
|
||||
let recommended = descriptor
|
||||
.recommended
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|type_name| !component_present(world, entity, type_name))
|
||||
.collect::<Vec<_>>();
|
||||
let reason = if duplicate {
|
||||
Some("Already present on this actor.".to_string())
|
||||
} else if !conflicts.is_empty() {
|
||||
Some(format!(
|
||||
"Conflicts with {}.",
|
||||
conflicts
|
||||
.iter()
|
||||
.map(|type_name| component_display_name(descriptors, type_name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ComponentAddState {
|
||||
addable: reason.is_none(),
|
||||
reason,
|
||||
recommended,
|
||||
conflicts,
|
||||
}
|
||||
}
|
||||
|
||||
fn component_hover_text(
|
||||
descriptor: &EditorComponentDescriptor,
|
||||
state: &ComponentAddState,
|
||||
) -> String {
|
||||
let mut lines = vec![
|
||||
descriptor.description.to_string(),
|
||||
format!("Type: {}", descriptor.type_name),
|
||||
format!("Hydration: {}", descriptor.hydration_effect),
|
||||
format!(
|
||||
"Inspector: removable={} reorderable={}",
|
||||
descriptor.removable, descriptor.reorderable
|
||||
),
|
||||
];
|
||||
if let Some(reason) = &state.reason {
|
||||
lines.push(format!("Unavailable: {reason}"));
|
||||
}
|
||||
if !state.recommended.is_empty() {
|
||||
lines.push(format!(
|
||||
"Recommended with: {}",
|
||||
state.recommended.join(", ")
|
||||
));
|
||||
}
|
||||
if !state.conflicts.is_empty() {
|
||||
lines.push(format!("Conflicts: {}", state.conflicts.join(", ")));
|
||||
}
|
||||
lines.push(format!("Search: {}", descriptor.search_terms.join(", ")));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn component_display_name(
|
||||
descriptors: &[EditorComponentDescriptor],
|
||||
type_name: &str,
|
||||
) -> &'static str {
|
||||
descriptors
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.type_name == type_name)
|
||||
.map(|descriptor| descriptor.display_name)
|
||||
.unwrap_or("component")
|
||||
}
|
||||
|
||||
fn component_present(world: &World, entity: Entity, type_name: &str) -> bool {
|
||||
match type_name {
|
||||
"shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
|
||||
"shared::components::StaticMeshRenderer" => {
|
||||
world.get::<StaticMeshRenderer>(entity).is_some()
|
||||
}
|
||||
"shared::components::MaterialDesc" => world.get::<MaterialDesc>(entity).is_some(),
|
||||
"shared::components::LightDesc" => world.get::<LightDesc>(entity).is_some(),
|
||||
"shared::components::RigidBodyDesc" => world.get::<RigidBodyDesc>(entity).is_some(),
|
||||
"shared::components::ColliderDesc" => world.get::<ColliderDesc>(entity).is_some(),
|
||||
"shared::components::PlayerSpawn" => world.get::<PlayerSpawn>(entity).is_some(),
|
||||
"shared::components::WeaponSpawn" => world.get::<WeaponSpawn>(entity).is_some(),
|
||||
"shared::components::TriggerVolume" => world.get::<TriggerVolume>(entity).is_some(),
|
||||
"shared::components::TeamSpawn" => world.get::<TeamSpawn>(entity).is_some(),
|
||||
"shared::components::ObjectiveMarker" => world.get::<ObjectiveMarker>(entity).is_some(),
|
||||
"shared::components::PostProcessVolumeDesc" => {
|
||||
world.get::<PostProcessVolumeDesc>(entity).is_some()
|
||||
}
|
||||
"shared::components::MaterialDesc" => world.get::<MaterialDesc>(entity).is_none(),
|
||||
"shared::components::LightDesc" => world.get::<LightDesc>(entity).is_none(),
|
||||
"shared::components::RigidBodyDesc" => world.get::<RigidBodyDesc>(entity).is_none(),
|
||||
"shared::components::ColliderDesc" => world.get::<ColliderDesc>(entity).is_none(),
|
||||
"shared::components::PlayerSpawn" => world.get::<PlayerSpawn>(entity).is_none(),
|
||||
"shared::components::WeaponSpawn" => world.get::<WeaponSpawn>(entity).is_none(),
|
||||
"shared::components::TriggerVolume" => world.get::<TriggerVolume>(entity).is_none(),
|
||||
"shared::components::TeamSpawn" => world.get::<TeamSpawn>(entity).is_none(),
|
||||
"shared::components::ObjectiveMarker" => world.get::<ObjectiveMarker>(entity).is_none(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@ -791,6 +1139,11 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st
|
||||
objective_id: "objective".into(),
|
||||
});
|
||||
}),
|
||||
"shared::components::PostProcessVolumeDesc" => {
|
||||
insert_component(world, entity, |world, e| {
|
||||
world.entity_mut(e).insert(PostProcessVolumeDesc::default());
|
||||
})
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,31 @@ pub struct UiState {
|
||||
last_mode_tab: Option<EditorMode>,
|
||||
}
|
||||
|
||||
pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool {
|
||||
ctx.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())
|
||||
@ -113,6 +138,7 @@ impl UiState {
|
||||
);
|
||||
|
||||
editor_toolbar_panel(world, ctx);
|
||||
status_bar_ui(world, ctx, &self.selected_entities, mode);
|
||||
|
||||
self.pointer_in_viewport = false;
|
||||
self.viewport_pointer_pos = None;
|
||||
@ -154,8 +180,6 @@ impl UiState {
|
||||
);
|
||||
tick_layout_save(world, dt);
|
||||
|
||||
status_bar_ui(world, ctx, &self.selected_entities, mode);
|
||||
|
||||
let mut diagnostics_open = world.resource::<DiagnosticsPanel>().open;
|
||||
diagnostics_window(world, ctx, &mut diagnostics_open);
|
||||
world.resource_mut::<DiagnosticsPanel>().open = diagnostics_open;
|
||||
@ -226,6 +250,7 @@ impl Plugin for EditorUiPlugin {
|
||||
.init_resource::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
|
||||
.init_resource::<asset_browser::AssetBrowserUiState>()
|
||||
.init_resource::<inspector::InspectorClipboard>()
|
||||
.init_resource::<inspector::InspectorPanelState>()
|
||||
.init_resource::<ViewportUiState>()
|
||||
.init_resource::<LayoutSaveTimer>()
|
||||
.add_systems(
|
||||
|
||||
@ -58,8 +58,9 @@ pub fn viewport_tab_ui(
|
||||
.filter(|pos| rect.contains(*pos));
|
||||
*pointer_in_viewport = viewport_pointer_pos.is_some();
|
||||
|
||||
let primary_clicked =
|
||||
ui.input(|input| input.pointer.button_clicked(egui::PointerButton::Primary));
|
||||
let primary_clicked = ui.input(|input| {
|
||||
input.pointer.button_clicked(egui::PointerButton::Primary) && !input.modifiers.alt
|
||||
});
|
||||
if primary_clicked && !clean_game_view {
|
||||
if let Some(pos) = response
|
||||
.interact_pointer_pos()
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
use bevy::input::mouse::{AccumulatedMouseMotion, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorOptions, PrimaryWindow};
|
||||
use bevy_egui::{EguiContexts, EguiGlobalSettings, PrimaryEguiContext};
|
||||
use transform_gizmo_bevy::prelude::GizmoCamera;
|
||||
|
||||
use crate::infra::EditorOnly;
|
||||
use crate::state::scene_tools_active;
|
||||
use crate::ui::UiState;
|
||||
use crate::ui::{egui_captures_keyboard, UiState};
|
||||
use settings::ProjectSettings;
|
||||
|
||||
#[derive(Component)]
|
||||
@ -28,9 +29,24 @@ impl Default for EditorCamera {
|
||||
|
||||
pub struct EditorCameraPlugin;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CameraNavigationMode {
|
||||
Look,
|
||||
Pan,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct EditorCameraInputState {
|
||||
mode: Option<CameraNavigationMode>,
|
||||
cursor_hidden_for_look: bool,
|
||||
cursor_visible_before_look: bool,
|
||||
}
|
||||
|
||||
impl Plugin for EditorCameraPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, spawn_editor_camera).add_systems(
|
||||
app.init_resource::<EditorCameraInputState>()
|
||||
.add_systems(Startup, spawn_editor_camera)
|
||||
.add_systems(
|
||||
Update,
|
||||
(sync_editor_camera_from_settings, update_editor_camera)
|
||||
.chain()
|
||||
@ -104,25 +120,61 @@ fn update_editor_camera(
|
||||
mut scroll: MessageReader<MouseWheel>,
|
||||
mut contexts: EguiContexts,
|
||||
ui_state: Res<UiState>,
|
||||
mut input_state: ResMut<EditorCameraInputState>,
|
||||
mut cameras: Query<(&mut Transform, &EditorCamera)>,
|
||||
mut cursor: Single<&mut CursorOptions, With<PrimaryWindow>>,
|
||||
) -> Result {
|
||||
let ctx = contexts.ctx_mut()?;
|
||||
let in_scene = ui_state.pointer_in_viewport;
|
||||
if ctx.wants_keyboard_input() {
|
||||
let navigation_pressed =
|
||||
buttons.pressed(MouseButton::Right) || buttons.pressed(MouseButton::Middle);
|
||||
if !navigation_pressed {
|
||||
input_state.mode = None;
|
||||
restore_editor_camera_cursor(&mut input_state, &mut cursor);
|
||||
}
|
||||
if egui_captures_keyboard(ctx) {
|
||||
input_state.mode = None;
|
||||
restore_editor_camera_cursor(&mut input_state, &mut cursor);
|
||||
return Ok(());
|
||||
}
|
||||
if !in_scene && ctx.wants_pointer_input() {
|
||||
if in_scene && buttons.just_pressed(MouseButton::Right) {
|
||||
input_state.mode = Some(CameraNavigationMode::Look);
|
||||
input_state.cursor_visible_before_look = cursor.visible;
|
||||
input_state.cursor_hidden_for_look = true;
|
||||
cursor.visible = false;
|
||||
} else if in_scene && buttons.just_pressed(MouseButton::Middle) {
|
||||
input_state.mode = Some(CameraNavigationMode::Pan);
|
||||
restore_editor_camera_cursor(&mut input_state, &mut cursor);
|
||||
}
|
||||
if input_state.mode.is_none() && navigation_pressed {
|
||||
return Ok(());
|
||||
}
|
||||
if in_scene && ctx.wants_pointer_input() && !buttons.pressed(MouseButton::Right) {
|
||||
if !in_scene && input_state.mode.is_none() && ctx.wants_pointer_input() {
|
||||
return Ok(());
|
||||
}
|
||||
if in_scene
|
||||
&& input_state.mode.is_none()
|
||||
&& ctx.wants_pointer_input()
|
||||
&& !buttons.pressed(MouseButton::Right)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let dt = time.delta_secs();
|
||||
let scroll_delta: f32 = scroll.read().map(|event| event.y).sum();
|
||||
let raw_scroll_delta: f32 = scroll.read().map(|event| event.y).sum();
|
||||
let scroll_delta = if in_scene && !ctx.wants_pointer_input() {
|
||||
raw_scroll_delta
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
for (mut transform, camera) in &mut cameras {
|
||||
if buttons.pressed(MouseButton::Right) {
|
||||
let looking = matches!(input_state.mode, Some(CameraNavigationMode::Look))
|
||||
&& buttons.pressed(MouseButton::Right);
|
||||
let panning = matches!(input_state.mode, Some(CameraNavigationMode::Pan))
|
||||
&& buttons.pressed(MouseButton::Middle);
|
||||
|
||||
if looking {
|
||||
let delta = mouse_motion.delta;
|
||||
if delta != Vec2::ZERO {
|
||||
let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ);
|
||||
@ -140,6 +192,7 @@ fn update_editor_camera(
|
||||
let up = Vec3::Y;
|
||||
let mut wish = Vec3::ZERO;
|
||||
|
||||
if looking {
|
||||
if keys.pressed(KeyCode::KeyW) {
|
||||
wish += forward;
|
||||
}
|
||||
@ -158,6 +211,7 @@ fn update_editor_camera(
|
||||
if keys.pressed(KeyCode::KeyQ) {
|
||||
wish -= up;
|
||||
}
|
||||
}
|
||||
|
||||
let sprint = if keys.pressed(KeyCode::ShiftLeft) {
|
||||
3.0
|
||||
@ -167,7 +221,7 @@ fn update_editor_camera(
|
||||
transform.translation += wish.normalize_or_zero() * camera.fly_speed * sprint * dt;
|
||||
transform.translation += forward * scroll_delta * camera.fly_speed * 0.08;
|
||||
|
||||
if buttons.pressed(MouseButton::Middle) {
|
||||
if panning {
|
||||
let delta = mouse_motion.delta;
|
||||
let distance_scale = transform.translation.length().max(1.0).sqrt();
|
||||
transform.translation +=
|
||||
@ -177,3 +231,13 @@ fn update_editor_camera(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_editor_camera_cursor(
|
||||
input_state: &mut EditorCameraInputState,
|
||||
cursor: &mut CursorOptions,
|
||||
) {
|
||||
if input_state.cursor_hidden_for_look {
|
||||
cursor.visible = input_state.cursor_visible_before_look;
|
||||
input_state.cursor_hidden_for_look = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
use bevy::math::Rect;
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::EguiContexts;
|
||||
use transform_gizmo_bevy::prelude::{GizmoMode, GizmoOptions, GizmoOrientation};
|
||||
|
||||
use crate::ui::UiState;
|
||||
use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
|
||||
use crate::viewport::ViewportDisplayMode;
|
||||
|
||||
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@ -40,12 +41,19 @@ impl Plugin for EditorGizmoPlugin {
|
||||
|
||||
fn handle_gizmo_hotkeys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
mut contexts: EguiContexts,
|
||||
ui_state: Res<UiState>,
|
||||
display: Res<ViewportDisplayMode>,
|
||||
mut mode: ResMut<EditorGizmoMode>,
|
||||
mut space: ResMut<EditorGizmoSpace>,
|
||||
) {
|
||||
) -> Result {
|
||||
if display.clean_game_view {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
let ctx = contexts.ctx_mut()?;
|
||||
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) {
|
||||
return Ok(());
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyW) {
|
||||
*mode = EditorGizmoMode::Translate;
|
||||
@ -62,6 +70,7 @@ fn handle_gizmo_hotkeys(
|
||||
EditorGizmoSpace::Local => EditorGizmoSpace::World,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Maps the viewport egui panel to gizmo cursor space (required for render-to-texture).
|
||||
|
||||
@ -3,10 +3,11 @@
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::state::scene_tools_active;
|
||||
use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
|
||||
use crate::viewport::viewport_mode::EditorViewportModePlugin;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::Window;
|
||||
use bevy_egui::egui;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
/// Viewport display and snapping options (editor-only).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
@ -155,22 +156,29 @@ pub fn snap_translation(position: Vec3, settings: &ViewportSettings) -> Vec3 {
|
||||
|
||||
fn focus_selection_hotkey(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
mut contexts: EguiContexts,
|
||||
ui_state: Res<UiState>,
|
||||
display: Res<ViewportDisplayMode>,
|
||||
selected: Res<SelectedEntity>,
|
||||
mut editor_cameras: Query<&mut Transform, With<EditorCamera>>,
|
||||
targets: Query<&GlobalTransform>,
|
||||
) {
|
||||
) -> Result {
|
||||
if display.clean_game_view {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
let ctx = contexts.ctx_mut()?;
|
||||
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) {
|
||||
return Ok(());
|
||||
}
|
||||
if !keys.just_pressed(KeyCode::KeyF) {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(entity) = selected.0 else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(target) = targets.get(entity) else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
let center = target.translation();
|
||||
for mut transform in &mut editor_cameras {
|
||||
@ -179,13 +187,21 @@ fn focus_selection_hotkey(
|
||||
transform.translation = center - transform.forward().as_vec3() * distance;
|
||||
transform.look_at(center, Vec3::Y);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_viewport_hotkeys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
mut contexts: EguiContexts,
|
||||
ui_state: Res<UiState>,
|
||||
mut settings: ResMut<ViewportSettings>,
|
||||
mut display: ResMut<ViewportDisplayMode>,
|
||||
) {
|
||||
) -> Result {
|
||||
let ctx = contexts.ctx_mut()?;
|
||||
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) {
|
||||
return Ok(());
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyG) {
|
||||
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
|
||||
if ctrl {
|
||||
@ -194,18 +210,26 @@ fn toggle_viewport_hotkeys(
|
||||
display.clean_game_view = !display.clean_game_view;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn camera_bookmark_hotkeys(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
mut contexts: EguiContexts,
|
||||
ui_state: Res<UiState>,
|
||||
scene_io: Res<crate::scene_io::SceneIo>,
|
||||
mut bookmarks: ResMut<CameraBookmarks>,
|
||||
mut editor_cameras: Query<&mut Transform, With<EditorCamera>>,
|
||||
) {
|
||||
) -> Result {
|
||||
let ctx = contexts.ctx_mut()?;
|
||||
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) {
|
||||
return Ok(());
|
||||
}
|
||||
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
|
||||
if !shift || !ctrl {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scene_key = scene_io
|
||||
@ -216,19 +240,20 @@ fn camera_bookmark_hotkeys(
|
||||
|
||||
if keys.just_pressed(KeyCode::Digit1) {
|
||||
let Ok(transform) = editor_cameras.single() else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
save_camera_bookmark(&mut bookmarks, &scene_key, *transform);
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::Digit2) {
|
||||
let Some(saved) = recall_camera_bookmark(&bookmarks, &scene_key) else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
for mut transform in &mut editor_cameras {
|
||||
*transform = saved;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_viewport_grid(
|
||||
|
||||
@ -92,22 +92,26 @@ fn handle_pick_events(
|
||||
proxies: Query<&EditorVisualizerProxy>,
|
||||
icon_proxies: Query<&ActorIconProxy>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
display: Res<ViewportDisplayMode>,
|
||||
gizmo_targets: Query<&GizmoTarget>,
|
||||
hierarchy: Option<Res<HierarchyPanelState>>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
) -> Result {
|
||||
if ui_state.pointer_in_viewport
|
||||
&& !buttons.any_pressed([MouseButton::Right, MouseButton::Middle])
|
||||
&& keys.just_pressed(KeyCode::Escape)
|
||||
{
|
||||
ui_state.selected_entities.clear();
|
||||
selected.0 = None;
|
||||
pick_stack.entities.clear();
|
||||
pick_stack.index = 0;
|
||||
pick_stack.anchor = None;
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if display.clean_game_view {
|
||||
viewport_click.0 = None;
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if gizmo_targets
|
||||
@ -115,7 +119,7 @@ fn handle_pick_events(
|
||||
.any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g))
|
||||
{
|
||||
viewport_click.0 = None;
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additive = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]);
|
||||
@ -134,11 +138,11 @@ fn handle_pick_events(
|
||||
pointer_pos,
|
||||
additive,
|
||||
);
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !ui_state.pointer_in_viewport {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for event in click_events.read() {
|
||||
@ -187,6 +191,7 @@ fn handle_pick_events(
|
||||
additive,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cycle_overlapping_viewport_pick(
|
||||
@ -194,25 +199,29 @@ fn cycle_overlapping_viewport_pick(
|
||||
mut selected: ResMut<SelectedEntity>,
|
||||
mut pick_stack: ResMut<ViewportPickStack>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
display: Res<ViewportDisplayMode>,
|
||||
) {
|
||||
) -> Result {
|
||||
if display.clean_game_view {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
if !ui_state.pointer_in_viewport
|
||||
|| buttons.any_pressed([MouseButton::Right, MouseButton::Middle])
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if !keys.just_pressed(KeyCode::Tab) || pick_stack.entities.len() < 2 {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
if keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]) {
|
||||
return;
|
||||
}
|
||||
if !ui_state.pointer_in_viewport {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pick_stack.index = (pick_stack.index + 1) % pick_stack.entities.len();
|
||||
let entity = pick_stack.entities[pick_stack.index];
|
||||
ui_state.selected_entities.select_replace(entity);
|
||||
selected.0 = Some(entity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_viewport_pick(
|
||||
|
||||
@ -48,8 +48,9 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
||||
- **Player placement** is authored through `PlayerSpawn`; selecting the runtime player in Edit mode redirects to a saved `Player Start`.
|
||||
- **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live.
|
||||
- **Rendering** — `GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD.
|
||||
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and a pinned bottom Add Component footer (`actor_inspector`); only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/material selectors, visibility, shadow flags, and per-actor material overrides. Empty actor material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
|
||||
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/material selectors, visibility, shadow flags, and per-actor material overrides. Empty actor material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
|
||||
- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands.
|
||||
- **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active.
|
||||
- **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`.
|
||||
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders.
|
||||
- **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user