Deryks _dev

Full-Stack Web Developer & Technical Leader

<< Back / Lesson 08 / Text Editor GUI in Rust / ~25 minutes

Knowing When to Stop

Highlighting made the editor slow. The fix is four lines, and choosing them is the lesson.

Lesson 7 gave you syntax highlighting. It also, quietly, gave you a function that re-scans your entire file every time egui needs to draw it — which is most frames.

You built the instrument for exactly this inlesson 6. Now use it on your own feature, decide from the numbers what to do, and — this is the part most tutorials skip — decide what not to do.

Every optimisation is a trade, and some trades aren't worth making.
A feature that degrades deliberately at a limit you measured beats one that's clever everywhere and correct nowhere.

Where you're starting

Lesson 7's 184 lines, with highlighting. Check againstlesson 7's checkpoint.

adding   removing   existing code, shown only for context

Point the instrument at your own feature

Numbers, from your editor, on files of five different sizes.

No code this stage. Build the files and read the frame counter you already have:

cd editor
for n in 1000 5000 10000 25000 50000; do
  seq 1 $n | awk '{print " let value_" $1 " = compute(" $1 "); // note"}' > big-$n.rs
done
Five files, 40 KB to 2.2 MB. All disposable.

cargo run --release -- big-50000.rs
→ scroll, type a character, read the ms/frame
Release. Lesson 6's most useful number was that debug lies to you by 10×.

Here's what I measured, with lesson 7's highlighting on and — for comparison — the plain single-colour layouter from lesson 7 stage 1:

FilePlain, typingHighlighted, idleHighlighted, typing
1,000 lines (40 KB)0.17 ms0.57 ms0.74 ms
5,000 lines (212 KB)0.84 ms2.09 ms3.46 ms
10,000 lines (427 KB)1.62 ms4.16 ms7.11 ms
25,000 lines (1.1 MB)3.62 ms10.64 ms17.39 ms
50,000 lines (2.2 MB)7.41 ms28.57 ms41.57 ms

Highlighting costs roughly 4–5× the plain layout, and it crosses the 16.7 ms frame budget somewhere between 10,000 and 25,000 lines. Above that the editor lags your typing.

The idle column is the surprising one

Look again at 50,000 lines: 28.57 ms even when you aren't typing.Nothing changed, and it still costs more than a frame.

Before reading on: the text hasn't changed since the last frame. Why is the editor doing 28 ms of work?

Because your layouter runs anyway. TextEdit calls it whenever it needs the text laid out, and it has no way to know that your scanner would produce exactly the same answer as last time. The scan is 2 MB of character-by-character work, every frame.

Worth knowing: egui does cache the expensive part downstream. Itslayout_job is memoised, so handing it an identical job twice is cheap the second time. What isn't cached is building the job — your scanner — because that's your code and egui has never seen it.

Draw the line where the measurement is

Big files open instantly again, in plain text, and say why.

The table says highlighting is comfortable at 427 KB (7.11 ms while typing) and over budget at 1.1 MB (17.39 ms). So the limit goes between them, nearer the safe end.Two edits.

editor/src/main.rswith the other colours

const COMMENT: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x92, 0x9c);

/// Above this size, highlighting costs more than a frame is worth. Chosen from
/// measurement: at 427 KB a keystroke costs 7.1 ms, at 1.1 MB it costs 17.4 ms,
/// and a 60fps frame is 16.7 ms.
const HIGHLIGHT_LIMIT: usize = 512 * 1024;
The comment is the point, not the constantA bare 512 * 1024 is a magic number, and the next person to read it — including you in four months — has no way to tell whether it was measured or guessed. Writing down the two numbers it sits between turns an arbitrary constant into a decision someone can check, and disagree with, and re-measure on faster hardware. In an interview, this comment is worth more than the code above it.

Second — the early exit, at the top of highlight:

editor/src/main.rsin fn highlight, before the scanner

    job.wrap.max_width = wrap_width;

    if text.len() > HIGHLIGHT_LIMIT {
        job.append(
            text,
            0.0,
            egui::TextFormat {
                font_id: font,
                color: PLAIN,
                ..Default::default()
            },
        );
        return ui.ctx().fonts_mut(|f| f.layout_job(job));
    }

    let mut rest = text;

One run, one colour, one append — which is exactly lesson 7 stage 1 again. The fast path was already written; it just needed a reason to exist.

Note font_id: font here, without .clone(). This branch uses the font once and then returns, so it can hand over ownership. The scanner below needs it on every iteration, which is why that one clones. The compiler enforces the distinction, and it's a small example of ownership doing useful work rather than being an obstacle.

cargo run --release -- big-50000.rs
→ opens instantly, plain text, ~7 ms/frame — back to lesson 6's numbers
cargo run --release -- big-10000.rs
→ still fully highlighted

