Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 09 / Text Editor GUI in Rust / ~35 minutes

More Than One File

Three stages to tabs — and the moment your struct stops being one thing.

Every feature so far lived on one struct called Editor, and that name has been quietly lying for a while. It holds a path, some text, a status message, and two performance counters. The first three describe a file. The last two describethe application.

Opening a second file forces the split, because you want two of the first group and still only one of the second.

A struct that needs two of some fields and one of the others is two structs.
You don't decide that by taste — you decide it the moment you need a second one.

Where you're starting

Lesson 8's 208 lines, with highlighting and the size limit. Check againstlesson 8's checkpoint.

adding   removing   existing code, shown only for context

Name what you actually have

Identical behaviour, honest names — one file, still working.

This stage changes no behaviour at all. It renames the thing you have and builds a new thing around it. Eight edits, and it won't compile until all eight are in.

First — rename the struct. The file-shaped fields stay; the app-shaped ones leave:

editor/src/main.rsthe Editor struct

struct Editor {
/// One open file.
struct Buffer {
    path: String,
    text: String,
    status: String,
    position: String,
    frame_ms: f32,
    line_col_us: f32,
}

Second — the impl block moves with it, and open stops setting the three fields that just left the struct:

editor/src/main.rsthe impl block

impl Editor {
impl Buffer {
open, save and line_col carry on unchanged — only the tail of open loses lines
            text,
            status,
            position: "no cursor".to_owned(),
            frame_ms: 0.0,
            line_col_us: 0.0,
        }
    }

Third — a tab needs a short label, so give Buffer one more method, after line_col:

editor/src/main.rsin impl Buffer, after line_col


    /// Just the file name, for the tab label.
    fn name(&self) -> &str {
        self.path.rsplit('/').next().unwrap_or(&self.path)
    }

Returning &str rather than String means no allocation per tab per frame — it's a borrowed window onto the path the buffer already owns. Remember this method. Lesson 10 comes back for it.

Fourth — a new Editor, holding many buffers and one of everything else:

editor/src/main.rsbelow impl Buffer

struct Editor {
    buffers: Vec<Buffer>,
    active: usize,
    position: String,
    frame_ms: f32,
    line_col_us: f32,
}

impl Editor {
    fn open(paths: &[String]) -> Self {
        let buffers = paths.iter().map(|p| Buffer::open(p)).collect();
        Self {
            buffers,
            active: 0,
            position: "no cursor".to_owned(),
            frame_ms: 0.0,
            line_col_us: 0.0,
        }
    }
}

Fifth — the status panel. It reads the file's text and status, and there is no longer a single file to read them from, so it goes through the active buffer:

editor/src/main.rsin fn ui, the bottom panel

        egui::Panel::bottom("status").show(ui, |ui| {
            let highlighting = if self.text.len() > HIGHLIGHT_LIMIT {
            let highlighting = if self.buffers[self.active].text.len() > HIGHLIGHT_LIMIT {
the two arms and the start of the label, unchanged
                self.position,
                self.text.len(),
                self.buffers[self.active].text.len(),
                self.line_col_us,
                self.frame_ms
            ));
            ui.label(&self.status);
            ui.label(&self.buffers[self.active].status);
        });

Why not let buffer = … up here too? Because this closure runs before the one below it, and each panel borrows self separately — a binding made here wouldn't be in scope down there. Three uses isn't enough repetition to be worth restructuring for.

Sixth — the central panel, where it is worth it. Reach the current file once at the top and use it throughout:

editor/src/main.rsin fn ui, top of the central panel closure

            let buffer = &mut self.buffers[self.active];
            let save_requested = ui.input(…);
Then every self. in that panel becomes buffer.self.save() → buffer.save(), self.status →buffer.status, &self.path → &buffer.path,&mut self.text → &mut buffer.text, andself.line_col(…) → buffer.line_col(…). Three stay onself — self.position, self.line_col_us andself.frame_ms — because they belong to the application, not the file. The compiler will find every one you miss; work through them until it stops complaining.

Seventh — Editor::open wants a slice of paths, and mainstill has a single String. Wrap it in a one-element vector; stage 2 replaces this line with the real thing:

editor/src/main.rstop of main

    let path = env::args()
        .nth(1)
        .unwrap_or_else(|| "scratch.md".to_owned());
    let paths = vec![path];

Eighth — and now main can pass it:

editor/src/main.rsat the bottom of main

        Box::new(|_cc| Ok(Box::new(Editor::open(&path)))),
        Box::new(|_cc| Ok(Box::new(Editor::open(&paths)))),

cargo run
→ exactly what you had before
A refactor that changes nothing visible is the safest kind, and the only kind you can be sure you got right.

One warning is expected heremethod `name` is never used — you wrote the tab label in the third edit and nothing calls it until stage 3 draws the tabs. Rust warns about dead code by default, which is usually what you want; here it's just telling you the lesson isn't finished.

Take more than one path

cargo run -- a.rs b.rs opens both. You'll only see the first.

One edit. nth(1) took the second argument;skip(1) takes all of them:

editor/src/main.rstop of main

