1use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
28use ratatui::layout::Rect;
29use ratatui::style::{Color, Style};
30use ratatui::text::{Line, Span};
31use ratatui::widgets::Paragraph;
32use ratatui::Frame;
33
34use crate::palette::Theme;
35
36const MAX_EDIT_FILE_SIZE: u64 = 10 * 1024 * 1024;
40
41const TAB_WIDTH: usize = 4;
43
44const PAGE_SIZE: usize = 20;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum EditorAction {
52 Continue,
54 Saved,
56 Exit,
58}
59
60pub struct InlineEditor {
64 lines: Vec<String>,
66 cursor_row: usize,
68 cursor_col: usize,
70 scroll_row: usize,
72 scroll_col: usize,
74 path: PathBuf,
76 modified: bool,
78 status: String,
80}
81
82impl InlineEditor {
83 pub fn open(path: &Path) -> io::Result<Self> {
88 let meta = fs::metadata(path)?;
89 if meta.len() > MAX_EDIT_FILE_SIZE {
90 return Err(io::Error::new(
91 io::ErrorKind::InvalidData,
92 format!(
93 "file is too large ({} bytes, max {})",
94 meta.len(),
95 MAX_EDIT_FILE_SIZE
96 ),
97 ));
98 }
99
100 let content = fs::read_to_string(path)?;
101 let mut lines: Vec<String> = content.lines().map(String::from).collect();
102 if lines.is_empty() {
103 lines.push(String::new());
104 }
105
106 Ok(Self {
107 lines,
108 cursor_row: 0,
109 cursor_col: 0,
110 scroll_row: 0,
111 scroll_col: 0,
112 path: path.to_path_buf(),
113 modified: false,
114 status: String::new(),
115 })
116 }
117
118 pub fn handle_key(&mut self, key: KeyEvent) -> EditorAction {
122 if key.kind != KeyEventKind::Press {
124 return EditorAction::Continue;
125 }
126
127 match (key.modifiers, key.code) {
128 (KeyModifiers::CONTROL, KeyCode::Char('s')) => match self.save() {
130 Ok(()) => {
131 self.status = "saved".into();
132 EditorAction::Saved
133 }
134 Err(e) => {
135 self.status = format!("save failed: {e}");
136 EditorAction::Continue
137 }
138 },
139
140 (_, KeyCode::Esc) => EditorAction::Exit,
142
143 (_, KeyCode::Up) => {
145 self.move_up();
146 EditorAction::Continue
147 }
148 (_, KeyCode::Down) => {
149 self.move_down();
150 EditorAction::Continue
151 }
152 (_, KeyCode::Left) => {
153 self.move_left();
154 EditorAction::Continue
155 }
156 (_, KeyCode::Right) => {
157 self.move_right();
158 EditorAction::Continue
159 }
160 (_, KeyCode::Home) => {
161 self.cursor_col = 0;
162 self.adjust_scroll_col();
163 EditorAction::Continue
164 }
165 (_, KeyCode::End) => {
166 self.cursor_col = self.current_line_len();
167 self.adjust_scroll_col();
168 EditorAction::Continue
169 }
170 (_, KeyCode::PageUp) => {
171 self.page_up();
172 EditorAction::Continue
173 }
174 (_, KeyCode::PageDown) => {
175 self.page_down();
176 EditorAction::Continue
177 }
178
179 (_, KeyCode::Char(c))
181 if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
182 {
183 self.insert_char(c);
184 EditorAction::Continue
185 }
186 (_, KeyCode::Enter) => {
187 self.insert_newline();
188 EditorAction::Continue
189 }
190 (_, KeyCode::Backspace) => {
191 self.backspace();
192 EditorAction::Continue
193 }
194 (_, KeyCode::Delete) => {
195 self.delete();
196 EditorAction::Continue
197 }
198 (_, KeyCode::Tab) => {
199 self.insert_tab();
200 EditorAction::Continue
201 }
202
203 _ => EditorAction::Continue,
205 }
206 }
207
208 pub fn save(&mut self) -> io::Result<()> {
212 let content = self.lines.join("\n");
213 fs::write(&self.path, &content)?;
214 self.modified = false;
215 Ok(())
216 }
217
218 pub fn line_count(&self) -> usize {
222 self.lines.len()
223 }
224
225 pub fn is_modified(&self) -> bool {
227 self.modified
228 }
229
230 pub fn path(&self) -> &Path {
232 &self.path
233 }
234
235 pub fn status(&self) -> &str {
237 &self.status
238 }
239
240 pub fn cursor_row(&self) -> usize {
242 self.cursor_row
243 }
244
245 pub fn cursor_col(&self) -> usize {
247 self.cursor_col
248 }
249
250 pub fn lines(&self) -> &[String] {
252 &self.lines
253 }
254
255 pub fn scroll_row(&self) -> usize {
257 self.scroll_row
258 }
259
260 pub fn scroll_col(&self) -> usize {
262 self.scroll_col
263 }
264
265 fn move_up(&mut self) {
268 if self.cursor_row > 0 {
269 self.cursor_row -= 1;
270 self.clamp_cursor_col();
271 self.adjust_scroll_row();
272 }
273 }
274
275 fn move_down(&mut self) {
276 if self.cursor_row + 1 < self.lines.len() {
277 self.cursor_row += 1;
278 self.clamp_cursor_col();
279 self.adjust_scroll_row();
280 }
281 }
282
283 fn move_left(&mut self) {
284 if self.cursor_col > 0 {
285 self.cursor_col -= 1;
286 } else if self.cursor_row > 0 {
287 self.cursor_row -= 1;
288 self.cursor_col = self.current_line_len();
289 }
290 self.adjust_scroll_row();
291 self.adjust_scroll_col();
292 }
293
294 fn move_right(&mut self) {
295 let len = self.current_line_len();
296 if self.cursor_col < len {
297 self.cursor_col += 1;
298 } else if self.cursor_row + 1 < self.lines.len() {
299 self.cursor_row += 1;
300 self.cursor_col = 0;
301 }
302 self.adjust_scroll_row();
303 self.adjust_scroll_col();
304 }
305
306 fn page_up(&mut self) {
307 self.cursor_row = self.cursor_row.saturating_sub(PAGE_SIZE);
308 self.scroll_row = self.scroll_row.saturating_sub(PAGE_SIZE);
309 self.clamp_cursor_col();
310 self.adjust_scroll_row();
311 }
312
313 fn page_down(&mut self) {
314 let max_row = self.lines.len().saturating_sub(1);
315 self.cursor_row = (self.cursor_row + PAGE_SIZE).min(max_row);
316 self.scroll_row = (self.scroll_row + PAGE_SIZE).min(max_row);
317 self.clamp_cursor_col();
318 self.adjust_scroll_row();
319 }
320
321 fn insert_char(&mut self, c: char) {
324 let byte_idx = self.cursor_byte_offset();
325 self.lines[self.cursor_row].insert(byte_idx, c);
326 self.cursor_col += 1;
327 self.modified = true;
328 self.adjust_scroll_col();
329 }
330
331 fn insert_newline(&mut self) {
332 let byte_idx = self.cursor_byte_offset();
333 let tail = self.lines[self.cursor_row][byte_idx..].to_string();
334 self.lines[self.cursor_row].truncate(byte_idx);
335 self.cursor_row += 1;
336 self.cursor_col = 0;
337 self.lines.insert(self.cursor_row, tail);
338 self.modified = true;
339 self.adjust_scroll_row();
340 self.adjust_scroll_col();
341 }
342
343 fn backspace(&mut self) {
344 if self.cursor_col > 0 {
345 let byte_start = self.byte_offset_of_char(self.cursor_row, self.cursor_col - 1);
346 let byte_end = self.byte_offset_of_char(self.cursor_row, self.cursor_col);
347 self.lines[self.cursor_row].replace_range(byte_start..byte_end, "");
348 self.cursor_col -= 1;
349 self.modified = true;
350 } else if self.cursor_row > 0 {
351 let removed = self.lines.remove(self.cursor_row);
352 self.cursor_row -= 1;
353 self.cursor_col = self.current_line_len();
354 self.lines[self.cursor_row].push_str(&removed);
355 self.modified = true;
356 }
357 self.adjust_scroll_row();
358 self.adjust_scroll_col();
359 }
360
361 fn delete(&mut self) {
362 let len = self.current_line_len();
363 if self.cursor_col < len {
364 let byte_start = self.cursor_byte_offset();
365 let byte_end = self.byte_offset_of_char(self.cursor_row, self.cursor_col + 1);
366 self.lines[self.cursor_row].replace_range(byte_start..byte_end, "");
367 self.modified = true;
368 } else if self.cursor_row + 1 < self.lines.len() {
369 let next = self.lines.remove(self.cursor_row + 1);
370 self.lines[self.cursor_row].push_str(&next);
371 self.modified = true;
372 }
373 }
374
375 fn insert_tab(&mut self) {
376 for _ in 0..TAB_WIDTH {
377 self.insert_char(' ');
378 }
379 }
380
381 fn current_line_len(&self) -> usize {
385 self.lines[self.cursor_row].chars().count()
386 }
387
388 fn cursor_byte_offset(&self) -> usize {
390 self.byte_offset_of_char(self.cursor_row, self.cursor_col)
391 }
392
393 fn byte_offset_of_char(&self, line_idx: usize, col: usize) -> usize {
395 self.lines[line_idx]
396 .char_indices()
397 .nth(col)
398 .map(|(i, _)| i)
399 .unwrap_or(self.lines[line_idx].len())
400 }
401
402 fn clamp_cursor_col(&mut self) {
404 let len = self.current_line_len();
405 if self.cursor_col > len {
406 self.cursor_col = len;
407 }
408 }
409
410 fn adjust_scroll_row(&mut self) {
412 if self.cursor_row < self.scroll_row {
413 self.scroll_row = self.cursor_row;
414 } else if self.cursor_row >= self.scroll_row + PAGE_SIZE {
415 self.scroll_row = self.cursor_row.saturating_sub(PAGE_SIZE - 1);
416 }
417 }
418
419 fn adjust_scroll_col(&mut self) {
421 if self.cursor_col < self.scroll_col {
422 self.scroll_col = self.cursor_col;
423 }
424 let visible_cols = 80usize;
427 if self.cursor_col >= self.scroll_col + visible_cols {
428 self.scroll_col = self.cursor_col.saturating_sub(visible_cols - 1);
429 }
430 }
431}
432
433pub fn render_inline_editor(frame: &mut Frame, area: Rect, editor: &InlineEditor, theme: &Theme) {
443 if area.height < 3 {
444 return; }
446
447 let header_area = Rect {
448 x: area.x,
449 y: area.y,
450 width: area.width,
451 height: 1,
452 };
453 let footer_area = Rect {
454 x: area.x,
455 y: area.y + area.height - 1,
456 width: area.width,
457 height: 1,
458 };
459 let content_area = Rect {
460 x: area.x,
461 y: area.y + 1,
462 width: area.width,
463 height: area.height.saturating_sub(2),
464 };
465
466 let file_name = editor
468 .path
469 .file_name()
470 .map(|n| n.to_string_lossy().to_string())
471 .unwrap_or_else(|| editor.path.display().to_string());
472
473 let mod_indicator = if editor.modified { " [modified]" } else { "" };
474 let header_text = format!("✏️ Editing: {file_name}{mod_indicator}");
475 let header = Paragraph::new(Line::from(vec![Span::styled(
476 header_text,
477 Style::default().fg(theme.brand).bold(),
478 )]));
479 frame.render_widget(header, header_area);
480
481 let visible_rows = content_area.height as usize;
483 let gutter_width: u16 = 5; let sep_width: u16 = 3; let text_start_col = content_area.x + gutter_width + sep_width;
486 let text_width = content_area.width.saturating_sub(gutter_width + sep_width) as usize;
487
488 for row_offset in 0..visible_rows {
489 let line_idx = editor.scroll_row + row_offset;
490 let y = content_area.y + row_offset as u16;
491
492 if line_idx >= editor.lines.len() {
493 let tilde = Paragraph::new(Line::from(Span::styled(
495 " ~",
496 Style::default().fg(theme.dim),
497 )));
498 frame.render_widget(
499 tilde,
500 Rect {
501 x: content_area.x,
502 y,
503 width: content_area.width,
504 height: 1,
505 },
506 );
507 continue;
508 }
509
510 let is_cursor_line = line_idx == editor.cursor_row;
511 let line_bg = if is_cursor_line {
512 theme.sel_bg
513 } else {
514 Color::Reset
515 };
516
517 let line_num = format!("{:>5}", line_idx + 1);
519 let gutter = Paragraph::new(Line::from(Span::styled(
520 line_num,
521 Style::default().fg(theme.accent).bg(line_bg),
522 )));
523 frame.render_widget(
524 gutter,
525 Rect {
526 x: content_area.x,
527 y,
528 width: gutter_width,
529 height: 1,
530 },
531 );
532
533 let sep = Paragraph::new(Line::from(Span::styled(
535 " │ ",
536 Style::default().fg(theme.dim).bg(line_bg),
537 )));
538 frame.render_widget(
539 sep,
540 Rect {
541 x: content_area.x + gutter_width,
542 y,
543 width: sep_width,
544 height: 1,
545 },
546 );
547
548 let line = &editor.lines[line_idx];
550 let chars: Vec<char> = line.chars().collect();
551 let visible_start = editor.scroll_col;
552
553 let mut spans: Vec<Span> = Vec::new();
554
555 if is_cursor_line {
556 for vi in 0..text_width {
558 let ci = visible_start + vi; if ci == editor.cursor_col {
560 if ci < chars.len() {
562 spans.push(Span::styled(
563 chars[ci].to_string(),
564 Style::default().fg(theme.brand).bg(theme.accent),
565 ));
566 } else {
567 spans.push(Span::styled(
568 "█",
569 Style::default().fg(theme.accent).bg(line_bg),
570 ));
571 if vi + 1 < text_width {
573 let pad = " ".repeat(text_width - vi - 1);
574 spans
575 .push(Span::styled(pad, Style::default().fg(theme.fg).bg(line_bg)));
576 }
577 break;
578 }
579 } else if ci < chars.len() {
580 spans.push(Span::styled(
581 chars[ci].to_string(),
582 Style::default().fg(theme.fg).bg(line_bg),
583 ));
584 } else {
585 let remaining = text_width - vi;
587 spans.push(Span::styled(
588 " ".repeat(remaining),
589 Style::default().fg(theme.fg).bg(line_bg),
590 ));
591 break;
592 }
593 }
594 } else {
597 let visible: String = chars.iter().skip(visible_start).take(text_width).collect();
599 spans.push(Span::styled(
600 visible,
601 Style::default().fg(theme.fg).bg(line_bg),
602 ));
603 }
604
605 let text_line = Paragraph::new(Line::from(spans));
606 frame.render_widget(
607 text_line,
608 Rect {
609 x: text_start_col,
610 y,
611 width: text_width as u16,
612 height: 1,
613 },
614 );
615 }
616
617 crate::render::paint_scrollbar(
619 frame,
620 content_area,
621 editor.lines.len(),
622 editor.scroll_row,
623 theme.accent,
624 );
625
626 let status_style = if editor.status.starts_with("save failed") {
628 Style::default().fg(theme.brand)
629 } else {
630 Style::default().fg(theme.success)
631 };
632
633 let right_info = format!(
634 "Ln {}, Col {} │ Ctrl+S save │ Esc exit",
635 editor.cursor_row + 1,
636 editor.cursor_col + 1,
637 );
638
639 let right_width = right_info.chars().count();
640 let left_width = area.width as usize - right_width.min(area.width as usize);
641
642 let status_display: String = if editor.status.len() > left_width {
643 editor.status.chars().take(left_width).collect()
644 } else {
645 let pad = left_width.saturating_sub(editor.status.chars().count());
646 format!("{}{}", editor.status, " ".repeat(pad))
647 };
648
649 let footer = Paragraph::new(Line::from(vec![
650 Span::styled(status_display, status_style),
651 Span::styled(right_info, Style::default().fg(theme.dim)),
652 ]));
653 frame.render_widget(footer, footer_area);
654}
655
656#[cfg(test)]
659mod tests {
660 use super::*;
661 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
662 use std::io::Write;
663 use tempfile::tempdir;
664
665 fn press(code: KeyCode) -> KeyEvent {
667 KeyEvent {
668 code,
669 modifiers: KeyModifiers::NONE,
670 kind: KeyEventKind::Press,
671 state: KeyEventState::NONE,
672 }
673 }
674
675 fn press_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
677 KeyEvent {
678 code,
679 modifiers,
680 kind: KeyEventKind::Press,
681 state: KeyEventState::NONE,
682 }
683 }
684
685 fn temp_file(content: &str) -> (tempfile::TempDir, PathBuf) {
687 let dir = tempdir().unwrap();
688 let path = dir.path().join("test.txt");
689 fs::write(&path, content).unwrap();
690 (dir, path)
691 }
692
693 #[test]
696 fn open_reads_file_content() {
697 let (_dir, path) = temp_file("hello\nworld");
698 let ed = InlineEditor::open(&path).unwrap();
699 assert_eq!(ed.lines(), &["hello", "world"]);
700 }
701
702 #[test]
703 fn open_empty_file_has_one_line() {
704 let (_dir, path) = temp_file("");
705 let ed = InlineEditor::open(&path).unwrap();
706 assert_eq!(ed.lines(), &[""]);
707 assert_eq!(ed.line_count(), 1);
708 }
709
710 #[test]
711 fn open_nonexistent_file_returns_error() {
712 let dir = tempdir().unwrap();
713 let path = dir.path().join("nope.txt");
714 assert!(InlineEditor::open(&path).is_err());
715 }
716
717 #[test]
718 fn open_too_large_file_returns_error() {
719 let dir = tempdir().unwrap();
720 let path = dir.path().join("big.txt");
721 let mut f = fs::File::create(&path).unwrap();
723 let chunk = vec![b'A'; 1024];
724 for _ in 0..(MAX_EDIT_FILE_SIZE / 1024 + 1) {
725 f.write_all(&chunk).unwrap();
726 }
727 drop(f);
728 assert!(InlineEditor::open(&path).is_err());
729 }
730
731 #[test]
734 fn line_count_matches_file_lines() {
735 let (_dir, path) = temp_file("a\nb\nc");
736 let ed = InlineEditor::open(&path).unwrap();
737 assert_eq!(ed.line_count(), 3);
738 }
739
740 #[test]
741 fn is_modified_false_initially() {
742 let (_dir, path) = temp_file("hello");
743 let ed = InlineEditor::open(&path).unwrap();
744 assert!(!ed.is_modified());
745 }
746
747 #[test]
748 fn path_returns_opened_path() {
749 let (_dir, path) = temp_file("hello");
750 let ed = InlineEditor::open(&path).unwrap();
751 assert_eq!(ed.path(), path);
752 }
753
754 #[test]
755 fn status_empty_initially() {
756 let (_dir, path) = temp_file("hello");
757 let ed = InlineEditor::open(&path).unwrap();
758 assert!(ed.status().is_empty());
759 }
760
761 #[test]
764 fn move_down_increments_row() {
765 let (_dir, path) = temp_file("a\nb\nc");
766 let mut ed = InlineEditor::open(&path).unwrap();
767 ed.handle_key(press(KeyCode::Down));
768 assert_eq!(ed.cursor_row(), 1);
769 }
770
771 #[test]
772 fn move_up_decrements_row() {
773 let (_dir, path) = temp_file("a\nb\nc");
774 let mut ed = InlineEditor::open(&path).unwrap();
775 ed.handle_key(press(KeyCode::Down));
776 ed.handle_key(press(KeyCode::Down));
777 ed.handle_key(press(KeyCode::Up));
778 assert_eq!(ed.cursor_row(), 1);
779 }
780
781 #[test]
782 fn move_up_at_top_stays() {
783 let (_dir, path) = temp_file("a\nb");
784 let mut ed = InlineEditor::open(&path).unwrap();
785 ed.handle_key(press(KeyCode::Up));
786 assert_eq!(ed.cursor_row(), 0);
787 }
788
789 #[test]
790 fn move_down_at_bottom_stays() {
791 let (_dir, path) = temp_file("a\nb");
792 let mut ed = InlineEditor::open(&path).unwrap();
793 ed.handle_key(press(KeyCode::Down));
794 ed.handle_key(press(KeyCode::Down)); assert_eq!(ed.cursor_row(), 1);
796 }
797
798 #[test]
799 fn move_right_increments_col() {
800 let (_dir, path) = temp_file("abc");
801 let mut ed = InlineEditor::open(&path).unwrap();
802 ed.handle_key(press(KeyCode::Right));
803 assert_eq!(ed.cursor_col(), 1);
804 }
805
806 #[test]
807 fn move_left_decrements_col() {
808 let (_dir, path) = temp_file("abc");
809 let mut ed = InlineEditor::open(&path).unwrap();
810 ed.handle_key(press(KeyCode::Right));
811 ed.handle_key(press(KeyCode::Right));
812 ed.handle_key(press(KeyCode::Left));
813 assert_eq!(ed.cursor_col(), 1);
814 }
815
816 #[test]
817 fn move_left_at_col_zero_goes_to_prev_line_end() {
818 let (_dir, path) = temp_file("abc\nde");
819 let mut ed = InlineEditor::open(&path).unwrap();
820 ed.handle_key(press(KeyCode::Down)); ed.handle_key(press(KeyCode::Left)); assert_eq!(ed.cursor_row(), 0);
823 assert_eq!(ed.cursor_col(), 3);
824 }
825
826 #[test]
827 fn move_right_at_line_end_goes_to_next_line_start() {
828 let (_dir, path) = temp_file("ab\ncd");
829 let mut ed = InlineEditor::open(&path).unwrap();
830 ed.handle_key(press(KeyCode::End));
832 assert_eq!(ed.cursor_col(), 2);
833 ed.handle_key(press(KeyCode::Right));
835 assert_eq!(ed.cursor_row(), 1);
836 assert_eq!(ed.cursor_col(), 0);
837 }
838
839 #[test]
840 fn cursor_col_clamped_on_vertical_move() {
841 let (_dir, path) = temp_file("abcdef\nab");
842 let mut ed = InlineEditor::open(&path).unwrap();
843 for _ in 0..5 {
845 ed.handle_key(press(KeyCode::Right));
846 }
847 assert_eq!(ed.cursor_col(), 5);
848 ed.handle_key(press(KeyCode::Down));
850 assert_eq!(ed.cursor_col(), 2);
851 }
852
853 #[test]
854 fn home_moves_to_col_zero() {
855 let (_dir, path) = temp_file("hello world");
856 let mut ed = InlineEditor::open(&path).unwrap();
857 ed.handle_key(press(KeyCode::End));
858 assert!(ed.cursor_col() > 0);
859 ed.handle_key(press(KeyCode::Home));
860 assert_eq!(ed.cursor_col(), 0);
861 }
862
863 #[test]
864 fn end_moves_to_line_end() {
865 let (_dir, path) = temp_file("hello");
866 let mut ed = InlineEditor::open(&path).unwrap();
867 ed.handle_key(press(KeyCode::End));
868 assert_eq!(ed.cursor_col(), 5);
869 }
870
871 #[test]
874 fn insert_char_at_cursor() {
875 let (_dir, path) = temp_file("ac");
876 let mut ed = InlineEditor::open(&path).unwrap();
877 ed.handle_key(press(KeyCode::Right)); ed.handle_key(press(KeyCode::Char('b')));
879 assert_eq!(ed.lines()[0], "abc");
880 assert_eq!(ed.cursor_col(), 2);
881 }
882
883 #[test]
884 fn insert_char_sets_modified() {
885 let (_dir, path) = temp_file("x");
886 let mut ed = InlineEditor::open(&path).unwrap();
887 assert!(!ed.is_modified());
888 ed.handle_key(press(KeyCode::Char('y')));
889 assert!(ed.is_modified());
890 }
891
892 #[test]
893 fn backspace_deletes_char() {
894 let (_dir, path) = temp_file("abc");
895 let mut ed = InlineEditor::open(&path).unwrap();
896 ed.handle_key(press(KeyCode::End)); ed.handle_key(press(KeyCode::Backspace));
898 assert_eq!(ed.lines()[0], "ab");
899 assert_eq!(ed.cursor_col(), 2);
900 }
901
902 #[test]
903 fn backspace_at_line_start_joins_lines() {
904 let (_dir, path) = temp_file("ab\ncd");
905 let mut ed = InlineEditor::open(&path).unwrap();
906 ed.handle_key(press(KeyCode::Down)); ed.handle_key(press(KeyCode::Backspace));
908 assert_eq!(ed.line_count(), 1);
909 assert_eq!(ed.lines()[0], "abcd");
910 assert_eq!(ed.cursor_row(), 0);
911 assert_eq!(ed.cursor_col(), 2); }
913
914 #[test]
915 fn delete_removes_char_at_cursor() {
916 let (_dir, path) = temp_file("abc");
917 let mut ed = InlineEditor::open(&path).unwrap();
918 ed.handle_key(press(KeyCode::Delete));
920 assert_eq!(ed.lines()[0], "bc");
921 assert_eq!(ed.cursor_col(), 0);
922 }
923
924 #[test]
925 fn delete_at_line_end_joins_with_next() {
926 let (_dir, path) = temp_file("ab\ncd");
927 let mut ed = InlineEditor::open(&path).unwrap();
928 ed.handle_key(press(KeyCode::End)); ed.handle_key(press(KeyCode::Delete));
930 assert_eq!(ed.line_count(), 1);
931 assert_eq!(ed.lines()[0], "abcd");
932 }
933
934 #[test]
935 fn enter_splits_line() {
936 let (_dir, path) = temp_file("abcd");
937 let mut ed = InlineEditor::open(&path).unwrap();
938 ed.handle_key(press(KeyCode::Right));
940 ed.handle_key(press(KeyCode::Right));
941 ed.handle_key(press(KeyCode::Enter));
942 assert_eq!(ed.line_count(), 2);
943 assert_eq!(ed.lines()[0], "ab");
944 assert_eq!(ed.lines()[1], "cd");
945 assert_eq!(ed.cursor_row(), 1);
946 assert_eq!(ed.cursor_col(), 0);
947 }
948
949 #[test]
950 fn tab_inserts_spaces() {
951 let (_dir, path) = temp_file("x");
952 let mut ed = InlineEditor::open(&path).unwrap();
953 ed.handle_key(press(KeyCode::Tab));
954 assert_eq!(ed.lines()[0], " x");
955 assert_eq!(ed.cursor_col(), TAB_WIDTH);
956 }
957
958 #[test]
961 fn save_writes_to_disk() {
962 let (_dir, path) = temp_file("original");
963 let mut ed = InlineEditor::open(&path).unwrap();
964 ed.handle_key(press(KeyCode::End));
965 ed.handle_key(press(KeyCode::Char('!')));
966 ed.save().unwrap();
967 let on_disk = fs::read_to_string(&path).unwrap();
968 assert_eq!(on_disk, "original!");
969 }
970
971 #[test]
972 fn save_clears_modified_flag() {
973 let (_dir, path) = temp_file("hi");
974 let mut ed = InlineEditor::open(&path).unwrap();
975 ed.handle_key(press(KeyCode::Char('x')));
976 assert!(ed.is_modified());
977 ed.save().unwrap();
978 assert!(!ed.is_modified());
979 }
980
981 #[test]
984 fn esc_returns_exit() {
985 let (_dir, path) = temp_file("x");
986 let mut ed = InlineEditor::open(&path).unwrap();
987 assert_eq!(ed.handle_key(press(KeyCode::Esc)), EditorAction::Exit);
988 }
989
990 #[test]
991 fn ctrl_s_saves_and_returns_saved() {
992 let (_dir, path) = temp_file("x");
993 let mut ed = InlineEditor::open(&path).unwrap();
994 let action = ed.handle_key(press_mod(KeyCode::Char('s'), KeyModifiers::CONTROL));
995 assert_eq!(action, EditorAction::Saved);
996 }
997
998 #[test]
999 fn regular_char_returns_continue() {
1000 let (_dir, path) = temp_file("x");
1001 let mut ed = InlineEditor::open(&path).unwrap();
1002 let action = ed.handle_key(press(KeyCode::Char('a')));
1003 assert_eq!(action, EditorAction::Continue);
1004 }
1005
1006 #[test]
1009 fn scroll_keeps_cursor_visible() {
1010 let content: String = (0..40)
1012 .map(|i| format!("line {i}"))
1013 .collect::<Vec<_>>()
1014 .join("\n");
1015 let (_dir, path) = temp_file(&content);
1016 let mut ed = InlineEditor::open(&path).unwrap();
1017 for _ in 0..25 {
1019 ed.handle_key(press(KeyCode::Down));
1020 }
1021 assert_eq!(ed.cursor_row(), 25);
1022 assert!(ed.scroll_row() <= ed.cursor_row());
1024 assert!(ed.cursor_row() < ed.scroll_row() + PAGE_SIZE);
1025 }
1026
1027 #[test]
1028 fn page_down_advances_scroll() {
1029 let content: String = (0..60)
1030 .map(|i| format!("line {i}"))
1031 .collect::<Vec<_>>()
1032 .join("\n");
1033 let (_dir, path) = temp_file(&content);
1034 let mut ed = InlineEditor::open(&path).unwrap();
1035 ed.handle_key(press(KeyCode::PageDown));
1036 assert_eq!(ed.cursor_row(), PAGE_SIZE);
1037 assert!(ed.scroll_row() <= ed.cursor_row());
1038 }
1039
1040 #[test]
1041 fn page_up_retreats_scroll() {
1042 let content: String = (0..60)
1043 .map(|i| format!("line {i}"))
1044 .collect::<Vec<_>>()
1045 .join("\n");
1046 let (_dir, path) = temp_file(&content);
1047 let mut ed = InlineEditor::open(&path).unwrap();
1048 ed.handle_key(press(KeyCode::PageDown));
1050 ed.handle_key(press(KeyCode::PageDown));
1051 let row_before = ed.cursor_row();
1052 ed.handle_key(press(KeyCode::PageUp));
1053 assert_eq!(ed.cursor_row(), row_before - PAGE_SIZE);
1054 }
1055
1056 #[test]
1059 fn release_event_is_ignored() {
1060 let (_dir, path) = temp_file("x");
1061 let mut ed = InlineEditor::open(&path).unwrap();
1062 let release = KeyEvent {
1063 code: KeyCode::Char('a'),
1064 modifiers: KeyModifiers::NONE,
1065 kind: KeyEventKind::Release,
1066 state: KeyEventState::NONE,
1067 };
1068 let action = ed.handle_key(release);
1069 assert_eq!(action, EditorAction::Continue);
1070 assert_eq!(ed.lines()[0], "x");
1072 }
1073
1074 #[test]
1077 fn insert_and_delete_multibyte_chars() {
1078 let (_dir, path) = temp_file("aé");
1079 let mut ed = InlineEditor::open(&path).unwrap();
1080 assert_eq!(ed.lines()[0].chars().count(), 2);
1081
1082 ed.handle_key(press(KeyCode::Right));
1084 ed.handle_key(press(KeyCode::Char('→')));
1085 assert_eq!(ed.lines()[0], "a→é");
1086 assert_eq!(ed.cursor_col(), 2);
1087
1088 ed.handle_key(press(KeyCode::Backspace));
1090 assert_eq!(ed.lines()[0], "aé");
1091 assert_eq!(ed.cursor_col(), 1);
1092 }
1093
1094 #[test]
1097 fn crlf_line_endings_are_handled() {
1098 let dir = tempdir().unwrap();
1099 let path = dir.path().join("crlf.txt");
1100 fs::write(&path, "line1\r\nline2\r\n").unwrap();
1101 let ed = InlineEditor::open(&path).unwrap();
1102 assert_eq!(ed.lines(), &["line1", "line2"]);
1105 }
1106}