Back to Blog

Rust egui Settings Dashboard — Tabbed Panels & Persistence | Ep31

Celest KimCelest Kim

Video: Rust egui Settings Dashboard — Tabbed Panels & Persistence | Ep31 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

A side panel of tabs, three settings pages, all persisted with serde and eframe::Storage. The right shape for any "preferences" window.

We have done persistence (Episode 23). We have done side panels (Episode 9). We have done multi-section UIs. Today we combine all three into the architectural pattern most apps use for their settings pane: a left sidebar of tabs, a central panel that swaps among them based on which tab is selected, and serde-backed persistence so settings survive restarts.

What we are building

A settings dashboard with three tabs:

  • Display — theme (Dark / Light / System radio), font size slider, "show grid" checkbox.
  • Audio — volume slider, notification checkbox, sound effects checkbox.
  • Editor — tab size slider, word wrap, line numbers, auto save checkboxes.

Plus a "Reset All" button in the header. Every change persists immediately (well, on the next periodic save). Quit, restart — everything is back where you left it.

State

#[derive(Serialize, Deserialize, Clone, PartialEq)]
enum Theme { Dark, Light, System }

#[derive(Serialize, Deserialize, Clone)]
struct Settings {
  theme: Theme,
  font_size: f32,
  show_grid: bool,
  volume: f32,
  notifications: bool,
  sound_effects: bool,
  tab_size: usize,
  word_wrap: bool,
  line_numbers: bool,
  auto_save: bool,
}

#[derive(PartialEq, Clone, Copy)]
enum Tab { Display, Audio, Editor }

pub struct MyApp {
  settings: Settings,
  active_tab: Tab,
}

Two structs.

Settings holds all user-facing settings. Serialize, Deserialize so it can be saved. Default for first-run defaults. Settings have nothing to do with which tab is active — they survive across sessions.

