use basalt_core::obsidian::{directory::Directory, rename_dir, rename_note, Note};
use ratatui::{
buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::{Constraint, Layout, Offset, Position, Rect},
style::{Color, Style, Stylize},
text::Span,
widgets::{Block, BorderType, Clear, Padding, Paragraph, StatefulWidget, Widget},
};
use crate::app::{ActivePane, Message as AppMessage};
#[derive(Clone, Default, Debug, PartialEq)]
enum InputMode {
#[default]
Normal,
Editing,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Callback {
RenameDir(Directory),
RenameNote(Note),
}
#[derive(Clone, Default, Debug, PartialEq)]
pub struct InputModalState {
input: String,
input_original: String,
cursor_col: usize,
cursor_row: usize,
input_mode: InputMode,
scroll: usize,
modified: bool,
visible: bool,
label: String,
offset_x: usize,
callback: Option<Callback>,
}
impl InputModalState {
pub fn new(value: &str, row: usize, visible: bool) -> Self {
Self {
input: value.to_string(),
input_original: value.to_string(),
cursor_col: value.chars().count(),
cursor_row: row,
input_mode: InputMode::Editing,
scroll: 0,
offset_x: 0,
modified: false,
visible,
label: String::from("Input"),
callback: None,
}
}
pub fn set_input(&mut self, value: &str) {
self.input = value.to_string();
self.input_original = value.to_string();
self.scroll = 0;
self.cursor_col = value.chars().count();
self.input_mode = InputMode::Editing;
}
pub fn set_label(&mut self, label: &str) {
self.label = label.to_string();
}
pub fn set_row(&mut self, row: usize) {
self.cursor_row = row;
}
pub fn set_offset_x(&mut self, x: usize) {
self.offset_x = x;
}
pub fn set_callback(&mut self, callback: &Callback) {
self.callback = Some(callback.clone());
}
pub fn run_callback(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let result = if let Some(callback) = &self.callback {
match callback {
Callback::RenameNote(note) => {
let original_path = note.path().to_path_buf();
rename_note(note.clone(), &self.input)
.ok()
.map(|n| (original_path, n.path().to_path_buf()))
}
Callback::RenameDir(directory) => {
let original_path = directory.path().to_path_buf();
rename_dir(directory.clone(), &self.input)
.ok()
.map(|d| (original_path, d.path().to_path_buf()))
}
}
} else {
None
};
self.callback = None;
result
}
pub fn toggle_visibility(&mut self) {
self.visible = !self.visible;
}
pub fn is_editing(&self) -> bool {
matches!(self.input_mode, InputMode::Editing)
}
fn cursor_left(&mut self, amount: usize) {
let new_cursor_pos = self.cursor_col.saturating_sub(amount);
self.cursor_col = self.clamp_cursor(new_cursor_pos);
}
fn cursor_word_backward(&mut self) {
let remainder = &self.input[..self.byte_index()];
let offset = remainder
.chars()
.rev()
.skip_while(|c| c == &' ')
.skip_while(|c| c != &' ')
.count();
self.cursor_col -= remainder.chars().count() - offset;
}
fn cursor_word_forward(&mut self) {
let remainder = &self.input[self.byte_index()..];
let offset = remainder
.chars()
.skip_while(|c| c != &' ')
.skip_while(|c| c == &' ')
.count();
self.cursor_col += remainder.chars().count() - offset;
}
fn cursor_right(&mut self, amount: usize) {
let new_cursor_pos = self.cursor_col.saturating_add(amount);
self.cursor_col = self.clamp_cursor(new_cursor_pos);
}
pub fn insert_char(&mut self, char: char) {
let index = self.byte_index();
self.input.insert(index, char);
self.modified = self.input != self.input_original;
self.cursor_right(1);
}
pub fn delete_char(&mut self) {
let index = self.byte_index();
if index == 0 {
return;
}
if let Some((byte_index, _)) = self.input.char_indices().nth(self.cursor_col - 1) {
self.input.remove(byte_index);
self.modified = self.input != self.input_original;
self.cursor_left(1);
}
}
fn byte_index(&self) -> usize {
self.input
.char_indices()
.map(|(i, _)| i)
.nth(self.cursor_col)
.unwrap_or(self.input.len())
}
fn clamp_cursor(&self, cursor_pos: usize) -> usize {
cursor_pos.clamp(0, self.input.chars().count())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct InputModalConfig {
pub position: Position,
pub label: String,
pub initial_input: String,
pub callback: Callback,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Message {
CursorLeft,
CursorRight,
CursorWordForward,
CursorWordBackward,
Open(InputModalConfig),
Accept,
Delete,
KeyEvent(KeyEvent),
Cancel,
EditMode,
}
pub fn update<'a>(message: Message, state: &mut InputModalState) -> Option<AppMessage<'a>> {
match message {
Message::CursorLeft => {
state.cursor_left(1);
}
Message::CursorRight => {
state.cursor_right(1);
}
Message::CursorWordForward => {
state.cursor_word_forward();
}
Message::CursorWordBackward => {
state.cursor_word_backward();
}
Message::Cancel => match state.input_mode {
InputMode::Editing => state.input_mode = InputMode::Normal,
InputMode::Normal => {
state.toggle_visibility();
state.modified = false;
return Some(AppMessage::SetActivePane(ActivePane::Explorer));
}
},
Message::EditMode => {
state.input_mode = InputMode::Editing;
}
Message::KeyEvent(key) => match key.code {
KeyCode::Char(c) => {
state.insert_char(c);
}
KeyCode::Enter => {
if state.modified {
let rename = state.run_callback();
state.input_mode = InputMode::Normal;
state.toggle_visibility();
state.modified = false;
let select = rename.as_ref().map(|(_, new)| new.clone());
return Some(AppMessage::RefreshVault { rename, select });
} else {
state.input_mode = InputMode::Normal;
return Some(AppMessage::Input(Message::Cancel));
}
}
_ => {}
},
Message::Open(InputModalConfig {
position,
label,
initial_input,
callback,
}) => {
state.set_input(&initial_input);
state.set_row(position.y as usize);
state.set_offset_x(position.x as usize);
state.set_label(&label);
state.set_callback(&callback);
state.toggle_visibility();
return Some(AppMessage::SetActivePane(ActivePane::Input));
}
Message::Delete => state.delete_char(),
_ => {}
}
None
}
pub fn handle_editing_event(key: KeyEvent) -> Option<Message> {
match key.code {
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::ALT) => {
Some(Message::CursorWordForward)
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::ALT) => {
Some(Message::CursorWordBackward)
}
KeyCode::Left => Some(Message::CursorLeft),
KeyCode::Right => Some(Message::CursorRight),
KeyCode::Esc => Some(Message::Cancel),
KeyCode::Backspace => Some(Message::Delete),
_ => Some(Message::KeyEvent(key)),
}
}
#[derive(Clone, Debug, Default)]
pub struct Input {
pub border_type: BorderType,
}
impl Input {
pub fn new(border_type: BorderType) -> Self {
Self { border_type }
}
}
impl StatefulWidget for Input {
type State = InputModalState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
if !state.visible {
return;
}
let height = 3;
let width = 40u16
.saturating_sub((state.offset_x * 2) as u16)
.min(area.width);
let row = state.cursor_row;
let y = if area.bottom() <= (row + height) as u16 {
(row - (height + 1)) as i32
} else {
row as i32
};
let area = area.offset(Offset {
x: state.offset_x as i32,
y,
});
let vertical = Layout::vertical([Constraint::Length(height as u16)]);
let horizontal =
Layout::horizontal([Constraint::Length(width + state.offset_x as u16 * 2)]);
let [area] = vertical.areas::<1>(area);
let [area] = horizontal.areas::<1>(area);
Clear.render(area, buf);
let row = y as u16;
let col = state.cursor_col as u16 + area.left();
if state.cursor_col > state.scroll + width as usize {
state.scroll = state.cursor_col.saturating_sub(width as usize);
} else if state.cursor_col < state.scroll {
state.scroll = state.cursor_col;
}
let input = &state.input[state.scroll..];
let mode_color = match state.input_mode {
InputMode::Editing => Color::Green,
InputMode::Normal => Color::Red,
};
let mode = format!("{:?}", state.input_mode)
.fg(mode_color)
.bold()
.italic();
let edited_marker = if state.modified {
"*".bold().italic()
} else {
"".into()
};
Paragraph::new(input)
.block(
Block::bordered()
.border_type(self.border_type)
.border_style(Style::default().dark_gray())
.title(vec![
Span::from(" "),
Span::from(&state.label),
Span::from(": "),
])
.padding(Padding::horizontal(1))
.title_bottom(vec![Span::from(" "), mode, edited_marker, Span::from(" ")]),
)
.render(area, buf);
buf.set_style(
Rect::new(col.saturating_sub(state.scroll as u16), row, 1, 1)
.offset(Offset { x: 2, y: 1 }),
Style::default().reversed().dark_gray(),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn test_input_states() {
type TestCase = (&'static str, Box<dyn Fn() -> InputModalState>);
let tests: Vec<TestCase> = vec![
("default", Box::new(InputModalState::default)),
(
"with_value",
Box::new(|| InputModalState::new("Hello world", 0, true)),
),
(
"with_value_next_row",
Box::new(|| InputModalState::new("Hello world", 1, true)),
),
(
"insert",
Box::new(|| {
let mut state = InputModalState::new("", 0, true);
state.insert_char('B');
state.insert_char('a');
state.insert_char('s');
state.insert_char('a');
state.insert_char('l');
state.insert_char('t');
state
}),
),
(
"delete",
Box::new(|| {
let mut state = InputModalState::new("Basalt", 0, true);
state.cursor_left(2);
state.delete_char();
state.cursor_left(1);
state.delete_char();
state
}),
),
(
"text_unicode",
Box::new(|| InputModalState::new("café 世界 🎉", 0, true)),
),
(
"text_scrolled",
Box::new(|| {
let mut state = InputModalState::new(
"This is a very long text that should trigger scrolling when rendered in the widget",
0,
true
);
state.cursor_left(10);
state
}),
),
(
"text_with_leading_spaces",
Box::new(|| InputModalState::new(" indented text", 0, true)),
),
(
"text_with_multiple_spaces",
Box::new(|| InputModalState::new("hello world test", 0, true)),
),
];
let mut terminal = Terminal::new(TestBackend::new(30, 5)).unwrap();
tests.into_iter().for_each(|(name, state_fn)| {
_ = terminal.clear();
terminal
.draw(|frame| {
let mut state = state_fn();
Input::new(BorderType::Rounded).render(
frame.area(),
frame.buffer_mut(),
&mut state,
)
})
.unwrap();
assert_snapshot!(name, terminal.backend());
});
}
}