1use 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
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
60#[derive(Debug)]
64pub struct InlineEditor {
65 lines: Vec<String>,
67 cursor_row: usize,
69 cursor_col: usize,
71 scroll_row: usize,
73 scroll_col: usize,
75 path: PathBuf,
77 modified: bool,
79 status: String,
81}
82
83impl InlineEditor {
84 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 pub fn handle_key(&mut self, key: KeyEvent) -> EditorAction {
123 press_only!(key, EditorAction::Continue);
124
125 match (key.modifiers, key.code) {
126 (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 (_, KeyCode::Esc) => EditorAction::Exit,
140
141 (_, 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 (_, 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 _ => EditorAction::Continue,
203 }
204 }
205
206 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 #[must_use]
220 pub fn line_count(&self) -> usize {
221 self.lines.len()
222 }
223
224 #[must_use]
226 pub fn is_modified(&self) -> bool {
227 self.modified
228 }
229
230 #[must_use]
232 pub fn path(&self) -> &Path {
233 &self.path
234 }
235
236 #[must_use]
238 pub fn status(&self) -> &str {
239 &self.status
240 }
241
242 #[must_use]
244 pub fn cursor_row(&self) -> usize {
245 self.cursor_row
246 }
247
248 #[must_use]
250 pub fn cursor_col(&self) -> usize {
251 self.cursor_col
252 }
253
254 #[must_use]
256 pub fn lines(&self) -> &[String] {
257 &self.lines
258 }
259
260 #[must_use]
262 pub fn scroll_row(&self) -> usize {
263 self.scroll_row
264 }
265
266 #[must_use]
268 pub fn scroll_col(&self) -> usize {
269 self.scroll_col
270 }
271
272 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 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 fn current_line_len(&self) -> usize {
392 self.lines[self.cursor_row].chars().count()
393 }
394
395 fn cursor_byte_offset(&self) -> usize {
397 self.byte_offset_of_char(self.cursor_row, self.cursor_col)
398 }
399
400 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 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 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 fn adjust_scroll_col(&mut self) {
427 if self.cursor_col < self.scroll_col {
428 self.scroll_col = self.cursor_col;
429 }
430 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
439pub fn render_inline_editor(frame: &mut Frame, area: Rect, editor: &InlineEditor, theme: &Theme) {
449 if area.height < 3 {
450 return; }
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 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 let visible_rows = content_area.height as usize;
488 let gutter_width: u16 = 5; let sep_width: u16 = 3; 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 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 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 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 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 for vi in 0..text_width {
563 let ci = visible_start + vi; if ci == editor.cursor_col {
565 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 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 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 } else {
602 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 crate::render::paint_scrollbar(
624 frame,
625 content_area,
626 editor.lines.len(),
627 editor.scroll_row,
628 theme.accent,
629 );
630
631 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#[cfg(test)]
664mod tests;