    let path = env::args()
        .nth(1)
        .unwrap_or_else(|| "scratch.md".to_owned());
    let paths = vec![path];
    let paths: Vec<String> = env::args().skip(1).collect();
    let paths = if paths.is_empty() {
        vec!["scratch.md".to_owned()]
    } else {
        paths
    };

collect() turns an iterator into a collection, and the type annotationVec<String> is how it knows which collection — the same call could build a HashSet or a String. This is one of the few places Rust genuinely needs you to say the type out loud.

The second let paths shadows the first. Shadowing isn't mutation — it's a new binding with the same name, and it's idiomatic for exactly this: take a value, transform it, keep calling it the thing it is.

printf 'fn a() {}\n' > editor/one.rs; printf 'fn b() {}\n' > editor/two.rs
cargo run -- one.rs two.rs
→ one.rs, highlighted. two.rs is open but has no way to be seen yet.

Tabs

Click a name, switch files.

One edit, at the very top of the panel — above thelet buffer = … line you added in stage 1:

editor/src/main.rsin fn ui, top of the central panel closure

            ui.horizontal(|ui| {
                for index in 0..self.buffers.len() {
                    let selected = index == self.active;
                    if ui
                        .selectable_label(selected, self.buffers[index].name())
                        .clicked()
                    {
                        self.active = index;
                    }
                }
            });
            let buffer = &mut self.buffers[self.active];

selectable_label(selected, text) draws a label that looks pressed whenselected is true — immediate mode again, so there's no tab widget holding state anywhere. You describe how it looks right now, and self.active is the only thing that remembers anything.

cargo run -- one.rs two.rs
→ two tabs; click to switch. Edit one, switch away, switch back — your edit is still there.

Why active: usize and not active: &mut Buffer

Storing an index looks primitive. Surely the editor should just hold a reference to the buffer it's editing? Try it:

struct Editor {
    buffers: Vec<Buffer>,
    active: &mut Buffer,
}
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:13
  |
5 |     active: &mut Buffer,
  |             ^ expected named lifetime parameter

Take the compiler's advice, add the lifetime, and try to build one:

error[E0505]: cannot move out of `buffers` because it is borrowed
  |
9 |     let mut buffers = vec![Buffer { … }];
10 |     let active = &mut buffers[0];
  |                       ------- borrow of `buffers` occurs here
11 |     let editor = Editor { buffers, active };
  |                           ^^^^^^^  ------ borrow later used here
  |                           |
  |                           move out of `buffers` occurs here

The struct wants to own the vector and hold a reference into it at the same time. That's a self-referential struct, and safe Rust doesn't permit it: moving the Editor would move the vector, and the reference would be left pointing at where the data used to be.

This is why real Rust code is full of indicesNot because indices are elegant — they aren't; nothing stops you using a stale one — but because "own the collection, refer to a member by position" is the shape that survives being moved. Every arena — one big allocation you hand out numbered slots from — every entity-component system in a game engine, every graph library in Rust reaches for the same answer. Recognising the shape now saves you a very long fight later.

rm editor/one.rs editor/two.rs
cargo fmt && cargo clippy
Both silent.

Where you ended up

246 lines in total. The restructure touches the top of the file and the whole offn ui; the highlighting code in between is untouched.

editor/src/main.rsBuffer and Editor, replacing the old Editor

/// One open file.
struct Buffer {
    path: String,
    text: String,
    status: String,
}

impl Buffer {
    /// Open a file into a new editor. A file that doesn't exist yet is not an
    /// error — it's a new file, and starts empty.
    fn open(path: &str) -> Self {
        let (text, status) = match fs::read_to_string(path) {
            Ok(contents) => (contents, String::new()),
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                (String::new(), format!("new file: {path}"))
            }
            Err(error) => (String::new(), format!("could not open {path}: {error}")),
        };
        Self {
            path: path.to_owned(),
            text,
            status,
        }
    }

    /// Write the buffer back to the file it came from.
    fn save(&mut self) -> io::Result<()> {
        fs::write(&self.path, &self.text)?;
        self.status = format!("saved {} bytes to {}", self.text.len(), self.path);
        Ok(())
    }

    /// Turn a character offset into a 1-based line and column.
    fn line_col(&self, char_index: usize) -> (usize, usize) {
        let mut line = 1;
        let mut column = 1;
        for ch in self.text.chars().take(char_index) {
            if ch == '\n' {
                line += 1;
                column = 1;
            } else {
                column += 1;
            }
        }
        (line, column)
    }

    /// Just the file name, for the tab label.
    fn name(&self) -> &str {
        self.path.rsplit('/').next().unwrap_or(&self.path)
    }
}

