Skip to main content

tui_file_explorer/
inline_editor.rs

1//! Inline text editor for the TUI file explorer.
2//!
3//! Provides a minimal, modal-free text editor that can open, display, edit, and
4//! save plain-text files directly inside the terminal UI.  Only files up to
5//! [`MAX_EDIT_FILE_SIZE`] bytes are accepted — anything larger is rejected to
6//! avoid freezing the terminal.
7//!
8//! ## Key bindings
9//!
10//! | Key           | Action                                       |
11//! |---------------|----------------------------------------------|
12//! | Arrow keys    | Move cursor                                  |
13//! | Home / End    | Jump to beginning / end of line               |
14//! | PgUp / PgDn   | Scroll one page (20 lines)                   |
15//! | Printable     | Insert character at cursor                   |
16//! | Enter         | Split line at cursor                         |
17//! | Backspace     | Delete char before cursor / join lines        |
18//! | Delete        | Delete char at cursor / join lines            |
19//! | Tab           | Insert 4 spaces                              |
20//! | Ctrl+S        | Save to disk                                 |
21//! | Esc           | Exit the editor                              |
22
23use 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
36// ── Constants ─────────────────────────────────────────────────────────────────
37
38/// Maximum file size the editor will open (10 MiB).
39const MAX_EDIT_FILE_SIZE: u64 = 10 * 1024 * 1024;
40
41/// Number of spaces inserted for a Tab key press.
42const TAB_WIDTH: usize = 4;
43
44/// Number of lines scrolled per PgUp / PgDn key press.
45const PAGE_SIZE: usize = 20;
46
47// ── EditorAction ──────────────────────────────────────────────────────────────
48
49/// Result of handling a key event in the inline editor.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum EditorAction {
52    /// Key was consumed, continue editing.
53    Continue,
54    /// File was saved successfully.
55    Saved,
56    /// User wants to exit the editor.
57    Exit,
58}
59
60// ── InlineEditor ──────────────────────────────────────────────────────────────
61
62/// A simple, single-file text editor that lives inside the TUI.
63pub struct InlineEditor {
64    /// Lines of the file being edited.
65    lines: Vec<String>,
66    /// Current cursor row (0-based line index).
67    cursor_row: usize,
68    /// Current cursor column (0-based character index).
69    cursor_col: usize,
70    /// Vertical scroll offset (first visible line).
71    scroll_row: usize,
72    /// Horizontal scroll offset.
73    scroll_col: usize,
74    /// Path to the file being edited.
75    path: PathBuf,
76    /// Whether the buffer has been modified since last save.
77    modified: bool,
78    /// Status message shown at the bottom.
79    status: String,
80}
81
82impl InlineEditor {
83    /// Open a file for editing.
84    ///
85    /// Returns `Err` if the file does not exist, cannot be read, or exceeds
86    /// [`MAX_EDIT_FILE_SIZE`].
87    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    // ── Key handling ──────────────────────────────────────────────────────
119
120    /// Handle a key event. Returns the [`EditorAction`] to take.
121    pub fn handle_key(&mut self, key: KeyEvent) -> EditorAction {
122        // Only react to key-press events.
123        if key.kind != KeyEventKind::Press {
124            return EditorAction::Continue;
125        }
126
127        match (key.modifiers, key.code) {
128            // ── Save ──────────────────────────────────────────────────
129            (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            // ── Exit ──────────────────────────────────────────────────
141            (_, KeyCode::Esc) => EditorAction::Exit,
142
143            // ── Navigation ────────────────────────────────────────────
144            (_, 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            // ── Editing ───────────────────────────────────────────────
180            (_, 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            // ── Everything else ───────────────────────────────────────
204            _ => EditorAction::Continue,
205        }
206    }
207
208    // ── Persistence ───────────────────────────────────────────────────────
209
210    /// Save the current buffer to disk.
211    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    // ── Getters ───────────────────────────────────────────────────────────
219
220    /// Number of lines in the buffer.
221    pub fn line_count(&self) -> usize {
222        self.lines.len()
223    }
224
225    /// Whether the buffer has been modified since the last save.
226    pub fn is_modified(&self) -> bool {
227        self.modified
228    }
229
230    /// Path to the file being edited.
231    pub fn path(&self) -> &Path {
232        &self.path
233    }
234
235    /// Current status message.
236    pub fn status(&self) -> &str {
237        &self.status
238    }
239
240    /// Current cursor row (0-based).
241    pub fn cursor_row(&self) -> usize {
242        self.cursor_row
243    }
244
245    /// Current cursor column (0-based).
246    pub fn cursor_col(&self) -> usize {
247        self.cursor_col
248    }
249
250    /// Slice of all lines in the buffer.
251    pub fn lines(&self) -> &[String] {
252        &self.lines
253    }
254
255    /// Vertical scroll offset (first visible line).
256    pub fn scroll_row(&self) -> usize {
257        self.scroll_row
258    }
259
260    /// Horizontal scroll offset.
261    pub fn scroll_col(&self) -> usize {
262        self.scroll_col
263    }
264
265    // ── Movement helpers (private) ────────────────────────────────────────
266
267    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    // ── Editing helpers (private) ─────────────────────────────────────────
322
323    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    // ── Internal utilities ────────────────────────────────────────────────
382
383    /// Character-length of the current line.
384    fn current_line_len(&self) -> usize {
385        self.lines[self.cursor_row].chars().count()
386    }
387
388    /// Byte offset corresponding to `self.cursor_col` in the current line.
389    fn cursor_byte_offset(&self) -> usize {
390        self.byte_offset_of_char(self.cursor_row, self.cursor_col)
391    }
392
393    /// Byte offset of the `col`-th character in `line_idx`.
394    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    /// Clamp `cursor_col` to the length of the current line.
403    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    /// Ensure `scroll_row` keeps the cursor visible within `PAGE_SIZE` lines.
411    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    /// Ensure `scroll_col` keeps the cursor visible.
420    fn adjust_scroll_col(&mut self) {
421        if self.cursor_col < self.scroll_col {
422            self.scroll_col = self.cursor_col;
423        }
424        // We use a generous window — the render function uses the actual
425        // available width, but here we clamp to a sane default.
426        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
433// ── Rendering ─────────────────────────────────────────────────────────────────
434
435/// Render the inline editor into the given area.
436///
437/// Layout (top to bottom):
438///
439/// 1. **Header** — 1 line: `✏️  Editing: <filename> [modified]`
440/// 2. **Content** — remaining lines minus footer: line numbers + text
441/// 3. **Footer** — 1 line: status message + cursor position + key hints
442pub fn render_inline_editor(frame: &mut Frame, area: Rect, editor: &InlineEditor, theme: &Theme) {
443    if area.height < 3 {
444        return; // not enough room for header + 1 content line + footer
445    }
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    // ── Header ────────────────────────────────────────────────────────────
467    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    // ── Content ───────────────────────────────────────────────────────────
482    let visible_rows = content_area.height as usize;
483    let gutter_width: u16 = 5; // "NNNNN"
484    let sep_width: u16 = 3; // " │ "
485    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            // Past end of file — render a tilde like vi
494            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        // ── Line number ───────────────────────────────────────────────
518        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        // ── Separator ─────────────────────────────────────────────────
534        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        // ── Line text with cursor ─────────────────────────────────────
549        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            // Build char-by-char so we can highlight the cursor position.
557            for vi in 0..text_width {
558                let ci = visible_start + vi; // character index in line
559                if ci == editor.cursor_col {
560                    // Cursor: render a block character in accent colour.
561                    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                        // pad the rest if needed
572                        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                    // Past end of line — fill remaining with bg.
586                    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            // Edge case: cursor is exactly at visible_start + 0 and text_width
595            // is 0 — nothing to render.
596        } else {
597            // Non-cursor line: render the visible portion in one go.
598            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    // ── Scroll thumb ─────────────────────────────────────────────────────────────
618    crate::render::paint_scrollbar(
619        frame,
620        content_area,
621        editor.lines.len(),
622        editor.scroll_row,
623        theme.accent,
624    );
625
626    // ── Footer ───────────────────────────────────────────────────────────────────
627    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// ── Tests ─────────────────────────────────────────────────────────────────────
657
658#[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    /// Helper: create a press `KeyEvent`.
666    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    /// Helper: create a press `KeyEvent` with modifiers.
676    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    /// Helper: write `content` into a temp file and return (`dir`, `path`).
686    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    // ── Construction ──────────────────────────────────────────────────────
694
695    #[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        // Create a file just over the limit.
722        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    // ── Getters ───────────────────────────────────────────────────────────
732
733    #[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    // ── Cursor movement ───────────────────────────────────────────────────
762
763    #[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)); // already at last line
795        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)); // row 1, col 0
821        ed.handle_key(press(KeyCode::Left)); // should go to row 0, col 3
822        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        // Move to end of first line
831        ed.handle_key(press(KeyCode::End));
832        assert_eq!(ed.cursor_col(), 2);
833        // Move right should go to row 1, col 0
834        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        // Move to col 5 on the first line
844        for _ in 0..5 {
845            ed.handle_key(press(KeyCode::Right));
846        }
847        assert_eq!(ed.cursor_col(), 5);
848        // Move down to shorter line — col should clamp to 2
849        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    // ── Text editing ──────────────────────────────────────────────────────
872
873    #[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)); // after 'a'
878        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)); // col 3
897        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)); // row 1, col 0
907        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); // end of "ab"
912    }
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        // cursor at col 0 — delete 'a'
919        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)); // col 2
929        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        // Move to col 2 then press Enter
939        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    // ── Save ──────────────────────────────────────────────────────────────
959
960    #[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    // ── Key handling — action types ───────────────────────────────────────
982
983    #[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    // ── Scroll ────────────────────────────────────────────────────────────
1007
1008    #[test]
1009    fn scroll_keeps_cursor_visible() {
1010        // Create a file with 40 lines — more than one page (PAGE_SIZE = 20).
1011        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        // Move cursor down past the page boundary.
1018        for _ in 0..25 {
1019            ed.handle_key(press(KeyCode::Down));
1020        }
1021        assert_eq!(ed.cursor_row(), 25);
1022        // scroll_row should have adjusted so cursor is visible.
1023        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        // Go down two pages then up one.
1049        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    // ── Release / Repeat events are ignored ───────────────────────────────
1057
1058    #[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        // Nothing should have been inserted.
1071        assert_eq!(ed.lines()[0], "x");
1072    }
1073
1074    // ── UTF-8 safety ──────────────────────────────────────────────────────
1075
1076    #[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        // Move to col 1 (between 'a' and 'é'), insert '→'
1083        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        // Backspace should remove '→'
1089        ed.handle_key(press(KeyCode::Backspace));
1090        assert_eq!(ed.lines()[0], "aé");
1091        assert_eq!(ed.cursor_col(), 1);
1092    }
1093
1094    // ── CRLF handling ─────────────────────────────────────────────────────
1095
1096    #[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        // `str::lines()` strips the trailing empty line produced by a
1103        // trailing line-ending, so we only get two lines here.
1104        assert_eq!(ed.lines(), &["line1", "line2"]);
1105    }
1106}