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 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 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 y = if area.bottom() <= (row + height) as u16 {
317 (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 .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 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 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}