<< Back / Lesson 04 / Text Editor GUI in Rust / ~30 minutes
Where Is the Cursor?
Three stages to a Ln/Col readout — and the counting bug hiding underneath it.
Every code editor you've used shows the cursor position in a corner somewhere.Ln 42, Col 7. It looks like the most trivial feature in the product. It isn't, and the reason it isn't is the reason text editing is hard.
egui knows where the cursor is. It will tell you — as a single number, an offset into the text. Turning that number into a line and a column is your job, and doing it right means being precise about what the number counts.
Byte offset, character offset, and screen column are all different — and using the wrong one is how editors corrupt files.
Where you're starting
Lesson 3's 65 lines, saving with Ctrl+S. Check againstlesson 3's checkpoint if you're unsure.
adding removing existing code, shown only for context
Ask egui what it knows
The editor reports the cursor's raw offset — or says there isn't one.
So far you've drawn the text box with ui.add(…), which hands back only whether it was clicked. TextEdit has a richer door: .show(ui)returns everything it learned while drawing. Three edits.
First — swap ui.add for .show and keep the result:
editor/src/main.rsin fn ui, the text box
ui.add(
egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY),
);
let output = egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY)
.show(ui);ui.add(…), so the surrounding parentheses and the trailing ); are gone, and the whole chain shifts four spaces to the left — also called a dedent, or an outdent in some editors. It's the same builder chain with .show(ui) on the end instead of being passed to add. Run cargo fmt if the indentation drifts.Second — report what came back, just above the labels you already have:
editor/src/main.rsin fn ui, under the text box
let position = match output.cursor_range {
Some(range) => format!("offset {}", range.primary.index.0),
None => "no cursor".to_owned(),
};
ui.label(format!("{} bytes", self.text.len()));Third — fold the position into the byte-count label. The status label below it is shown only so you can see where the change lands; leave that line alone.
editor/src/main.rsin fn ui, replacing the byte-count label
ui.label(format!("{} bytes", self.text.len()));
ui.label(format!("{position} · {} bytes", self.text.len()));
ui.label(&self.status);The other enum
cursor_range is an Option<CCursorRange>, andOption is Result's sibling. Same idea — an enum with two variants, the compiler forcing you through both — but for absence rather than failure:
| Type | Variants | Answers |
|---|---|---|
Result<T, E> | Ok(T) / Err(E) | Did it work? |
Option<T> | Some(T) / None | Is there one? |
There genuinely might not be a cursor — click outside the text box and it loses focus, and there is nothing to report. Languages with null pointers express that by handing you a value that looks fine until you touch it. Rust makes you name the empty case before it will let you at the full one.
range.primary.index.0 — primary is the moving end of a selection (secondary is the anchor), and the .0unwraps a one-field wrapper type. Stage 2 explains why that wrapper exists; it turns out to be the most important thing on this page.
cargo run
→ click in the text: offset 34 · 61 bytes
→ click outside it: no cursor · 61 bytes
Turn one number into two
A real Ln / Col readout that tracks your cursor.
An offset is where the cursor is; a line and column is where it feels like it is. Converting means walking the text and counting newlines. Two edits.
First — a method, after save in your impl Editor block:
editor/src/main.rsin impl Editor, after save
}
/// 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)
}
}The return type (usize, usize) is a tuple — two values travelling together without inventing a struct for them. usize is the unsigned integer type used for sizes and indices; it's as wide as a pointer on your machine.
self.text.chars() is an iterator over characters, and.take(char_index) stops it after that many. So the loop walks exactly the text before the cursor, counting newlines. Lines and columns start at 1 because that's what every editor and compiler shows — including the src/main.rs:39:17 in the errors you've been reading.
Second — use it:
editor/src/main.rsin fn ui, the position match
let position = match output.cursor_range {
Some(range) => format!("offset {}", range.primary.index.0),
Some(range) => {
let (line, column) = self.line_col(range.primary.index.0);
format!("Ln {line}, Col {column}")
}
None => "no cursor".to_owned(),
};let (line, column) = … is destructuring: the tuple comes back and is immediately taken apart into two named values. Same pattern-matching machinery as Some(range) on the line above, used for a happier purpose.
cargo run
→ move the cursor around: Ln 2, Col 5 · 61 bytes
The bug you can't see in English
Understand why char_index is not self.text[..i].
No code change in this stage — this one is about a mistake you haven't made yet, so that you don't. Put a non-English character in your scratch file:
printf 'let x = 1;\nlet café = 2;\nlet y = 3;\n' > editor/scratch.md
→ run, and click just after the é
Look at the two numbers you're now showing. That text is:
bytes = 37, chars = 36Thirty-seven bytes, thirty-six characters. The é takes two bytes. Rust strings are UTF-8, where a character is one to four bytes depending on what it is — ASCII is one, accented Latin is two, most CJK is three, emoji are four.
So there are two numbers you could call "the position", and they disagree the moment text stops being English:
| Index kind | Counts | Who uses it |
|---|---|---|
| Byte offset | Bytes of UTF-8 | text.len(), slicing &text[a..b], files on disk |
| Char offset | char values | text.chars(), egui's cursor |
Before reading on: egui hands you char offset 20. What happens if you treat it as a byte offset and write &self.text[..20]?
On that sample you get 19 characters instead of 20 — silently wrong, because theé ate an extra byte. And if the offset had landed inside a multi-byte character rather than after it, the program would have crashed outright.
The silent version is bad. The loud version looks like this — and it's apanic, at runtime, on your user's machine:
thread 'main' panicked at src/main.rs:9:18:
end byte index 8 is not a char boundary; it is inside 'é' (bytes 7..9 of string)This is why line_col takes self.text.chars() and never slices. It counts the same units egui counts, so the two can't drift apart.
Why the API made you write .0
Remember range.primary.index.0, and that odd .0. Here's how egui declares that field:
/// Character offset (NOT byte offset!).
pub index: CharIndex,
// …where CharIndex is:
pub struct CharIndex(pub usize);It's a usize in a wrapper that exists purely to have a different name. That's a newtype, and the point is that a CharIndex won't compile where a byte index is expected, even though both are just numbers underneath. The capital-letters comment tells you the library authors have been bitten; the type makes sure you can't be, at least not by accident.
There's a third number this lesson is quietly ignoring: what the user sees as a column. A tab may render as eight spaces, and a family emoji is severalchar values that draw as one glyph. "Column" in a real editor means grapheme clusters, not chars. We'll take that on when we paint text ourselves.
Where you ended up
89 lines, after cargo fmt.
editor/src/main.rs
use eframe::egui;
use std::fs;
use std::io;
struct Editor {
path: String,
text: String,
status: String,
}
impl Editor {
/// Open a file into a new editor. A failure becomes the buffer contents,
/// so the window always opens.
fn open(path: &str) -> Self {
let text = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) => format!("could not open {path}\n\n{error}"),
};
Self {
path: path.to_owned(),
text,
status: String::new(),
}
}
/// 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)
}
}
impl eframe::App for Editor {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
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);
let output = egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY)
.show(ui);
let position = match output.cursor_range {
Some(range) => {
let (line, column) = self.line_col(range.primary.index.0);
format!("Ln {line}, Col {column}")
}
None => "no cursor".to_owned(),
};
ui.label(format!("{position} · {} bytes", self.text.len()));
ui.label(&self.status);
});
}
}
fn main() -> eframe::Result {
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("scratch.md")))),
)
}Check yourself
Why is cursor_range an Option rather than a plain value?
Click outside the text box and there is genuinely no cursor to report.Option makes that case a thing you must name before the compiler lets you at the value — the same trick Result plays with failure.↑ Review: Option vs Result (stage 1)
Your text is "let café = 2;". How do len() and chars().count() compare?
len() returns bytes. Rust strings are UTF-8, so a character is one to four bytes; é is two. That's why the sample measures 37 bytes but 36 chars, and why mixing the two indices silently misplaces the cursor.↑ Review: byte vs char offsets (stage 3)
From lesson 3: what does ? do to an Err inside save?
Early return. save can't know whether a failed write deserves a message or a retry, so it propagates upward to the code that does — which is also why the success status never gets set on a failed save.↑ Review: what ? expands to (lesson 3, stage 4)
Read this before lesson 5
Primary source:The Rust Programming Language, 8.2 — Storing UTF-8 Encoded Text with Strings.
Short, and squarely the thing you just met. It works through why Rust refuses to let you index a string with s[0] at all, and lays out the three ways to look at text — bytes, chars, and grapheme clusters. The section on grapheme clusters is the column-counting problem this lesson deferred.