Deryks _dev

Full-Stack Web Developer & Technical Leader

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

Colouring the Code

Three stages to syntax highlighting — by writing a tiny scanner and handing egui the result.

This is the feature that makes a text box look like a code editor. It's also the one that sounds like it needs a parser, a grammar file, and a week. It needs about forty lines.

The trick is that egui lets you take over one specific job: turning text into positioned, coloured glyphs. Everything else — typing, selection, clipboard, scrolling, the cursor — stays exactly as it is.

Highlighting is not a parser.
It's a scanner that walks the text once, decides what colour each run of characters is, and never has to be right about the whole program.

Where you're starting

Lesson 6's 121 lines, with the frame-time readout and the status panel. Check againstlesson 6's checkpoint.

adding   removing   existing code, shown only for context

Take over the layout

The text changes colour — proving the hook works before any cleverness.

A galley is egui's word for laid-out text: every glyph, positioned, coloured, wrapped. Normally TextEdit builds one for you. It also lets you supply your own builder, called a layouter, which it will call every time it needs the text laid out. Three edits.

editor/src/main.rstop of the file

use eframe::egui;
use egui::text::LayoutJob;
use std::env;
use std::fs;
use std::io;
use std::sync::Arc;
use std::time::Instant;

Second — a free function, above struct Editor. Not a method: it needs nothing from the editor, and keeping it out of impl Editor avoids borrowingself while TextEdit already holds&mut self.text.

editor/src/main.rsabove struct Editor

const PLAIN: egui::Color32 = egui::Color32::from_rgb(0xe8, 0xe5, 0xde);

/// 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;

    job.append(
        text,
        0.0,
        egui::TextFormat {
            font_id: font,
            color: PLAIN,
            ..Default::default()
        },
    );

    ui.ctx().fonts_mut(|f| f.layout_job(job))
}

A LayoutJob is a list of text runs, each with aTextFormat. Right now there's one run in one colour — deliberately boring.job.wrap.max_width is the width egui asks you to wrap at; pass it through or your long lines run off the edge.

Third — tell TextEdit to use it:

editor/src/main.rsin fn ui, inside the scroll area

            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 self.text)
                    .font(egui::TextStyle::Monospace)
                    .desired_width(f32::INFINITY)
                    .layouter(&mut layouter)
                    .show(ui);
It goes inside the scroll areaLesson 6 wrapped the text box in egui::ScrollArea::vertical(), so the closure belongs in there with it, one indent level deeper than the rest of the central panel. That extra indent is also why cargo fmt splits the closure across two lines — the one-line version no longer fits in 100 columns.
.font(…) comes outOnce you supply a layouter, you choose the font — that's whatTextStyle::Monospace.resolve(ui.style()) is doing insidehighlight. Leaving .font(…) on the builder isn't an error, it just stops meaning anything, and a line that looks like it does something but doesn't is worse than no line.

cargo run
→ same text, now in the off-white you chose. The hook works.

Find the keywords

fn, let, struct turn purple.

Now the scanner. It walks the text once, chopping off one run at a time and deciding its colour. Two edits.

editor/src/main.rsabove fn highlight

/// Words the highlighter treats as keywords.
const KEYWORDS: &[&str] = &[
    "as", "const", "else", "enum", "fn", "for", "if", "impl", "in", "let", "match", "mod", "move",
    "mut", "pub", "return", "self", "struct", "trait", "use", "while",
];

const PLAIN: egui::Color32 = egui::Color32::from_rgb(0xe8, 0xe5, 0xde);
const KEYWORD: egui::Color32 = egui::Color32::from_rgb(0xcf, 0x9b, 0xe0);

Second — replace the single job.append(…) with the loop:

