Back to Blog

Rust Module System in egui — Separate UI from Logic | GUI Tutorial

Celest KimCelest Kim

Video: Rust Module System in egui — Separate UI from Logic | GUI Tutorial by Taught by Celeste AI - AI Coding Coach

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

mod app;, mod models;, mod ui; — split a growing egui project into typed-state, view-functions, and the app entry point.

By Episode 23 our practice project for this series has hit a recurring shape: one app struct, one update method, a few helper functions. That works for short tutorials. For real apps that grow past 200 lines, you want structure — distinct files for distinct concerns. Today's episode is a small but important one: how to organise an egui project across modules.

What we are building

A contact book. A models.rs module for the Contact struct. A ui.rs module containing reusable view functions (show_contact_list, show_contact_form). An app.rs module with the MyApp struct and its update method. A main.rs that wires them all together.

Functionally it is the simplest CRUD app: a list of contacts on the left, a form for adding new contacts on the right, click a contact to view its details, click Delete to remove. The interesting part isn't the features — it is where each piece lives.

The directory layout

src/
├── main.rs       # entry point: declares modules, calls run_native
├── app.rs        # MyApp struct, update(), top-level layout
├── models.rs     # Contact struct (and any other data types)
└── ui.rs         # show_contact_list, show_contact_form

Four files. Each has a clear job.

main.rs

mod app;
mod models;
mod ui;

use app::MyApp;

fn main() -> eframe::Result {
  let options = eframe::NativeOptions {
    viewport: eframe::egui::ViewportBuilder::default()
      .with_inner_size([1024.0, 768.0]),
    ..Default::default()
  };
  eframe::run_native(
    "Contact Book",
    options,
    Box::new(|_cc| Ok(Box::new(MyApp::default()))),
  )
}

mod app; declares app as a module — Rust looks for src/app.rs. Same for models and ui. The use app::MyApp; brings the type into scope.

main is now the absolute minimum: declare modules, configure the window, spawn the app. No business logic.

models.rs

pub struct Contact {
  pub name: String,
  pub email: String,
  pub phone: String,
}

Every data type lives in models. Plain Rust structs, fields pub so other modules can read and write them.

For a larger app, models grows: a User struct, a Project struct, an enum SortOrder. They all live here. When you need to use one, use crate::models::Contact; from any other module.

ui.rs

use eframe::egui;
use crate::models::Contact;

pub fn show_contact_list(
  ui: &mut egui::Ui,
  contacts: &[Contact],
  selected: &mut Option<usize>,
) {
  // render the list, handle clicks, set *selected
}

pub fn show_contact_form(
  ui: &mut egui::Ui,
  name: &mut String,
  email: &mut String,
  phone: &mut String,
) -> Option<Contact> {
  // render the form, return Some(Contact) on Submit
}

Reusable view functions. Each one takes a &mut Ui (the layout context) and references to whatever state it needs. They render widgets, interpret events, and return data.

show_contact_list doesn't own the contacts — it borrows the slice. It writes to *selected (an Option<usize>) when the user clicks a row. Pure view + click handling.

show_contact_form borrows three string fields (the form's text inputs) and returns Option<Contact>Some(Contact) if the user clicked Submit, None otherwise. The caller decides what to do with the new contact.

This is the egui pattern for composing UI: small functions that take a Ui and return a result. They live in ui.rs. They call each other freely. The app is the orchestrator.

app.rs

use crate::models::Contact;
use crate::ui;

pub struct MyApp {
  contacts: Vec<Contact>,
  selected: Option<usize>,
  new_name: String,
  new_email: String,
  new_phone: String,
}

impl eframe::App for MyApp {
  fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
    egui::SidePanel::left("contacts_panel").show(ctx, |ui| {
      ui::show_contact_list(ui, &self.contacts, &mut self.selected);
    });

    egui::CentralPanel::default().show(ctx, |ui| {
      if let Some(idx) = self.selected {
        // render contact details
      } else {
        if let Some(contact) = ui::show_contact_form(
          ui,
          &mut self.new_name,
          &mut self.new_email,
          &mut self.new_phone,
        ) {
          self.contacts.push(contact);
        }
      }
    });
  }
}

The app owns the state. The top-level layout — which panels exist, what they contain — lives here. The actual rendering of each panel calls into ui::* functions.

Note the if let Some(contact) = ui::show_contact_form(...) — the form returns a result that the app uses. The view function does its job and reports back; the app decides what to do.

This separation pays for itself when:

  • You want to test the form's logic. With a pure function, you can pass mock state and assert what comes back.
  • You want to use the same form in two places. Call show_contact_form from a side panel and from a window — both work.
  • The app gets large. The orchestration in app.rs stays readable because the view details are elsewhere.

When to refactor

Don't pre-emptively split a 50-line app into four files. The right time to introduce modules is when:

  • A single file is over 300 lines and growing.
  • You have repeated view code that you want to factor out.
  • The app has clear domain types (User, Order, Settings) that deserve their own home.
  • You need to reuse a sub-component in another part of the app.

Until then, one main.rs plus one app.rs is fine.

Visibility patterns

  • pub — visible from anywhere.
  • pub(crate) — visible within the same crate (binary or library). Use this for cross-module access without exposing in a public API.
  • pub(super) — visible to the parent module only. Useful for tightly-coupled sub-modules.
  • (no modifier) — private to the current module.

For an app's internals, pub everywhere usually works. If you build a library that other crates use, prefer pub(crate) for internal types and pub only for the API surface.

Submodules and subdirectories

For deeper structure, a module can have sub-modules:

src/
├── main.rs
├── app.rs
└── ui/
    ├── mod.rs       # declares sub-modules
    ├── list.rs
    └── form.rs

In src/ui/mod.rs:

pub mod list;
pub mod form;

pub use list::show_contact_list;
pub use form::show_contact_form;

That gives you crate::ui::show_contact_list (re-exported from list.rs). Tools like Cargo and the Rust compiler handle the directory ↔ module mapping for you.

Running it

cargo run. The window opens with two contacts in the sidebar and the "Add New Contact" form on the right. Type a name, email, phone, click Add — the new contact appears in the sidebar. Click a contact in the sidebar — the right side switches to a detail view with a Delete button.

The user-facing behaviour is the same as a one-file app would have given. The win is internal: each file has one job, and you can jump to it directly.

Common mistakes

Forgetting pub on struct fields. A Contact from models is opaque to other modules without pub fields. Either expose them or provide accessor methods.

Circular imports. If ui.rs imports from app.rs and vice versa, Rust handles it but the design is muddled. Keep the dependency direction clear: app imports ui and models; ui imports models; models imports nothing.

Putting state in ui.rs. View functions are stateless — they take state as arguments. Storing state in ui.rs (with static or lazy_static) leads to bugs in multi-window apps and breaks testability.

Premature splitting. Three files for a 50-line app is overhead. Wait for the file to want to split.

What's next

Next episode: custom widgets. Implement egui::Widget for a struct so it can be added with ui.add(MyToggle::new(...)) like any built-in widget. The pattern that makes reusable UI components first-class.

Recap

Split egui projects into main.rs (entry), app.rs (state and orchestration), models.rs (data types), ui.rs (view functions). View functions take &mut Ui and references to state, return events. Use mod declarations and use crate::* imports. Refactor when files exceed ~300 lines or when reuse is in sight.

Next episode: custom widgets. 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.