use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::palette::Theme;
const MAX_EDIT_FILE_SIZE: u64 = 10 * 1024 * 1024;
const TAB_WIDTH: usize = 4;
const PAGE_SIZE: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorAction {
Continue,
Saved,
Exit,
}
pub struct InlineEditor {
lines: Vec<String>,
cursor_row: usize,
cursor_col: usize,
scroll_row: usize,
scroll_col: usize,
path: PathBuf,
modified: bool,
status: String,
}
impl InlineEditor {
pub fn open(path: &Path) -> io::Result<Self> {
let meta = fs::metadata(path)?;
if meta.len() > MAX_EDIT_FILE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"file is too large ({} bytes, max {})",
meta.len(),
MAX_EDIT_FILE_SIZE
),
));
}
let content = fs::read_to_string(path)?;
let mut lines: Vec<String> = content.lines().map(String::from).collect();
if lines.is_empty() {
lines.push(String::new());
}
Ok(Self {
lines,
cursor_row: 0,
cursor_col: 0,
scroll_row: 0,
scroll_col: 0,
path: path.to_path_buf(),
modified: false,
status: String::new(),
})
}
pub fn handle_key(&mut self, key: KeyEvent) -> EditorAction {
if key.kind != KeyEventKind::Press {
return EditorAction::Continue;
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('s')) => match self.save() {
Ok(()) => {
self.status = "saved".into();
EditorAction::Saved
}
Err(e) => {
self.status = format!("save failed: {e}");
EditorAction::Continue
}
},
(_, KeyCode::Esc) => EditorAction::Exit,
(_, KeyCode::Up) => {
self.move_up();
EditorAction::Continue
}
(_, KeyCode::Down) => {
self.move_down();
EditorAction::Continue
}
(_, KeyCode::Left) => {
self.move_left();
EditorAction::Continue
}
(_, KeyCode::Right) => {
self.move_right();
EditorAction::Continue
}
(_, KeyCode::Home) => {
self.cursor_col = 0;
self.adjust_scroll_col();
EditorAction::Continue
}
(_, KeyCode::End) => {
self.cursor_col = self.current_line_len();
self.adjust_scroll_col();
EditorAction::Continue
}
(_, KeyCode::PageUp) => {
self.page_up();
EditorAction::Continue
}
(_, KeyCode::PageDown) => {
self.page_down();
EditorAction::Continue
}
(_, KeyCode::Char(c))
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
{
self.insert_char(c);
EditorAction::Continue
}
(_, KeyCode::Enter) => {
self.insert_newline();
EditorAction::Continue
}
(_, KeyCode::Backspace) => {
self.backspace();
EditorAction::Continue
}
(_, KeyCode::Delete) => {
self.delete();
EditorAction::Continue
}
(_, KeyCode::Tab) => {
self.insert_tab();
EditorAction::Continue
}
_ => EditorAction::Continue,
}
}
pub fn save(&mut self) -> io::Result<()> {
let content = self.lines.join("\n");
fs::write(&self.path, &content)?;
self.modified = false;
Ok(())
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn status(&self) -> &str {
&self.status
}
pub fn cursor_row(&self) -> usize {
self.cursor_row
}
pub fn cursor_col(&self) -> usize {
self.cursor_col
}
pub fn lines(&self) -> &[String] {
&self.lines
}
pub fn scroll_row(&self) -> usize {
self.scroll_row
}
pub fn scroll_col(&self) -> usize {
self.scroll_col
}
fn move_up(&mut self) {
if self.cursor_row > 0 {
self.cursor_row -= 1;
self.clamp_cursor_col();
self.adjust_scroll_row();
}
}
fn move_down(&mut self) {
if self.cursor_row + 1 < self.lines.len() {
self.cursor_row += 1;
self.clamp_cursor_col();
self.adjust_scroll_row();
}
}
fn move_left(&mut self) {
if self.cursor_col > 0 {
self.cursor_col -= 1;
} else if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = self.current_line_len();
}
self.adjust_scroll_row();
self.adjust_scroll_col();
}
fn move_right(&mut self) {
let len = self.current_line_len();
if self.cursor_col < len {
self.cursor_col += 1;
} else if self.cursor_row + 1 < self.lines.len() {
self.cursor_row += 1;
self.cursor_col = 0;
}
self.adjust_scroll_row();
self.adjust_scroll_col();
}
fn page_up(&mut self) {
self.cursor_row = self.cursor_row.saturating_sub(PAGE_SIZE);
self.scroll_row = self.scroll_row.saturating_sub(PAGE_SIZE);
self.clamp_cursor_col();
self.adjust_scroll_row();
}
fn page_down(&mut self) {
let max_row = self.lines.len().saturating_sub(1);
self.cursor_row = (self.cursor_row + PAGE_SIZE).min(max_row);
self.scroll_row = (self.scroll_row + PAGE_SIZE).min(max_row);
self.clamp_cursor_col();
self.adjust_scroll_row();
}
fn insert_char(&mut self, c: char) {
let byte_idx = self.cursor_byte_offset();
self.lines[self.cursor_row].insert(byte_idx, c);
self.cursor_col += 1;
self.modified = true;
self.adjust_scroll_col();
}
fn insert_newline(&mut self) {
let byte_idx = self.cursor_byte_offset();
let tail = self.lines[self.cursor_row][byte_idx..].to_string();
self.lines[self.cursor_row].truncate(byte_idx);
self.cursor_row += 1;
self.cursor_col = 0;
self.lines.insert(self.cursor_row, tail);
self.modified = true;
self.adjust_scroll_row();
self.adjust_scroll_col();
}
fn backspace(&mut self) {
if self.cursor_col > 0 {
let byte_start = self.byte_offset_of_char(self.cursor_row, self.cursor_col - 1);
let byte_end = self.byte_offset_of_char(self.cursor_row, self.cursor_col);
self.lines[self.cursor_row].replace_range(byte_start..byte_end, "");
self.cursor_col -= 1;
self.modified = true;
} else if self.cursor_row > 0 {
let removed = self.lines.remove(self.cursor_row);
self.cursor_row -= 1;
self.cursor_col = self.current_line_len();
self.lines[self.cursor_row].push_str(&removed);
self.modified = true;
}
self.adjust_scroll_row();
self.adjust_scroll_col();
}
fn delete(&mut self) {
let len = self.current_line_len();
if self.cursor_col < len {
let byte_start = self.cursor_byte_offset();
let byte_end = self.byte_offset_of_char(self.cursor_row, self.cursor_col + 1);
self.lines[self.cursor_row].replace_range(byte_start..byte_end, "");
self.modified = true;
} else if self.cursor_row + 1 < self.lines.len() {
let next = self.lines.remove(self.cursor_row + 1);
self.lines[self.cursor_row].push_str(&next);
self.modified = true;
}
}
fn insert_tab(&mut self) {
for _ in 0..TAB_WIDTH {
self.insert_char(' ');
}
}
fn current_line_len(&self) -> usize {
self.lines[self.cursor_row].chars().count()
}
fn cursor_byte_offset(&self) -> usize {
self.byte_offset_of_char(self.cursor_row, self.cursor_col)
}
fn byte_offset_of_char(&self, line_idx: usize, col: usize) -> usize {
self.lines[line_idx]
.char_indices()
.nth(col)
.map(|(i, _)| i)
.unwrap_or(self.lines[line_idx].len())
}
fn clamp_cursor_col(&mut self) {
let len = self.current_line_len();
if self.cursor_col > len {
self.cursor_col = len;
}
}
fn adjust_scroll_row(&mut self) {
if self.cursor_row < self.scroll_row {
self.scroll_row = self.cursor_row;
} else if self.cursor_row >= self.scroll_row + PAGE_SIZE {
self.scroll_row = self.cursor_row.saturating_sub(PAGE_SIZE - 1);
}
}
fn adjust_scroll_col(&mut self) {
if self.cursor_col < self.scroll_col {
self.scroll_col = self.cursor_col;
}
let visible_cols = 80usize;
if self.cursor_col >= self.scroll_col + visible_cols {
self.scroll_col = self.cursor_col.saturating_sub(visible_cols - 1);
}
}
}
pub fn render_inline_editor(frame: &mut Frame, area: Rect, editor: &InlineEditor, theme: &Theme) {
if area.height < 3 {
return; }
let header_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let footer_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
let content_area = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: area.height.saturating_sub(2),
};
let file_name = editor
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| editor.path.display().to_string());
let mod_indicator = if editor.modified { " [modified]" } else { "" };
let header_text = format!("✏️ Editing: {file_name}{mod_indicator}");
let header = Paragraph::new(Line::from(vec![Span::styled(
header_text,
Style::default().fg(theme.brand).bold(),
)]));
frame.render_widget(header, header_area);
let visible_rows = content_area.height as usize;
let gutter_width: u16 = 5; let sep_width: u16 = 3; let text_start_col = content_area.x + gutter_width + sep_width;
let text_width = content_area.width.saturating_sub(gutter_width + sep_width) as usize;
for row_offset in 0..visible_rows {
let line_idx = editor.scroll_row + row_offset;
let y = content_area.y + row_offset as u16;
if line_idx >= editor.lines.len() {
let tilde = Paragraph::new(Line::from(Span::styled(
" ~",
Style::default().fg(theme.dim),
)));
frame.render_widget(
tilde,
Rect {
x: content_area.x,
y,
width: content_area.width,
height: 1,
},
);
continue;
}
let is_cursor_line = line_idx == editor.cursor_row;
let line_bg = if is_cursor_line {
theme.sel_bg
} else {
Color::Reset
};
let line_num = format!("{:>5}", line_idx + 1);
let gutter = Paragraph::new(Line::from(Span::styled(
line_num,
Style::default().fg(theme.accent).bg(line_bg),
)));
frame.render_widget(
gutter,
Rect {
x: content_area.x,
y,
width: gutter_width,
height: 1,
},
);
let sep = Paragraph::new(Line::from(Span::styled(
" │ ",
Style::default().fg(theme.dim).bg(line_bg),
)));
frame.render_widget(
sep,
Rect {
x: content_area.x + gutter_width,
y,
width: sep_width,
height: 1,
},
);
let line = &editor.lines[line_idx];
let chars: Vec<char> = line.chars().collect();
let visible_start = editor.scroll_col;
let mut spans: Vec<Span> = Vec::new();
if is_cursor_line {
for vi in 0..text_width {
let ci = visible_start + vi; if ci == editor.cursor_col {
if ci < chars.len() {
spans.push(Span::styled(
chars[ci].to_string(),
Style::default().fg(theme.brand).bg(theme.accent),
));
} else {
spans.push(Span::styled(
"█",
Style::default().fg(theme.accent).bg(line_bg),
));
if vi + 1 < text_width {
let pad = " ".repeat(text_width - vi - 1);
spans
.push(Span::styled(pad, Style::default().fg(theme.fg).bg(line_bg)));
}
break;
}
} else if ci < chars.len() {
spans.push(Span::styled(
chars[ci].to_string(),
Style::default().fg(theme.fg).bg(line_bg),
));
} else {
let remaining = text_width - vi;
spans.push(Span::styled(
" ".repeat(remaining),
Style::default().fg(theme.fg).bg(line_bg),
));
break;
}
}
} else {
let visible: String = chars.iter().skip(visible_start).take(text_width).collect();
spans.push(Span::styled(
visible,
Style::default().fg(theme.fg).bg(line_bg),
));
}
let text_line = Paragraph::new(Line::from(spans));
frame.render_widget(
text_line,
Rect {
x: text_start_col,
y,
width: text_width as u16,
height: 1,
},
);
}
crate::render::paint_scrollbar(
frame,
content_area,
editor.lines.len(),
editor.scroll_row,
theme.accent,
);
let status_style = if editor.status.starts_with("save failed") {
Style::default().fg(theme.brand)
} else {
Style::default().fg(theme.success)
};
let right_info = format!(
"Ln {}, Col {} │ Ctrl+S save │ Esc exit",
editor.cursor_row + 1,
editor.cursor_col + 1,
);
let right_width = right_info.chars().count();
let left_width = area.width as usize - right_width.min(area.width as usize);
let status_display: String = if editor.status.len() > left_width {
editor.status.chars().take(left_width).collect()
} else {
let pad = left_width.saturating_sub(editor.status.chars().count());
format!("{}{}", editor.status, " ".repeat(pad))
};
let footer = Paragraph::new(Line::from(vec![
Span::styled(status_display, status_style),
Span::styled(right_info, Style::default().fg(theme.dim)),
]));
frame.render_widget(footer, footer_area);
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use std::io::Write;
use tempfile::tempdir;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn press_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn temp_file(content: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
fs::write(&path, content).unwrap();
(dir, path)
}
#[test]
fn open_reads_file_content() {
let (_dir, path) = temp_file("hello\nworld");
let ed = InlineEditor::open(&path).unwrap();
assert_eq!(ed.lines(), &["hello", "world"]);
}
#[test]
fn open_empty_file_has_one_line() {
let (_dir, path) = temp_file("");
let ed = InlineEditor::open(&path).unwrap();
assert_eq!(ed.lines(), &[""]);
assert_eq!(ed.line_count(), 1);
}
#[test]
fn open_nonexistent_file_returns_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("nope.txt");
assert!(InlineEditor::open(&path).is_err());
}
#[test]
fn open_too_large_file_returns_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("big.txt");
let mut f = fs::File::create(&path).unwrap();
let chunk = vec![b'A'; 1024];
for _ in 0..(MAX_EDIT_FILE_SIZE / 1024 + 1) {
f.write_all(&chunk).unwrap();
}
drop(f);
assert!(InlineEditor::open(&path).is_err());
}
#[test]
fn line_count_matches_file_lines() {
let (_dir, path) = temp_file("a\nb\nc");
let ed = InlineEditor::open(&path).unwrap();
assert_eq!(ed.line_count(), 3);
}
#[test]
fn is_modified_false_initially() {
let (_dir, path) = temp_file("hello");
let ed = InlineEditor::open(&path).unwrap();
assert!(!ed.is_modified());
}
#[test]
fn path_returns_opened_path() {
let (_dir, path) = temp_file("hello");
let ed = InlineEditor::open(&path).unwrap();
assert_eq!(ed.path(), path);
}
#[test]
fn status_empty_initially() {
let (_dir, path) = temp_file("hello");
let ed = InlineEditor::open(&path).unwrap();
assert!(ed.status().is_empty());
}
#[test]
fn move_down_increments_row() {
let (_dir, path) = temp_file("a\nb\nc");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Down));
assert_eq!(ed.cursor_row(), 1);
}
#[test]
fn move_up_decrements_row() {
let (_dir, path) = temp_file("a\nb\nc");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Down));
ed.handle_key(press(KeyCode::Down));
ed.handle_key(press(KeyCode::Up));
assert_eq!(ed.cursor_row(), 1);
}
#[test]
fn move_up_at_top_stays() {
let (_dir, path) = temp_file("a\nb");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Up));
assert_eq!(ed.cursor_row(), 0);
}
#[test]
fn move_down_at_bottom_stays() {
let (_dir, path) = temp_file("a\nb");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Down));
ed.handle_key(press(KeyCode::Down)); assert_eq!(ed.cursor_row(), 1);
}
#[test]
fn move_right_increments_col() {
let (_dir, path) = temp_file("abc");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Right));
assert_eq!(ed.cursor_col(), 1);
}
#[test]
fn move_left_decrements_col() {
let (_dir, path) = temp_file("abc");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Right));
ed.handle_key(press(KeyCode::Right));
ed.handle_key(press(KeyCode::Left));
assert_eq!(ed.cursor_col(), 1);
}
#[test]
fn move_left_at_col_zero_goes_to_prev_line_end() {
let (_dir, path) = temp_file("abc\nde");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Down)); ed.handle_key(press(KeyCode::Left)); assert_eq!(ed.cursor_row(), 0);
assert_eq!(ed.cursor_col(), 3);
}
#[test]
fn move_right_at_line_end_goes_to_next_line_start() {
let (_dir, path) = temp_file("ab\ncd");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::End));
assert_eq!(ed.cursor_col(), 2);
ed.handle_key(press(KeyCode::Right));
assert_eq!(ed.cursor_row(), 1);
assert_eq!(ed.cursor_col(), 0);
}
#[test]
fn cursor_col_clamped_on_vertical_move() {
let (_dir, path) = temp_file("abcdef\nab");
let mut ed = InlineEditor::open(&path).unwrap();
for _ in 0..5 {
ed.handle_key(press(KeyCode::Right));
}
assert_eq!(ed.cursor_col(), 5);
ed.handle_key(press(KeyCode::Down));
assert_eq!(ed.cursor_col(), 2);
}
#[test]
fn home_moves_to_col_zero() {
let (_dir, path) = temp_file("hello world");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::End));
assert!(ed.cursor_col() > 0);
ed.handle_key(press(KeyCode::Home));
assert_eq!(ed.cursor_col(), 0);
}
#[test]
fn end_moves_to_line_end() {
let (_dir, path) = temp_file("hello");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::End));
assert_eq!(ed.cursor_col(), 5);
}
#[test]
fn insert_char_at_cursor() {
let (_dir, path) = temp_file("ac");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Right)); ed.handle_key(press(KeyCode::Char('b')));
assert_eq!(ed.lines()[0], "abc");
assert_eq!(ed.cursor_col(), 2);
}
#[test]
fn insert_char_sets_modified() {
let (_dir, path) = temp_file("x");
let mut ed = InlineEditor::open(&path).unwrap();
assert!(!ed.is_modified());
ed.handle_key(press(KeyCode::Char('y')));
assert!(ed.is_modified());
}
#[test]
fn backspace_deletes_char() {
let (_dir, path) = temp_file("abc");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::End)); ed.handle_key(press(KeyCode::Backspace));
assert_eq!(ed.lines()[0], "ab");
assert_eq!(ed.cursor_col(), 2);
}
#[test]
fn backspace_at_line_start_joins_lines() {
let (_dir, path) = temp_file("ab\ncd");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Down)); ed.handle_key(press(KeyCode::Backspace));
assert_eq!(ed.line_count(), 1);
assert_eq!(ed.lines()[0], "abcd");
assert_eq!(ed.cursor_row(), 0);
assert_eq!(ed.cursor_col(), 2); }
#[test]
fn delete_removes_char_at_cursor() {
let (_dir, path) = temp_file("abc");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Delete));
assert_eq!(ed.lines()[0], "bc");
assert_eq!(ed.cursor_col(), 0);
}
#[test]
fn delete_at_line_end_joins_with_next() {
let (_dir, path) = temp_file("ab\ncd");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::End)); ed.handle_key(press(KeyCode::Delete));
assert_eq!(ed.line_count(), 1);
assert_eq!(ed.lines()[0], "abcd");
}
#[test]
fn enter_splits_line() {
let (_dir, path) = temp_file("abcd");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Right));
ed.handle_key(press(KeyCode::Right));
ed.handle_key(press(KeyCode::Enter));
assert_eq!(ed.line_count(), 2);
assert_eq!(ed.lines()[0], "ab");
assert_eq!(ed.lines()[1], "cd");
assert_eq!(ed.cursor_row(), 1);
assert_eq!(ed.cursor_col(), 0);
}
#[test]
fn tab_inserts_spaces() {
let (_dir, path) = temp_file("x");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Tab));
assert_eq!(ed.lines()[0], " x");
assert_eq!(ed.cursor_col(), TAB_WIDTH);
}
#[test]
fn save_writes_to_disk() {
let (_dir, path) = temp_file("original");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::End));
ed.handle_key(press(KeyCode::Char('!')));
ed.save().unwrap();
let on_disk = fs::read_to_string(&path).unwrap();
assert_eq!(on_disk, "original!");
}
#[test]
fn save_clears_modified_flag() {
let (_dir, path) = temp_file("hi");
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::Char('x')));
assert!(ed.is_modified());
ed.save().unwrap();
assert!(!ed.is_modified());
}
#[test]
fn esc_returns_exit() {
let (_dir, path) = temp_file("x");
let mut ed = InlineEditor::open(&path).unwrap();
assert_eq!(ed.handle_key(press(KeyCode::Esc)), EditorAction::Exit);
}
#[test]
fn ctrl_s_saves_and_returns_saved() {
let (_dir, path) = temp_file("x");
let mut ed = InlineEditor::open(&path).unwrap();
let action = ed.handle_key(press_mod(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert_eq!(action, EditorAction::Saved);
}
#[test]
fn regular_char_returns_continue() {
let (_dir, path) = temp_file("x");
let mut ed = InlineEditor::open(&path).unwrap();
let action = ed.handle_key(press(KeyCode::Char('a')));
assert_eq!(action, EditorAction::Continue);
}
#[test]
fn scroll_keeps_cursor_visible() {
let content: String = (0..40)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (_dir, path) = temp_file(&content);
let mut ed = InlineEditor::open(&path).unwrap();
for _ in 0..25 {
ed.handle_key(press(KeyCode::Down));
}
assert_eq!(ed.cursor_row(), 25);
assert!(ed.scroll_row() <= ed.cursor_row());
assert!(ed.cursor_row() < ed.scroll_row() + PAGE_SIZE);
}
#[test]
fn page_down_advances_scroll() {
let content: String = (0..60)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (_dir, path) = temp_file(&content);
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::PageDown));
assert_eq!(ed.cursor_row(), PAGE_SIZE);
assert!(ed.scroll_row() <= ed.cursor_row());
}
#[test]
fn page_up_retreats_scroll() {
let content: String = (0..60)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let (_dir, path) = temp_file(&content);
let mut ed = InlineEditor::open(&path).unwrap();
ed.handle_key(press(KeyCode::PageDown));
ed.handle_key(press(KeyCode::PageDown));
let row_before = ed.cursor_row();
ed.handle_key(press(KeyCode::PageUp));
assert_eq!(ed.cursor_row(), row_before - PAGE_SIZE);
}
#[test]
fn release_event_is_ignored() {
let (_dir, path) = temp_file("x");
let mut ed = InlineEditor::open(&path).unwrap();
let release = KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
};
let action = ed.handle_key(release);
assert_eq!(action, EditorAction::Continue);
assert_eq!(ed.lines()[0], "x");
}
#[test]
fn insert_and_delete_multibyte_chars() {
let (_dir, path) = temp_file("aé");
let mut ed = InlineEditor::open(&path).unwrap();
assert_eq!(ed.lines()[0].chars().count(), 2);
ed.handle_key(press(KeyCode::Right));
ed.handle_key(press(KeyCode::Char('→')));
assert_eq!(ed.lines()[0], "a→é");
assert_eq!(ed.cursor_col(), 2);
ed.handle_key(press(KeyCode::Backspace));
assert_eq!(ed.lines()[0], "aé");
assert_eq!(ed.cursor_col(), 1);
}
#[test]
fn crlf_line_endings_are_handled() {
let dir = tempdir().unwrap();
let path = dir.path().join("crlf.txt");
fs::write(&path, "line1\r\nline2\r\n").unwrap();
let ed = InlineEditor::open(&path).unwrap();
assert_eq!(ed.lines(), &["line1", "line2"]);
}
}