Skip to content

Commit 6610d1a

Browse files
committed
Layout persistence + Add Component from hierarchy + paste-on-floor
Layout persistence: - DockTree + WorkspaceLayout + LayoutManager now Serialize/Deserialize. The full workspace (all named layouts + active index) is saved to {user_config}/renzora/layout.toml. - Auto-save: mark_layout_dirty mirrors DockingState into the active layout's slot whenever it changes; flush_layout_save writes to disk after 8 stable frames (~0.13s @ 60fps) so drags don't hammer the disk. - LayoutManager::switch now stores the current tree back into the outgoing slot before loading the incoming one — switching layouts no longer wipes your edits. - New Edit -> Reset Layout menu item resets only the active layout to its factory default via LayoutManager::reset_active. Other layouts keep their customisations. - Dropped the Sandbox layout. Hierarchy Add Component: - Right-click menu in hierarchy now has an "Add Component..." entry. - Fires OpenAddComponentMenuRequest resource (defined in SDK so any panel can trigger); context_menu plugin drains it and opens its component picker overlay at the cursor. - Picker is sourced from InspectorRegistry — same set and metadata the inspector shows. Skips components already on the entity and any without an add_fn. Grouped by category, searchable. Paste positioning: - Ctrl+V over the viewport now aligns the lowest point of the pasted group's world-space AABB with the ground plane, instead of placing the group centroid at y=0 (which had half the mesh buried below).
1 parent 7f0ccec commit 6610d1a

8 files changed

Lines changed: 407 additions & 44 deletions

File tree

crates/editor/renzora_context_menu/src/lib.rs

Lines changed: 174 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ use renzora::bevy_egui::{EguiContexts, EguiPrimaryContextPass};
2323
use renzora::core::keybindings::{EditorAction, KeyBindings};
2424
use renzora::core::viewport_types::ViewportState;
2525
use renzora::core::EditorCamera;
26-
use renzora::editor::{ActiveTool, EditorSelection, SpawnRegistry, SplashState};
26+
use renzora::editor::{
27+
ActiveTool, EditorSelection, InspectorRegistry, OpenAddComponentMenuRequest,
28+
SpawnRegistry, SplashState,
29+
};
2730
use renzora::theme::ThemeManager;
2831

2932
// ── State ──────────────────────────────────────────────────────────────────
@@ -60,8 +63,12 @@ pub enum MenuKind {
6063
/// Spawn a preset at this world position.
6164
AddHere { world_pos: Vec3 },
6265
/// Act on the current `EditorSelection` (Duplicate, Delete, Focus,
63-
/// Deselect). Shown when at least one entity is selected.
66+
/// Deselect, Add Component). Shown when at least one entity is selected.
6467
EntityActions,
68+
/// Component picker — reached from the EntityActions menu via "Add
69+
/// Component". Lists reflected components that can be attached to the
70+
/// current selection.
71+
AddComponent,
6572
}
6673

