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

This demo shows an app with UI options to:

  1. Reset DockState to a default.
  2. Snapshot DockState, and then reload it.
  3. Export DockState in ron format and then import it.
  4. All state is stored within the DockLayoutDemo struct, which means it all serializes to disk so nothing is lost on save/reload.
egui_dock_snapshot_demo.webm

@Omustardo
Copy link
Author

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);
        }
  }
}

@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