diff --git a/src/widgets.rs b/src/widgets.rs index 608f50e..a36f231 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -1,6 +1,7 @@ mod background; mod border; mod cursor; +mod editor; mod either; mod empty; mod float; @@ -13,6 +14,7 @@ mod text; pub use background::*; pub use border::*; pub use cursor::*; +pub use editor::*; pub use either::*; pub use empty::*; pub use float::*; diff --git a/src/widgets/editor.rs b/src/widgets/editor.rs new file mode 100644 index 0000000..e433e88 --- /dev/null +++ b/src/widgets/editor.rs @@ -0,0 +1,512 @@ +use std::iter; + +use async_trait::async_trait; +use crossterm::style::Stylize; +use unicode_segmentation::UnicodeSegmentation; + +use crate::{AsyncWidget, Frame, Pos, Size, Style, Styled, Widget, WidthDb}; + +/// Like [`WidthDb::wrap`] but includes a final break index if the text ends +/// with a newline. +fn wrap(widthdb: &mut WidthDb, text: &str, width: usize) -> Vec { + let mut breaks = widthdb.wrap(text, width); + if text.ends_with('\n') { + breaks.push(text.len()) + } + breaks +} + +/////////// +// State // +/////////// + +pub struct EditorState { + text: String, + + /// Index of the cursor in the text. + /// + /// Must point to a valid grapheme boundary. + cursor_idx: usize, + + /// Column of the cursor on the screen just after it was last moved + /// horizontally. + cursor_col: usize, + + /// Width of the text when the editor was last rendered. + /// + /// Does not include additional column for cursor. + last_width: u16, +} + +impl EditorState { + pub fn new() -> Self { + Self::with_initial_text(String::new()) + } + + pub fn with_initial_text(text: String) -> Self { + Self { + cursor_idx: text.len(), + cursor_col: 0, + last_width: u16::MAX, + text, + } + } + + /////////////////////////////// + // Grapheme helper functions // + /////////////////////////////// + + fn grapheme_boundaries(&self) -> Vec { + self.text + .grapheme_indices(true) + .map(|(i, _)| i) + .chain(iter::once(self.text.len())) + .collect() + } + + /// Ensure the cursor index lies on a grapheme boundary. If it doesn't, it + /// is moved to the next grapheme boundary. + /// + /// Can handle arbitrary cursor index. + fn move_cursor_to_grapheme_boundary(&mut self) { + for i in self.grapheme_boundaries() { + #[allow(clippy::comparison_chain)] + if i == self.cursor_idx { + // We're at a valid grapheme boundary already + return; + } else if i > self.cursor_idx { + // There was no valid grapheme boundary at our cursor index, so + // we'll take the next one we can get. + self.cursor_idx = i; + return; + } + } + + // The cursor was out of bounds, so move it to the last valid index. + self.cursor_idx = self.text.len(); + } + + /////////////////////////////// + // Line/col helper functions // + /////////////////////////////// + + /// Like [`Self::grapheme_boundaries`] but for lines. + /// + /// Note that the last line can have a length of 0 if the text ends with a + /// newline. + fn line_boundaries(&self) -> Vec { + let newlines = self + .text + .char_indices() + .filter(|(_, c)| *c == '\n') + .map(|(i, _)| i + 1); // utf-8 encodes '\n' as a single byte + iter::once(0) + .chain(newlines) + .chain(iter::once(self.text.len())) + .collect() + } + + /// Find the cursor's current line. + /// + /// Returns `(line_nr, start_idx, end_idx)`. + fn cursor_line(&self, boundaries: &[usize]) -> (usize, usize, usize) { + let mut result = (0, 0, 0); + for (i, (start, end)) in boundaries.iter().zip(boundaries.iter().skip(1)).enumerate() { + if self.cursor_idx >= *start { + result = (i, *start, *end); + } else { + break; + } + } + result + } + + fn cursor_col(&self, widthdb: &mut WidthDb, line_start: usize) -> usize { + widthdb.width(&self.text[line_start..self.cursor_idx]) + } + + fn line(&self, line: usize) -> (usize, usize) { + let boundaries = self.line_boundaries(); + boundaries + .iter() + .copied() + .zip(boundaries.iter().copied().skip(1)) + .nth(line) + .expect("line exists") + } + + fn move_cursor_to_line_col(&mut self, widthdb: &mut WidthDb, line: usize, col: usize) { + let (start, end) = self.line(line); + let line = &self.text[start..end]; + + let mut width = 0; + for (gi, g) in line.grapheme_indices(true) { + self.cursor_idx = start + gi; + if col > width { + width += widthdb.grapheme_width(g, width) as usize; + } else { + return; + } + } + + if !line.ends_with('\n') { + self.cursor_idx = end; + } + } + + fn record_cursor_col(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.line_boundaries(); + let (_, start, _) = self.cursor_line(&boundaries); + self.cursor_col = self.cursor_col(widthdb, start); + } + + ///////////// + // Editing // + ///////////// + + pub fn text(&self) -> &str { + &self.text + } + + pub fn set_text(&mut self, widthdb: &mut WidthDb, text: String) { + self.text = text; + self.move_cursor_to_grapheme_boundary(); + self.record_cursor_col(widthdb); + } + + pub fn clear(&mut self) { + self.text = String::new(); + self.cursor_idx = 0; + self.cursor_col = 0; + } + + /// Insert a character at the current cursor position and move the cursor + /// accordingly. + pub fn insert_char(&mut self, widthdb: &mut WidthDb, ch: char) { + self.text.insert(self.cursor_idx, ch); + self.cursor_idx += ch.len_utf8(); + self.record_cursor_col(widthdb); + } + + /// Insert a string at the current cursor position and move the cursor + /// accordingly. + pub fn insert_str(&mut self, widthdb: &mut WidthDb, str: &str) { + self.text.insert_str(self.cursor_idx, str); + self.cursor_idx += str.len(); + self.record_cursor_col(widthdb); + } + + /// Delete the grapheme before the cursor position. + pub fn backspace(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.grapheme_boundaries(); + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *end == self.cursor_idx { + self.text.replace_range(start..end, ""); + self.cursor_idx = *start; + self.record_cursor_col(widthdb); + break; + } + } + } + + /// Delete the grapheme after the cursor position. + pub fn delete(&mut self) { + let boundaries = self.grapheme_boundaries(); + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *start == self.cursor_idx { + self.text.replace_range(start..end, ""); + break; + } + } + } + + ///////////////////// + // Cursor movement // + ///////////////////// + + pub fn move_cursor_left(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.grapheme_boundaries(); + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *end == self.cursor_idx { + self.cursor_idx = *start; + self.record_cursor_col(widthdb); + break; + } + } + } + + pub fn move_cursor_right(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.grapheme_boundaries(); + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *start == self.cursor_idx { + self.cursor_idx = *end; + self.record_cursor_col(widthdb); + break; + } + } + } + + pub fn move_cursor_left_a_word(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.grapheme_boundaries(); + let mut encountered_word = false; + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)).rev() { + if *end == self.cursor_idx { + let g = &self.text[*start..*end]; + let whitespace = g.chars().all(|c| c.is_whitespace()); + if encountered_word && whitespace { + break; + } else if !whitespace { + encountered_word = true; + } + self.cursor_idx = *start; + } + } + self.record_cursor_col(widthdb); + } + + pub fn move_cursor_right_a_word(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.grapheme_boundaries(); + let mut encountered_word = false; + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *start == self.cursor_idx { + let g = &self.text[*start..*end]; + let whitespace = g.chars().all(|c| c.is_whitespace()); + if encountered_word && whitespace { + break; + } else if !whitespace { + encountered_word = true; + } + self.cursor_idx = *end; + } + } + self.record_cursor_col(widthdb); + } + + pub fn move_cursor_to_start_of_line(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.line_boundaries(); + let (line, _, _) = self.cursor_line(&boundaries); + self.move_cursor_to_line_col(widthdb, line, 0); + self.record_cursor_col(widthdb); + } + + pub fn move_cursor_to_end_of_line(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.line_boundaries(); + let (line, _, _) = self.cursor_line(&boundaries); + self.move_cursor_to_line_col(widthdb, line, usize::MAX); + self.record_cursor_col(widthdb); + } + + pub fn move_cursor_up(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.line_boundaries(); + let (line, _, _) = self.cursor_line(&boundaries); + if line > 0 { + self.move_cursor_to_line_col(widthdb, line - 1, self.cursor_col); + } + } + + pub fn move_cursor_down(&mut self, widthdb: &mut WidthDb) { + let boundaries = self.line_boundaries(); + + // There's always at least one line, and always at least two line + // boundaries at 0 and self.text.len(). + let amount_of_lines = boundaries.len() - 1; + + let (line, _, _) = self.cursor_line(&boundaries); + if line + 1 < amount_of_lines { + self.move_cursor_to_line_col(widthdb, line + 1, self.cursor_col); + } + } + + pub fn widget(&mut self) -> Editor<'_> { + Editor { + highlighted: Styled::new_plain(&self.text), + hidden: None, + focus: true, + state: self, + } + } +} + +impl Default for EditorState { + fn default() -> Self { + Self::new() + } +} + +//////////// +// Widget // +//////////// + +pub struct Editor<'a> { + state: &'a mut EditorState, + highlighted: Styled, + hidden: Option, + focus: bool, +} + +impl Editor<'_> { + pub fn state(&mut self) -> &mut EditorState { + self.state + } + + pub fn highlight(mut self, highlight: F) -> Self + where + F: FnOnce(&str) -> Styled, + { + self.highlighted = highlight(&self.state.text); + assert_eq!(self.state.text, self.highlighted.text()); + self + } + + pub fn focus(mut self, active: bool) -> Self { + self.focus = active; + self + } + + pub fn hidden(self) -> Self { + self.hidden_with_placeholder(("", Style::new().grey().italic())) + } + + pub fn hidden_with_placeholder>(mut self, placeholder: S) -> Self { + self.hidden = Some(placeholder.into()); + self + } + + fn wrapped_cursor(cursor_idx: usize, break_indices: &[usize]) -> (usize, usize) { + let mut row = 0; + let mut line_idx = cursor_idx; + + for break_idx in break_indices { + if cursor_idx < *break_idx { + break; + } else { + row += 1; + line_idx = cursor_idx - break_idx; + } + } + + (row, line_idx) + } + + pub fn cursor_row(&self, widthdb: &mut WidthDb) -> usize { + let width = self.state.last_width; + let text_width = (width - 1) as usize; + let indices = wrap(widthdb, &self.state.text, text_width); + let (row, _) = Self::wrapped_cursor(self.state.cursor_idx, &indices); + row + } + + fn indices(&self, widthdb: &mut WidthDb, max_width: Option) -> Vec { + let max_width = max_width + // One extra column for cursor + .map(|w| w.saturating_sub(1) as usize) + .unwrap_or(usize::MAX); + wrap(widthdb, self.state.text(), max_width) + } + + fn rows(&self, indices: &[usize]) -> Vec { + let text = self.hidden.as_ref().unwrap_or(&self.highlighted); + text.clone().split_at_indices(indices) + } + + fn size(widthdb: &mut WidthDb, rows: &[Styled]) -> Size { + let width = rows + .iter() + .map(|row| widthdb.width(row.text())) + .max() + .unwrap_or(0) + // One extra column for cursor + .saturating_add(1); + let height = rows.len(); + + let width: u16 = width.try_into().unwrap_or(u16::MAX); + let height: u16 = height.try_into().unwrap_or(u16::MAX); + Size::new(width, height) + } + + fn cursor( + &self, + widthdb: &mut WidthDb, + width: u16, + indices: &[usize], + rows: &[Styled], + ) -> Option { + if !self.focus { + return None; + } + + if self.hidden.is_some() { + return Some(Pos::new(0, 0)); + } + + let (cursor_row, cursor_line_idx) = Self::wrapped_cursor(self.state.cursor_idx, indices); + let cursor_col = widthdb.width(rows[cursor_row].text().split_at(cursor_line_idx).0); + + // Ensure the cursor is always visible + let cursor_col = cursor_col.min(width.saturating_sub(1).into()); + + let cursor_row: i32 = cursor_row.try_into().unwrap_or(i32::MAX); + let cursor_col: i32 = cursor_col.try_into().unwrap_or(i32::MAX); + Some(Pos::new(cursor_row, cursor_col)) + } + + fn draw(frame: &mut Frame, rows: Vec, cursor: Option) { + for (i, row) in rows.into_iter().enumerate() { + frame.write(Pos::new(0, i as i32), row); + } + + if let Some(cursor) = cursor { + frame.set_cursor(Some(cursor)); + } + } +} + +impl Widget for Editor<'_> { + fn size( + &self, + frame: &mut Frame, + max_width: Option, + _max_height: Option, + ) -> Result { + let widthdb = frame.widthdb(); + let indices = self.indices(widthdb, max_width); + let rows = self.rows(&indices); + Ok(Self::size(widthdb, &rows)) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + let size = frame.size(); + let widthdb = frame.widthdb(); + let indices = self.indices(widthdb, Some(size.width)); + let rows = self.rows(&indices); + let cursor = self.cursor(widthdb, size.width, &indices, &rows); + Self::draw(frame, rows, cursor); + Ok(()) + } +} + +#[allow(single_use_lifetimes)] +#[async_trait] +impl AsyncWidget for Editor<'_> { + async fn size( + &self, + frame: &mut Frame, + max_width: Option, + _max_height: Option, + ) -> Result { + let widthdb = frame.widthdb(); + let indices = self.indices(widthdb, max_width); + let rows = self.rows(&indices); + Ok(Self::size(widthdb, &rows)) + } + + async fn draw(self, frame: &mut Frame) -> Result<(), E> { + let size = frame.size(); + let widthdb = frame.widthdb(); + let indices = self.indices(widthdb, Some(size.width)); + let rows = self.rows(&indices); + let cursor = self.cursor(widthdb, size.width, &indices, &rows); + Self::draw(frame, rows, cursor); + Ok(()) + } +}