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