struct Editor {
    buffers: Vec<Buffer>,
    active: usize,
    position: String,
    frame_ms: f32,
    line_col_us: f32,
}

impl Editor {
    fn open(paths: &[String]) -> Self {
        let buffers = paths.iter().map(|p| Buffer::open(p)).collect();
        Self {
            buffers,
            active: 0,
            position: "no cursor".to_owned(),
            frame_ms: 0.0,
            line_col_us: 0.0,
        }
    }
}
The constants and fn highlight sit above this, unchangedAbout eighty lines — KEYWORDS, the colours, HIGHLIGHT_LIMITand all of highlight — are exactly aslesson 8 left them. Nothing in this lesson goes near them.

editor/src/main.rsthe whole of fn ui, plus main

impl eframe::App for Editor {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        let frame_start = Instant::now();
        ui.ctx().request_repaint();

        egui::Panel::bottom("status").show(ui, |ui| {
            let highlighting = if self.buffers[self.active].text.len() > HIGHLIGHT_LIMIT {
                "highlighting off (file too large)"
            } else {
                "highlighting on"
            };

            ui.label(format!(
                "{}  ·  {} bytes  ·  {highlighting}  ·  line_col {:.0} µs  ·  {:.2} ms/frame",
                self.position,
                self.buffers[self.active].text.len(),
                self.line_col_us,
                self.frame_ms
            ));
            ui.label(&self.buffers[self.active].status);
        });

        egui::CentralPanel::default().show(ui, |ui| {
            ui.horizontal(|ui| {
                for index in 0..self.buffers.len() {
                    let selected = index == self.active;
                    if ui
                        .selectable_label(selected, self.buffers[index].name())
                        .clicked()
                    {
                        self.active = index;
                    }
                }
            });

            let buffer = &mut self.buffers[self.active];

            let save_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S));
            if save_requested && let Err(error) = buffer.save() {
                buffer.status = format!("save failed: {error}");
            }

            ui.heading(&buffer.path);

            egui::ScrollArea::vertical().show(ui, |ui| {
                let mut layouter =
                    |ui: &egui::Ui, buffer: &dyn egui::TextBuffer, wrap_width: f32| {
                        highlight(ui, buffer.as_str(), wrap_width)
                    };

                let output = egui::TextEdit::multiline(&mut buffer.text)
                    .desired_width(f32::INFINITY)
                    .layouter(&mut layouter)
                    .show(ui);

                self.position = match output.cursor_range {
                    Some(range) => {
                        let started = Instant::now();
                        let (line, column) = buffer.line_col(range.primary.index.0);
                        self.line_col_us = started.elapsed().as_secs_f32() * 1_000_000.0;
                        format!("Ln {line}, Col {column}")
                    }
                    None => "no cursor".to_owned(),
                };
            });
        });

        self.frame_ms = frame_start.elapsed().as_secs_f32() * 1000.0;
    }
}

fn main() -> eframe::Result {
    let paths: Vec<String> = env::args().skip(1).collect();
    let paths = if paths.is_empty() {
        vec!["scratch.md".to_owned()]
    } else {
        paths
    };

    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]),
        ..Default::default()
    };

    eframe::run_native(
        "editor",
        options,
        Box::new(|_cc| Ok(Box::new(Editor::open(&paths)))),
    )
}

246 lines, cargo clippy silent.

Check yourself

What told you it was time to split Editor into two structs?

  • Needing two of some fields, one of others
  • The struct had grown past five total fields
  • Rust forbids methods returning borrowed strings
  • Tabs require a dedicated widget state type

path, text and status describe a file; frame_ms and line_col_us describe the application. That was always true, but harmless until a second file made you need two of one group and one of the other.↑ Review: naming what you have (stage 1)

Why can't Editor hold &mut Buffer beside the Vec it came from?

  • Moving the struct would invalidate the reference
  • References are not allowed in structs at all
  • A Vec cannot be borrowed while it is owned
  • Only one mutable borrow may exist per program

It would be self-referential: the struct owns the vector and points into it. Move the Editor and the vector moves too, leaving the reference aimed at the old address. Indices survive the move, which is why Rust code uses them.↑ Review: index, not reference (stage 3)

From lesson 5: why does collect() need a type annotation here?

  • It can build many different collections
  • Iterators have no type until collected
  • Command-line arguments have unknown types
  • Annotations are required on every let binding

collect() is generic over what it builds — the same iterator could become a Vec, a HashSet or aString. The annotation on the binding is how you choose.↑ Review: collecting the arguments (stage 2)

Read this before lesson 10

Primary source:The Rust Programming Language, Chapter 5 — Using Structs to Structure Related Data (Brown University interactive edition).

You've been writing structs since lesson 1 and just did your first real split, so this lands better now than it would have then. Chapter 5.3 on method syntax covers exactly what you did to Buffer, and the chapter closes with an example that grows a struct the same way this lesson did.