Skip to content

Instantly share code, notes, and snippets.

@Omustardo
Last active August 19, 2025 08:22
Show Gist options
  • Select an option

  • Save Omustardo/6bf42b80123dd07cd0375cc3fdd26286 to your computer and use it in GitHub Desktop.

Select an option

Save Omustardo/6bf42b80123dd07cd0375cc3fdd26286 to your computer and use it in GitHub Desktop.
egui_dock save and load DockState from a menu
// Demo of snapshotting and loading DockState.
//
// The general idea is to allow a user to save their layout and then be able to reload it later.
// For example, in an IDE it might be helpful to have one layout for UI programming (e.g. a render
// window on one side) while more space for terminal output might be preferable in other situations.
//
// Being able to save and load DockState allows quickly swapping between layouts. It also allows
// the program to provide preset layouts that users could choose between.
use eframe::NativeOptions;
use egui::containers::menu::MenuConfig;
use egui::{
CentralPanel, PopupCloseBehavior, TopBottomPanel, Ui, ViewportBuilder, Widget, WidgetText, vec2,
};
use egui_dock::tab_viewer::OnCloseResponse;
use egui_dock::{DockArea, DockState, NodeIndex, SurfaceIndex, TabViewer};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use strum::IntoEnumIterator;
use strum_macros::EnumIter;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
enum TabName {
Tab1,
Tab2,
Tab3,
Tab4,
}
impl TabName {
fn display_name(&self) -> &'static str {
match self {
TabName::Tab1 => "Tab 1",
TabName::Tab2 => "Tab 2",
TabName::Tab3 => "Tab 3",
TabName::Tab4 => "Tab 4",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SavedLayout {
dock_state: DockState<TabName>,
locked: bool,
}
#[derive(Debug, Clone)]
enum UiCommand {
/// Save the current layout using the given name. If there is an existing saved layout with the
/// same name, the new layout will overwrite it. The "Default" layout cannot be modified.
SaveLayout { name: String },
/// Load a saved layout that has the given name. The current layout will be overwritten,
/// so be sure to save it first if desired.
/// If None is provided, the hard-coded default layout will be loaded.
LoadLayout { name: Option<String> },
/// Delete a saved layout with the given name. If the layout does not exist, this does nothing.
/// If the layout is locked, this does nothing.
DeleteLayout { name: String },
/// Export a layout with the given name to the user's clipboard.
ExportLayout { name: String },
/// Import a layout from the provided buffer.
ImportLayout { data: String },
/// Add a new tab to the specified location in the dock layout.
/// If the location doesn't exist when this command is processed, this does nothing.
AddTab {
tab: TabName,
surface: SurfaceIndex,
node: NodeIndex,
},
/// Add a new tab to the parent tab's location. If the parent tab is not open, this does nothing.
/// If multiple instances of the parent tab are open, the first one is used.
AddTabOnParent {
new_tab: TabName,
parent_tab: TabName,
},
/// Set the protection status of a saved layout to prevent or allow deletion.
/// If the layout does not exist, this does nothing.
SetLayoutLock { name: String, locked: bool },
}
/// Data to show in the UI that doesn't need to be saved to disk on program exit.
#[derive(Debug, Clone, 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>,
save_error_message: Option<String>,
save_error_timer: Option<std::time::Instant>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MyAppState {
current_layout_name: String,
saved_layouts: HashMap<String, SavedLayout>,
// Not serialized since it doesn't need to be saved to disk on program exit
#[serde(skip)]
session: SessionState,
// In a real program, there would be a lot more content in this struct.
// ...
}
impl Default for MyAppState {
fn default() -> Self {
Self {
current_layout_name: "Default".to_string(),
saved_layouts: HashMap::new(),
session: Default::default(),
}
}
}
impl MyAppState {
fn get_open_tabs(&self, dock_state: &DockState<TabName>) -> Vec<TabName> {
dock_state
.iter_all_tabs()
.map(|(_, tab_name)| tab_name.clone())
.collect()
}
fn get_closed_tabs(&self, dock_state: &DockState<TabName>) -> Vec<TabName> {
let open_tabs: std::collections::HashSet<_> =
self.get_open_tabs(dock_state).into_iter().collect();
TabName::iter()
.filter(|tab| !open_tabs.contains(tab))
.collect()
}
fn generate_random_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.saved_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 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;
}
}
if let Some(timer) = self.session.save_error_timer {
if timer.elapsed() > std::time::Duration::from_secs(5) {
self.session.save_error_message = None;
self.session.save_error_timer = None;
}
}
}
fn show_menu(&mut self, ctx: &egui::Context) -> Vec<UiCommand> {
let mut commands = Vec::new();
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| {
// Use this label constructor rather than `ui.Label()` to ensure
// the UI doesn't get into a bad state based on bad user input.
// For example, pasting the exported layout into the name field
// and "accidentally" saving it would make the UI so tall that
// it wouldn't be possible to delete the offending layout!
//
// This doesn't sanitize the underlying name, so if the user
// wants to store a massive unreadable string in this field, it is left
// up to their disgression.
egui::widgets::Label::new(format!(
"Current Layout: {}",
&self.current_layout_name
))
.truncate()
.ui(ui);
ui.separator();
if ui.button("Save Current Layout").clicked() {
commands.push(UiCommand::SaveLayout {
name: self.current_layout_name.clone(),
});
}
ui.separator();
ui.horizontal(|ui| {
if ui.button("Save Current Layout as:").clicked() {
let name = if self.session.layout_name_input.trim().is_empty() {
// TODO: Ensure that this doesn't overwrite any existing layouts. It's unlikely, but not impossible for it to happen by chance.
self.generate_random_name()
} else {
self.session.layout_name_input.trim().to_string()
};
commands.push(UiCommand::SaveLayout { name });
}
ui.add(
egui::TextEdit::singleline(&mut self.session.layout_name_input)
.hint_text("Layout name, or empty for generated"),
)
});
ui.separator();
if ui.button("Reset to Default").clicked() {
commands.push(UiCommand::LoadLayout { name: None });
ui.close();
}
ui.separator();
ui.horizontal(|ui| {
if ui.button("Import Layout data:").clicked() {
commands.push(UiCommand::ImportLayout {
data: self.session.import_layout_buffer.clone(),
});
}
ui.add(
egui::TextEdit::singleline(
&mut self.session.import_layout_buffer,
)
.hint_text("Paste layout data here"),
)
});
ui.separator();
if !self.saved_layouts.is_empty() {
ui.label("Saved Layouts:");
// Clone to avoid borrow checker issues
let mut layouts: Vec<(String, SavedLayout)> = self
.saved_layouts
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// Sort by the layout name. This is known to be unique since it just came from a HashMap.
layouts.sort_by(|a, b| a.0.cmp(&b.0));
for (name, saved_layout) in layouts {
ui.horizontal(|ui| {
ui.set_min_width(200.0);
if ui.button("Load").clicked() {
commands.push(UiCommand::LoadLayout {
name: Some(name.clone()),
});
// I explicitly don't close the UI here because I don't
// like that interaction.
// ui.close();
}
egui::widgets::Label::new(name.clone()).truncate().ui(ui);
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()
{
commands.push(UiCommand::DeleteLayout {
name: name.clone(),
});
}
let mut locked = saved_layout.locked;
if ui
.checkbox(&mut locked, "Protect")
.on_hover_text("Protect from deletion")
.changed()
{
commands.push(UiCommand::SetLayoutLock {
name: name.clone(),
locked,
});
}
if ui
.button("Export")
.on_hover_text("Copy to clipboard")
.clicked()
{
commands.push(UiCommand::ExportLayout {
name: 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(error_msg) = &self.session.save_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);
});
});
});
});
commands
}
}
#[derive(Serialize, Deserialize)]
struct MyApp {
state: MyAppState,
dock: DockState<TabName>,
}
impl Default for MyApp {
fn default() -> Self {
// Create a 2x2 grid layout with all 4 tabs
let mut dock_state = DockState::new(vec![TabName::Tab1]);
// 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::Tab2]);
// Split left side vertically
let [_top_left, _bottom_left] =
dock_state
.main_surface_mut()
.split_below(left_side, 0.5, vec![TabName::Tab3]);
// Split right side vertically
let [_top_right, _bottom_right] =
dock_state
.main_surface_mut()
.split_below(right_side, 0.5, vec![TabName::Tab4]);
Self {
state: MyAppState::default(),
dock: dock_state,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ExportedLayout {
name: String,
dock_state: DockState<TabName>,
locked: bool,
}
impl MyApp {
fn create_default_dock_state() -> DockState<TabName> {
// Create a 2x2 grid layout with all 4 tabs
let mut dock_state = DockState::new(vec![TabName::Tab1]);
// 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::Tab2]);
// Split left side vertically
let [_top_left, _bottom_left] =
dock_state
.main_surface_mut()
.split_below(left_side, 0.5, vec![TabName::Tab3]);
// Split right side vertically
let [_top_right, _bottom_right] =
dock_state
.main_surface_mut()
.split_below(right_side, 0.5, vec![TabName::Tab4]);
dock_state
}
fn process_commands(&mut self, commands: Vec<UiCommand>, ctx: &egui::Context) {
// TODO: Add a vscroll around the saved layout listing. Until then, limit the number of
// layouts for the sake of a usable UI.
const MAX_LAYOUTS: usize = 16;
for command in commands {
match command {
UiCommand::SaveLayout { name } => {
if name == "Default" {
self.state.session.save_error_message =
Some("Cannot modify the Default layout".to_string());
self.state.session.save_error_timer = Some(std::time::Instant::now());
continue;
}
if !self.state.saved_layouts.contains_key(&name)
&& self.state.saved_layouts.len() >= MAX_LAYOUTS
{
self.state.session.save_error_message = Some(format!(
"No room for additional layouts. {} is the maximum.",
MAX_LAYOUTS
));
self.state.session.save_error_timer = Some(std::time::Instant::now());
continue;
}
let saved_layout = SavedLayout {
dock_state: self.dock.clone(),
locked: false,
};
self.state.saved_layouts.insert(name.clone(), saved_layout);
self.state.current_layout_name = name;
self.state.session.layout_name_input.clear();
self.state.session.save_error_message = None;
self.state.session.save_error_timer = None;
}
UiCommand::LoadLayout { name } => {
if let Some(name) = name {
if let Some(saved_layout) = self.state.saved_layouts.get(&name) {
self.dock = saved_layout.dock_state.clone();
self.state.current_layout_name = name;
} else {
self.dock = Self::create_default_dock_state();
self.state.current_layout_name = "Default".to_string();
}
} else {
self.dock = Self::create_default_dock_state();
self.state.current_layout_name = "Default".to_string();
}
}
UiCommand::DeleteLayout { name } => {
if let Some(saved_layout) = self.state.saved_layouts.get(&name) {
if !saved_layout.locked {
self.state.saved_layouts.remove(&name);
}
}
}
UiCommand::ExportLayout { name } => {
if let Some(saved_layout) = self.state.saved_layouts.get(&name) {
let exported = ExportedLayout {
name: name.clone(),
dock_state: saved_layout.dock_state.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.state.session.import_error_message = None;
self.state.session.import_error_timer = None;
self.state.session.export_success_message =
Some("Layout copied to clipboard!".to_string());
self.state.session.export_success_timer =
Some(std::time::Instant::now());
}
Err(e) => {
self.state.session.import_error_message =
Some(format!("Failed to export layout: {}", e));
self.state.session.import_error_timer =
Some(std::time::Instant::now());
self.state.session.export_success_message = None;
self.state.session.export_success_timer = None;
}
}
}
}
UiCommand::ImportLayout { data } => {
if data.trim().is_empty() {
self.state.session.import_error_message =
Some("No layout data provided".to_string());
self.state.session.import_error_timer = Some(std::time::Instant::now());
continue;
}
if self.state.saved_layouts.len() >= MAX_LAYOUTS {
self.state.session.save_error_message = Some(format!(
"No room for additional layouts. {} is the maximum.",
MAX_LAYOUTS
));
continue;
}
match ron::from_str::<ExportedLayout>(&data) {
Ok(imported) => {
let original_name = imported.name.clone();
let mut final_name = imported.name;
if self.state.saved_layouts.contains_key(&final_name) {
final_name = format!("{} (imported)", original_name);
let mut counter = 2;
while self.state.saved_layouts.contains_key(&final_name) {
final_name =
format!("{} (imported {})", original_name, counter);
counter += 1;
}
}
let saved_layout = SavedLayout {
dock_state: imported.dock_state.clone(),
locked: imported.locked,
};
self.state
.saved_layouts
.insert(final_name.clone(), saved_layout);
self.dock = imported.dock_state;
self.state.current_layout_name = final_name;
self.state.session.import_error_message = None;
self.state.session.import_error_timer = None;
self.state.session.export_success_message = None;
self.state.session.export_success_timer = None;
self.state.session.import_layout_buffer.clear();
}
Err(e) => {
self.state.session.import_error_message =
Some(format!("Failed to parse layout: {}", e));
self.state.session.import_error_timer = Some(std::time::Instant::now());
self.state.session.export_success_message = None;
self.state.session.export_success_timer = None;
}
}
}
UiCommand::AddTab { tab, surface, node } => {
self.dock[surface].set_focused_node(node);
self.dock[surface].push_to_focused_leaf(tab);
}
UiCommand::AddTabOnParent {
new_tab,
parent_tab,
} => {
if let Some((surface, node, _)) = self.dock.find_tab(&parent_tab) {
self.dock[surface].set_focused_node(node);
self.dock[surface].push_to_focused_leaf(new_tab);
} else {
eprintln!(
"Attempted to add tab {:?} to parent {:?}, but parent tab wasn't found",
new_tab, parent_tab
)
}
}
UiCommand::SetLayoutLock { name, locked } => {
if let Some(layout) = self.state.saved_layouts.get_mut(&name) {
layout.locked = locked;
}
}
}
}
}
}
// A custom TabViewer that has access to all of the app state, as well as
// access to pre-computed information about DockState (the `available_tabs`).
// Since this struct cannot have a reference to DockState (it would cause borrow issues),
// it populates `commands`.
// Commands then need to be handled by the caller after `DockArea::new(dock_state)::show(tab_viewer);`
struct MyAppTabViewer<'a> {
state: &'a mut MyAppState,
available_tabs: Vec<TabName>,
commands: &'a mut Vec<UiCommand>,
}
impl<'a> TabViewer for MyAppTabViewer<'a> {
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::Tab1 => {
ui.vertical_centered(|ui| {
ui.heading("Tab 1");
ui.label("First demo tab");
ui.label(format!(
"There are {} saved layouts",
self.state.saved_layouts.len()
));
});
}
TabName::Tab2 => {
ui.vertical_centered(|ui| {
ui.heading("Tab 2");
ui.label("Second demo tab");
ui.label(format!(
"The current layout is named: {}",
self.state.current_layout_name
));
});
}
TabName::Tab3 => {
ui.vertical_centered(|ui| {
ui.heading("Tab 3");
ui.label("Third demo tab");
if !self.state.session.import_layout_buffer.is_empty() {
ui.label("There is text in the import_layout_buffer");
}
});
}
TabName::Tab4 => {
ui.vertical_centered(|ui| {
ui.heading("Tab 4");
ui.label("Fourth demo tab");
for openable_tab in self.available_tabs.iter() {
if ui
.button(format!("Open tab {}", openable_tab.display_name()))
.clicked()
{
self.commands.push(UiCommand::AddTabOnParent {
new_tab: openable_tab.clone(),
parent_tab: tab.clone(),
})
}
}
});
}
}
}
fn on_close(&mut self, _tab: &mut Self::Tab) -> OnCloseResponse {
OnCloseResponse::Close
}
fn on_add(&mut self, _surface: SurfaceIndex, _node: NodeIndex) {
// This method is called when the add button is pressed. If you want that to immediately
// open a tab, then push a command here.
// For this program I want the add button to open the popup (the dropdown menu)
// to select which tab to open, so nothing needs to be done here.
}
fn add_popup(&mut self, ui: &mut Ui, surface: SurfaceIndex, node: NodeIndex) {
if self.available_tabs.is_empty() {
ui.label("No available tabs to open!");
return;
}
for tab in &self.available_tabs {
if ui.button(tab.display_name()).clicked() {
self.commands.push(UiCommand::AddTab {
tab: tab.clone(),
surface,
node,
});
ui.close();
}
}
}
}
impl eframe::App for MyApp {
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.state.update_message_timers();
let mut commands = self.state.show_menu(ctx);
CentralPanel::default().show(ctx, |ui| {
// Pre-compute available tabs before creating the TabViewer.
// Because everything is within MyApp, we cannot provide `self.dock` into DockArea::new
// and also provide it into tab_viewer without being blocked by Rust's borrow checker.
// Making a copy of relevant information within DockState is necessary.
let available_tabs = self.state.get_closed_tabs(&self.dock);
// This option isn't required, but I like removing the add tab interactions
// when there aren't actually any tabs available. This assumes that all of your tabs
// are unique: there should never be multiple of the same tab open. This may not
// be the case for your program!
let show_add_buttons = !available_tabs.is_empty();
let mut tab_viewer_commands = Vec::new();
let mut tab_viewer = MyAppTabViewer {
state: &mut self.state,
available_tabs,
commands: &mut tab_viewer_commands,
};
DockArea::new(&mut self.dock)
.show_add_buttons(show_add_buttons)
.show_add_popup(show_add_buttons)
.show_close_buttons(true)
.show_inside(ui, &mut tab_viewer);
commands.extend(tab_viewer_commands)
});
self.process_commands(commands, 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::<MyApp>(storage, eframe::APP_KEY) {
return Ok(Box::new(app));
}
}
Ok(Box::new(MyApp::default()))
}),
)
}
@Omustardo
Copy link
Author

egui_dock_snapshot_demo2.noaudio.webm

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