Back to Blog

Text Editor in Rust egui — Open, Save & File Dialogs | Learn egui Ep32

Celest KimCelest Kim

Video: Text Editor in Rust egui — Open, Save & File Dialogs | Learn egui Ep32 by Taught by Celeste AI - AI Coding Coach

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

The series finale. New / Open / Save / Save As, native file dialogs via rfd, an editable buffer, a live status bar.

This is the last episode of Learn egui in Neovim. We finish with a complete, useful little app: a text editor. New file. Open from disk. Edit. Save. Save As. The shape every text editor on every OS shares — and now you've built it.

The new piece is the rfd crate (Rusty File Dialogs) for native open/save dialogs. egui itself doesn't ship a file dialog; rfd plugs that gap with one line per call.

What we are building

Toolbar with New / Open / Save / Save As buttons plus the current file's name. A multi-line text editor that fills the central panel. A status bar at the bottom showing the latest action and a character count.

State

pub struct MyApp {
  content: String,
  file_path: Option<PathBuf>,
  status: String,
}

Three fields:

  • content — the text being edited.
  • file_pathSome(path) if we have an open file, None for an unsaved new document.
  • status — the message shown in the status bar.

File dialogs with rfd

use rfd::FileDialog;

if let Some(path) = FileDialog::new()
  .add_filter("Text", &["txt", "rs", "md", "toml"])
  .add_filter("All", &["*"])
  .pick_file()
{
  // user picked a file
}

rfd::FileDialog::new() builds a dialog. .add_filter(label, &[exts]) adds a filter group (visible in the dialog's "type" dropdown). .pick_file() blocks until the user picks a file or cancels — returns Option<PathBuf>.

For saving:

if let Some(path) = FileDialog::new()
  .add_filter("Text", &["txt", "rs", "md", "toml"])
  .add_filter("All", &["*"])
  .save_file()
{
  // user picked a save destination
}

save_file() instead of pick_file(). The dialog asks for a destination filename and returns the chosen path.

rfd is a separate crate — cargo add rfd and you have it. It uses native dialogs on each platform (NSOpenPanel on macOS, GtkFileChooser on Linux, IFileDialog on Windows), which is what users expect.

Open

fn open_file(&mut self) {
  if let Some(path) = rfd::FileDialog::new()
    .add_filter("Text", &["txt", "rs", "md", "toml"])
    .add_filter("All", &["*"])
    .pick_file()
  {
    match std::fs::read_to_string(&path) {
      Ok(text) => {
        self.content = text;
        self.status = format!("Opened: {}", path.display());
        self.file_path = Some(path);
      }
      Err(e) => {
        self.status = format!("Error: {}", e);
      }
    }
  }
}

Read the file as a string. On success, update content, file_path, and status. On error, surface the error in the status bar.

Save

fn save_file(&mut self) {
  if let Some(ref path) = self.file_path {
    match std::fs::write(path, &self.content) {
      Ok(()) => self.status = format!("Saved: {}", path.display()),
      Err(e) => self.status = format!("Error: {}", e),
    }
  } else {
    self.save_file_as();
  }
}

If there's a file path, write to it. If not, fall through to save_file_as so the user can pick a destination.

This is the standard Save = "Save As" if no path yet logic that every text editor implements.

Save As

fn save_file_as(&mut self) {
  if let Some(path) = rfd::FileDialog::new()
    .add_filter("Text", &["txt", "rs", "md", "toml"])
    .add_filter("All", &["*"])
    .save_file()
  {
    match std::fs::write(&path, &self.content) {
      Ok(()) => {
        self.status = format!("Saved: {}", path.display());
        self.file_path = Some(path);
      }
      Err(e) => {
        self.status = format!("Error: {}", e);
      }
    }
  }
}

Always shows the dialog. Saves to the chosen path. After a successful Save As, file_path is updated so subsequent Saves go to the new location.

New file

fn new_file(&mut self) {
  self.content.clear();
  self.file_path = None;
  self.status = "New file".to_string();
}

Wipe content and path. Now the editor is back to "untitled," and the next Save will trigger Save As.

For polish: prompt the user to save unsaved changes first. Compare current content against the on-disk file (or maintain a "dirty" flag). The pattern is the same as Episode 22's confirm-delete dialog.

Multiline editor with monospace

egui::CentralPanel::default().show(ctx, |ui| {
  egui::ScrollArea::vertical().show(ui, |ui| {
    ui.add(
      egui::TextEdit::multiline(&mut self.content)
        .desired_width(f32::INFINITY)
        .font(egui::TextStyle::Monospace),
    );
  });
});

Three configurations on the text edit:

  • multiline(&mut content) — the multi-line builder.
  • .desired_width(f32::INFINITY) — fill the available width. Without this, the text edit sizes to its content.
  • .font(TextStyle::Monospace) — use the monospace font. Right for any text editor.

Wrapped in ScrollArea::vertical() so long files scroll.

Status bar

egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
  ui.horizontal(|ui| {
    ui.label(&self.status);
    ui.with_layout(
      egui::Layout::right_to_left(egui::Align::Center),
      |ui| {
        ui.label(format!("{} chars", self.content.len()));
      },
    );
  });
});

