Skip to content

Instantly share code, notes, and snippets.

@Omustardo
Last active August 18, 2025 23:24
Show Gist options
  • Select an option

  • Save Omustardo/556fe3d0288740f46919b5b9b2533e69 to your computer and use it in GitHub Desktop.

Select an option

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)
// 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()))
}),
)
}
@Omustardo
Copy link
Author

Omustardo commented Aug 18, 2025

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment