Last active
August 18, 2025 23:24
-
-
Save Omustardo/556fe3d0288740f46919b5b9b2533e69 to your computer and use it in GitHub Desktop.
Standalone demo of saving and loading layout state in egui_dock, while it's running (v1)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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())) | |
| }), | |
| ) | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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