Krolyxon
Commit 957ffc9

rmptuisrcuiinput.rs

160 lines · 4.9 KB · Rust

1//! A single-line text editor.
2
3use ratatui::{
4 Frame,
5 layout::{Position, Rect},
6 text::{Line, Span},
7};
8use unicode_width::UnicodeWidthStr;
9
10use crate::{action::Action, config::Theme};
11
12#[derive(Clone, Debug, Default)]
13pub 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)]
21pub enum InputEvent {
22 Changed,
23 Moved,
24 Submit,
25 Cancel,
26 Ignored,
27}
28
29impl 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.header()),
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)]
134mod 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}