editor/src/main.rsin fn highlight, replacing the single append

    let mut rest = text;
    while !rest.is_empty() {
        let (chunk, colour) = if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
            let end = rest
                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
                .unwrap_or(rest.len());
            let word = &rest[..end];
            let colour = if KEYWORDS.contains(&word) {
                KEYWORD
            } else {
                PLAIN
            };
            (word, colour)
        } else {
            let end = rest.char_indices().nth(1).map_or(rest.len(), |(i, _)| i);
            (&rest[..end], PLAIN)
        };

        job.append(
            chunk,
            0.0,
            egui::TextFormat {
                font_id: font.clone(),
                color: colour,
                ..Default::default()
            },
        );
        rest = &rest[chunk.len()..];
    }

The whole scanner is that shape: look at what rest starts with, take the longest run that belongs together, colour it, and advance. Two cases so far — a word, or a single character of anything else.

rest.find(predicate) returns the byte offset of the first character that isn't part of the word, and &rest[..end] slices there. That's safe because find only ever returns real character boundaries — the thing lesson 4 warned you about is only a danger when you invent an index yourself.

The else branch takes one character, not one byte —char_indices().nth(1) gives the byte offset where the secondcharacter starts. Slicing at a fixed 1 would panic the first time someone typed an accented letter outside a word.

printf 'fn main() {\n let x = 1;\n}\n' > editor/scratch.md
cargo run
→ fn, let in purple; the rest off-white

Comments and strings

Two more colours, and the scanner starts to earn its name.

Keywords alone look wrong, because the two things your eye most wants separated from code are comments and string literals. Two edits.

editor/src/main.rswith the other colours

const KEYWORD: egui::Color32 = egui::Color32::from_rgb(0xcf, 0x9b, 0xe0);
const STRING: egui::Color32 = egui::Color32::from_rgb(0xa3, 0xcf, 0x8c);
const COMMENT: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x92, 0x9c);

Second — two new arms, before the word arm. The line you already have gets pushed down to third place, so it's the same condition with } else in front of it instead of let (chunk, colour) =:

editor/src/main.rsin fn highlight, the top of the if-chain

        let (chunk, colour) = if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
        let (chunk, colour) = if rest.starts_with("//") {
            let end = rest.find('\n').unwrap_or(rest.len());
            (&rest[..end], COMMENT)
        } else if let Some(after_quote) = rest.strip_prefix('"') {
            let end = after_quote.find('"').map_or(rest.len(), |i| i + 2);
            (&rest[..end], STRING)
        } else if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
Order is the logicThese arms go above the word arm, not below. A comment starts with/ and a string with ", so neither would be caught by the word arm — but the principle bites the moment you add one that overlaps. Anif-chain is tried top to bottom, so the arms are in specificity order, and moving one changes the language you're highlighting.

strip_prefix('"') returns Option<&str> — the rest of the string with the quote removed, or None. Using it in anif let both tests for the quote and gives you what follows, so you search for the closing quote in the right place. The + 2 puts both quotes back into the chunk.

map_or(rest.len(), …) handles the unterminated string — you're editing code, so half-typed literals are the normal state of the world. It colours to end of text rather than panicking or giving up.

printf 'fn main() {\n // greet\n let s = "hello";\n}\n' > editor/scratch.md
cargo run
→ purple keywords, grey comment, green string

That's syntax highlightingForty lines, no parser, no grammar file. It doesn't know what a function is — it knows that fn is in a list. Real highlighters are this shape too, just with more arms and a little state carried between lines for things like block comments.

cargo fmt && cargo clippy
Both silent. Clippy will suggest strip_prefix if you wrote the quote test by hand — it's right.

Where you ended up

184 lines in total. Almost everything here is new — the constants and thehighlight function — plus one small change inside fn ui.

editor/src/main.rsnew, above struct Editor

use eframe::egui;
use egui::text::LayoutJob;
use std::env;
use std::fs;
use std::io;
use std::sync::Arc;
use std::time::Instant;

/// Words the highlighter treats as keywords.
const KEYWORDS: &[&str] = &[
    "as", "const", "else", "enum", "fn", "for", "if", "impl", "in", "let", "match", "mod", "move",
    "mut", "pub", "return", "self", "struct", "trait", "use", "while",
];

