Polish inspector add component workflow
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 03:08:28 -04:00
parent 19c4d017dc
commit 2fa64106c8
12 changed files with 747 additions and 174 deletions

View File

@ -97,7 +97,7 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
| `Ctrl+G` | Toggle viewport grid | | `Ctrl+G` | Toggle viewport grid |
| `F` | Focus editor camera on selection | | `F` | Focus editor camera on selection |
| `Ctrl+Shift+1` / `Ctrl+Shift+2` | Save / recall viewport camera bookmark | | `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 + `W` `A` `S` `D` | Editor camera fly |
| RMB + `Q` / `E` | Editor camera down / up | | RMB + `Q` / `E` | Editor camera down / up |
| Mouse wheel | Dolly editor camera | | 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 | | Hierarchy context | Reparent to other selection / Unparent |
| File menu | New, Open, Save, Save As, Import Assets, Export Selection, Save Selection As Prefab, Recent Scenes | | 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 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) | | 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 ### Play Mode
- Press **F5** (or Play menu) to run the **real game** in-process: same `GamePlugin`, player, - Press **F5** (or Play menu) to run the **real game** in-process: same `GamePlugin`, player,

View File

@ -13,7 +13,7 @@ use crate::ui::hierarchy_ops::{
apply_sibling_change, apply_sibling_change_new, next_sibling_index, apply_sibling_change, apply_sibling_change_new, next_sibling_index,
reorder_entities_under_parent, would_create_cycle, reorder_entities_under_parent, would_create_cycle,
}; };
use crate::ui::UiState; use crate::ui::{egui_captures_keyboard_from_world, UiState};
mod commands; mod commands;
pub use commands::{EditorCommand, EditorEntitySnapshot, SiblingChange}; 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) { fn handle_history_hotkeys(world: &mut World) {
if egui_captures_keyboard_from_world(world) {
return;
}
let (undo, redo, delete, duplicate) = { let (undo, redo, delete, duplicate) = {
let keys = world.resource::<ButtonInput<KeyCode>>(); let keys = world.resource::<ButtonInput<KeyCode>>();
let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]);

View File

@ -79,40 +79,33 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
}); });
ui.add_space(6.0); ui.add_space(6.0);
let footer_reserve = 52.0; egui::ScrollArea::vertical()
let body_height = (ui.available_height() - footer_reserve).max(1.0); .auto_shrink([false, false])
ui.allocate_ui_with_layout( .show(ui, |ui| {
egui::vec2(ui.available_width(), body_height), transform_inspector_ui(world, ui, entity);
egui::Layout::top_down(egui::Align::Min),
|ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
transform_inspector_ui(world, ui, entity);
match kind { match kind {
ActorKind::StaticMesh ActorKind::StaticMesh
| ActorKind::ImportedModel | ActorKind::ImportedModel
| ActorKind::Light | ActorKind::Light
| ActorKind::Empty | ActorKind::Empty
| ActorKind::PrefabAnchor | ActorKind::PrefabAnchor
| ActorKind::PlayerSpawn | ActorKind::PlayerSpawn
| ActorKind::WeaponSpawn | ActorKind::WeaponSpawn
| ActorKind::TriggerVolume | ActorKind::TriggerVolume
| ActorKind::PostProcessVolume | ActorKind::PostProcessVolume
| ActorKind::TeamSpawn | ActorKind::TeamSpawn
| ActorKind::Objective => { | ActorKind::Objective => {
inspector::authoring_inspector_ui(world, ui, entity); inspector::authoring_inspector_ui(world, ui, entity);
} }
} }
world.resource_scope(|world, registry: Mut<ActorInspectorSectionRegistry>| { world.resource_scope(|world, registry: Mut<ActorInspectorSectionRegistry>| {
registry.draw_sections(world, ui, entity); registry.draw_sections(world, ui, entity);
}); });
});
}, inspector::add_component_footer(world, ui, entity);
); });
inspector::add_component_footer(world, ui, entity);
} }
fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon { fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {

View File

@ -5,9 +5,11 @@ use egui_phosphor_icons::icons;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorComponentCategory { pub enum EditorComponentCategory {
Authoring,
Rendering, Rendering,
Physics, Physics,
Gameplay, Gameplay,
Volumes,
Editor, Editor,
} }
@ -19,8 +21,13 @@ pub struct EditorComponentDescriptor {
pub addable: bool, pub addable: bool,
pub removable: bool, pub removable: bool,
pub reorderable: bool, pub reorderable: bool,
pub hidden: bool,
pub icon: &'static str, pub icon: &'static str,
pub description: &'static str,
pub search_terms: &'static [&'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)] #[derive(Resource, Debug, Clone)]
@ -39,8 +46,14 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::CUBE.as_str(), 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 { EditorComponentDescriptor {
type_name: "shared::components::MaterialDesc", type_name: "shared::components::MaterialDesc",
@ -49,8 +62,13 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::PALETTE.as_str(), icon: icons::PALETTE.as_str(),
description: "Defines an authored material directly on this actor.",
search_terms: &["material", "shader", "texture"], search_terms: &["material", "shader", "texture"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Hydrates into Bevy material assets for renderable actors.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::RigidBodyDesc", type_name: "shared::components::RigidBodyDesc",
@ -59,8 +77,13 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::SPHERE.as_str(), icon: icons::SPHERE.as_str(),
description: "Controls authored rigid-body behavior for physics hydration.",
search_terms: &["body", "physics", "rigidbody"], search_terms: &["body", "physics", "rigidbody"],
recommended: &["shared::components::ColliderDesc"],
conflicts_with: &[],
hydration_effect: "Hydrates into runtime Avian rigid-body state.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::ColliderDesc", type_name: "shared::components::ColliderDesc",
@ -69,8 +92,13 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::SELECTION.as_str(), icon: icons::SELECTION.as_str(),
description: "Defines authored collision shape data.",
search_terms: &["collision", "trigger", "physics"], search_terms: &["collision", "trigger", "physics"],
recommended: &["shared::components::RigidBodyDesc"],
conflicts_with: &[],
hydration_effect: "Hydrates into runtime Avian collider state.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::LightDesc", type_name: "shared::components::LightDesc",
@ -79,18 +107,29 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::LIGHTBULB.as_str(), 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 { EditorComponentDescriptor {
type_name: "shared::components::Primitive", type_name: "shared::components::Primitive",
display_name: "Primitive", display_name: "Primitive",
category: EditorComponentCategory::Rendering, category: EditorComponentCategory::Authoring,
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::CUBE.as_str(), icon: icons::CUBE.as_str(),
description: "Creates an authored built-in primitive shape.",
search_terms: &["box", "sphere", "ramp", "primitive"], search_terms: &["box", "sphere", "ramp", "primitive"],
recommended: &[],
conflicts_with: &["shared::components::StaticMeshRenderer"],
hydration_effect:
"Hydrates into generated primitive render and collision data.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::PlayerSpawn", type_name: "shared::components::PlayerSpawn",
@ -99,8 +138,14 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::PERSON_SIMPLE_RUN.as_str(), icon: icons::PERSON_SIMPLE_RUN.as_str(),
description: "Marks where Play mode should spawn the player.",
search_terms: &["player", "spawn", "start"], search_terms: &["player", "spawn", "start"],
recommended: &[],
conflicts_with: &[],
hydration_effect:
"Used by PIE/session startup; no saved runtime player is created.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::WeaponSpawn", type_name: "shared::components::WeaponSpawn",
@ -109,18 +154,28 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::CROSSHAIR.as_str(), icon: icons::CROSSHAIR.as_str(),
description: "Marks a gameplay weapon spawn point.",
search_terms: &["weapon", "spawn"], search_terms: &["weapon", "spawn"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Hydrates into gameplay spawn metadata.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::TriggerVolume", type_name: "shared::components::TriggerVolume",
display_name: "Trigger Volume", display_name: "Trigger Volume",
category: EditorComponentCategory::Gameplay, category: EditorComponentCategory::Volumes,
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::SELECTION.as_str(), icon: icons::SELECTION.as_str(),
description: "Adds an authored gameplay trigger volume.",
search_terms: &["trigger", "volume", "event"], search_terms: &["trigger", "volume", "event"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Hydrates into gameplay trigger queries and visualizers.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::TeamSpawn", type_name: "shared::components::TeamSpawn",
@ -129,8 +184,13 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::FLAG.as_str(), icon: icons::FLAG.as_str(),
description: "Marks a gameplay team spawn point.",
search_terms: &["team", "spawn"], search_terms: &["team", "spawn"],
recommended: &[],
conflicts_with: &[],
hydration_effect: "Hydrates into gameplay spawn metadata.",
}, },
EditorComponentDescriptor { EditorComponentDescriptor {
type_name: "shared::components::ObjectiveMarker", type_name: "shared::components::ObjectiveMarker",
@ -139,8 +199,29 @@ impl Default for EditorComponentRegistry {
addable: true, addable: true,
removable: true, removable: true,
reorderable: true, reorderable: true,
hidden: false,
icon: icons::FLAG.as_str(), icon: icons::FLAG.as_str(),
description: "Marks an authored gameplay objective location.",
search_terms: &["objective", "marker", "gameplay"], 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 { EditorComponentDescriptor {
type_name: "shared::components::InspectorOrder", type_name: "shared::components::InspectorOrder",
@ -149,8 +230,14 @@ impl Default for EditorComponentRegistry {
addable: false, addable: false,
removable: false, removable: false,
reorderable: false, reorderable: false,
hidden: true,
icon: icons::LIST_BULLETS.as_str(), icon: icons::LIST_BULLETS.as_str(),
description: "Stores editor-only component ordering and active-state metadata.",
search_terms: &["editor", "order"], search_terms: &["editor", "order"],
recommended: &[],
conflicts_with: &[],
hydration_effect:
"Controls editor presentation and authoring component active state.",
}, },
], ],
} }

View File

@ -34,7 +34,9 @@ use crate::ui::theme::{
}; };
use crate::ui::widgets::{icon_button_small, phosphor_icon, phosphor_icon_text}; 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::dock_tabs::open_and_focus_tab;
use super::helpers::create_scene_sun_override_from_project_settings; use super::helpers::create_scene_sun_override_from_project_settings;
use super::{EditorTab, UiState}; use super::{EditorTab, UiState};
@ -101,6 +103,22 @@ pub(crate) struct InspectorClipboard {
component: Option<CopiedComponent>, 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)] #[derive(Debug, Clone)]
enum CopiedComponent { enum CopiedComponent {
Primitive(Primitive), 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) { 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); ui.add_space(4.0);
egui::Frame::new() let button_response = egui::Frame::new()
.fill(WIDGET_BG) .fill(WIDGET_BG)
.stroke(egui::Stroke::new(1.0, BORDER)) .stroke(egui::Stroke::new(1.0, BORDER))
.corner_radius(egui::CornerRadius::same(4)) .corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6)) .inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal_wrapped(|ui| { let open_for_entity = add_component_picker_open_for(world, entity);
let mut search = String::new(); let label = if open_for_entity {
let search_width = fit_width(ui, MIN_INLINE_CONTROL_WIDTH, TEXT_FIELD_MAX_WIDTH); "Close Add Component"
ui.add( } else {
egui::TextEdit::singleline(&mut search) "+ Add Component"
.hint_text("Search components...") };
.desired_width(search_width), if ui.button(label).clicked() {
); if let Some(mut state) = world.get_resource_mut::<InspectorPanelState>() {
ui.menu_button("Add Component", |ui| { if open_for_entity {
for category in [ state.add_component_open = false;
EditorComponentCategory::Rendering, state.add_component_target = None;
EditorComponentCategory::Physics, } else {
EditorComponentCategory::Gameplay, state.add_component_open = true;
] { state.add_component_focus_search = true;
let mut wrote_category = false; state.add_component_scroll_selected = true;
for descriptor in descriptors.iter().filter(|descriptor| { state.add_component_selected_index = 0;
descriptor.addable && descriptor.category == category state.add_component_target = Some(entity);
}) { state.add_component_search.clear();
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();
}
} }
}); }
}); }
});
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()),
);
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 { 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 { match type_name {
"shared::components::Primitive" => world.get::<Primitive>(entity).is_none(), "shared::components::Primitive" => world.get::<Primitive>(entity).is_some(),
"shared::components::StaticMeshRenderer" => { "shared::components::StaticMeshRenderer" => {
world.get::<StaticMeshRenderer>(entity).is_none() 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, _ => false,
} }
} }
@ -791,6 +1139,11 @@ fn insert_registered_component(world: &mut World, entity: Entity, type_name: &st
objective_id: "objective".into(), objective_id: "objective".into(),
}); });
}), }),
"shared::components::PostProcessVolumeDesc" => {
insert_component(world, entity, |world, e| {
world.entity_mut(e).insert(PostProcessVolumeDesc::default());
})
}
_ => {} _ => {}
} }
} }

View File

@ -65,6 +65,31 @@ pub struct UiState {
last_mode_tab: Option<EditorMode>, 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 { impl UiState {
pub fn default_layout() -> Self { pub fn default_layout() -> Self {
Self::from_prefs(&UserPreferences::default()) Self::from_prefs(&UserPreferences::default())
@ -113,6 +138,7 @@ impl UiState {
); );
editor_toolbar_panel(world, ctx); editor_toolbar_panel(world, ctx);
status_bar_ui(world, ctx, &self.selected_entities, mode);
self.pointer_in_viewport = false; self.pointer_in_viewport = false;
self.viewport_pointer_pos = None; self.viewport_pointer_pos = None;
@ -154,8 +180,6 @@ impl UiState {
); );
tick_layout_save(world, dt); tick_layout_save(world, dt);
status_bar_ui(world, ctx, &self.selected_entities, mode);
let mut diagnostics_open = world.resource::<DiagnosticsPanel>().open; let mut diagnostics_open = world.resource::<DiagnosticsPanel>().open;
diagnostics_window(world, ctx, &mut diagnostics_open); diagnostics_window(world, ctx, &mut diagnostics_open);
world.resource_mut::<DiagnosticsPanel>().open = 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::<crate::rendering_diagnostics::RenderingDiagnosticsPanel>()
.init_resource::<asset_browser::AssetBrowserUiState>() .init_resource::<asset_browser::AssetBrowserUiState>()
.init_resource::<inspector::InspectorClipboard>() .init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>()
.init_resource::<ViewportUiState>() .init_resource::<ViewportUiState>()
.init_resource::<LayoutSaveTimer>() .init_resource::<LayoutSaveTimer>()
.add_systems( .add_systems(

View File

@ -58,8 +58,9 @@ pub fn viewport_tab_ui(
.filter(|pos| rect.contains(*pos)); .filter(|pos| rect.contains(*pos));
*pointer_in_viewport = viewport_pointer_pos.is_some(); *pointer_in_viewport = viewport_pointer_pos.is_some();
let primary_clicked = let primary_clicked = ui.input(|input| {
ui.input(|input| input.pointer.button_clicked(egui::PointerButton::Primary)); input.pointer.button_clicked(egui::PointerButton::Primary) && !input.modifiers.alt
});
if primary_clicked && !clean_game_view { if primary_clicked && !clean_game_view {
if let Some(pos) = response if let Some(pos) = response
.interact_pointer_pos() .interact_pointer_pos()

View File

@ -1,12 +1,13 @@
use bevy::camera::visibility::RenderLayers; use bevy::camera::visibility::RenderLayers;
use bevy::input::mouse::{AccumulatedMouseMotion, MouseWheel}; use bevy::input::mouse::{AccumulatedMouseMotion, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorOptions, PrimaryWindow};
use bevy_egui::{EguiContexts, EguiGlobalSettings, PrimaryEguiContext}; use bevy_egui::{EguiContexts, EguiGlobalSettings, PrimaryEguiContext};
use transform_gizmo_bevy::prelude::GizmoCamera; use transform_gizmo_bevy::prelude::GizmoCamera;
use crate::infra::EditorOnly; use crate::infra::EditorOnly;
use crate::state::scene_tools_active; use crate::state::scene_tools_active;
use crate::ui::UiState; use crate::ui::{egui_captures_keyboard, UiState};
use settings::ProjectSettings; use settings::ProjectSettings;
#[derive(Component)] #[derive(Component)]
@ -28,14 +29,29 @@ impl Default for EditorCamera {
pub struct EditorCameraPlugin; 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 { impl Plugin for EditorCameraPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_editor_camera).add_systems( app.init_resource::<EditorCameraInputState>()
Update, .add_systems(Startup, spawn_editor_camera)
(sync_editor_camera_from_settings, update_editor_camera) .add_systems(
.chain() Update,
.run_if(scene_tools_active), (sync_editor_camera_from_settings, update_editor_camera)
); .chain()
.run_if(scene_tools_active),
);
} }
} }
@ -104,25 +120,61 @@ fn update_editor_camera(
mut scroll: MessageReader<MouseWheel>, mut scroll: MessageReader<MouseWheel>,
mut contexts: EguiContexts, mut contexts: EguiContexts,
ui_state: Res<UiState>, ui_state: Res<UiState>,
mut input_state: ResMut<EditorCameraInputState>,
mut cameras: Query<(&mut Transform, &EditorCamera)>, mut cameras: Query<(&mut Transform, &EditorCamera)>,
mut cursor: Single<&mut CursorOptions, With<PrimaryWindow>>,
) -> Result { ) -> Result {
let ctx = contexts.ctx_mut()?; let ctx = contexts.ctx_mut()?;
let in_scene = ui_state.pointer_in_viewport; 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(()); 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(()); 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(()); return Ok(());
} }
let dt = time.delta_secs(); 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 { 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; let delta = mouse_motion.delta;
if delta != Vec2::ZERO { if delta != Vec2::ZERO {
let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ); let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ);
@ -140,23 +192,25 @@ fn update_editor_camera(
let up = Vec3::Y; let up = Vec3::Y;
let mut wish = Vec3::ZERO; let mut wish = Vec3::ZERO;
if keys.pressed(KeyCode::KeyW) { if looking {
wish += forward; if keys.pressed(KeyCode::KeyW) {
} wish += forward;
if keys.pressed(KeyCode::KeyS) { }
wish -= forward; if keys.pressed(KeyCode::KeyS) {
} wish -= forward;
if keys.pressed(KeyCode::KeyD) { }
wish += right; if keys.pressed(KeyCode::KeyD) {
} wish += right;
if keys.pressed(KeyCode::KeyA) { }
wish -= right; if keys.pressed(KeyCode::KeyA) {
} wish -= right;
if keys.pressed(KeyCode::KeyE) { }
wish += up; if keys.pressed(KeyCode::KeyE) {
} wish += up;
if keys.pressed(KeyCode::KeyQ) { }
wish -= up; if keys.pressed(KeyCode::KeyQ) {
wish -= up;
}
} }
let sprint = if keys.pressed(KeyCode::ShiftLeft) { let sprint = if keys.pressed(KeyCode::ShiftLeft) {
@ -167,7 +221,7 @@ fn update_editor_camera(
transform.translation += wish.normalize_or_zero() * camera.fly_speed * sprint * dt; transform.translation += wish.normalize_or_zero() * camera.fly_speed * sprint * dt;
transform.translation += forward * scroll_delta * camera.fly_speed * 0.08; transform.translation += forward * scroll_delta * camera.fly_speed * 0.08;
if buttons.pressed(MouseButton::Middle) { if panning {
let delta = mouse_motion.delta; let delta = mouse_motion.delta;
let distance_scale = transform.translation.length().max(1.0).sqrt(); let distance_scale = transform.translation.length().max(1.0).sqrt();
transform.translation += transform.translation +=
@ -177,3 +231,13 @@ fn update_editor_camera(
Ok(()) 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;
}
}

View File

@ -1,8 +1,9 @@
use bevy::math::Rect; use bevy::math::Rect;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::EguiContexts;
use transform_gizmo_bevy::prelude::{GizmoMode, GizmoOptions, GizmoOrientation}; use transform_gizmo_bevy::prelude::{GizmoMode, GizmoOptions, GizmoOrientation};
use crate::ui::UiState; use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::ViewportDisplayMode; use crate::viewport::ViewportDisplayMode;
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
@ -40,12 +41,19 @@ impl Plugin for EditorGizmoPlugin {
fn handle_gizmo_hotkeys( fn handle_gizmo_hotkeys(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
mut mode: ResMut<EditorGizmoMode>, mut mode: ResMut<EditorGizmoMode>,
mut space: ResMut<EditorGizmoSpace>, mut space: ResMut<EditorGizmoSpace>,
) { ) -> Result {
if display.clean_game_view { 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) { if keys.just_pressed(KeyCode::KeyW) {
*mode = EditorGizmoMode::Translate; *mode = EditorGizmoMode::Translate;
@ -62,6 +70,7 @@ fn handle_gizmo_hotkeys(
EditorGizmoSpace::Local => EditorGizmoSpace::World, EditorGizmoSpace::Local => EditorGizmoSpace::World,
}; };
} }
Ok(())
} }
/// Maps the viewport egui panel to gizmo cursor space (required for render-to-texture). /// Maps the viewport egui panel to gizmo cursor space (required for render-to-texture).

View File

@ -3,10 +3,11 @@
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::selection::SelectedEntity; use crate::selection::SelectedEntity;
use crate::state::scene_tools_active; use crate::state::scene_tools_active;
use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::viewport_mode::EditorViewportModePlugin; use crate::viewport::viewport_mode::EditorViewportModePlugin;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::Window; use bevy::window::Window;
use bevy_egui::egui; use bevy_egui::{egui, EguiContexts};
/// Viewport display and snapping options (editor-only). /// Viewport display and snapping options (editor-only).
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
@ -155,22 +156,29 @@ pub fn snap_translation(position: Vec3, settings: &ViewportSettings) -> Vec3 {
fn focus_selection_hotkey( fn focus_selection_hotkey(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
selected: Res<SelectedEntity>, selected: Res<SelectedEntity>,
mut editor_cameras: Query<&mut Transform, With<EditorCamera>>, mut editor_cameras: Query<&mut Transform, With<EditorCamera>>,
targets: Query<&GlobalTransform>, targets: Query<&GlobalTransform>,
) { ) -> Result {
if display.clean_game_view { 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) { if !keys.just_pressed(KeyCode::KeyF) {
return; return Ok(());
} }
let Some(entity) = selected.0 else { let Some(entity) = selected.0 else {
return; return Ok(());
}; };
let Ok(target) = targets.get(entity) else { let Ok(target) = targets.get(entity) else {
return; return Ok(());
}; };
let center = target.translation(); let center = target.translation();
for mut transform in &mut editor_cameras { for mut transform in &mut editor_cameras {
@ -179,13 +187,21 @@ fn focus_selection_hotkey(
transform.translation = center - transform.forward().as_vec3() * distance; transform.translation = center - transform.forward().as_vec3() * distance;
transform.look_at(center, Vec3::Y); transform.look_at(center, Vec3::Y);
} }
Ok(())
} }
fn toggle_viewport_hotkeys( fn toggle_viewport_hotkeys(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
mut settings: ResMut<ViewportSettings>, mut settings: ResMut<ViewportSettings>,
mut display: ResMut<ViewportDisplayMode>, 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) { if keys.just_pressed(KeyCode::KeyG) {
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
if ctrl { if ctrl {
@ -194,18 +210,26 @@ fn toggle_viewport_hotkeys(
display.clean_game_view = !display.clean_game_view; display.clean_game_view = !display.clean_game_view;
} }
} }
Ok(())
} }
fn camera_bookmark_hotkeys( fn camera_bookmark_hotkeys(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
scene_io: Res<crate::scene_io::SceneIo>, scene_io: Res<crate::scene_io::SceneIo>,
mut bookmarks: ResMut<CameraBookmarks>, mut bookmarks: ResMut<CameraBookmarks>,
mut editor_cameras: Query<&mut Transform, With<EditorCamera>>, 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 shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
if !shift || !ctrl { if !shift || !ctrl {
return; return Ok(());
} }
let scene_key = scene_io let scene_key = scene_io
@ -216,19 +240,20 @@ fn camera_bookmark_hotkeys(
if keys.just_pressed(KeyCode::Digit1) { if keys.just_pressed(KeyCode::Digit1) {
let Ok(transform) = editor_cameras.single() else { let Ok(transform) = editor_cameras.single() else {
return; return Ok(());
}; };
save_camera_bookmark(&mut bookmarks, &scene_key, *transform); save_camera_bookmark(&mut bookmarks, &scene_key, *transform);
} }
if keys.just_pressed(KeyCode::Digit2) { if keys.just_pressed(KeyCode::Digit2) {
let Some(saved) = recall_camera_bookmark(&bookmarks, &scene_key) else { let Some(saved) = recall_camera_bookmark(&bookmarks, &scene_key) else {
return; return Ok(());
}; };
for mut transform in &mut editor_cameras { for mut transform in &mut editor_cameras {
*transform = saved; *transform = saved;
} }
} }
Ok(())
} }
fn draw_viewport_grid( fn draw_viewport_grid(

View File

@ -92,22 +92,26 @@ fn handle_pick_events(
proxies: Query<&EditorVisualizerProxy>, proxies: Query<&EditorVisualizerProxy>,
icon_proxies: Query<&ActorIconProxy>, icon_proxies: Query<&ActorIconProxy>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
gizmo_targets: Query<&GizmoTarget>, gizmo_targets: Query<&GizmoTarget>,
hierarchy: Option<Res<HierarchyPanelState>>, hierarchy: Option<Res<HierarchyPanelState>>,
) { ) -> Result {
if keys.just_pressed(KeyCode::Escape) { if ui_state.pointer_in_viewport
&& !buttons.any_pressed([MouseButton::Right, MouseButton::Middle])
&& keys.just_pressed(KeyCode::Escape)
{
ui_state.selected_entities.clear(); ui_state.selected_entities.clear();
selected.0 = None; selected.0 = None;
pick_stack.entities.clear(); pick_stack.entities.clear();
pick_stack.index = 0; pick_stack.index = 0;
pick_stack.anchor = None; pick_stack.anchor = None;
return; return Ok(());
} }
if display.clean_game_view { if display.clean_game_view {
viewport_click.0 = None; viewport_click.0 = None;
return; return Ok(());
} }
if gizmo_targets if gizmo_targets
@ -115,7 +119,7 @@ fn handle_pick_events(
.any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g)) .any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g))
{ {
viewport_click.0 = None; viewport_click.0 = None;
return; return Ok(());
} }
let additive = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]); let additive = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]);
@ -134,11 +138,11 @@ fn handle_pick_events(
pointer_pos, pointer_pos,
additive, additive,
); );
return; return Ok(());
} }
if !ui_state.pointer_in_viewport { if !ui_state.pointer_in_viewport {
return; return Ok(());
} }
for event in click_events.read() { for event in click_events.read() {
@ -187,6 +191,7 @@ fn handle_pick_events(
additive, additive,
); );
} }
Ok(())
} }
fn cycle_overlapping_viewport_pick( fn cycle_overlapping_viewport_pick(
@ -194,25 +199,29 @@ fn cycle_overlapping_viewport_pick(
mut selected: ResMut<SelectedEntity>, mut selected: ResMut<SelectedEntity>,
mut pick_stack: ResMut<ViewportPickStack>, mut pick_stack: ResMut<ViewportPickStack>,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
) { ) -> Result {
if display.clean_game_view { 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 { if !keys.just_pressed(KeyCode::Tab) || pick_stack.entities.len() < 2 {
return; return Ok(());
} }
if keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]) { if keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]) {
return; return Ok(());
}
if !ui_state.pointer_in_viewport {
return;
} }
pick_stack.index = (pick_stack.index + 1) % pick_stack.entities.len(); pick_stack.index = (pick_stack.index + 1) % pick_stack.entities.len();
let entity = pick_stack.entities[pick_stack.index]; let entity = pick_stack.entities[pick_stack.index];
ui_state.selected_entities.select_replace(entity); ui_state.selected_entities.select_replace(entity);
selected.0 = Some(entity); selected.0 = Some(entity);
Ok(())
} }
fn apply_viewport_pick( fn apply_viewport_pick(

View File

@ -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`. - **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. - **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. - **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. - **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`. - **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. - **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. - **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.