<< 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.
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:
| File | Plain, typing | Highlighted, idle | Highlighted, typing |
|---|---|---|---|
| 1,000 lines (40 KB) | 0.17 ms | 0.57 ms | 0.74 ms |
| 5,000 lines (212 KB) | 0.84 ms | 2.09 ms | 3.46 ms |
| 10,000 lines (427 KB) | 1.62 ms | 4.16 ms | 7.11 ms |
| 25,000 lines (1.1 MB) | 3.62 ms | 10.64 ms | 17.39 ms |
| 50,000 lines (2.2 MB) | 7.41 ms | 28.57 ms | 41.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;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 lines | Idle | Typing |
|---|---|---|
| No cache | 26.80 ms | 47.11 ms |
| Naive cache (compare text, keep last galley) | 5.39 ms | 186.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.
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, 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?
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?
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?
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.