Tell the user, don't just do it

The status bar says highlighting is off, so silence isn't mistaken for a bug.

An editor that silently stops highlighting looks broken. One that says "highlighting off (file too large)" looks considered. One edit.

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

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

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

Both branches produce a &'static str — text baked into the binary, borrowed, never allocated. There's no String here and noto_owned(), because there's nothing to own: the two messages exist for the whole life of the program.

cargo run --release -- big-50000.rs
→ … 2277780 bytes · highlighting off (file too large) · …

What we're deliberately not doing

The obvious next move is to cache the highlight so idle frames stop rescanning. I tried it before writing this lesson, and I'm not teaching it, for a reason worth reading:

50,000 linesIdleTyping
No cache26.80 ms47.11 ms
Naive cache (compare text, keep last galley)5.39 ms186.22 ms

Idle got five times better. Typing got four times worse — and when I measured the parts, the comparison cost 0.04 ms and the copy 0.11 ms, which does not come close to explaining the other 140. I don't yet know what does.

This is a real engineering position, not a cop-outShipping an optimisation you can't explain is how you get a program that's fast on your machine and mysterious on everyone else's. The honest options were: spend a day profiling properly, or take the four-line change that's obviously correct and move on. For a 512 KB limit that no real source file reaches, the four lines win. The measurement is written down so the question can be reopened when it matters.

rm editor/big-*.rs
cargo fmt && cargo clippy
Both silent.

Where you ended up

208 lines in total. Two small regions changed: the top of highlight, and the status line in the bottom panel.

editor/src/main.rsthe constant, and the top of fn highlight

/// Above this size, highlighting costs more than a frame is worth. Chosen from
/// measurement: at 427 KB a keystroke costs 7.1 ms, at 1.1 MB it costs 17.4 ms,
/// and a 60fps frame is 16.7 ms.
const HIGHLIGHT_LIMIT: usize = 512 * 1024;

/// Split `text` into coloured runs and hand egui a ready-made layout.
fn highlight(ui: &egui::Ui, text: &str, wrap_width: f32) -> Arc<egui::Galley> {
    let font = egui::TextStyle::Monospace.resolve(ui.style());
    let mut job = LayoutJob::default();
    job.wrap.max_width = wrap_width;

    if text.len() > HIGHLIGHT_LIMIT {
        job.append(
            text,
            0.0,
            egui::TextFormat {
                font_id: font,
                color: PLAIN,
                ..Default::default()
            },
        );
        return ui.ctx().fonts_mut(|f| f.layout_job(job));
    }

    let mut rest = text;
    while !rest.is_empty() {
the scanner from lesson 7, unchanged
Everything else is unchangedstruct Editor, impl Editor, the central panel andmain are aslesson 7 left them.

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

        egui::Panel::bottom("status").show(ui, |ui| {
            let highlighting = if self.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.text.len(),
                self.line_col_us,
                self.frame_ms
            ));
            ui.label(&self.status);
        });

208 lines, cargo clippy silent.

Check yourself

At 50,000 lines the editor spends 28 ms per frame even when you aren't typing. Why?

  • The layouter re-scans the text every frame
  • egui re-uploads the font atlas each frame
  • The frame counter forces a full redraw always
  • Idle frames still write the file back to disk

TextEdit calls your layouter whenever it needs a layout, and it can't know your scanner would return the same answer. egui memoises the galley it builds from the job — but building the job is your code, which it has never seen.↑ Review: the idle column (stage 1)

What makes HIGHLIGHT_LIMIT defensible rather than arbitrary?

  • A comment recording the measurements around it
  • Being a round power of two in bytes
  • Matching the limits other editors happen to use
  • Being small enough that nobody will notice

Any constant can be typed. What makes this one reviewable is the note that highlighting costs 7.1 ms at 427 KB and 17.4 ms at 1.1 MB against a 16.7 ms budget — so a reader can check the reasoning, disagree, or re-measure on different hardware.↑ Review: the comment is the point (stage 2)

The naive highlight cache made idle 5× faster and typing 4× slower. What did I do about it?

  • Left it out, and wrote down the measurement
  • Shipped it, since idle is the common case
  • Shipped it behind a configuration flag
  • Removed highlighting from large files entirely

The slowdown couldn't be explained by the parts I measured, and shipping an optimisation you can't explain is how a program ends up fast on one machine and mysterious everywhere else. The numbers are recorded so the question can be reopened when something actually depends on it.↑ Review: what we're deliberately not doing (stage 3)

Read this before lesson 9

Primary source:The Rust Performance Book — General Tips.

Short, and the right chapter for where you are: it opens by insisting that optimisation without measurement is guessing, which you've now proved twice — once whenString turned out to be innocent, and once when a cache made things worse. It also covers when not to optimise, which is the harder half.