Status text on the left, character count right-aligned. The right-to-left layout block pushes its widgets to the right edge of the row. Standard editor footer.

Toolbar

egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
  ui.horizontal(|ui| {
    if ui.button("New").clicked() { self.new_file(); }
    if ui.button("Open").clicked() { self.open_file(); }
    if ui.button("Save").clicked() { self.save_file(); }
    if ui.button("Save As").clicked() { self.save_file_as(); }
    ui.separator();
    if let Some(ref path) = self.file_path {
      ui.label(
        egui::RichText::new(
          path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
        ).strong()
      );
    } else {
      ui.label("Untitled");
    }
  });
});

Four buttons each calling its method. The current file name displays bold to the right of the buttons; "Untitled" when there's no path.

Running it

cargo run. The editor opens to an empty Untitled buffer. Click Open — a native file dialog appears. Pick a .txt file — its contents fill the editor. Edit — characters appear. Save — the file is overwritten on disk.

Click New — back to Untitled. Type some text. Click Save — the dialog appears (because there's no path yet), pick a location, save. Now Save just writes to that path; Save As lets you pick a different one.

The status bar shows what just happened. The character count updates live.

What's missing for a "real" editor

For a serious text editor you would add:

  • Syntax highlighting — egui has egui_extras::syntax_highlighting.
  • Line numbers — render in a left margin.
  • Undo/redo — keep a history of content snapshots.
  • Find and replace — a small floating window or top panel.
  • Multiple tabs / filesVec<EditorTab> instead of one file.
  • Unsaved-change indicator — track a "dirty" bool.
  • Keyboard shortcutsCmd+S to save, etc.

Each is straightforward to add. The shape from this episode handles all the load-bearing pieces: file IO, dialogs, an editable buffer, and the status bar.

Common mistakes

Forgetting to add rfd to Cargo.toml. The compiler will tell you, but a beginner can confuse the missing crate for an egui issue.

Using sync pick_file() on the main thread of a long-running app. rfd's sync API blocks the UI while the dialog is open. For a snappier feel, use the async variant (rfd has both). For a small editor like this, blocking is fine.

Saving without confirming overwrite. rfd's save dialog already asks the user to confirm overwrites; the OS handles that.

No error handling. If read_to_string fails (permissions, missing file, binary file), the user gets nothing. Always surface errors to the status bar or a dialog.

Series wrap-up

Thirty-two episodes. From a 38-line "Hello, egui!" to a working text editor. Along the way:

  • Layout: panels, sidebars, grids, scroll areas.
  • Widgets: buttons, sliders, drag values, checkboxes, radio buttons, comboboxes, text edits.
  • State: bound fields, vectors with CRUD, persistence with serde.
  • Painting: shapes, lines, transforms, custom canvases.
  • Apps: counter, contact book, recipe viewer, color palette, calculator, drawing app, settings dashboard, text editor.

Every app is short — usually under 200 lines. egui is small enough that you can hold it in your head; the patterns from this series are most of what you'll ever need.

The real next step is yours: build something. The skeleton is here. Pick a problem, scaffold a Cargo project, run cargo add eframe, and ship a tool. egui doesn't have an empire of libraries pulling you in five directions — it has one core that handles the basics and gets out of the way.

Recap

rfd::FileDialog::new().pick_file() and .save_file() for native open/save. std::fs::read_to_string and std::fs::write for the actual file IO. Option<PathBuf> to track the current file. Save = Save As when there's no path yet. TextEdit::multiline plus desired_width(f32::INFINITY) plus TextStyle::Monospace for the editing surface. Status bar at the bottom for feedback.

That's the series. Thanks for following along.

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.