@@ -23,7 +23,10 @@ use renzora::bevy_egui::{EguiContexts, EguiPrimaryContextPass};
2323use renzora:: core:: keybindings:: { EditorAction , KeyBindings } ;
2424use renzora:: core:: viewport_types:: ViewportState ;
2525use renzora:: core:: EditorCamera ;
26- use renzora:: editor:: { ActiveTool , EditorSelection , SpawnRegistry , SplashState } ;
26+ use renzora:: editor:: {
27+ ActiveTool , EditorSelection , InspectorRegistry , OpenAddComponentMenuRequest ,
28+ SpawnRegistry , SplashState ,
29+ } ;
2730use 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
9199renzora:: 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
95120fn 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 {
328379enum 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+
435493fn 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