-
-
Save Omustardo/556fe3d0288740f46919b5b9b2533e69 to your computer and use it in GitHub Desktop.
| // Demo of saving and loading UI layouts using egui_dock. | |
| use eframe::NativeOptions; | |
| use egui::containers::menu::MenuConfig; | |
| use egui::{ | |
| CentralPanel, PopupCloseBehavior, TopBottomPanel, Ui, ViewportBuilder, WidgetText, vec2, | |
| }; | |
| use egui_dock::tab_viewer::OnCloseResponse; | |
| use egui_dock::{DockArea, DockState, NodeIndex, Style, SurfaceIndex, TabViewer}; | |
| use serde::{Deserialize, Serialize}; | |
| use std::collections::{HashMap, HashSet}; | |
| #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| enum TabName { | |
| Window1, | |
| Window2, | |
| Window3, | |
| Window4, | |
| } | |
| impl TabName { | |
| fn display_name(&self) -> &'static str { | |
| match self { | |
| TabName::Window1 => "Window 1", | |
| TabName::Window2 => "Window 2", | |
| TabName::Window3 => "Window 3", | |
| TabName::Window4 => "Window 4", | |
| } | |
| } | |
| fn all_tabs() -> Vec<TabName> { | |
| vec![ | |
| TabName::Window1, | |
| TabName::Window2, | |
| TabName::Window3, | |
| TabName::Window4, | |
| ] | |
| } | |
| } | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| struct Dock { | |
| state: DockState<TabName>, | |
| open_tabs: HashSet<TabName>, | |
| #[serde(skip)] | |
| pending_add_tab: Vec<(TabName, SurfaceIndex, NodeIndex)>, | |
| } | |
| impl Default for Dock { | |
| fn default() -> Self { | |
| // Create a 2x2 grid layout with all 4 windows | |
| let mut dock_state = DockState::new(vec![TabName::Window1]); | |
| // Split horizontally to create left and right sides | |
| let [left_side, right_side] = dock_state.main_surface_mut().split_right( | |
| NodeIndex::root(), | |
| 0.5, | |
| vec![TabName::Window2], | |
| ); | |
| // Split left side vertically | |
| let [_top_left, _bottom_left] = | |
| dock_state | |
| .main_surface_mut() | |
| .split_below(left_side, 0.5, vec![TabName::Window3]); | |
| // Split right side vertically | |
| let [_top_right, _bottom_right] = | |
| dock_state | |
| .main_surface_mut() | |
| .split_below(right_side, 0.5, vec![TabName::Window4]); | |
| let mut open_tabs = HashSet::new(); | |
| for node in dock_state[SurfaceIndex::main()].iter() { | |
| if let Some(tabs) = node.tabs() { | |
| for tab in tabs { | |
| open_tabs.insert(tab.clone()); | |
| } | |
| } | |
| } | |
| Self { | |
| state: dock_state, | |
| open_tabs, | |
| pending_add_tab: Vec::new(), | |
| } | |
| } | |
| } | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| struct SavedLayout { | |
| dock: Dock, | |
| locked: bool, | |
| } | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| struct ExportedLayout { | |
| name: String, | |
| dock: Dock, | |
| locked: bool, | |
| } | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| struct UiState { | |
| dock: Dock, | |
| current_layout_name: String, | |
| saved_dock_layouts: HashMap<String, SavedLayout>, | |
| } | |
| impl Default for UiState { | |
| fn default() -> Self { | |
| Self { | |
| dock: Dock::default(), | |
| current_layout_name: "Default".to_string(), | |
| saved_dock_layouts: HashMap::new(), | |
| } | |
| } | |
| } | |
| #[derive(Default)] | |
| struct SessionState { | |
| layout_name_input: String, | |
| import_layout_buffer: String, | |
| import_error_message: Option<String>, | |
| import_error_timer: Option<std::time::Instant>, | |
| export_success_message: Option<String>, | |
| export_success_timer: Option<std::time::Instant>, | |
| } | |
| #[derive(Serialize, Deserialize, Default)] | |
| struct DockLayoutDemo { | |
| ui_state: UiState, | |
| #[serde(skip)] | |
| session: SessionState, | |
| } | |
| impl TabViewer for DockLayoutDemo { | |
| type Tab = TabName; | |
| fn title(&mut self, tab: &mut Self::Tab) -> WidgetText { | |
| tab.display_name().into() | |
| } | |
| fn ui(&mut self, ui: &mut Ui, tab: &mut Self::Tab) { | |
| match tab { | |
| TabName::Window1 => { | |
| ui.vertical_centered(|ui| { | |
| ui.heading("Window 1"); | |
| ui.label("First demo window"); | |
| }); | |
| } | |
| TabName::Window2 => { | |
| ui.vertical_centered(|ui| { | |
| ui.heading("Window 2"); | |
| ui.label("Second demo window"); | |
| }); | |
| } | |
| TabName::Window3 => { | |
| ui.vertical_centered(|ui| { | |
| ui.heading("Window 3"); | |
| ui.label("Third demo window"); | |
| }); | |
| } | |
| TabName::Window4 => { | |
| ui.vertical_centered(|ui| { | |
| ui.heading("Window 4"); | |
| ui.label("Fourth demo window"); | |
| }); | |
| } | |
| } | |
| } | |
| fn on_close(&mut self, tab: &mut Self::Tab) -> OnCloseResponse { | |
| self.ui_state.dock.open_tabs.remove(tab); | |
| OnCloseResponse::Close | |
| } | |
| fn on_add(&mut self, _surface: SurfaceIndex, _node: NodeIndex) { | |
| // Use add_popup instead | |
| } | |
| fn add_popup(&mut self, ui: &mut Ui, surface: SurfaceIndex, node: NodeIndex) { | |
| if !self.ui_state.dock.pending_add_tab.is_empty() { | |
| ui.label("A tab is currently pending..."); | |
| return; | |
| } | |
| let closed_tabs: Vec<TabName> = TabName::all_tabs() | |
| .into_iter() | |
| .filter(|tab| !self.ui_state.dock.open_tabs.contains(tab)) | |
| .collect(); | |
| if closed_tabs.is_empty() { | |
| ui.label("All tabs are already open"); | |
| return; | |
| } | |
| for tab in closed_tabs { | |
| if ui.button(tab.display_name()).clicked() { | |
| self.ui_state.dock.open_tabs.insert(tab.clone()); | |
| self.ui_state | |
| .dock | |
| .pending_add_tab | |
| .push((tab, surface, node)); | |
| ui.close(); | |
| } | |
| } | |
| } | |
| } | |
| impl DockLayoutDemo { | |
| fn generate_default_name(&self) -> String { | |
| let colors = [ | |
| "Red", "Blue", "Green", "Purple", "Orange", "Yellow", "Pink", "Cyan", "Magenta", | |
| "Lime", "Teal", "Navy", "Maroon", "Olive", "Silver", "Gold", "Coral", "Indigo", | |
| "Violet", "Crimson", | |
| ]; | |
| let animals = [ | |
| "Tiger", "Eagle", "Dolphin", "Wolf", "Fox", "Bear", "Hawk", "Lion", "Shark", "Falcon", | |
| "Panther", "Raven", "Lynx", "Cobra", "Moose", "Otter", "Jaguar", "Whale", "Bison", | |
| "Falcon", | |
| ]; | |
| let adjectives = [ | |
| "Swift", "Bold", "Calm", "Bright", "Sharp", "Strong", "Quick", "Wise", "Fierce", | |
| "Gentle", "Noble", "Silent", "Rapid", "Steady", "Clever", "Brave", "Agile", "Mighty", | |
| "Alert", "Graceful", | |
| ]; | |
| use std::collections::hash_map::DefaultHasher; | |
| use std::hash::{Hash, Hasher}; | |
| let mut hasher = DefaultHasher::new(); | |
| std::time::SystemTime::now().hash(&mut hasher); | |
| self.ui_state.saved_dock_layouts.len().hash(&mut hasher); | |
| let seed = hasher.finish(); | |
| let color = colors[(seed % colors.len() as u64) as usize]; | |
| let animal = animals[((seed >> 8) % animals.len() as u64) as usize]; | |
| let adjective = adjectives[((seed >> 16) % adjectives.len() as u64) as usize]; | |
| format!("{} {} {}", adjective, color, animal) | |
| } | |
| fn save_current_layout(&mut self) { | |
| let name = if self.session.layout_name_input.trim().is_empty() { | |
| let mut tmp = self.generate_default_name(); | |
| while self.ui_state.saved_dock_layouts.contains_key(&tmp) { | |
| tmp = format!("{} (new)", tmp); | |
| } | |
| tmp | |
| } else { | |
| self.session.layout_name_input.trim().to_string() | |
| }; | |
| if self.ui_state.saved_dock_layouts.len() < 16 { | |
| let saved_layout = SavedLayout { | |
| dock: Dock { | |
| state: self.ui_state.dock.state.clone(), | |
| open_tabs: self.ui_state.dock.open_tabs.clone(), | |
| pending_add_tab: vec![], | |
| }, | |
| locked: false, | |
| }; | |
| self.ui_state | |
| .saved_dock_layouts | |
| .insert(name.clone(), saved_layout); | |
| self.ui_state.current_layout_name = name; | |
| self.session.layout_name_input.clear(); | |
| } | |
| } | |
| fn load_layout(&mut self, name: String) { | |
| if let Some(saved_layout) = self.ui_state.saved_dock_layouts.get(&name) { | |
| self.ui_state.dock.state = saved_layout.dock.state.clone(); | |
| self.ui_state.dock.open_tabs = saved_layout.dock.open_tabs.clone(); | |
| self.ui_state.current_layout_name = name; | |
| } | |
| } | |
| fn export_layout_to_clipboard(&mut self, ctx: &egui::Context, name: &str) { | |
| if let Some(saved_layout) = self.ui_state.saved_dock_layouts.get(name) { | |
| let exported = ExportedLayout { | |
| name: name.to_string(), | |
| dock: saved_layout.dock.clone(), | |
| locked: saved_layout.locked, | |
| }; | |
| match ron::ser::to_string_pretty(&exported, ron::ser::PrettyConfig::default()) { | |
| Ok(ron_data) => { | |
| ctx.copy_text(ron_data); | |
| self.session.import_error_message = None; | |
| self.session.import_error_timer = None; | |
| self.session.export_success_message = | |
| Some("Layout copied to clipboard!".to_string()); | |
| self.session.export_success_timer = Some(std::time::Instant::now()); | |
| } | |
| Err(e) => { | |
| self.session.import_error_message = | |
| Some(format!("Failed to export layout: {}", e)); | |
| self.session.import_error_timer = Some(std::time::Instant::now()); | |
| self.session.export_success_message = None; | |
| self.session.export_success_timer = None; | |
| } | |
| } | |
| } else { | |
| self.session.import_error_message = Some("Layout not found".to_string()); | |
| self.session.import_error_timer = Some(std::time::Instant::now()); | |
| self.session.export_success_message = None; | |
| self.session.export_success_timer = None; | |
| } | |
| } | |
| fn import_layout_from_buffer(&mut self) { | |
| if self.session.import_layout_buffer.trim().is_empty() { | |
| self.session.import_error_message = Some("No layout data provided".to_string()); | |
| self.session.import_error_timer = Some(std::time::Instant::now()); | |
| return; | |
| } | |
| match ron::from_str::<ExportedLayout>(&self.session.import_layout_buffer) { | |
| Ok(imported) => { | |
| let original_name = imported.name.clone(); | |
| let mut final_name = imported.name; | |
| if self.ui_state.saved_dock_layouts.contains_key(&final_name) { | |
| final_name = format!("{} (imported)", original_name); | |
| let mut counter = 2; | |
| while self.ui_state.saved_dock_layouts.contains_key(&final_name) { | |
| final_name = format!("{} (imported {})", original_name, counter); | |
| counter += 1; | |
| } | |
| } | |
| let saved_layout = SavedLayout { | |
| dock: imported.dock, | |
| locked: imported.locked, | |
| }; | |
| self.ui_state | |
| .saved_dock_layouts | |
| .insert(final_name.clone(), saved_layout); | |
| self.session.import_error_message = None; | |
| self.session.import_error_timer = None; | |
| self.session.export_success_message = None; | |
| self.session.export_success_timer = None; | |
| self.session.import_layout_buffer.clear(); | |
| self.load_layout(final_name); | |
| } | |
| Err(e) => { | |
| self.session.import_error_message = Some(format!("Failed to parse layout: {}", e)); | |
| self.session.import_error_timer = Some(std::time::Instant::now()); | |
| self.session.export_success_message = None; | |
| self.session.export_success_timer = None; | |
| } | |
| } | |
| } | |
| fn reset_to_default(&mut self) { | |
| self.ui_state.dock = Dock::default(); | |
| self.ui_state.current_layout_name = "Default".to_string(); | |
| } | |
| fn update_message_timers(&mut self) { | |
| if let Some(timer) = self.session.export_success_timer { | |
| if timer.elapsed() > std::time::Duration::from_secs(3) { | |
| self.session.export_success_message = None; | |
| self.session.export_success_timer = None; | |
| } | |
| } | |
| if let Some(timer) = self.session.import_error_timer { | |
| if timer.elapsed() > std::time::Duration::from_secs(5) { | |
| self.session.import_error_message = None; | |
| self.session.import_error_timer = None; | |
| } | |
| } | |
| } | |
| fn handle_menu_actions( | |
| &mut self, | |
| ctx: &egui::Context, | |
| ) -> ( | |
| bool, | |
| bool, | |
| bool, | |
| Option<String>, | |
| Option<String>, | |
| Option<String>, | |
| ) { | |
| let mut reset_requested = false; | |
| let mut save_requested = false; | |
| let mut import_requested = false; | |
| let mut layout_to_load: Option<String> = None; | |
| let mut layout_to_delete: Option<String> = None; | |
| let mut layout_to_export: Option<String> = None; | |
| TopBottomPanel::top("menu_bar").show(ctx, |ui| { | |
| egui::MenuBar::new() | |
| .config(MenuConfig::new().close_behavior(PopupCloseBehavior::CloseOnClickOutside)) | |
| .ui(ui, |ui| { | |
| ui.menu_button("Settings", |ui| { | |
| ui.menu_button("Layouts", |ui| { | |
| ui.label(format!( | |
| "Current Layout: {}", | |
| self.ui_state.current_layout_name | |
| )); | |
| ui.separator(); | |
| ui.label("Save Current Layout:"); | |
| ui.horizontal(|ui| { | |
| ui.text_edit_singleline(&mut self.session.layout_name_input); | |
| if ui.button("Save").clicked() { | |
| save_requested = true; | |
| // Explicitly don't exit the menu. The user may want to export or do some other action. | |
| // ui.close(); | |
| } | |
| }); | |
| ui.small("(Leave empty for auto-generated name)"); | |
| if self.ui_state.saved_dock_layouts.len() >= 16 { | |
| ui.colored_label(egui::Color32::YELLOW, "Max 16 layouts reached"); | |
| } | |
| ui.separator(); | |
| if ui.button("Reset to Default").clicked() { | |
| reset_requested = true; | |
| ui.close(); | |
| } | |
| ui.separator(); | |
| ui.label("Import Layout:"); | |
| ui.add( | |
| egui::TextEdit::singleline(&mut self.session.import_layout_buffer) | |
| .hint_text("Paste layout data here..."), | |
| ).on_hover_text("Paste exported layout data here and then press \"import\". Clipboard access requires manual pasting or we could do it entirely using the import button."); | |
| ui.horizontal(|ui| { | |
| if ui.button("Import").clicked() { | |
| import_requested = true; | |
| } | |
| if ui.button("Clear").clicked() { | |
| self.session.import_layout_buffer.clear(); | |
| self.session.import_error_message = None; | |
| self.session.import_error_timer = None; | |
| self.session.export_success_message = None; | |
| self.session.export_success_timer = None; | |
| } | |
| }); | |
| ui.separator(); | |
| if !self.ui_state.saved_dock_layouts.is_empty() { | |
| ui.label("Saved Layouts:"); | |
| for (name, saved_layout) in | |
| &self.ui_state.saved_dock_layouts.clone() | |
| { | |
| ui.horizontal(|ui| { | |
| ui.set_min_width(200.0); | |
| if ui.button("Load").clicked() { | |
| layout_to_load = Some(name.clone()); | |
| ui.close(); | |
| } | |
| ui.label(name); | |
| ui.with_layout( | |
| egui::Layout::right_to_left(egui::Align::Center), | |
| |ui| { | |
| let delete_enabled = !saved_layout.locked; | |
| if ui | |
| .add_enabled( | |
| delete_enabled, | |
| egui::Button::new("Delete"), | |
| ) | |
| .on_disabled_hover_text("Unprotect to delete") | |
| .clicked() | |
| { | |
| layout_to_delete = Some(name.clone()); | |
| } | |
| let mut locked = saved_layout.locked; | |
| if ui | |
| .checkbox(&mut locked, "Protect") | |
| .on_hover_text("Protect from deletion") | |
| .changed() | |
| { | |
| if let Some(layout) = self | |
| .ui_state | |
| .saved_dock_layouts | |
| .get_mut(name) | |
| { | |
| layout.locked = locked; | |
| } | |
| } | |
| if ui | |
| .button("Export") | |
| .on_hover_text("Copy to clipboard") | |
| .clicked() | |
| { | |
| layout_to_export = Some(name.clone()); | |
| } | |
| }, | |
| ); | |
| }); | |
| } | |
| } else { | |
| ui.label("No saved layouts"); | |
| } | |
| if let Some(error_msg) = &self.session.import_error_message { | |
| ui.separator(); | |
| ui.colored_label(egui::Color32::RED, error_msg); | |
| } | |
| if let Some(success_msg) = &self.session.export_success_message { | |
| ui.separator(); | |
| ui.colored_label(egui::Color32::GREEN, success_msg); | |
| } | |
| }); | |
| ui.menu_button("Theme", |ui| { | |
| egui::widgets::global_theme_preference_buttons(ui); | |
| }); | |
| }); | |
| }); | |
| }); | |
| ( | |
| reset_requested, | |
| save_requested, | |
| import_requested, | |
| layout_to_load, | |
| layout_to_delete, | |
| layout_to_export, | |
| ) | |
| } | |
| fn process_layout_operations( | |
| &mut self, | |
| ctx: &egui::Context, | |
| reset_requested: bool, | |
| save_requested: bool, | |
| import_requested: bool, | |
| layout_to_load: Option<String>, | |
| layout_to_delete: Option<String>, | |
| layout_to_export: Option<String>, | |
| ) { | |
| if reset_requested { | |
| self.reset_to_default(); | |
| } | |
| if save_requested { | |
| self.save_current_layout(); | |
| } | |
| if import_requested { | |
| self.import_layout_from_buffer(); | |
| } | |
| if let Some(name) = layout_to_load { | |
| self.load_layout(name); | |
| } | |
| if let Some(name) = layout_to_delete { | |
| self.ui_state.saved_dock_layouts.remove(&name); | |
| } | |
| if let Some(name) = layout_to_export { | |
| self.export_layout_to_clipboard(ctx, &name); | |
| } | |
| } | |
| fn render_dock_area(&mut self, ctx: &egui::Context) { | |
| CentralPanel::default().show(ctx, |ui| { | |
| let style = Style::from_egui(ui.style().as_ref()); | |
| let mut dock_state = self.ui_state.dock.state.clone(); | |
| DockArea::new(&mut dock_state) | |
| .style(style) | |
| .show_close_buttons(true) | |
| .show_add_buttons(true) | |
| .show_add_popup(true) | |
| .show_leaf_collapse_buttons(true) | |
| .show_inside(ui, self); | |
| self.ui_state.dock.state = dock_state; | |
| for (tab_to_add, surface, node) in self.ui_state.dock.pending_add_tab.drain(..) { | |
| self.ui_state.dock.state[surface].set_focused_node(node); | |
| self.ui_state.dock.state[surface].push_to_focused_leaf(tab_to_add); | |
| } | |
| }); | |
| } | |
| } | |
| impl eframe::App for DockLayoutDemo { | |
| fn save(&mut self, storage: &mut dyn eframe::Storage) { | |
| eframe::set_value(storage, eframe::APP_KEY, self); | |
| } | |
| fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { | |
| self.update_message_timers(); | |
| let ( | |
| reset_requested, | |
| save_requested, | |
| import_requested, | |
| layout_to_load, | |
| layout_to_delete, | |
| layout_to_export, | |
| ) = self.handle_menu_actions(ctx); | |
| self.process_layout_operations( | |
| ctx, | |
| reset_requested, | |
| save_requested, | |
| import_requested, | |
| layout_to_load, | |
| layout_to_delete, | |
| layout_to_export, | |
| ); | |
| self.render_dock_area(ctx); | |
| } | |
| } | |
| fn main() -> eframe::Result<()> { | |
| let options = NativeOptions { | |
| viewport: ViewportBuilder::default() | |
| .with_inner_size(vec2(1200.0, 800.0)) | |
| .with_title("Dock Layout Demo"), | |
| ..Default::default() | |
| }; | |
| eframe::run_native( | |
| "Dock Layout Demo", | |
| options, | |
| Box::new(|cc| { | |
| if let Some(storage) = cc.storage { | |
| if let Some(app) = eframe::get_value::<DockLayoutDemo>(storage, eframe::APP_KEY) { | |
| return Ok(Box::new(app)); | |
| } | |
| } | |
| Ok(Box::new(DockLayoutDemo::default())) | |
| }), | |
| ) | |
| } |
I think this code is not structured in a normal way for egui_dock, and I suspect it will run into issues later because of it, but it does work for this demo.
My understanding is that egui_dock has a rather hard requirement on separating DockState and the rest of the app's context. For example, in a canonical example, they are entirely separate fields:
struct MyApp {
context: MyContext,
tree: DockState<String>,
}
impl TabViewer for MyContext {
// implements the `ui` method that renders a UI based only on the app context. It cannot use DockState.
}
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Fram
// Note that the app state and DockState are kept entirely separate.
DockArea::new(&mut self.tree)
.show_inside(ui, &mut self.context);
}
}
I'd like the user to be able to reset, save, and load DockState. It needs to be triggered from the UI, but this means that TabViewer needs access to DockState. I ended up with:
struct DockLayoutDemo {
// it has some nested fields, but the important part is that it contains: DockState<TabName>
// as well as all of the other app state.
}
impl TabViewer for DockLayoutDemo {
// implements the `ui` method. Since it has access to DockState, it can have conditional logic based on it.
// For example, in the `add_popup`, it can choose to say that there are no remaining tabs to open.
}
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Fram
// It wouldn't be possible to pass dock state directly into DockArea::new since that would result
// in two mutable references to `self`. Instead, copy it and copy it back afterwards.
let mut dock_state = self.ui_state.dock.state.clone();
DockArea::new(&mut dock_state).show_inside(ui, self);
self.ui_state.dock.state = dock_state;
// This approach has a bad code smell because of the clone, and because any changes to `self.ui.dock.state` get overwritten by whatever happened in DockArea.
// It also requires operations from the UI to be handled here, rather than directly in the rendering code like egui normally works.
for (tab_to_add, surface, node) in self.ui_state.dock.pending_add_tab.drain(..) {
self.ui_state.dock.state[surface].set_focused_node(node);
self.ui_state.dock.state[surface].push_to_focused_leaf(tab_to_add);
}
}
}
I worked on this for a few more hours. This new demo has a lot more error handling, a simplified UI, and more importantly it resolved the architectural issue that the previous demo had. I'm posting it in a new gist:
https://gist.github.com/Omustardo/6bf42b80123dd07cd0375cc3fdd26286
This demo shows an app with UI options to:
DockLayoutDemostruct, which means it all serializes to disk so nothing is lost on save/reload.egui_dock_snapshot_demo.webm