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 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 let height = 3;
306
307 let width = 40.min(area.width);
308
309 let row = state.cursor_row;
310
311 let y = if area.bottom() <= (row + height) as u16 {
313 (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 .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 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 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}