Skip to main content

clap_tui/
editor_state.rs

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}