| 1 | //! A single-line text editor. |
| 2 | |
| 3 | use ratatui::{ |
| 4 | Frame, |
| 5 | layout::{Position, Rect}, |
| 6 | text::{Line, Span}, |
| 7 | }; |
| 8 | use unicode_width::UnicodeWidthStr; |
| 9 | |
| 10 | use crate::{action::Action, config::Theme}; |
| 11 | |
| 12 | #[derive(Clone, Debug, Default)] |
| 13 | pub struct LineInput { |
| 14 | text: String, |
| 15 | /// Cursor position as a byte offset on a char boundary. |
| 16 | cursor: usize, |
| 17 | } |
| 18 | |
| 19 | /// What an edit did. |
| 20 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 21 | pub enum InputEvent { |
| 22 | Changed, |
| 23 | Moved, |
| 24 | Submit, |
| 25 | Cancel, |
| 26 | Ignored, |
| 27 | } |
| 28 | |
| 29 | impl LineInput { |
| 30 | pub fn new(text: impl Into<String>) -> Self { |
| 31 | let text = text.into(); |
| 32 | Self { |
| 33 | cursor: text.len(), |
| 34 | text, |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | pub fn text(&self) -> &str { |
| 39 | &self.text |
| 40 | } |
| 41 | |
| 42 | pub fn insert(&mut self, ch: char) { |
| 43 | self.text.insert(self.cursor, ch); |
| 44 | self.cursor += ch.len_utf8(); |
| 45 | } |
| 46 | |
| 47 | fn previous_boundary(&self) -> usize { |
| 48 | self.text[..self.cursor] |
| 49 | .char_indices() |
| 50 | .next_back() |
| 51 | .map_or(0, |(i, _)| i) |
| 52 | } |
| 53 | |
| 54 | fn next_boundary(&self) -> usize { |
| 55 | self.text[self.cursor..] |
| 56 | .chars() |
| 57 | .next() |
| 58 | .map_or(self.cursor, |c| self.cursor + c.len_utf8()) |
| 59 | } |
| 60 | |
| 61 | /// Applies an input action. |
| 62 | pub fn apply(&mut self, action: Action) -> InputEvent { |
| 63 | match action { |
| 64 | Action::Submit => InputEvent::Submit, |
| 65 | Action::Cancel => InputEvent::Cancel, |
| 66 | Action::Backspace if self.cursor > 0 => { |
| 67 | let start = self.previous_boundary(); |
| 68 | self.text.drain(start..self.cursor); |
| 69 | self.cursor = start; |
| 70 | InputEvent::Changed |
| 71 | } |
| 72 | Action::DeleteChar if self.cursor < self.text.len() => { |
| 73 | let end = self.next_boundary(); |
| 74 | self.text.drain(self.cursor..end); |
| 75 | InputEvent::Changed |
| 76 | } |
| 77 | Action::DeleteWord if self.cursor > 0 => { |
| 78 | let before = &self.text[..self.cursor]; |
| 79 | let trimmed = before.trim_end(); |
| 80 | let start = trimmed.rfind(char::is_whitespace).map_or(0, |i| { |
| 81 | i + trimmed[i..].chars().next().map_or(1, char::len_utf8) |
| 82 | }); |
| 83 | self.text.drain(start..self.cursor); |
| 84 | self.cursor = start; |
| 85 | InputEvent::Changed |
| 86 | } |
| 87 | Action::CursorLeft => { |
| 88 | self.cursor = self.previous_boundary(); |
| 89 | InputEvent::Moved |
| 90 | } |
| 91 | Action::CursorRight => { |
| 92 | self.cursor = self.next_boundary(); |
| 93 | InputEvent::Moved |
| 94 | } |
| 95 | Action::CursorHome => { |
| 96 | self.cursor = 0; |
| 97 | InputEvent::Moved |
| 98 | } |
| 99 | Action::CursorEnd => { |
| 100 | self.cursor = self.text.len(); |
| 101 | InputEvent::Moved |
| 102 | } |
| 103 | _ => InputEvent::Ignored, |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// Draws `prefix` followed by the text and places the terminal cursor. |
| 108 | pub fn render(&self, frame: &mut Frame, area: Rect, prefix: &str, theme: &Theme) { |
| 109 | let prefix_width = prefix.width(); |
| 110 | let available = usize::from(area.width).saturating_sub(prefix_width + 1); |
| 111 | let before_cursor = self.text[..self.cursor].width(); |
| 112 | // Scroll horizontally so that the cursor stays visible. |
| 113 | let skip = before_cursor.saturating_sub(available); |
| 114 | let mut visible = String::new(); |
| 115 | let mut width = 0; |
| 116 | for ch in self.text.chars() { |
| 117 | let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); |
| 118 | if width >= skip { |
| 119 | visible.push(ch); |
| 120 | } |
| 121 | width += w; |
| 122 | } |
| 123 | let line = Line::from(vec![ |
| 124 | Span::styled(prefix.to_owned(), theme.accent()), |
| 125 | Span::styled(visible, theme.text()), |
| 126 | ]); |
| 127 | frame.render_widget(line, area); |
| 128 | let x = area.x + (prefix_width + before_cursor - skip) as u16; |
| 129 | frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), area.y)); |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | #[cfg(test)] |
| 134 | mod tests { |
| 135 | use super::*; |
| 136 | |
| 137 | #[test] |
| 138 | fn edits_unicode_text() { |
| 139 | let mut input = LineInput::new("héllo"); |
| 140 | assert_eq!(input.apply(Action::Backspace), InputEvent::Changed); |
| 141 | assert_eq!(input.text(), "héll"); |
| 142 | input.apply(Action::CursorHome); |
| 143 | input.apply(Action::CursorRight); |
| 144 | input.apply(Action::DeleteChar); |
| 145 | assert_eq!(input.text(), "hll"); |
| 146 | input.insert('é'); |
| 147 | assert_eq!(input.text(), "héll"); |
| 148 | assert_eq!(input.apply(Action::Submit), InputEvent::Submit); |
| 149 | } |
| 150 | |
| 151 | #[test] |
| 152 | fn deletes_words() { |
| 153 | let mut input = LineInput::new("one two "); |
| 154 | input.apply(Action::DeleteWord); |
| 155 | assert_eq!(input.text(), "one "); |
| 156 | input.apply(Action::DeleteWord); |
| 157 | assert_eq!(input.text(), ""); |
| 158 | assert_eq!(input.apply(Action::Backspace), InputEvent::Ignored); |
| 159 | } |
| 160 | } |