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, KeyModifiers};
28use ratatui::Frame;
29use ratatui::layout::Rect;
30use ratatui::style::{Color, Style};
31use ratatui::text::{Line, Span};
32use ratatui::widgets::Paragraph;
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.
63#[derive(Debug)]
64pub struct InlineEditor {
65    /// Lines of the file being edited.
66    lines: Vec<String>,
67    /// Current cursor row (0-based line index).
68    cursor_row: usize,
69    /// Current cursor column (0-based character index).
70    cursor_col: usize,
71    /// Vertical scroll offset (first visible line).
72    scroll_row: usize,
73    /// Horizontal scroll offset.
74    scroll_col: usize,
75    /// Path to the file being edited.
76    path: PathBuf,
77    /// Whether the buffer has been modified since last save.
78    modified: bool,
79    /// Status message shown at the bottom.
80    status: String,
81}
82
83impl InlineEditor {
84    /// Open a file for editing.
85    ///
86    /// Returns `Err` if the file does not exist, cannot be read, or exceeds
87    /// `MAX_EDIT_FILE_SIZE`.
88    pub fn open(path: &Path) -> io::Result<Self> {
89        let meta = fs::metadata(path)?;
90        if meta.len() > MAX_EDIT_FILE_SIZE {
91            return Err(io::Error::new(
92                io::ErrorKind::InvalidData,
93                format!(
94                    "file is too large ({} bytes, max {})",
95                    meta.len(),
96                    MAX_EDIT_FILE_SIZE
97                ),
98            ));
99        }
100
101        let content = fs::read_to_string(path)?;
102        let mut lines: Vec<String> = content.lines().map(String::from).collect();
103        if lines.is_empty() {
104            lines.push(String::new());
105        }
106
107        Ok(Self {
108            lines,
109            cursor_row: 0,
110            cursor_col: 0,
111            scroll_row: 0,
112            scroll_col: 0,
113            path: path.to_path_buf(),
114            modified: false,
115            status: String::new(),
116        })
117    }
118
119    // ── Key handling ──────────────────────────────────────────────────────
120
121    /// Handle a key event. Returns the [`EditorAction`] to take.
122    pub fn handle_key(&mut self, key: KeyEvent) -> EditorAction {
123        press_only!(key, EditorAction::Continue);
124
125        match (key.modifiers, key.code) {
126            // ── Save ──────────────────────────────────────────────────
127            (KeyModifiers::CONTROL, KeyCode::Char('s')) => match self.save() {
128                Ok(()) => {
129                    self.status = "saved".into();
130                    EditorAction::Saved
131                }
132                Err(e) => {
133                    self.status = format!("save failed: {e}");
134                    EditorAction::Continue
135                }
136            },
137
138            // ── Exit ──────────────────────────────────────────────────
139            (_, KeyCode::Esc) => EditorAction::Exit,
140
141            // ── Navigation ────────────────────────────────────────────
142            (_, KeyCode::Up) => {
143                self.move_up();
144                EditorAction::Continue
145            }
146            (_, KeyCode::Down) => {
147                self.move_down();
148                EditorAction::Continue
149            }
150            (_, KeyCode::Left) => {
151                self.move_left();
152                EditorAction::Continue
153            }
154            (_, KeyCode::Right) => {
155                self.move_right();
156                EditorAction::Continue
157            }
158            (_, KeyCode::Home) => {
159                self.cursor_col = 0;
160                self.adjust_scroll_col();
161                EditorAction::Continue
162            }
163            (_, KeyCode::End) => {
164                self.cursor_col = self.current_line_len();
165                self.adjust_scroll_col();
166                EditorAction::Continue
167            }
168            (_, KeyCode::PageUp) => {
169                self.page_up();
170                EditorAction::Continue
171            }
172            (_, KeyCode::PageDown) => {
173                self.page_down();
174                EditorAction::Continue
175            }
176
177            // ── Editing ───────────────────────────────────────────────
178            (_, KeyCode::Char(c))
179                if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
180            {
181                self.insert_char(c);
182                EditorAction::Continue
183            }
184            (_, KeyCode::Enter) => {
185                self.insert_newline();
186                EditorAction::Continue
187            }
188            (_, KeyCode::Backspace) => {
189                self.backspace();
190                EditorAction::Continue
191            }
192            (_, KeyCode::Delete) => {
193                self.delete();
194                EditorAction::Continue
195            }
196            (_, KeyCode::Tab) => {
197                self.insert_tab();
198                EditorAction::Continue
199            }
200
201            // ── Everything else ───────────────────────────────────────
202            _ => EditorAction::Continue,
203        }
204    }
205
206    // ── Persistence ───────────────────────────────────────────────────────
207
208    /// Save the current buffer to disk.
209    pub fn save(&mut self) -> io::Result<()> {
210        let content = self.lines.join("\n");
211        fs::write(&self.path, &content)?;
212        self.modified = false;
213        Ok(())
214    }
215
216    // ── Getters ───────────────────────────────────────────────────────────
217
218    /// Number of lines in the buffer.
219    #[must_use]
220    pub fn line_count(&self) -> usize {
221        self.lines.len()
222    }
223
224    /// Whether the buffer has been modified since the last save.
225    #[must_use]
226    pub fn is_modified(&self) -> bool {
227        self.modified
228    }
229
230    /// Path to the file being edited.
231    #[must_use]
232    pub fn path(&self) -> &Path {
233        &self.path
234    }
235
236    /// Current status message.
237    #[must_use]
238    pub fn status(&self) -> &str {
239        &self.status
240    }
241
242    /// Current cursor row (0-based).
243    #[must_use]
244    pub fn cursor_row(&self) -> usize {
245        self.cursor_row
246    }
247
248    /// Current cursor column (0-based).
249    #[must_use]
250    pub fn cursor_col(&self) -> usize {
251        self.cursor_col
252    }
253
254    /// Slice of all lines in the buffer.
255    #[must_use]
256    pub fn lines(&self) -> &[String] {
257        &self.lines
258    }
259
260    /// Vertical scroll offset (first visible line).
261    #[must_use]
262    pub fn scroll_row(&self) -> usize {
263        self.scroll_row
264    }
265
266    /// Horizontal scroll offset.
267    #[must_use]
268    pub fn scroll_col(&self) -> usize {
269        self.scroll_col
270    }
271
272    // ── Movement helpers (private) ────────────────────────────────────────
273
274    fn move_up(&mut self) {
275        if self.cursor_row > 0 {
276            self.cursor_row -= 1;
277            self.clamp_cursor_col();
278            self.adjust_scroll_row();
279        }
280    }
281
282    fn move_down(&mut self) {
283        if self.cursor_row + 1 < self.lines.len() {
284            self.cursor_row += 1;
285            self.clamp_cursor_col();
286            self.adjust_scroll_row();
287        }
288    }
289
290    fn move_left(&mut self) {
291        if self.cursor_col > 0 {
292            self.cursor_col -= 1;
293        } else if self.cursor_row > 0 {
294            self.cursor_row -= 1;
295            self.cursor_col = self.current_line_len();
296        }
297        self.adjust_scroll_row();
298        self.adjust_scroll_col();
299    }
300
301    fn move_right(&mut self) {
302        let len = self.current_line_len();
303        if self.cursor_col < len {
304            self.cursor_col += 1;
305        } else if self.cursor_row + 1 < self.lines.len() {
306            self.cursor_row += 1;
307            self.cursor_col = 0;
308        }
309        self.adjust_scroll_row();
310        self.adjust_scroll_col();
311    }
312
313    fn page_up(&mut self) {
314        self.cursor_row = self.cursor_row.saturating_sub(PAGE_SIZE);
315        self.scroll_row = self.scroll_row.saturating_sub(PAGE_SIZE);
316        self.clamp_cursor_col();
317        self.adjust_scroll_row();
318    }
319
320    fn page_down(&mut self) {
321        let max_row = self.lines.len().saturating_sub(1);
322        self.cursor_row = (self.cursor_row + PAGE_SIZE).min(max_row);
323        self.scroll_row = (self.scroll_row + PAGE_SIZE).min(max_row);
324        self.clamp_cursor_col();
325        self.adjust_scroll_row();
326    }
327
328    // ── Editing helpers (private) ─────────────────────────────────────────
329
330    fn insert_char(&mut self, c: char) {
331        let byte_idx = self.cursor_byte_offset();
332        self.lines[self.cursor_row].insert(byte_idx, c);
333        self.cursor_col += 1;
334        self.modified = true;
335        self.adjust_scroll_col();
336    }
337
338    fn insert_newline(&mut self) {
339        let byte_idx = self.cursor_byte_offset();
340        let tail = self.lines[self.cursor_row][byte_idx..].to_string();
341        self.lines[self.cursor_row].truncate(byte_idx);
342        self.cursor_row += 1;
343        self.cursor_col = 0;
344        self.lines.insert(self.cursor_row, tail);
345        self.modified = true;
346        self.adjust_scroll_row();
347        self.adjust_scroll_col();
348    }
349
350    fn backspace(&mut self) {
351        if self.cursor_col > 0 {
352            let byte_start = self.byte_offset_of_char(self.cursor_row, self.cursor_col - 1);
353            let byte_end = self.byte_offset_of_char(self.cursor_row, self.cursor_col);
354            self.lines[self.cursor_row].replace_range(byte_start..byte_end, "");
355            self.cursor_col -= 1;
356            self.modified = true;
357        } else if self.cursor_row > 0 {
358            let removed = self.lines.remove(self.cursor_row);
359            self.cursor_row -= 1;
360            self.cursor_col = self.current_line_len();
361            self.lines[self.cursor_row].push_str(&removed);
362            self.modified = true;
363        }
364        self.adjust_scroll_row();
365        self.adjust_scroll_col();
366    }
367
368    fn delete(&mut self) {
369        let len = self.current_line_len();
370        if self.cursor_col < len {
371            let byte_start = self.cursor_byte_offset();
372            let byte_end = self.byte_offset_of_char(self.cursor_row, self.cursor_col + 1);
373            self.lines[self.cursor_row].replace_range(byte_start..byte_end, "");
374            self.modified = true;
375        } else if self.cursor_row + 1 < self.lines.len() {
376            let next = self.lines.remove(self.cursor_row + 1);
377            self.lines[self.cursor_row].push_str(&next);
378            self.modified = true;
379        }
380    }
381
382    fn insert_tab(&mut self) {
383        for _ in 0..TAB_WIDTH {
384            self.insert_char(' ');
385        }
386    }
387
388    // ── Internal utilities ────────────────────────────────────────────────
389
390    /// Character-length of the current line.
391    fn current_line_len(&self) -> usize {
392        self.lines[self.cursor_row].chars().count()
393    }
394
395    /// Byte offset corresponding to `self.cursor_col` in the current line.
396    fn cursor_byte_offset(&self) -> usize {
397        self.byte_offset_of_char(self.cursor_row, self.cursor_col)
398    }
399
400    /// Byte offset of the `col`-th character in `line_idx`.
401    fn byte_offset_of_char(&self, line_idx: usize, col: usize) -> usize {
402        self.lines[line_idx]
403            .char_indices()
404            .nth(col)
405            .map_or(self.lines[line_idx].len(), |(i, _)| i)
406    }
407
408    /// Clamp `cursor_col` to the length of the current line.
409    fn clamp_cursor_col(&mut self) {
410        let len = self.current_line_len();
411        if self.cursor_col > len {
412            self.cursor_col = len;
413        }
414    }
415
416    /// Ensure `scroll_row` keeps the cursor visible within `PAGE_SIZE` lines.
417    fn adjust_scroll_row(&mut self) {
418        if self.cursor_row < self.scroll_row {
419            self.scroll_row = self.cursor_row;
420        } else if self.cursor_row >= self.scroll_row + PAGE_SIZE {
421            self.scroll_row = self.cursor_row.saturating_sub(PAGE_SIZE - 1);
422        }
423    }
424
425    /// Ensure `scroll_col` keeps the cursor visible.
426    fn adjust_scroll_col(&mut self) {
427        if self.cursor_col < self.scroll_col {
428            self.scroll_col = self.cursor_col;
429        }
430        // We use a generous window — the render function uses the actual
431        // available width, but here we clamp to a sane default.
432        let visible_cols = 80usize;
433        if self.cursor_col >= self.scroll_col + visible_cols {
434            self.scroll_col = self.cursor_col.saturating_sub(visible_cols - 1);
435        }
436    }
437}
438
439// ── Rendering ─────────────────────────────────────────────────────────────────
440
441/// Render the inline editor into the given area.
442///
443/// Layout (top to bottom):
444///
445/// 1. **Header** — 1 line: `✏️  Editing: <filename> [modified]`
446/// 2. **Content** — remaining lines minus footer: line numbers + text
447/// 3. **Footer** — 1 line: status message + cursor position + key hints
448pub fn render_inline_editor(frame: &mut Frame, area: Rect, editor: &InlineEditor, theme: &Theme) {
449    if area.height < 3 {
450        return; // not enough room for header + 1 content line + footer
451    }
452
453    let header_area = Rect {
454        x: area.x,
455        y: area.y,
456        width: area.width,
457        height: 1,
458    };
459    let footer_area = Rect {
460        x: area.x,
461        y: area.y + area.height - 1,
462        width: area.width,
463        height: 1,
464    };
465    let content_area = Rect {
466        x: area.x,
467        y: area.y + 1,
468        width: area.width,
469        height: area.height.saturating_sub(2),
470    };
471
472    // ── Header ────────────────────────────────────────────────────────────
473    let file_name = editor.path.file_name().map_or_else(
474        || editor.path.display().to_string(),
475        |n| n.to_string_lossy().to_string(),
476    );
477
478    let mod_indicator = if editor.modified { " [modified]" } else { "" };
479    let header_text = format!("✏️  Editing: {file_name}{mod_indicator}");
480    let header = Paragraph::new(Line::from(vec![Span::styled(
481        header_text,
482        Style::default().fg(theme.brand).bold(),
483    )]));
484    frame.render_widget(header, header_area);
485
486    // ── Content ───────────────────────────────────────────────────────────
487    let visible_rows = content_area.height as usize;
488    let gutter_width: u16 = 5; // "NNNNN"
489    let sep_width: u16 = 3; // " │ "
490    let text_start_col = content_area.x + gutter_width + sep_width;
491    let text_width = content_area.width.saturating_sub(gutter_width + sep_width) as usize;
492
493    for row_offset in 0..visible_rows {
494        let line_idx = editor.scroll_row + row_offset;
495        let y = content_area.y + crate::to_u16(row_offset);
496
497        if line_idx >= editor.lines.len() {
498            // Past end of file — render a tilde like vi
499            let tilde = Paragraph::new(Line::from(Span::styled(
500                "    ~",
501                Style::default().fg(theme.dim),
502            )));
503            frame.render_widget(
504                tilde,
505                Rect {
506                    x: content_area.x,
507                    y,
508                    width: content_area.width,
509                    height: 1,
510                },
511            );
512            continue;
513        }
514
515        let is_cursor_line = line_idx == editor.cursor_row;
516        let line_bg = if is_cursor_line {
517            theme.sel_bg
518        } else {
519            Color::Reset
520        };
521
522        // ── Line number ───────────────────────────────────────────────
523        let line_num = format!("{:>5}", line_idx + 1);
524        let gutter = Paragraph::new(Line::from(Span::styled(
525            line_num,
526            Style::default().fg(theme.accent).bg(line_bg),
527        )));
528        frame.render_widget(
529            gutter,
530            Rect {
531                x: content_area.x,
532                y,
533                width: gutter_width,
534                height: 1,
535            },
536        );
537
538        // ── Separator ─────────────────────────────────────────────────
539        let sep = Paragraph::new(Line::from(Span::styled(
540            " │ ",
541            Style::default().fg(theme.dim).bg(line_bg),
542        )));
543        frame.render_widget(
544            sep,
545            Rect {
546                x: content_area.x + gutter_width,
547                y,
548                width: sep_width,
549                height: 1,
550            },
551        );
552
553        // ── Line text with cursor ─────────────────────────────────────
554        let line = &editor.lines[line_idx];
555        let chars: Vec<char> = line.chars().collect();
556        let visible_start = editor.scroll_col;
557
558        let mut spans: Vec<Span> = Vec::new();
559
560        if is_cursor_line {
561            // Build char-by-char so we can highlight the cursor position.
562            for vi in 0..text_width {
563                let ci = visible_start + vi; // character index in line
564                if ci == editor.cursor_col {
565                    // Cursor: render a block character in accent colour.
566                    if ci < chars.len() {
567                        spans.push(Span::styled(
568                            chars[ci].to_string(),
569                            Style::default().fg(theme.brand).bg(theme.accent),
570                        ));
571                    } else {
572                        spans.push(Span::styled(
573                            "█",
574                            Style::default().fg(theme.accent).bg(line_bg),
575                        ));
576                        // pad the rest if needed
577                        if vi + 1 < text_width {
578                            let pad = " ".repeat(text_width - vi - 1);
579                            spans
580                                .push(Span::styled(pad, Style::default().fg(theme.fg).bg(line_bg)));
581                        }
582                        break;
583                    }
584                } else if ci < chars.len() {
585                    spans.push(Span::styled(
586                        chars[ci].to_string(),
587                        Style::default().fg(theme.fg).bg(line_bg),
588                    ));
589                } else {
590                    // Past end of line — fill remaining with bg.
591                    let remaining = text_width - vi;
592                    spans.push(Span::styled(
593                        " ".repeat(remaining),
594                        Style::default().fg(theme.fg).bg(line_bg),
595                    ));
596                    break;
597                }
598            }
599            // Edge case: cursor is exactly at visible_start + 0 and text_width
600            // is 0 — nothing to render.
601        } else {
602            // Non-cursor line: render the visible portion in one go.
603            let visible: String = chars.iter().skip(visible_start).take(text_width).collect();
604            spans.push(Span::styled(
605                visible,
606                Style::default().fg(theme.fg).bg(line_bg),
607            ));
608        }
609
610        let text_line = Paragraph::new(Line::from(spans));
611        frame.render_widget(
612            text_line,
613            Rect {
614                x: text_start_col,
615                y,
616                width: crate::to_u16(text_width),
617                height: 1,
618            },
619        );
620    }
621
622    // ── Scroll thumb ─────────────────────────────────────────────────────────────
623    crate::render::paint_scrollbar(
624        frame,
625        content_area,
626        editor.lines.len(),
627        editor.scroll_row,
628        theme.accent,
629    );
630
631    // ── Footer ───────────────────────────────────────────────────────────────────
632    let status_style = if editor.status.starts_with("save failed") {
633        Style::default().fg(theme.brand)
634    } else {
635        Style::default().fg(theme.success)
636    };
637
638    let right_info = format!(
639        "Ln {}, Col {} │ Ctrl+S save │ Esc exit",
640        editor.cursor_row + 1,
641        editor.cursor_col + 1,
642    );
643
644    let right_width = right_info.chars().count();
645    let left_width = area.width as usize - right_width.min(area.width as usize);
646
647    let status_display: String = if editor.status.len() > left_width {
648        editor.status.chars().take(left_width).collect()
649    } else {
650        let pad = left_width.saturating_sub(editor.status.chars().count());
651        format!("{}{}", editor.status, " ".repeat(pad))
652    };
653
654    let footer = Paragraph::new(Line::from(vec![
655        Span::styled(status_display, status_style),
656        Span::styled(right_info, Style::default().fg(theme.dim)),
657    ]));
658    frame.render_widget(footer, footer_area);
659}
660
661// ── Tests ─────────────────────────────────────────────────────────────────────
662
663#[cfg(test)]
664mod tests;