diff --git a/rmf_site_editor/Cargo.toml b/rmf_site_editor/Cargo.toml index f1f2221b..a6fdf41c 100644 --- a/rmf_site_editor/Cargo.toml +++ b/rmf_site_editor/Cargo.toml @@ -11,6 +11,10 @@ name = "librmf_site_editor" path = "src/main.rs" name = "rmf_site_editor" +[[example]] +name = "extending_site_editor" +path = "examples/extending_menu.rs" + [dependencies] bevy_egui = "0.19" bevy_mod_picking = "0.11" diff --git a/rmf_site_editor/examples/extending_menu.rs b/rmf_site_editor/examples/extending_menu.rs new file mode 100644 index 00000000..1454bb1f --- /dev/null +++ b/rmf_site_editor/examples/extending_menu.rs @@ -0,0 +1,92 @@ +use bevy::prelude::*; +use librmf_site_editor::{widgets::menu_bar::*, SiteEditor}; + +#[derive(Debug, Default)] +struct MyMenuPlugin; + +#[derive(Debug, Resource)] +struct MyMenuHandler { + unique_export: Entity, + custom_nested_menu: Entity, +} + +impl FromWorld for MyMenuHandler { + fn from_world(world: &mut World) -> Self { + // This is all it takes to register a new menu item + // We need to keep track of the entity in order to make + // sure that we can check the callback + let unique_export = world + .spawn(MenuItem::Text("My unique export".to_string())) + .id(); + + // Make it a child of the "File Menu" + let file_header = world.resource::().get(); + world + .entity_mut(file_header) + .push_children(&[unique_export]); + + // For top level menus simply spawn a menu with no parent + let menu = world + .spawn(Menu::from_title("My Awesome Menu".to_string())) + .id(); + + // We can use bevy's parent-child system to handle nesting + let sub_menu = world + .spawn(Menu::from_title("My Awesome sub menu".to_string())) + .id(); + world.entity_mut(menu).push_children(&[sub_menu]); + + // Finally we can create a custom action + let custom_nested_menu = world + .spawn(MenuItem::Text("My Awesome Action".to_string())) + .id(); + world + .entity_mut(sub_menu) + .push_children(&[custom_nested_menu]); + + // Track the entity so that we know when to handle events from it in + Self { + unique_export, + custom_nested_menu, + } + } +} + +/// Handler for unique export menu item. All one needs to do is check that you recieve +/// an event that is of the same type as the one we are supposed to +/// handle. +fn watch_unique_export_click(mut reader: EventReader, menu_handle: Res) { + for event in reader.iter() { + if event.clicked() && event.source() == menu_handle.unique_export { + println!("Doing our epic export"); + } + } +} + +/// Handler for unique export menu item. All one needs to do is check that you recieve +/// an event that is of the same type as the one we are supposed to +/// handle. +fn watch_submenu_click(mut reader: EventReader, menu_handle: Res) { + for event in reader.iter() { + if event.clicked() && event.source() == menu_handle.custom_nested_menu { + println!("Submenu clicked"); + } + } +} + +/// The actual plugin +impl Plugin for MyMenuPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_system(watch_unique_export_click) + .add_system(watch_submenu_click); + } +} + +/// Lets embed site editor in our application with our own plugin +fn main() { + App::new() + .add_plugin(SiteEditor) + .add_plugin(MyMenuPlugin) + .run(); +} diff --git a/rmf_site_editor/src/lib.rs b/rmf_site_editor/src/lib.rs index 03f69d96..19bdf0ad 100644 --- a/rmf_site_editor/src/lib.rs +++ b/rmf_site_editor/src/lib.rs @@ -24,8 +24,8 @@ mod settings; use settings::*; mod save; use save::*; -mod widgets; -use widgets::*; +pub mod widgets; +use widgets::{menu_bar::MenuPluginManager, *}; pub mod occupancy; use occupancy::OccupancyPlugin; @@ -126,66 +126,74 @@ pub fn run(command_line_args: Vec) { } } - #[cfg(target_arch = "wasm32")] - { - app.add_plugins( - DefaultPlugins - .build() - .disable::() - .set(WindowPlugin { - window: WindowDescriptor { - title: "RMF Site Editor".to_owned(), - canvas: Some(String::from("#rmf_site_editor_canvas")), + app.add_plugin(SiteEditor); + app.run(); +} + +pub struct SiteEditor; + +impl Plugin for SiteEditor { + fn build(&self, app: &mut App) { + #[cfg(target_arch = "wasm32")] + { + app.add_plugins( + DefaultPlugins + .build() + .disable::() + .set(WindowPlugin { + window: WindowDescriptor { + title: "RMF Site Editor".to_owned(), + canvas: Some(String::from("#rmf_site_editor_canvas")), + ..default() + }, ..default() - }, - ..default() - }) - .add_after::(SiteAssetIoPlugin), - ) - .add_system_set( - SystemSet::new() - .with_run_criteria(FixedTimestep::step(0.5)) - .with_system(check_browser_window_size), - ); - } + }) + .add_after::(SiteAssetIoPlugin), + ) + .add_system_set( + SystemSet::new() + .with_run_criteria(FixedTimestep::step(0.5)) + .with_system(check_browser_window_size), + ); + } - #[cfg(not(target_arch = "wasm32"))] - { - app.add_plugins( - DefaultPlugins - .build() - .disable::() - .set(WindowPlugin { - window: WindowDescriptor { - title: "RMF Site Editor".to_owned(), - width: 1600., - height: 900., + #[cfg(not(target_arch = "wasm32"))] + { + app.add_plugins( + DefaultPlugins + .build() + .disable::() + .set(WindowPlugin { + window: WindowDescriptor { + title: "RMF Site Editor".to_owned(), + width: 1600., + height: 900., + ..default() + }, ..default() - }, - ..default() - }) - .add_after::(SiteAssetIoPlugin), - ); + }) + .add_after::(SiteAssetIoPlugin), + ); + } + app.init_resource::() + .add_startup_system(init_settings) + .insert_resource(DirectionalLightShadowMap { size: 2048 }) + .add_plugin(LogHistoryPlugin) + .add_plugin(AabbUpdatePlugin) + .add_plugin(EguiPlugin) + .add_plugin(KeyboardInputPlugin) + .add_plugin(SavePlugin) + .add_plugin(SdfPlugin) + .add_state(AppState::MainMenu) + .add_plugin(MainMenuPlugin) + // .add_plugin(WarehouseGeneratorPlugin) + .add_plugin(WorkcellEditorPlugin) + .add_plugin(SitePlugin) + .add_plugin(InteractionPlugin) + .add_plugin(StandardUiLayout) + .add_plugin(AnimationPlugin) + .add_plugin(OccupancyPlugin) + .add_plugin(WorkspacePlugin) + .add_plugin(MenuPluginManager); } - - app.init_resource::() - .add_startup_system(init_settings) - .insert_resource(DirectionalLightShadowMap { size: 2048 }) - .add_plugin(LogHistoryPlugin) - .add_plugin(AabbUpdatePlugin) - .add_plugin(EguiPlugin) - .add_plugin(KeyboardInputPlugin) - .add_plugin(SavePlugin) - .add_plugin(SdfPlugin) - .add_state(AppState::MainMenu) - .add_plugin(MainMenuPlugin) - // .add_plugin(WarehouseGeneratorPlugin) - .add_plugin(WorkcellEditorPlugin) - .add_plugin(SitePlugin) - .add_plugin(InteractionPlugin) - .add_plugin(StandardUiLayout) - .add_plugin(AnimationPlugin) - .add_plugin(OccupancyPlugin) - .add_plugin(WorkspacePlugin) - .run(); } diff --git a/rmf_site_editor/src/widgets/menu_bar.rs b/rmf_site_editor/src/widgets/menu_bar.rs index 8b5bd4c8..5d746582 100644 --- a/rmf_site_editor/src/widgets/menu_bar.rs +++ b/rmf_site_editor/src/widgets/menu_bar.rs @@ -17,15 +17,163 @@ use crate::{CreateNewWorkspace, FileEvents, LoadWorkspace, SaveWorkspace, VisibilityParameters}; +use bevy::prelude::{ + App, Children, Component, Entity, EventWriter, FromWorld, Parent, Plugin, Query, Res, Resource, + Without, World, +}; use bevy_egui::{ - egui::{self, Button}, + egui::{self, epaint::ahash::HashSet, Button, Ui}, EguiContext, }; +/// This component represents a menu. Menus and menu items +/// can be arranged in trees using bevy's own parent-child system. +#[derive(Component)] +pub struct Menu { + text: String, +} + +impl Menu { + /// Create a new menu from the title + pub fn from_title(text: String) -> Self { + Self { text } + } + + /// Retrieve the menu name + fn get(&self) -> String { + self.text.clone() + } +} + +/// Create a new menu item +#[derive(Component)] +#[non_exhaustive] +pub enum MenuItem { + Text(String), +} + +/// This resource provides the root entity for the file menu +#[derive(Resource)] +pub struct FileMenu { + /// Map of menu items + menu_item: Entity, +} + +impl FileMenu { + pub fn get(&self) -> Entity { + return self.menu_item; + } +} + +impl FromWorld for FileMenu { + fn from_world(world: &mut World) -> Self { + let menu_item = world + .spawn(Menu { + text: "File".to_string(), + }) + .id(); + Self { menu_item } + } +} + +#[non_exhaustive] +pub enum MenuEvent { + MenuClickEvent(Entity), +} + +impl MenuEvent { + pub fn clicked(&self) -> bool { + matches!(self, Self::MenuClickEvent(_)) + } + + pub fn source(&self) -> Entity { + match self { + Self::MenuClickEvent(entity) => *entity, + } + } +} + +pub struct MenuPluginManager; + +impl Plugin for MenuPluginManager { + fn build(&self, app: &mut App) { + app.add_event::().init_resource::(); + } +} + +/// Helper function to render a submenu starting at the entity. +fn render_sub_menu( + ui: &mut Ui, + entity: &Entity, + children: &Query<&Children>, + menus: &Query<(&Menu, Entity)>, + menu_items: &Query<&MenuItem>, + extension_events: &mut EventWriter, + skip_top_label: bool, +) { + if let Ok(e) = menu_items.get(*entity) { + // Draw ui + match e { + MenuItem::Text(title) => { + if ui.add(Button::new(title)).clicked() { + extension_events.send(MenuEvent::MenuClickEvent(*entity)); + } + } + } + return; + } + + let Ok((menu, _)) = menus.get(*entity) else { + return; + }; + + if !skip_top_label { + ui.menu_button(&menu.get(), |ui| { + let Ok(child_items) = children.get(*entity) else { + return; + }; + + for child in child_items.iter() { + render_sub_menu( + ui, + child, + children, + menus, + menu_items, + extension_events, + false, + ); + } + }); + } else { + let Ok(child_items) = children.get(*entity) else { + return; + }; + + for child in child_items.iter() { + render_sub_menu( + ui, + child, + children, + menus, + menu_items, + extension_events, + false, + ); + } + } +} + pub fn top_menu_bar( egui_context: &mut EguiContext, file_events: &mut FileEvents, params: &mut VisibilityParameters, + file_menu: &Res, + top_level_components: &Query<(), Without>, + children: &Query<&Children>, + menus: &Query<(&Menu, Entity)>, + menu_items: &Query<&MenuItem>, + extension_events: &mut EventWriter, ) { egui::TopBottomPanel::top("top_panel").show(egui_context.ctx_mut(), |ui| { egui::menu::bar(ui, |ui| { @@ -56,6 +204,16 @@ pub fn top_menu_bar( { file_events.load_workspace.send(LoadWorkspace::Dialog); } + + render_sub_menu( + ui, + &file_menu.get(), + children, + menus, + menu_items, + extension_events, + true, + ); }); ui.menu_button("View", |ui| { if ui @@ -145,6 +303,20 @@ pub fn top_menu_bar( params.events.walls.send((!params.resources.walls.0).into()); } }); + + for (_, entity) in menus.iter().filter(|(_, entity)| { + top_level_components.contains(*entity) && *entity != file_menu.get() + }) { + render_sub_menu( + ui, + &entity, + children, + menus, + menu_items, + extension_events, + false, + ); + } }); }); } diff --git a/rmf_site_editor/src/widgets/mod.rs b/rmf_site_editor/src/widgets/mod.rs index 2253370e..f1118ec4 100644 --- a/rmf_site_editor/src/widgets/mod.rs +++ b/rmf_site_editor/src/widgets/mod.rs @@ -286,6 +286,12 @@ fn site_ui_layout( nav_graphs: NavGraphParams, layers: LayersParams, mut events: AppEvents, + file_menu: Res, + children: Query<&Children>, + top_level_components: Query<(), Without>, + menus: Query<(&Menu, Entity)>, + menu_items: Query<&MenuItem>, + mut extension_events: EventWriter, ) { egui::SidePanel::right("right_panel") .resizable(true) @@ -350,6 +356,12 @@ fn site_ui_layout( &mut egui_context, &mut events.file_events, &mut events.visibility_parameters, + &file_menu, + &top_level_components, + &children, + &menus, + &menu_items, + &mut extension_events, ); egui::TopBottomPanel::bottom("log_console") @@ -385,6 +397,12 @@ fn site_drawing_ui_layout( inspector_params: InspectorParams, create_params: CreateParams, mut events: AppEvents, + file_menu: Res, + children: Query<&Children>, + top_level_components: Query<(), Without>, + menus: Query<(&Menu, Entity)>, + menu_items: Query<&MenuItem>, + mut extension_events: EventWriter, ) { egui::SidePanel::right("right_panel") .resizable(true) @@ -435,6 +453,12 @@ fn site_drawing_ui_layout( &mut egui_context, &mut events.file_events, &mut events.visibility_parameters, + &file_menu, + &top_level_components, + &children, + &menus, + &menu_items, + &mut extension_events, ); let egui_context = egui_context.ctx_mut(); @@ -461,6 +485,12 @@ fn site_visualizer_ui_layout( inspector_params: InspectorParams, mut events: AppEvents, levels: LevelParams, + file_menu: Res, + top_level_components: Query<(), Without>, + children: Query<&Children>, + menus: Query<(&Menu, Entity)>, + menu_items: Query<&MenuItem>, + mut extension_events: EventWriter, ) { egui::SidePanel::right("right_panel") .resizable(true) @@ -512,6 +542,12 @@ fn site_visualizer_ui_layout( &mut egui_context, &mut events.file_events, &mut events.visibility_parameters, + &file_menu, + &top_level_components, + &children, + &menus, + &menu_items, + &mut extension_events, ); let egui_context = egui_context.ctx_mut(); @@ -538,6 +574,12 @@ fn workcell_ui_layout( inspector_params: InspectorParams, create_params: CreateParams, mut events: AppEvents, + mut extension_events: EventWriter, + file_menu: Res, + top_level_components: Query<(), Without>, + children: Query<&Children>, + menus: Query<(&Menu, Entity)>, + menu_items: Query<&MenuItem>, ) { egui::SidePanel::right("right_panel") .resizable(true) @@ -575,6 +617,12 @@ fn workcell_ui_layout( &mut egui_context, &mut events.file_events, &mut events.visibility_parameters, + &file_menu, + &top_level_components, + &children, + &menus, + &menu_items, + &mut extension_events, ); let egui_context = egui_context.ctx_mut();