use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget},
};
use crate::config::Theme;
use crate::ui::button_row::{button_row_width, render_button_row};
use crate::ui::controls;
use crate::ui::cursor::text_field_spans;
use crate::ui::scroll_container::{
centered_rect_for_content, draw_frame, ContentSize, FrameOpts, ModalKind,
};
const BUTTON_LABELS: &[&str] = &["Save", "Cancel"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveCopyField {
Path,
Save,
Cancel,
}
impl SaveCopyField {
fn next(self) -> Self {
match self {
Self::Path => Self::Save,
Self::Save => Self::Cancel,
Self::Cancel => Self::Path,
}
}
fn prev(self) -> Self {
match self {
Self::Path => Self::Cancel,
Self::Save => Self::Path,
Self::Cancel => Self::Save,
}
}
fn is_path(self) -> bool {
matches!(self, Self::Path)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SaveCopyResponse {
Continue,
Cancelled,
Save(String),
}
#[derive(Debug, Clone)]
pub struct SaveCopyState {
pub path: String,
pub cursor: usize,
pub focus: SaveCopyField,
pub last_error: Option<String>,
pub esc_button_rect: Option<Rect>,
}
impl SaveCopyState {
pub fn new(default_path: String) -> Self {
let cursor = default_path.chars().count();
Self {
path: default_path,
cursor,
focus: SaveCopyField::Path,
last_error: None,
esc_button_rect: None,
}
}
pub fn handle_key(&mut self, key: &KeyEvent) -> SaveCopyResponse {
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return SaveCopyResponse::Continue;
}
match key.code {
KeyCode::Esc => return SaveCopyResponse::Cancelled,
KeyCode::Tab | KeyCode::Down => self.focus = self.focus.next(),
KeyCode::BackTab | KeyCode::Up => self.focus = self.focus.prev(),
KeyCode::Left => {
if self.focus.is_path() {
self.cursor = self.cursor.saturating_sub(1);
} else {
self.focus = self.focus.prev();
}
}
KeyCode::Right => {
if self.focus.is_path() {
let len = self.path.chars().count();
if self.cursor < len {
self.cursor += 1;
}
} else {
self.focus = self.focus.next();
}
}
KeyCode::Home if self.focus.is_path() => {
self.cursor = 0;
}
KeyCode::End if self.focus.is_path() => {
self.cursor = self.path.chars().count();
}
KeyCode::Backspace if self.focus.is_path() => {
if self.cursor > 0 {
let target = self.cursor - 1;
remove_char_at(&mut self.path, target);
self.cursor = target;
self.last_error = None;
}
}
KeyCode::Delete if self.focus.is_path() => {
if self.cursor < self.path.chars().count() {
remove_char_at(&mut self.path, self.cursor);
self.last_error = None;
}
}
KeyCode::Char(c) if self.focus.is_path() => {
insert_char_at(&mut self.path, self.cursor, c);
self.cursor += 1;
self.last_error = None;
}
KeyCode::Enter => {
return match self.focus {
SaveCopyField::Cancel => SaveCopyResponse::Cancelled,
SaveCopyField::Save | SaveCopyField::Path => self.try_save(),
};
}
KeyCode::Char(' ') if !self.focus.is_path() => {
return match self.focus {
SaveCopyField::Cancel => SaveCopyResponse::Cancelled,
SaveCopyField::Save => self.try_save(),
SaveCopyField::Path => unreachable!(),
};
}
_ => {}
}
SaveCopyResponse::Continue
}
pub fn paste(&mut self, text: &str) {
if !self.focus.is_path() {
return;
}
let clean = crate::ui::sanitize_paste(text);
if clean.is_empty() {
return;
}
for c in clean.chars() {
insert_char_at(&mut self.path, self.cursor, c);
self.cursor += 1;
}
self.last_error = None;
}
fn try_save(&mut self) -> SaveCopyResponse {
let trimmed = self.path.trim();
if trimmed.is_empty() {
self.last_error = Some("Path required".to_owned());
self.focus = SaveCopyField::Path;
return SaveCopyResponse::Continue;
}
SaveCopyResponse::Save(trimmed.to_owned())
}
}
fn insert_char_at(s: &mut String, cursor: usize, ch: char) {
let byte_idx = s
.char_indices()
.nth(cursor)
.map(|(b, _)| b)
.unwrap_or(s.len());
s.insert(byte_idx, ch);
}
fn remove_char_at(s: &mut String, cursor: usize) {
if let Some((byte_idx, ch)) = s.char_indices().nth(cursor) {
s.replace_range(byte_idx..byte_idx + ch.len_utf8(), "");
}
}
pub fn default_save_as_path(original: Option<&Path>) -> String {
let path = original
.map(Path::to_owned)
.unwrap_or_else(|| PathBuf::from("untitled.md"));
absolutize(path)
}
fn absolutize(path: PathBuf) -> String {
let absolute = if path.is_absolute() {
path
} else {
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
};
absolute.display().to_string()
}
pub struct SaveCopyView<'a> {
pub theme: &'a Theme,
pub cursor_visible: bool,
pub title: &'static str,
}
impl<'a> StatefulWidget for SaveCopyView<'a> {
type State = SaveCopyState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let body_rows = if state.last_error.is_some() { 4 } else { 3 };
let label_w = "Path".chars().count() as u16;
let path_w = (state.path.chars().count() as u16 + 4).max(40);
let buttons_w = button_row_width(BUTTON_LABELS);
let content_width = (label_w + 2 + path_w).max(buttons_w);
let content = ContentSize {
width: content_width,
height: 0,
pinned_top: body_rows,
pinned_bottom: 0,
..Default::default()
};
let modal_area = centered_rect_for_content(content, area);
let layout = draw_frame(
modal_area,
buf,
FrameOpts {
title: self.title,
kind: ModalKind::Normal,
show_close_hint: true,
content,
theme: self.theme,
},
);
state.esc_button_rect = layout.esc_hit_rect;
let inner = layout.body;
if inner.height == 0 || inner.width == 0 {
return;
}
let mut row_y = inner.y;
render_path_row(
buf,
inner,
row_y,
&state.path,
state.cursor,
state.focus == SaveCopyField::Path,
self.theme,
self.cursor_visible,
);
row_y = row_y.saturating_add(1);
if let Some(err) = state.last_error.as_deref() {
if row_y < inner.y + inner.height {
let err_area = Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
};
Paragraph::new(Line::from(Span::styled(
err.to_owned(),
self.theme.transient_error,
)))
.alignment(Alignment::Center)
.style(self.theme.modal_bg)
.render(err_area, buf);
row_y = row_y.saturating_add(1);
}
}
if row_y < inner.y + inner.height {
row_y = row_y.saturating_add(1);
}
if row_y >= inner.y + inner.height {
return;
}
let button_area = Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
};
render_buttons(button_area, buf, state.focus, self.theme);
}
}
#[allow(clippy::too_many_arguments)]
fn render_path_row(
buf: &mut Buffer,
inner: Rect,
y: u16,
value: &str,
cursor: usize,
focused: bool,
theme: &Theme,
cursor_visible: bool,
) {
let area = Rect {
x: inner.x,
y,
width: inner.width,
height: 1,
};
let value_style = controls::text_value_style(focused, theme);
let mut spans: Vec<Span<'_>> = Vec::with_capacity(6);
spans.push(Span::styled("Path", theme.modal_item));
spans.push(Span::raw(" "));
spans.push(Span::styled(" ", value_style));
if focused {
spans.extend(text_field_spans(
value,
cursor,
cursor_visible,
value_style,
theme.cursor,
));
spans.push(Span::styled(" ", value_style));
} else {
spans.push(Span::styled(value.to_owned(), value_style));
spans.push(Span::styled(" ", value_style));
}
Paragraph::new(Line::from(spans))
.style(theme.modal_bg)
.render(area, buf);
}
fn render_buttons(area: Rect, buf: &mut Buffer, focus: SaveCopyField, theme: &Theme) {
let focused_idx = match focus {
SaveCopyField::Save => 0,
SaveCopyField::Cancel => 1,
SaveCopyField::Path => usize::MAX,
};
render_button_row(area, buf, BUTTON_LABELS, focused_idx, theme);
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{backend::TestBackend, Terminal};
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn save_as_default_keeps_name_and_shows_absolute_directory() {
let p = Path::new("/tmp/notes.md");
assert_eq!(default_save_as_path(Some(p)), "/tmp/notes.md");
let cwd = std::env::current_dir().expect("cwd");
let rel = default_save_as_path(Some(Path::new("notes.md")));
assert_eq!(rel, cwd.join("notes.md").display().to_string());
let unnamed = default_save_as_path(None);
assert_eq!(unnamed, cwd.join("untitled.md").display().to_string());
}
#[test]
fn cursor_initially_at_end_of_default_path() {
let s = SaveCopyState::new("/tmp/notes copy.md".to_owned());
assert_eq!(s.cursor, "/tmp/notes copy.md".chars().count());
}
#[test]
fn left_arrow_in_path_moves_cursor_back() {
let mut s = SaveCopyState::new("abc".to_owned());
assert_eq!(s.cursor, 3);
s.handle_key(&key(KeyCode::Left));
assert_eq!(s.cursor, 2);
assert_eq!(s.focus, SaveCopyField::Path);
}
#[test]
fn left_arrow_clamps_at_zero() {
let mut s = SaveCopyState::new("ab".to_owned());
for _ in 0..10 {
s.handle_key(&key(KeyCode::Left));
}
assert_eq!(s.cursor, 0);
assert_eq!(s.focus, SaveCopyField::Path);
}
#[test]
fn right_arrow_in_path_advances_cursor_clamped() {
let mut s = SaveCopyState::new("ab".to_owned());
s.cursor = 0;
s.handle_key(&key(KeyCode::Right));
assert_eq!(s.cursor, 1);
s.handle_key(&key(KeyCode::Right));
s.handle_key(&key(KeyCode::Right));
assert_eq!(s.cursor, 2, "must clamp at path length");
}
#[test]
fn home_jumps_to_start_end_jumps_to_end() {
let mut s = SaveCopyState::new("hello".to_owned());
s.handle_key(&key(KeyCode::Home));
assert_eq!(s.cursor, 0);
s.handle_key(&key(KeyCode::End));
assert_eq!(s.cursor, 5);
}
#[test]
fn typing_inserts_at_cursor_position() {
let mut s = SaveCopyState::new("ac".to_owned());
s.cursor = 1;
s.handle_key(&key(KeyCode::Char('b')));
assert_eq!(s.path, "abc");
assert_eq!(s.cursor, 2);
}
#[test]
fn backspace_removes_char_before_cursor() {
let mut s = SaveCopyState::new("abc".to_owned());
s.cursor = 2;
s.handle_key(&key(KeyCode::Backspace));
assert_eq!(s.path, "ac");
assert_eq!(s.cursor, 1);
}
#[test]
fn backspace_at_start_is_noop() {
let mut s = SaveCopyState::new("abc".to_owned());
s.cursor = 0;
s.handle_key(&key(KeyCode::Backspace));
assert_eq!(s.path, "abc");
assert_eq!(s.cursor, 0);
}
#[test]
fn delete_removes_char_at_cursor() {
let mut s = SaveCopyState::new("abc".to_owned());
s.cursor = 1;
s.handle_key(&key(KeyCode::Delete));
assert_eq!(s.path, "ac");
assert_eq!(s.cursor, 1, "Delete must not move the cursor");
}
#[test]
fn delete_at_end_is_noop() {
let mut s = SaveCopyState::new("abc".to_owned());
s.handle_key(&key(KeyCode::Delete));
assert_eq!(s.path, "abc");
assert_eq!(s.cursor, 3);
}
#[test]
fn cursor_handles_multibyte_chars() {
let mut s = SaveCopyState::new("naïve".to_owned());
assert_eq!(s.cursor, 5);
s.cursor = 3;
s.handle_key(&key(KeyCode::Char('-')));
assert_eq!(s.path, "naï-ve");
}
#[test]
fn typing_appends_to_path() {
let mut s = SaveCopyState::new(String::new());
s.handle_key(&key(KeyCode::Char('a')));
s.handle_key(&key(KeyCode::Char('/')));
s.handle_key(&key(KeyCode::Char('b')));
assert_eq!(s.path, "a/b");
}
#[test]
fn space_in_path_field_inserts_literal_space() {
let mut s = SaveCopyState::new("foo".to_owned());
s.handle_key(&key(KeyCode::Char(' ')));
s.handle_key(&key(KeyCode::Char('b')));
assert_eq!(s.path, "foo b");
}
#[test]
fn backspace_pops_from_path() {
let mut s = SaveCopyState::new("abc".to_owned());
s.handle_key(&key(KeyCode::Backspace));
assert_eq!(s.path, "ab");
}
#[test]
fn tab_cycles_through_path_save_cancel() {
let mut s = SaveCopyState::new(String::new());
s.handle_key(&key(KeyCode::Tab));
assert_eq!(s.focus, SaveCopyField::Save);
s.handle_key(&key(KeyCode::Tab));
assert_eq!(s.focus, SaveCopyField::Cancel);
s.handle_key(&key(KeyCode::Tab));
assert_eq!(s.focus, SaveCopyField::Path);
}
#[test]
fn shift_tab_cycles_backwards() {
let mut s = SaveCopyState::new(String::new());
s.handle_key(&KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE));
assert_eq!(s.focus, SaveCopyField::Cancel);
}
#[test]
fn escape_cancels() {
let mut s = SaveCopyState::new("foo.md".to_owned());
let r = s.handle_key(&key(KeyCode::Esc));
assert_eq!(r, SaveCopyResponse::Cancelled);
}
#[test]
fn enter_on_path_submits_with_value() {
let mut s = SaveCopyState::new("foo.md".to_owned());
let r = s.handle_key(&key(KeyCode::Enter));
assert_eq!(r, SaveCopyResponse::Save("foo.md".to_owned()));
}
#[test]
fn enter_on_save_button_submits() {
let mut s = SaveCopyState::new("foo.md".to_owned());
s.focus = SaveCopyField::Save;
let r = s.handle_key(&key(KeyCode::Enter));
assert_eq!(r, SaveCopyResponse::Save("foo.md".to_owned()));
}
#[test]
fn enter_on_cancel_button_cancels() {
let mut s = SaveCopyState::new("foo.md".to_owned());
s.focus = SaveCopyField::Cancel;
let r = s.handle_key(&key(KeyCode::Enter));
assert_eq!(r, SaveCopyResponse::Cancelled);
}
#[test]
fn empty_path_blocks_submit_and_flags_error() {
let mut s = SaveCopyState::new(String::new());
let r = s.handle_key(&key(KeyCode::Enter));
assert_eq!(r, SaveCopyResponse::Continue);
assert!(s.last_error.is_some());
assert_eq!(s.focus, SaveCopyField::Path);
}
#[test]
fn whitespace_only_path_blocks_submit() {
let mut s = SaveCopyState::new(" ".to_owned());
let r = s.handle_key(&key(KeyCode::Enter));
assert_eq!(r, SaveCopyResponse::Continue);
assert!(s.last_error.is_some());
}
#[test]
fn ctrl_chars_do_not_pollute_path() {
let mut s = SaveCopyState::new("a".to_owned());
let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
s.handle_key(&ctrl_p);
assert_eq!(s.path, "a");
}
#[test]
fn left_right_swap_buttons_when_focused_on_a_button() {
let mut s = SaveCopyState::new(String::new());
s.focus = SaveCopyField::Save;
s.handle_key(&key(KeyCode::Right));
assert_eq!(s.focus, SaveCopyField::Cancel);
s.handle_key(&key(KeyCode::Left));
assert_eq!(s.focus, SaveCopyField::Save);
}
#[test]
fn renders_title_path_and_buttons() {
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = SaveCopyState::new("/tmp/notes copy.md".to_owned());
terminal
.draw(|frame| {
let m = SaveCopyView {
theme: theme(),
cursor_visible: true,
title: "Save a Copy",
};
frame.render_stateful_widget(m, frame.area(), &mut state);
})
.unwrap();
let contents: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
contents.contains("Save a Copy"),
"title missing: {contents}"
);
assert!(contents.contains("Path"), "path label missing: {contents}");
assert!(
contents.contains("notes copy.md"),
"path value missing: {contents}"
);
assert!(contents.contains("Save"), "save button missing: {contents}");
assert!(
contents.contains("Cancel"),
"cancel button missing: {contents}"
);
}
}