Skip to main content

basalt_tui/input/
mod.rs

1use basalt_core::obsidian::{directory::Directory, rename_dir, rename_note, Note};
2use ratatui::{
3    buffer::Buffer,
4    crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
5    layout::{Constraint, Layout, Offset, Position, Rect},
6    style::{Color, Style, Stylize},
7    text::Span,
8    widgets::{Block, BorderType, Clear, Padding, Paragraph, StatefulWidget, Widget},
9};
10
11use crate::app::{ActivePane, Message as AppMessage};
12
13#[derive(Clone, Default, Debug, PartialEq)]
14enum InputMode {
15    #[default]
16    Normal,
17    Editing,
18}
19
20#[derive(Clone, Debug, PartialEq)]
21pub enum Callback {
22    RenameDir(Directory),
23    RenameNote(Note),
24}
25
26#[derive(Clone, Default, Debug, PartialEq)]
27pub struct InputModalState {
28    input: String,
29    input_original: String,
30    cursor_col: usize,
31    cursor_row: usize,
32    input_mode: InputMode,
33    scroll: usize,
34    modified: bool,
35    visible: bool,
36    label: String,
37    offset_x: usize,
38    callback: Option<Callback>,
39}
40
41impl InputModalState {
42    pub fn new(value: &str, row: usize, visible: bool) -> Self {
43        Self {
44            input: value.to_string(),
45            input_original: value.to_string(),
46            cursor_col: value.chars().count(),
47            cursor_row: row,
48            input_mode: InputMode::Editing,
49            scroll: 0,
50            offset_x: 0,
51            modified: false,
52            visible,
53            label: String::from("Input"),
54            callback: None,
55        }
56    }
57
58    pub fn set_input(&mut self, value: &str) {
59        self.input = value.to_string();
60        self.input_original = value.to_string();
61        self.scroll = 0;
62        self.cursor_col = value.chars().count();
63        self.input_mode = InputMode::Editing;
64    }
65
66    pub fn set_label(&mut self, label: &str) {
67        self.label = label.to_string();
68    }
69
70    pub fn set_row(&mut self, row: usize) {
71        self.cursor_row = row;
72    }
73
74    pub fn set_offset_x(&mut self, x: usize) {
75        self.offset_x = x;
76    }
77
78    pub fn set_callback(&mut self, callback: &Callback) {
79        self.callback = Some(callback.clone());
80    }
81
82    pub fn run_callback(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
83        let result = if let Some(callback) = &self.callback {
84            // FIXME: Propagate errors
85            match callback {
86                Callback::RenameNote(note) => {
87                    let original_path = note.path().to_path_buf();
88                    rename_note(note.clone(), &self.input)
89                        .ok()
90                        .map(|n| (original_path, n.path().to_path_buf()))
91                }
92                Callback::RenameDir(directory) => {
93                    let original_path = directory.path().to_path_buf();
94                    rename_dir(directory.clone(), &self.input)
95                        .ok()
96                        .map(|d| (original_path, d.path().to_path_buf()))
97                }
98            }
99        } else {
100            None
101        };
102
103        self.callback = None;
104        result
105    }
106
107    pub fn toggle_visibility(&mut self) {
108        self.visible = !self.visible;
109    }
110
111    pub fn is_editing(&self) -> bool {
112        matches!(self.input_mode, InputMode::Editing)
113    }
114
115    fn cursor_left(&mut self, amount: usize) {
116        let new_cursor_pos = self.cursor_col.saturating_sub(amount);
117        self.cursor_col = self.clamp_cursor(new_cursor_pos);
118    }
119
120    fn cursor_word_backward(&mut self) {
121        let remainder = &self.input[..self.byte_index()];
122
123        let offset = remainder
124            .chars()
125            .rev()
126            .skip_while(|c| c == &' ')
127            .skip_while(|c| c != &' ')
128            .count();
129
130        self.cursor_col -= remainder.chars().count() - offset;
131    }
132
133    fn cursor_word_forward(&mut self) {
134        let remainder = &self.input[self.byte_index()..];
135
136        let offset = remainder
137            .chars()
138            .skip_while(|c| c != &' ')
139            .skip_while(|c| c == &' ')
140            .count();
141
142        self.cursor_col += remainder.chars().count() - offset;
143    }
144
145    fn cursor_right(&mut self, amount: usize) {
146        let new_cursor_pos = self.cursor_col.saturating_add(amount);
147        self.cursor_col = self.clamp_cursor(new_cursor_pos);
148    }
149
150    pub fn insert_char(&mut self, char: char) {
151        let index = self.byte_index();
152        self.input.insert(index, char);
153        self.modified = self.input != self.input_original;
154        self.cursor_right(1);
155    }
156
157    pub fn delete_char(&mut self) {
158        let index = self.byte_index();
159        if index == 0 {
160            return;
161        }
162
163        if let Some((byte_index, _)) = self.input.char_indices().nth(self.cursor_col - 1) {
164            self.input.remove(byte_index);
165            self.modified = self.input != self.input_original;
166            self.cursor_left(1);
167        }
168    }
169
170    fn byte_index(&self) -> usize {
171        self.input
172            .char_indices()
173            .map(|(i, _)| i)
174            .nth(self.cursor_col)
175            .unwrap_or(self.input.len())
176    }
177
178    fn clamp_cursor(&self, cursor_pos: usize) -> usize {
179        cursor_pos.clamp(0, self.input.chars().count())
180    }
181}
182
183#[derive(Clone, Debug, PartialEq)]
184pub struct InputModalConfig {
185    pub position: Position,
186    pub label: String,
187    pub initial_input: String,
188    pub callback: Callback,
189}
190
191#[derive(Clone, Debug, PartialEq)]
192pub enum Message {
193    CursorLeft,
194    CursorRight,
195    CursorWordForward,
196    CursorWordBackward,
197    Open(InputModalConfig),
198    Accept,
199    Delete,
200    KeyEvent(KeyEvent),
201    Cancel,
202    EditMode,
203}
204
205pub fn update<'a>(message: Message, state: &mut InputModalState) -> Option<AppMessage<'a>> {
206    match message {
207        Message::CursorLeft => {
208            state.cursor_left(1);
209        }
210        Message::CursorRight => {
211            state.cursor_right(1);
212        }
213        Message::CursorWordForward => {
214            state.cursor_word_forward();
215        }
216        Message::CursorWordBackward => {
217            state.cursor_word_backward();
218        }
219        Message::Cancel => match state.input_mode {
220            InputMode::Editing => state.input_mode = InputMode::Normal,
221            InputMode::Normal => {
222                state.toggle_visibility();
223                state.modified = false;
224                return Some(AppMessage::SetActivePane(ActivePane::Explorer));
225            }
226        },
227        Message::EditMode => {
228            state.input_mode = InputMode::Editing;
229        }
230        Message::KeyEvent(key) => match key.code {
231            KeyCode::Char(c) => {
232                state.insert_char(c);
233            }
234            KeyCode::Enter => {
235                if state.modified {
236                    let rename = state.run_callback();
237                    state.input_mode = InputMode::Normal;
238                    state.toggle_visibility();
239                    state.modified = false;
240                    let select = rename.as_ref().map(|(_, new)| new.clone());
241                    return Some(AppMessage::RefreshVault { rename, select });
242                } else {
243                    state.input_mode = InputMode::Normal;
244                    return Some(AppMessage::Input(Message::Cancel));
245                }
246            }
247            _ => {}
248        },
249        Message::Open(InputModalConfig {
250            position,
251            label,
252            initial_input,
253            callback,
254        }) => {
255            state.set_input(&initial_input);
256            state.set_row(position.y as usize);
257            state.set_offset_x(position.x as usize);
258            state.set_label(&label);
259            state.set_callback(&callback);
260            state.toggle_visibility();
261            return Some(AppMessage::SetActivePane(ActivePane::Input));
262        }
263        Message::Delete => state.delete_char(),
264        _ => {}
265    }
266
267    None
268}
269
270pub fn handle_editing_event(key: KeyEvent) -> Option<Message> {
271    match key.code {
272        KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::ALT) => {
273            Some(Message::CursorWordForward)
274        }
275        KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::ALT) => {
276            Some(Message::CursorWordBackward)
277        }
278        KeyCode::Left => Some(Message::CursorLeft),
279        KeyCode::Right => Some(Message::CursorRight),
280        KeyCode::Esc => Some(Message::Cancel),
281        KeyCode::Backspace => Some(Message::Delete),
282        _ => Some(Message::KeyEvent(key)),
283    }
284}
285
286#[derive(Clone, Debug, Default)]
287pub struct Input {
288    pub border_type: BorderType,
289}
290
291impl Input {
292    pub fn new(border_type: BorderType) -> Self {
293        Self { border_type }
294    }
295}
296
297impl StatefulWidget for Input {
298    type State = InputModalState;
299    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
300        if !state.visible {
301            return;
302        }
303
304        // Input widget height is set to 3 since we include the borders
305        let height = 3;
306
307        let width = 40.min(area.width);
308
309        let row = state.cursor_row;
310
311        // let area = area.offset(self.offset);
312        let y = if area.bottom() <= (row + height) as u16 {
313            // We add 1 to go past the original line so it is still visible.
314            (row - (height + 1)) as i32
315        } else {
316            row as i32
317        };
318
319        let area = area.offset(Offset {
320            x: state.offset_x as i32,
321            y,
322        });
323
324        let vertical = Layout::vertical([Constraint::Length(height as u16)]);
325        let horizontal =
326            Layout::horizontal([Constraint::Length(width + state.offset_x as u16 * 2)]);
327        let [area] = vertical.areas::<1>(area);
328        let [area] = horizontal.areas::<1>(area);
329
330        Clear.render(area, buf);
331
332        let row = y as u16;
333        let col = state.cursor_col as u16 + area.left();
334
335        if state.cursor_col > state.scroll + width as usize {
336            state.scroll = state.cursor_col.saturating_sub(width as usize);
337        } else if state.cursor_col < state.scroll {
338            state.scroll = state.cursor_col;
339        }
340
341        let input = &state.input[state.scroll..];
342
343        let mode_color = match state.input_mode {
344            InputMode::Editing => Color::Green,
345            InputMode::Normal => Color::Red,
346        };
347
348        let mode = format!("{:?}", state.input_mode)
349            .fg(mode_color)
350            .bold()
351            .italic();
352
353        let edited_marker = if state.modified {
354            "*".bold().italic()
355        } else {
356            "".into()
357        };
358
359        Paragraph::new(input)
360            .block(
361                Block::bordered()
362                    .border_type(self.border_type)
363                    .border_style(Style::default().dark_gray())
364                    // TODO: Use a label field from state
365                    .title(vec![
366                        Span::from(" "),
367                        Span::from(&state.label),
368                        Span::from(": "),
369                    ])
370                    .padding(Padding::horizontal(1))
371                    .title_bottom(vec![Span::from(" "), mode, edited_marker, Span::from(" ")]),
372            )
373            .render(area, buf);
374
375        // FIXME: When drawing the input above
376        buf.set_style(
377            Rect::new(col.saturating_sub(state.scroll as u16), row, 1, 1)
378                .offset(Offset { x: 2, y: 1 }),
379            Style::default().reversed().dark_gray(),
380        );
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use insta::assert_snapshot;
388    use ratatui::{backend::TestBackend, Terminal};
389
390    #[test]
391    fn test_input_states() {
392        type TestCase = (&'static str, Box<dyn Fn() -> InputModalState>);
393
394        let tests: Vec<TestCase> = vec![
395            ("default", Box::new(InputModalState::default)),
396            (
397                "with_value",
398                Box::new(|| InputModalState::new("Hello world", 0, true)),
399            ),
400            (
401                "with_value_next_row",
402                Box::new(|| InputModalState::new("Hello world", 1, true)),
403            ),
404            (
405                "insert",
406                Box::new(|| {
407                    let mut state = InputModalState::new("", 0, true);
408                    state.insert_char('B');
409                    state.insert_char('a');
410                    state.insert_char('s');
411                    state.insert_char('a');
412                    state.insert_char('l');
413                    state.insert_char('t');
414                    state
415                }),
416            ),
417            (
418                "delete",
419                Box::new(|| {
420                    let mut state = InputModalState::new("Basalt", 0, true);
421                    state.cursor_left(2);
422                    state.delete_char();
423                    state.cursor_left(1);
424                    state.delete_char();
425                    state
426                }),
427            ),
428            (
429                "text_unicode",
430                Box::new(|| InputModalState::new("café 世界 🎉", 0, true)),
431            ),
432            (
433                "text_scrolled",
434                Box::new(|| {
435                    let mut state = InputModalState::new(
436                        "This is a very long text that should trigger scrolling when rendered in the widget",
437                        0,
438                        true
439                    );
440                    // Move cursor to trigger scrolling
441                    state.cursor_left(10);
442                    state
443                }),
444            ),
445            (
446                "text_with_leading_spaces",
447                Box::new(|| InputModalState::new("   indented text", 0, true)),
448            ),
449            (
450                "text_with_multiple_spaces",
451                Box::new(|| InputModalState::new("hello   world   test", 0, true)),
452            ),
453        ];
454
455        let mut terminal = Terminal::new(TestBackend::new(30, 5)).unwrap();
456
457        tests.into_iter().for_each(|(name, state_fn)| {
458            _ = terminal.clear();
459            terminal
460                .draw(|frame| {
461                    let mut state = state_fn();
462                    Input::new(BorderType::Rounded).render(
463                        frame.area(),
464                        frame.buffer_mut(),
465                        &mut state,
466                    )
467                })
468                .unwrap();
469            assert_snapshot!(name, terminal.backend());
470        });
471    }
472}