MyApp holds the live Settings plus the active_tab. Notice Tab is not serialised — it is UI state, not user state. When the app restarts, you start on the Display tab regardless of what was active when you quit. (You could persist it if you wanted; it's a design choice.)

Loading and saving

impl MyApp {
  pub fn new(cc: &eframe::CreationContext) -> Self {
    let settings = cc
      .storage
      .and_then(|s| eframe::get_value(s, APP_KEY))
      .unwrap_or_default();
    Self { settings, active_tab: Tab::Display }
  }
}

impl eframe::App for MyApp {
  fn save(&mut self, storage: &mut dyn eframe::Storage) {
    eframe::set_value(storage, APP_KEY, &self.settings);
  }
  // ...
}

MyApp::new(cc) reads Settings from storage. If the read fails (first run, corrupted file), unwrap_or_default() falls back to Default::default(). The active_tab is always set to Tab::Display for a fresh start.

save writes only &self.settings — not the entire MyApp. The active tab is excluded by design.

APP_KEY is a string identifier — a custom one ("settings-dashboard") instead of eframe::APP_KEY because we are saving a slice of the app, not the whole app.

Tabs as a sidebar

egui::SidePanel::left("tabs")
  .default_width(120.0)
  .show(ctx, |ui| {
    ui.add_space(8.0);
    ui.selectable_value(&mut self.active_tab, Tab::Display, "Display");
    ui.selectable_value(&mut self.active_tab, Tab::Audio, "Audio");
    ui.selectable_value(&mut self.active_tab, Tab::Editor, "Editor");
  });

Same selectable_value trick from Episode 9's note viewer — exclusive choice into a single field. Three labels, one binding, automatic exclusion.

Switching panels with match

egui::CentralPanel::default().show(ctx, |ui| {
  match self.active_tab {
    Tab::Display => self.display_settings(ui),
    Tab::Audio => self.audio_settings(ui),
    Tab::Editor => self.editor_settings(ui),
  }
});

The central panel's content is a match on the active tab. Each branch calls a helper method that renders the corresponding settings page.

This is the pattern for tab UIs: enum tab + match dispatch + per-tab helper. Adding a fourth tab is two additions: a new Tab variant plus a new branch in the match. The compiler tells you immediately if you forgot a branch (because match on an enum is exhaustive).

Per-tab helpers

impl MyApp {
  fn display_settings(&mut self, ui: &mut egui::Ui) {
    ui.heading("Display Settings");
    ui.separator();
    // ...
  }

  fn audio_settings(&mut self, ui: &mut egui::Ui) {
    // ...
  }

  fn editor_settings(&mut self, ui: &mut egui::Ui) {
    // ...
  }
}

Each tab's UI lives in its own method. They take &mut self (so they can mutate self.settings directly) and &mut egui::Ui (the layout context).

This is the same factoring we used in Episode 24. The update method stays short; the per-page details live nearby but separate.

Reset All

ui.with_layout(
  egui::Layout::right_to_left(egui::Align::Center),
  |ui| {
    if ui.button("Reset All").clicked() {
      self.settings = Settings::default();
    }
  },
);

A right-aligned button in the header. Replacing self.settings with the default also covers every tab — defaults span all categories. Clean reset, no manual reset-per-tab logic.

Per-tab input forms

Inside display_settings:

ui.label("Theme:");
ui.horizontal(|ui| {
  ui.radio_value(&mut self.settings.theme, Theme::Dark, "Dark");
  ui.radio_value(&mut self.settings.theme, Theme::Light, "Light");
  ui.radio_value(&mut self.settings.theme, Theme::System, "System");
});

ui.horizontal(|ui| {
  ui.label("Font Size:");
  ui.add(egui::Slider::new(&mut self.settings.font_size, 10.0..=32.0));
});

ui.checkbox(&mut self.settings.show_grid, "Show Grid");

Standard egui forms. Bind to fields of self.settings. The persistence layer takes care of saving — you don't write any save logic per setting.

Running it

cargo run. The window opens on the Display tab. Pick a different theme, drag font size, toggle Show Grid. Switch to Audio, change volume. Switch to Editor, set tab size to 8.

Quit. Run again. Every change is preserved. The active tab resets to Display (we chose not to persist that), but every actual setting is back where you left it.

Click Reset All — every setting goes back to default. The next save (within ~30 seconds) writes the defaults to disk.

When to expand this pattern

This pattern scales to:

  • 5–10 tabs (more than that, switch to a search-driven settings UI).
  • Settings versioning with #[serde(default)] for new fields.
  • Per-tab "advanced" sections that toggle visibility.
  • Live preview of changes (the way macOS Settings shows wallpapers).

The architectural shape stays the same: enum tab, match dispatch, settings struct, persistence.

Common mistakes

Persisting active_tab. Easy to add to Settings, surprisingly often a UX regression. Users expect "open settings → see the page I most recently used in this session" — not "open settings → see the page I had open three months ago." If you want it persistent, that's fine; just be deliberate.

Not deriving Clone on Settings. Some operations (e.g., undo, A/B preview) need to clone settings. Future-proof by deriving Clone.

Forgetting #[serde(default)] on new fields. When you add a new field to Settings in version 2 of your app, version 1's saved data is missing it. Without #[serde(default)], the entire load fails and you lose every setting. Always add the attribute when extending a serialised struct.

Using eframe::APP_KEY for a partial app save. Use a custom string. APP_KEY is conventionally for the entire app state.

What's next

Next episode: a text editor. File dialogs (open/save), an editable buffer, status bar showing the file name and character count. The series finale: every pattern from these 32 episodes synthesised into one working app.

Recap

enum Tab for tab IDs, match self.active_tab to dispatch among per-tab methods. Settings struct with Serialize, Deserialize for saved data. MyApp::new(cc) to load, save(&mut self, storage) to write. Reset by replacing *self.settings with Default::default().

Next episode: text editor. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.