Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483//! A pure text editor: cursor movement and editing across lines, no I/O.//!//! Positions inside the buffer are char indices. What the terminal draws//! is measured in two other units, and both appear here: an edit that//! removes a symbol removes a whole grapheme cluster, so a letter and its//! accent go together, and a wrapped row is as long as the columns it//! prints in, so a row of Japanese breaks at half as many characters as a//! row of English.
use unicode_segmentation::UnicodeSegmentation;use unicode_width::UnicodeWidthStr;
/// The prompt's text, which may hold newlines, and where the cursor sits/// in it, a char index from 0 to the character count.#[derive(Debug, Clone, Default)]pub struct Editor { text: String, cursor: usize, /// The most recently killed text. One buffer, not readline's full kill /// ring: a kill replaces it rather than appending, and there is no /// Alt+Y rotation. kill: String,}
impl Editor { /// The current text. pub fn text(&self) -> &str { &self.text }
/// The cursor's position, a char index. The prompt draws the cursor /// from [`Editor::cursor_row_column`] instead, so this is what the /// editor's own tests read the position from. #[cfg(test)] pub fn cursor(&self) -> usize { self.cursor }
/// Inserts a character at the cursor and moves the cursor past it. A /// newline is an ordinary character here: it starts a new line in the /// buffer the same as any other insert. pub fn insert(&mut self, character: char) { let at = byte_index(&self.text, self.cursor); self.text.insert(at, character); self.cursor += 1; }
/// Inserts `text` at the cursor and moves the cursor past it, keeping /// any newlines the text carries. A bracketed paste lands this way, /// one insert instead of one per character. pub fn insert_str(&mut self, text: &str) { let at = byte_index(&self.text, self.cursor); self.text.insert_str(at, text); self.cursor += text.chars().count(); }
/// Removes the symbol before the cursor, every character of it: a /// letter takes its accent with it, and an emoji built from several /// characters goes in one press. Does nothing at the start. pub fn backspace(&mut self) { if self.cursor == 0 { return; } let previous = self.cluster_start_behind(); let start = byte_index(&self.text, previous); let end = byte_index(&self.text, self.cursor); self.text.replace_range(start..end, ""); self.cursor = previous; }
/// Removes the symbol under the cursor, every character of it, the /// same way [`Editor::backspace`] removes the one behind it. Does /// nothing at the end. pub fn delete(&mut self) { if self.cursor >= self.len() { return; } let start = byte_index(&self.text, self.cursor); let end = byte_index(&self.text, self.cluster_end_ahead()); self.text.replace_range(start..end, ""); }
/// The char index the symbol in front of the cursor starts at. A /// cursor sitting inside a cluster, which the character-at-a-time /// [`Editor::left`] can leave it doing, comes back to that cluster's /// own start. fn cluster_start_behind(&self) -> usize { clusters(&self.text) .map(|(start, _)| start) .take_while(|&start| start < self.cursor) .last() .expect("a cursor past the start has a cluster starting at 0 behind it") }
/// The char index just past the symbol under the cursor. fn cluster_end_ahead(&self) -> usize { clusters(&self.text) .map(|(start, cluster)| start + cluster.chars().count()) .find(|&end| end > self.cursor) .expect("a cursor short of the end has a cluster ending past it") }
/// Moves the cursor one character left. Stops at the start. pub fn left(&mut self) { self.cursor = self.cursor.saturating_sub(1); }
/// Moves the cursor one character right. Stops at the end. pub fn right(&mut self) { self.cursor = (self.cursor + 1).min(self.len()); }
/// Moves the cursor to the start of the current line: readline's C-a, /// bounded by the nearest newline behind the cursor rather than the /// start of the whole buffer. pub fn home(&mut self) { self.cursor = self.line_start(); }
/// Moves the cursor to the end of the current line: readline's C-e, /// bounded by the nearest newline ahead of the cursor rather than the /// end of the whole buffer. pub fn end(&mut self) { self.cursor = self.line_end(); }
/// Removes everything from the cursor to the end of the current line /// and saves the removed text in the kill buffer, readline's C-k. Does /// nothing at the line's end, and never reaches into the next line. pub fn kill_to_end(&mut self) { let start = byte_index(&self.text, self.cursor); let end = byte_index(&self.text, self.line_end()); let killed = self.text[start..end].to_string(); self.text.replace_range(start..end, ""); self.save_kill(killed); }
/// Removes everything from the start of the current line to the /// cursor and saves the removed text in the kill buffer, readline's /// C-u. Does nothing at the line's start, and never reaches into the /// previous line. pub fn kill_to_start(&mut self) { let start_char = self.line_start(); let start = byte_index(&self.text, start_char); let end = byte_index(&self.text, self.cursor); let killed = self.text[start..end].to_string(); self.text.replace_range(start..end, ""); self.save_kill(killed); self.cursor = start_char; }
/// The char index of the start of the cursor's current line: right /// after the nearest newline behind the cursor, or the start of the /// text when there is none. fn line_start(&self) -> usize { let chars: Vec<char> = self.text.chars().collect(); let mut at = self.cursor; while at > 0 && chars[at - 1] != '\n' { at -= 1; } at }
/// The char index of the end of the cursor's current line: the /// nearest newline ahead of the cursor, or the end of the text when /// there is none. fn line_end(&self) -> usize { let chars: Vec<char> = self.text.chars().collect(); let len = chars.len(); let mut at = self.cursor; while at < len && chars[at] != '\n' { at += 1; } at }
/// Moves the cursor to the start of the word to its left, emacs-style: /// past any whitespace first, then to the start of the word behind /// it. A newline counts as whitespace, so this crosses into the /// previous line when the cursor starts at or near the current /// line's beginning. /// /// The walk steps by symbol, not by character, so it lands where a /// removal can start: a mark riding on a space belongs to the space /// and moves with it. pub fn word_left(&mut self) { let clusters: Vec<(usize, &str)> = clusters(&self.text).collect(); let mut at = clusters.partition_point(|&(start, _)| start < self.cursor); while at > 0 && whitespace(clusters[at - 1].1) { at -= 1; } while at > 0 && !whitespace(clusters[at - 1].1) { at -= 1; } self.cursor = clusters.get(at).map_or(0, |&(start, _)| start); }
/// Moves the cursor past the word to its right, emacs-style: past any /// whitespace first, then to the end of the word ahead of it. A /// newline counts as whitespace, so this crosses into the next line /// when the cursor starts at or near the current line's end. pub fn word_right(&mut self) { let chars: Vec<char> = self.text.chars().collect(); let len = chars.len(); let mut at = self.cursor; while at < len && chars[at].is_whitespace() { at += 1; } while at < len && !chars[at].is_whitespace() { at += 1; } self.cursor = at; }
/// Removes from the cursor back to the start of the word to its left, /// same word semantics as `word_left`, and saves the removed text in /// the kill buffer. Does nothing when the cursor is already at the /// start. pub fn kill_word_back(&mut self) { let end = self.cursor; self.word_left(); let start_byte = byte_index(&self.text, self.cursor); let end_byte = byte_index(&self.text, end); let killed = self.text[start_byte..end_byte].to_string(); self.text.replace_range(start_byte..end_byte, ""); self.save_kill(killed); }
/// Removes from the cursor past the word to its right, same word /// semantics as `word_right`, and saves the removed text in the kill /// buffer. Does nothing when the cursor is already at the end. pub fn kill_word_forward(&mut self) { let start = self.cursor; let start_byte = byte_index(&self.text, start); self.word_right(); let end_byte = byte_index(&self.text, self.cursor); let killed = self.text[start_byte..end_byte].to_string(); self.text.replace_range(start_byte..end_byte, ""); self.save_kill(killed); self.cursor = start; }
/// Inserts the kill buffer at the cursor and moves the cursor past the /// inserted text. Does nothing when the buffer is empty. pub fn yank(&mut self) { if self.kill.is_empty() { return; } let at = byte_index(&self.text, self.cursor); self.text.insert_str(at, &self.kill); self.cursor += self.kill.chars().count(); }
/// Saves `killed` into the kill buffer. An empty removal leaves the /// buffer untouched. fn save_kill(&mut self, killed: String) { if !killed.is_empty() { self.kill = killed; } }
/// Returns the text and resets the editor to empty. pub fn take(&mut self) -> String { self.cursor = 0; std::mem::take(&mut self.text) }
/// Replaces the text wholesale and moves the cursor to the end. pub fn set_text(&mut self, text: impl Into<String>) { self.text = text.into(); self.cursor = self.len(); }
/// The character count of the text. fn len(&self) -> usize { self.text.chars().count() }
/// The text's rows at `width` columns: a hard newline starts a new /// row, and a row longer than `width` soft-wraps at the last word /// that fits, the same greedy strategy [`crate::play::wrap::wrap`] /// uses for the transcript. Unlike that wrap, a row here keeps the /// buffer's exact spacing, aside from the one space or newline the /// break consumed, so a cursor position maps back to a row and /// column without ambiguity. pub fn wrapped_rows(&self, width: usize) -> Vec<String> { let chars: Vec<char> = self.text.chars().collect(); row_ranges(&self.text, width) .into_iter() .map(|(start, end)| chars[start..end].iter().collect()) .collect() }
/// The number of rows `wrapped_rows(width)` returns. pub fn row_count(&self, width: usize) -> usize { row_ranges(&self.text, width).len() }
/// The cursor's row among `wrapped_rows(width)` and the screen column /// it sits at on that row, which is how many columns the row prints /// in ahead of it. A cursor sitting on the character a break /// consumed, a space or a newline, counts as the end of the row /// before it rather than the start of the row after. pub fn cursor_row_column(&self, width: usize) -> (usize, usize) { let ranges = row_ranges(&self.text, width); let (row, characters) = locate(&ranges, self.cursor); let chars: Vec<char> = self.text.chars().collect(); let (start, _) = ranges[row]; let ahead: String = chars[start..start + characters].iter().collect(); (row, ahead.width()) }
/// Moves the cursor up one wrapped row at `width` columns, to the /// same column when the row above has one, clamped to its end /// otherwise. Does nothing on the first row. pub fn up(&mut self, width: usize) { let ranges = row_ranges(&self.text, width); let (row, column) = locate(&ranges, self.cursor); if row == 0 { return; } let (start, end) = ranges[row - 1]; self.cursor = (start + column).min(end); }
/// Moves the cursor down one wrapped row at `width` columns, the same /// column semantics as [`Editor::up`]. Does nothing on the last row. pub fn down(&mut self, width: usize) { let ranges = row_ranges(&self.text, width); let (row, column) = locate(&ranges, self.cursor); if row + 1 >= ranges.len() { return; } let (start, end) = ranges[row + 1]; self.cursor = (start + column).min(end); }}
/// `text`'s grapheme clusters, each with the char index it starts at. A/// cluster is what the terminal draws as one symbol, and what an edit/// treats as one unit: a letter and the accent behind it are one/// cluster, and so is an emoji spelled with zero width joiners.fn clusters(text: &str) -> impl Iterator<Item = (usize, &str)> { let mut at = 0; text.graphemes(true).map(move |cluster| { let start = at; at += cluster.chars().count(); (start, cluster) })}
/// True for a cluster the word walk counts as a gap between words: one/// whose own character is whitespace, whatever rides on it.fn whitespace(cluster: &str) -> bool { cluster.starts_with(char::is_whitespace)}
/// One entry per character of `line`: the columns the cluster starting/// there prints in, and zero for a character that continues the cluster/// in front of it.////// Holding a whole cluster's columns on the character that opens it is/// what keeps a row break out of the middle of one. A continuation/// character adds nothing to the row it lands on, so the row is never/// full at one, and the break falls at the next cluster instead.fn cluster_widths(line: &[char]) -> Vec<usize> { let text: String = line.iter().collect(); let mut widths = vec![0; line.len()]; let mut at = 0; for cluster in text.graphemes(true) { widths[at] = cluster.width(); at += cluster.chars().count(); } widths}
/// The columns a run of characters prints in, from their `cluster_widths`.fn columns(widths: &[usize]) -> usize { widths.iter().sum()}
/// The byte offset of the `chars`-th character, or the end of the string/// once `chars` reaches or passes the last one.fn byte_index(text: &str, chars: usize) -> usize { text.char_indices() .nth(chars) .map(|(index, _)| index) .unwrap_or(text.len())}
/// The row `cursor` falls on among `ranges`, `row_ranges`'s output, and/// how many characters into that row it sits: the row whose range reaches/// at least as far as `cursor`, and the distance from that row's start.fn locate(ranges: &[(usize, usize)], cursor: usize) -> (usize, usize) { let (row, &(start, _)) = ranges .iter() .enumerate() .find(|&(_, &(_, end))| cursor <= end) .expect("the last row's end is the text length, which the cursor never passes"); (row, cursor - start)}
/// The char-index ranges of `text`'s wrapped rows at `width` columns. A/// hard newline splits the text into lines first, and each line/// soft-wraps on its own, greedily filling each row and breaking at the/// last whitespace that fits. A line with no whitespace to break on/// splits mid-word at the last symbol that leaves the row inside/// `width` columns.fn row_ranges(text: &str, width: usize) -> Vec<(usize, usize)> { let width = width.max(1); let chars: Vec<char> = text.chars().collect(); let mut ranges = Vec::new(); let mut line_start = 0; for (index, &character) in chars.iter().enumerate() { if character == '\n' { ranges.extend(wrap_range(&chars[line_start..index], line_start, width)); line_start = index + 1; } } ranges.extend(wrap_range(&chars[line_start..], line_start, width)); ranges}
/// Wraps one newline-free line, `offset` chars into the whole text, into/// row ranges. A row's range excludes the single whitespace character/// its break landed on, the same way a wrapped row drops the whitespace/// it broke on.fn wrap_range(line: &[char], offset: usize, width: usize) -> Vec<(usize, usize)> { if line.is_empty() { return vec![(offset, offset)]; } let widths = cluster_widths(line); let mut rows = Vec::new(); let mut row_start = 0; let mut last_space = None; let mut at = 0; while at < line.len() { if line[at].is_whitespace() { last_space = Some(at); } if at > row_start && columns(&widths[row_start..at]) + widths[at] > width { row_start = match last_space { Some(space) if space >= row_start => { rows.push((row_start + offset, space + offset)); space + 1 } _ => { rows.push((row_start + offset, at + offset)); at } }; last_space = None; // A break that lands on the character at `at` itself (the // whitespace that overflowed the row) advances past it; any // other break re-examines `at` against the new row_start. at = at.max(row_start); continue; } at += 1; } rows.push((row_start + offset, line.len() + offset)); rows}
/// Builds an `Editor` by typing `text` in one character at a time, the way/// a player's keystrokes arrive.#[cfg(test)]fn typed(text: &str) -> Editor { let mut editor = Editor::default(); for character in text.chars() { editor.insert(character); } editor}
#[cfg(test)]#[path = "editor_tests.rs"]mod tests;
#[cfg(test)]#[path = "editor_wrap_tests.rs"]mod wrap_tests;