6774
/// Drag threshold in pixels. Motion magnitude below this still counts as a tap.
@@ -79,7 +86,8 @@ impl Plugin for ContextMenuPlugin {
7986
.init_resource::<ContextMenuState>()
8087
.add_systems(
8188
Update,
82-
detect_right_click_tap.run_if(in_state(SplashState::Editor)),
89+
(detect_right_click_tap, consume_add_component_request)
90+
.run_if(in_state(SplashState::Editor)),
8391
)
8492
.add_systems(
8593
EguiPrimaryContextPass,
@@ -90,6 +98,23 @@ impl Plugin for ContextMenuPlugin {
9098

9199
renzora::add!(ContextMenuPlugin, Editor);
92100

101+
/// Drain pending [`OpenAddComponentMenuRequest`] — set by hierarchy /
102+
/// inspector / any panel that wants to trigger the component picker.
103+
fn consume_add_component_request(
104+
mut commands: Commands,
105+
request: Option<Res<OpenAddComponentMenuRequest>>,
106+
mut menu: ResMut<ContextMenuState>,
107+
) {
108+
let Some(req) = request else { return };
109+
menu.open = true;
110+
menu.screen_pos = req.screen_pos;
111+
menu.kind = MenuKind::AddComponent;
112+
menu.query.clear();
113+
menu.just_opened = true;
114+
menu.open_counter = menu.open_counter.wrapping_add(1);
115+
commands.remove_resource::<OpenAddComponentMenuRequest>();
116+
}
117+
93118
// ── Right-click tap detection ──────────────────────────────────────────────
94119

95120
fn detect_right_click_tap(
@@ -283,8 +308,24 @@ fn render_context_menu(world: &mut World) {
283308
render_entity_menu(
284309
ui, &query, enter_pressed,
285310
text_primary, text_muted, hover,
286-
|action| {
287-
pending_action = Some(PendingAction::Act { action });
311+
|result| {
312+
pending_action = Some(match result {
313+
EntityMenuResult::Action(action) => {
314+
PendingAction::Act { action }
315+
}
316+
EntityMenuResult::Switch(next) => {
317+
PendingAction::SwitchMenu(next)
318+
}
319+
});
320+
},
321+
);
322+
}
323+
MenuKind::AddComponent => {
324+
render_add_component_menu(
325+
ui, world, &query, enter_pressed,
326+
text_primary, text_muted, hover,
327+
|type_id| {
328+
pending_action = Some(PendingAction::AddComponent { type_id });
288329
},
289330
);
290331
}
@@ -301,15 +342,25 @@ fn render_context_menu(world: &mut World) {
301342
}
302343
}
303344

304-
if pending_action.is_some() {
345+
// SwitchMenu is a navigation event — it shouldn't close the menu. All
346+
// other actions close on commit.
347+
let closes_menu = !matches!(pending_action, Some(PendingAction::SwitchMenu(_)));
348+
if pending_action.is_some() && closes_menu {
305349
close = true;
306350
}
307351

308352
// Commit query back + clear the `just_opened` flag after first render.
353+
// When switching submenus, reset query + focus as if freshly opened.
309354
{
310355
let mut s = world.resource_mut::<ContextMenuState>();
311356
s.query = query;
312357
s.just_opened = false;
358+
if let Some(PendingAction::SwitchMenu(next)) = &pending_action {
359+
s.kind = *next;
360+
s.query.clear();
361+
s.just_opened = true;
362+
s.open_counter = s.open_counter.wrapping_add(1);
363+
}
313364
if close {
314365
s.open = false;
315366
}
@@ -328,6 +379,8 @@ fn matches_query(label: &str, query_lower: &str) -> bool {
328379
enum PendingAction {
329380
Spawn { id: &'static str, world_pos: Vec3 },
330381
Act { action: EntityAction },
382+
SwitchMenu(MenuKind),
383+
AddComponent { type_id: &'static str },
331384
}
332385

333386
#[derive(Clone, Copy)]
@@ -432,14 +485,19 @@ fn group_presets(registry: &SpawnRegistry) -> Vec<(&'static str, Vec<PresetRow>)
432485

433486
// ── Entity menu ────────────────────────────────────────────────────────────
434487

488+
enum EntityMenuResult {
489+
Action(EntityAction),
490+
Switch(MenuKind),
491+
}
492+
435493
fn render_entity_menu(
436494
ui: &mut egui::Ui,
437495
query: &str,
438496
enter_pressed: bool,
439497
text_primary: Color32,
440498
text_muted: Color32,
441499
hover: Color32,
442-
mut on_pick: impl FnMut(EntityAction),
500+
mut on_pick: impl FnMut(EntityMenuResult),
443501
) {
444502
ui.label(RichText::new("Entity").color(text_muted).size(14.0).monospace());
445503
ui.separator();
@@ -451,7 +509,6 @@ fn render_entity_menu(
451509
("\u{E07A}", "Deselect (Esc)", EntityAction::Deselect),
452510
("\u{E1A0}", "Delete (Del)", EntityAction::Delete),
453511
];
454-
455512
let visible: Vec<&(&str, &str, EntityAction)> = entries
456513
.iter()
457514
.filter(|(_, label, _)| matches_query(label, &q))
@@ -464,13 +521,13 @@ fn render_entity_menu(
464521

465522
for (icon, label, action) in &visible {
466523
if menu_row(ui, icon, label, text_primary, hover) {
467-
on_pick(*action);
524+
on_pick(EntityMenuResult::Action(*action));
468525
}
469526
}
470527

471528
if enter_pressed {
472529
if let Some((_, _, action)) = visible.first() {
473-
on_pick(*action);
530+
on_pick(EntityMenuResult::Action(*action));
474531
}
475532
}
476533
}
@@ -509,9 +566,6 @@ fn apply_action(world: &mut World, action: PendingAction) {
509566
spawn_preset_at(world, id, world_pos);
510567
}
511568
PendingAction::Act { action } => {
512-
// Operates on the current EditorSelection — dispatch through
513-
// KeyBindings so consumers react as if the bound key was pressed
514-
// (rebinds + existing handlers apply).
515569
if let Some(kb) = world.get_resource::<KeyBindings>() {
516570
match action {
517571
EntityAction::Focus => kb.dispatch(EditorAction::FocusSelected),
@@ -521,6 +575,113 @@ fn apply_action(world: &mut World, action: PendingAction) {
521575
}
522576
}
523577
}
578+
PendingAction::SwitchMenu(_) => {
579+
// Pure navigation — handled above by rewriting ContextMenuState.
580+
}
581+
PendingAction::AddComponent { type_id } => {
582+
add_component_to_selection(world, type_id);
583+
}
584+
}
585+
}
586+
587+
/// Invoke the `InspectorEntry::add_fn` for `type_id` on every entity in
588+
/// the current selection.
589+
fn add_component_to_selection(world: &mut World, type_id: &'static str) {
590+
let entities: Vec<Entity> = world
591+
.get_resource::<EditorSelection>()
592+
.map(|s| s.get_all())
593+
.unwrap_or_default();
594+
if entities.is_empty() { return; }
595+
596+
let add_fn = world
597+
.get_resource::<InspectorRegistry>()
598+
.and_then(|r| r.iter().find(|e| e.type_id == type_id).and_then(|e| e.add_fn));
599+
let Some(add_fn) = add_fn else {
600+
warn!("[context_menu] '{}' has no add_fn registered", type_id);
601+
return;
602+
};
603+
604+
for entity in entities {
605+
add_fn(world, entity);
606+
}
607+
}
608+
609+
// ── Add Component menu ─────────────────────────────────────────────────────
610+
//
611+
// Sourced from the editor's `InspectorRegistry` — same set of components
612+
// that the inspector shows, with their curated display names, icons,
613+
// categories, and `add_fn` registration.
614+
615+
fn render_add_component_menu(
616+
ui: &mut egui::Ui,
617+
world: &World,
618+
query: &str,
619+
enter_pressed: bool,
620+
text_primary: Color32,
621+
text_muted: Color32,
622+
hover: Color32,
623+
mut on_pick: impl FnMut(&'static str),
624+
) {
625+
ui.label(RichText::new("Add Component").color(text_muted).size(14.0).monospace());
626+
ui.separator();
627+
628+
let Some(registry) = world.get_resource::<InspectorRegistry>() else {
629+
ui.label(RichText::new("No InspectorRegistry").color(text_muted).size(14.0));
630+
return;
631+
};
632+
633+
// Target entity = current primary selection. Skip components that are
634+
// already on it, or that have no `add_fn`.
635+
let target = world
636+
.get_resource::<EditorSelection>()
637+
.and_then(|s| s.get());
638+
639+
let q = query.to_lowercase();
640+
let mut groups: Vec<(&'static str, Vec<&renzora::editor::InspectorEntry>)> = Vec::new();
641+
for entry in registry.iter() {
642+
if entry.add_fn.is_none() { continue; }
643+
if let Some(target) = target {
644+
if (entry.has_fn)(world, target) { continue; }
645+
}
646+
let matches = matches_query(entry.display_name, &q) || matches_query(entry.category, &q);
647+
if !matches { continue; }
648+
649+
if let Some(bucket) = groups.iter_mut().find(|(c, _)| *c == entry.category) {
650+
bucket.1.push(entry);
651+
} else {
652+
groups.push((entry.category, vec![entry]));
653+
}
654+
}
655+
656+
if groups.is_empty() {
657+
ui.label(RichText::new("No matches").color(text_muted).size(14.0));
658+
return;
659+
}
660+
661+
let mut first_visible: Option<&'static str> = None;
662+
egui::ScrollArea::vertical()
663+
.max_height(360.0)
664+
.auto_shrink([false, true])
665+
.show(ui, |ui| {
666+
for (category, entries) in &groups {
667+
if !category.is_empty() {
668+
ui.label(RichText::new(*category).color(text_muted).size(12.0).monospace());
669+
}
670+
for entry in entries {
671+
if first_visible.is_none() {
672+
first_visible = Some(entry.type_id);
673+
}
674+
if menu_row(ui, entry.icon, entry.display_name, text_primary, hover) {
675+
on_pick(entry.type_id);
676+
}
677+
}
678+
}
679+
});
680+
681+
if enter_pressed {
682+
if let Some(id) = first_visible {
683+
on_pick(id);
684+
}
524685
}
525686
}
526687

0 commit comments

Comments
 (0)