1use std::collections::HashMap;
2
3use ratatui_textarea::{CursorMove, Input, Key, TextArea};
4
5use crate::runtime::{AppKeyCode, AppKeyEvent};
6use crate::spec::CommandPath;
7
8#[derive(Debug, Default)]
9pub struct EditorState {
10 editors: HashMap<String, HashMap<String, TextEditor>>,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub(crate) struct TextPosition {
15 pub(crate) row: usize,
16 pub(crate) col: usize,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub(crate) struct TextEditor {
21 lines: Vec<String>,
22 cursor: TextPosition,
23 selection_anchor: Option<TextPosition>,
24}
25
26impl Default for TextEditor {
27 fn default() -> Self {
28 Self::from_displayed("")
29 }
30}
31
32impl EditorState {
33 pub fn editor(&self, command_key: &CommandPath, arg_id: &str) -> Option<&TextEditor> {
34 self.editors
35 .get(&command_key.storage_key())
36 .and_then(|editors| editors.get(arg_id))
37 }
38
39 pub fn ensure_editor_with<'a, F>(
40 &'a mut self,
41 command_key: &CommandPath,
42 arg_id: &str,
43 displayed: &str,
44 matches_displayed: F,
45 ) -> &'a mut TextEditor
46 where
47 F: Fn(&TextEditor, &str) -> bool,
48 {
49 let key = command_key.storage_key();
50 let editors = self.editors.entry(key).or_default();
51 let editor = editors
52 .entry(arg_id.to_string())
53 .or_insert_with(|| TextEditor::from_displayed(displayed));
54 if !matches_displayed(editor, displayed) {
55 *editor = TextEditor::from_displayed(displayed);
56 }
57 editor
58 }
59}
60
61impl TextEditor {
62 pub(crate) fn from_displayed(displayed: &str) -> Self {
63 let lines = if displayed.is_empty() {
64 vec![String::new()]
65 } else {
66 displayed.split('\n').map(ToString::to_string).collect()
67 };
68 Self {
69 lines,
70 cursor: TextPosition::default(),
71 selection_anchor: None,
72 }
73 }
74
75 pub(crate) fn text(&self) -> String {
76 self.lines.join("\n")
77 }
78
79 pub(crate) fn row_count(&self) -> usize {
80 self.lines.len()
81 }
82
83 pub(crate) fn current_row(&self) -> usize {
84 self.cursor.row.min(self.lines.len().saturating_sub(1))
85 }
86
87 pub(crate) fn cursor(&self) -> TextPosition {
88 self.cursor
89 }
90
91 pub(crate) fn current_line_len(&self) -> usize {
92 self.lines
93 .get(self.current_row())
94 .map_or(0, std::string::String::len)
95 }
96
97 pub(crate) fn lines(&self) -> &[String] {
98 &self.lines
99 }
100
101 pub(crate) fn line(&self, index: usize) -> Option<&str> {
102 self.lines.get(index).map(String::as_str)
103 }
104
105 pub(crate) fn selection_anchor(&self) -> Option<TextPosition> {
106 self.selection_anchor
107 }
108
109 pub(crate) fn matches_displayed(&self, displayed: &str) -> bool {
110 self.lines
111 .iter()
112 .map(String::as_str)
113 .eq(displayed.split('\n'))
114 }
115
116 pub(crate) fn cancel_selection(&mut self) {
117 self.selection_anchor = None;
118 }
119
120 pub(crate) fn move_cursor_to(&mut self, row: u16, col: u16) {
121 self.cursor = TextPosition {
122 row: usize::from(row),
123 col: usize::from(col),
124 };
125 }
126
127 pub(crate) fn start_selection(&mut self, row: u16, col: u16) {
128 self.cursor = TextPosition {
129 row: usize::from(row),
130 col: usize::from(col),
131 };
132 self.selection_anchor = Some(self.cursor);
133 }
134
135 pub(crate) fn apply_key(&mut self, key: AppKeyEvent) -> bool {
136 let cursor_before = self.cursor;
137 let selection_anchor = selection_anchor_for_key(self.selection_anchor, cursor_before, key);
138
139 let mut textarea = self.to_textarea(selection_anchor);
140 let modified = textarea.input(Input::from(key));
141 let cursor = textarea.cursor();
142 self.lines = textarea.lines().to_vec();
143 self.cursor = TextPosition {
144 row: cursor.0,
145 col: cursor.1,
146 };
147 self.selection_anchor = if textarea.is_selecting() {
148 selection_anchor.filter(|anchor| *anchor != self.cursor)
149 } else {
150 None
151 };
152 modified
153 }
154
155 pub(crate) fn insert_str(&mut self, text: &str) -> bool {
156 let mut textarea = self.to_textarea(self.selection_anchor);
157 let modified = textarea.insert_str(text);
158 let cursor = textarea.cursor();
159 self.lines = textarea.lines().to_vec();
160 self.cursor = TextPosition {
161 row: cursor.0,
162 col: cursor.1,
163 };
164 self.selection_anchor = if textarea.is_selecting() {
165 self.selection_anchor
166 .filter(|anchor| *anchor != self.cursor)
167 } else {
168 None
169 };
170 modified
171 }
172
173 pub(crate) fn insert_row_below(&mut self) {
174 let insert_at = self.current_row().saturating_add(1).min(self.lines.len());
175 self.lines.insert(insert_at, String::new());
176 self.cursor = TextPosition {
177 row: insert_at,
178 col: 0,
179 };
180 self.selection_anchor = None;
181 }
182
183 pub(crate) fn remove_current_row(&mut self) {
184 if self.lines.is_empty() {
185 self.lines.push(String::new());
186 self.cursor = TextPosition::default();
187 self.selection_anchor = None;
188 return;
189 }
190
191 let row = self.current_row();
192 self.lines.remove(row);
193 if self.lines.is_empty() {
194 self.lines.push(String::new());
195 }
196 let next_row = row.min(self.lines.len().saturating_sub(1));
197 let next_col = self.cursor.col.min(self.lines[next_row].len());
198 self.cursor = TextPosition {
199 row: next_row,
200 col: next_col,
201 };
202 self.selection_anchor = None;
203 }
204
205 pub(crate) fn move_current_row_up(&mut self) {
206 let row = self.current_row();
207 if row == 0 || row >= self.lines.len() {
208 return;
209 }
210 self.lines.swap(row, row - 1);
211 self.cursor = TextPosition {
212 row: row - 1,
213 col: self.cursor.col.min(self.lines[row - 1].len()),
214 };
215 self.selection_anchor = None;
216 }
217
218 pub(crate) fn move_current_row_down(&mut self) {
219 let row = self.current_row();
220 if row + 1 >= self.lines.len() {
221 return;
222 }
223 self.lines.swap(row, row + 1);
224 self.cursor = TextPosition {
225 row: row + 1,
226 col: self.cursor.col.min(self.lines[row + 1].len()),
227 };
228 self.selection_anchor = None;
229 }
230
231 pub(crate) fn to_textarea(&self, selection_anchor: Option<TextPosition>) -> TextArea<'static> {
232 let mut textarea = TextArea::new(self.lines.clone());
233 if let Some(anchor) = selection_anchor {
234 textarea.move_cursor(CursorMove::Jump(
235 u16::try_from(anchor.row).unwrap_or(u16::MAX),
236 u16::try_from(anchor.col).unwrap_or(u16::MAX),
237 ));
238 textarea.start_selection();
239 }
240 textarea.move_cursor(CursorMove::Jump(
241 u16::try_from(self.cursor.row).unwrap_or(u16::MAX),
242 u16::try_from(self.cursor.col).unwrap_or(u16::MAX),
243 ));
244 textarea
245 }
246}
247
248impl From<AppKeyEvent> for Input {
249 fn from(value: AppKeyEvent) -> Self {
250 Self {
251 key: Key::from(value.code),
252 ctrl: value.modifiers.control,
253 alt: value.modifiers.alt,
254 shift: value.modifiers.shift,
255 }
256 }
257}
258
259impl From<AppKeyCode> for Key {
260 fn from(value: AppKeyCode) -> Self {
261 match value {
262 AppKeyCode::Char(value) => Self::Char(value),
263 AppKeyCode::F(value) => Self::F(value),
264 AppKeyCode::Backspace => Self::Backspace,
265 AppKeyCode::Enter => Self::Enter,
266 AppKeyCode::Left => Self::Left,
267 AppKeyCode::Right => Self::Right,
268 AppKeyCode::Up => Self::Up,
269 AppKeyCode::Down => Self::Down,
270 AppKeyCode::Tab | AppKeyCode::BackTab => Self::Tab,
271 AppKeyCode::Delete => Self::Delete,
272 AppKeyCode::Home => Self::Home,
273 AppKeyCode::End => Self::End,
274 AppKeyCode::PageUp => Self::PageUp,
275 AppKeyCode::PageDown => Self::PageDown,
276 AppKeyCode::Esc => Self::Esc,
277 AppKeyCode::Null => Self::Null,
278 }
279 }
280}
281
282fn extends_selection(key: AppKeyEvent) -> bool {
283 if !key.modifiers.shift {
284 return false;
285 }
286 matches!(
287 key.code,
288 AppKeyCode::Left
289 | AppKeyCode::Right
290 | AppKeyCode::Up
291 | AppKeyCode::Down
292 | AppKeyCode::Home
293 | AppKeyCode::End
294 | AppKeyCode::PageUp
295 | AppKeyCode::PageDown
296 )
297}
298
299fn selection_anchor_for_key(
300 selection_anchor: Option<TextPosition>,
301 cursor_before: TextPosition,
302 key: AppKeyEvent,
303) -> Option<TextPosition> {
304 if extends_selection(key) {
305 return selection_anchor.or(Some(cursor_before));
306 }
307 if consumes_selection(key) {
308 return selection_anchor;
309 }
310 None
311}
312
313fn consumes_selection(key: AppKeyEvent) -> bool {
314 match key.code {
315 AppKeyCode::Backspace | AppKeyCode::Delete => true,
316 AppKeyCode::Char(_) if !key.modifiers.control && !key.modifiers.alt => true,
317 AppKeyCode::Char(c) if key.modifiers.control && !key.modifiers.alt => {
318 matches!(c, 'c' | 'd' | 'h' | 'j' | 'k' | 'm' | 'w' | 'x' | 'y')
319 }
320 AppKeyCode::Char(c) if !key.modifiers.control && key.modifiers.alt => {
321 matches!(c, 'd' | 'h')
322 }
323 _ => false,
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::{TextEditor, TextPosition};
330 use crate::runtime::{AppKeyCode, AppKeyEvent, AppKeyModifiers};
331
332 fn key(code: AppKeyCode) -> AppKeyEvent {
333 AppKeyEvent::new(code, AppKeyModifiers::default())
334 }
335
336 #[test]
337 fn editor_tracks_text_and_cursor_without_widget_storage() {
338 let mut editor = TextEditor::from_displayed("abc");
339 editor.apply_key(key(AppKeyCode::End));
340 editor.apply_key(key(AppKeyCode::Char('d')));
341
342 assert_eq!(editor.text(), "abcd");
343 assert_eq!(editor.cursor(), TextPosition { row: 0, col: 4 });
344 assert_eq!(editor.selection_anchor(), None);
345 }
346
347 #[test]
348 fn editor_tracks_mouse_selection_anchor() {
349 let mut editor = TextEditor::from_displayed("alpha");
350 editor.start_selection(0, 1);
351 editor.move_cursor_to(0, 4);
352
353 assert_eq!(
354 editor.selection_anchor(),
355 Some(TextPosition { row: 0, col: 1 })
356 );
357 assert_eq!(editor.cursor(), TextPosition { row: 0, col: 4 });
358 }
359
360 #[test]
361 fn backspace_deletes_the_entire_selected_range() {
362 let mut editor = TextEditor::from_displayed("alpha");
363 editor.start_selection(0, 1);
364 editor.move_cursor_to(0, 4);
365
366 editor.apply_key(key(AppKeyCode::Backspace));
367
368 assert_eq!(editor.text(), "aa");
369 assert_eq!(editor.cursor(), TextPosition { row: 0, col: 1 });
370 assert_eq!(editor.selection_anchor(), None);
371 }
372
373 #[test]
374 fn typing_replaces_the_current_selection() {
375 let mut editor = TextEditor::from_displayed("alpha");
376 editor.start_selection(0, 1);
377 editor.move_cursor_to(0, 4);
378
379 editor.apply_key(key(AppKeyCode::Char('x')));
380
381 assert_eq!(editor.text(), "axa");
382 assert_eq!(editor.cursor(), TextPosition { row: 0, col: 2 });
383 assert_eq!(editor.selection_anchor(), None);
384 }
385
386 #[test]
387 fn row_operations_insert_remove_and_reorder_lines() {
388 let mut editor = TextEditor::from_displayed("alpha\nbeta\ngamma");
389 editor.move_cursor_to(1, 2);
390
391 editor.insert_row_below();
392 assert_eq!(editor.text(), "alpha\nbeta\n\ngamma");
393 assert_eq!(editor.cursor(), TextPosition { row: 2, col: 0 });
394
395 editor.remove_current_row();
396 assert_eq!(editor.text(), "alpha\nbeta\ngamma");
397 assert_eq!(editor.cursor(), TextPosition { row: 2, col: 0 });
398
399 editor.move_current_row_up();
400 assert_eq!(editor.text(), "alpha\ngamma\nbeta");
401 assert_eq!(editor.cursor(), TextPosition { row: 1, col: 0 });
402
403 editor.move_current_row_down();
404 assert_eq!(editor.text(), "alpha\nbeta\ngamma");
405 assert_eq!(editor.cursor(), TextPosition { row: 2, col: 0 });
406 }
407}