const PLAIN: egui::Color32 = egui::Color32::from_rgb(0xe8, 0xe5, 0xde);
const KEYWORD: egui::Color32 = egui::Color32::from_rgb(0xcf, 0x9b, 0xe0);
const STRING: egui::Color32 = egui::Color32::from_rgb(0xa3, 0xcf, 0x8c);
const COMMENT: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x92, 0x9c);

/// 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;

    let mut rest = text;
    while !rest.is_empty() {
        let (chunk, colour) = if rest.starts_with("//") {
            let end = rest.find('\n').unwrap_or(rest.len());
            (&rest[..end], COMMENT)
        } else if let Some(after_quote) = rest.strip_prefix('"') {
            let end = after_quote.find('"').map_or(rest.len(), |i| i + 2);
            (&rest[..end], STRING)
        } else if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
            let end = rest
                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
                .unwrap_or(rest.len());
            let word = &rest[..end];
            let colour = if KEYWORDS.contains(&word) {
                KEYWORD
            } else {
                PLAIN
            };
            (word, colour)
        } else {
            let end = rest.char_indices().nth(1).map_or(rest.len(), |(i, _)| i);
            (&rest[..end], PLAIN)
        };

        job.append(
            chunk,
            0.0,
            egui::TextFormat {
                font_id: font.clone(),
                color: colour,
                ..Default::default()
            },
        );
        rest = &rest[chunk.len()..];
    }

    ui.ctx().fonts_mut(|f| f.layout_job(job))
}
Fifty-odd lines between these two are unchangedstruct Editor and the whole of impl Editor — open,save, line_col — are exactly aslesson 6 left them, and so is fn main below. Inside fn ui, only the text box changed.

editor/src/main.rsthe whole of fn ui

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| {
            ui.label(format!(
                "{}  ·  {} bytes  ·  line_col {:.0} µs  ·  {:.2} ms/frame",
                self.position,
                self.text.len(),
                self.line_col_us,
                self.frame_ms
            ));
            ui.label(&self.status);
        });

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

            ui.heading(&self.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 self.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) = self.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;
    }
}

184 lines, cargo clippy silent.

Check yourself

Why is highlight a free function rather than a method on Editor?

  • TextEdit already holds a borrow of the text
  • Free functions are measurably faster to call
  • Methods cannot return reference-counted values
  • egui requires layouters to be free functions

The layouter runs while TextEdit::multiline(&mut self.text)holds an exclusive borrow of the buffer. A method taking &self would want to borrow the whole editor at the same time, and the borrow checker would refuse.↑ Review: why it lives outside impl (stage 1)

Why must the comment and string arms come before the word arm?

  • An if-chain takes the first arm that matches
  • Comments may legally contain any keyword
  • Strings are longer than words on average
  • egui requires runs in order of their colour

Arms are tested top to bottom, so ordering encodes specificity — the same reason lesson 5's NotFound guard had to sit above the generalErr. Here nothing overlaps yet, but the habit is what keeps a scanner correct as arms are added.↑ Review: order is the logic (stage 3)

From lesson 4: why is slicing with &rest[..end] safe here?

  • find returns real character boundaries
  • Slicing a &str can never panic
  • The scanner rejects all non-ASCII input first
  • Byte and character offsets agree inside words

Slicing panics only when an index lands inside a multi-byte character. Every index here came from find or char_indices, which only ever report boundaries. The danger is indices you invent yourself.↑ Review: byte vs char offsets (lesson 4, stage 3)

Read this before lesson 8

Primary source:egui::text::LayoutJob — the API documentation, and theTextFormatpage beside it.

You've used two fields of TextFormat. Read the rest — there's underline, strikethrough, background colour, and vertical alignment, all of which you now know how to reach. Squiggly underlines for compiler errors are the same mechanism as the colours you just wrote, which is worth realising before you need it.