use edtui::{EditorEventHandler, EditorMode, EditorState, EditorTheme, EditorView, Lines};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Position, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::app::App;
use crate::theme::Palette;
pub struct TabEditor {
pub state: EditorState,
pub baseline: String,
pub command_line: Option<String>,
pub status_message: Option<String>,
pub close_after_save: bool,
pub first_draw_done: bool,
}
impl TabEditor {
#[must_use]
pub fn new(content: String) -> Self {
let state = EditorState::new(Lines::from(content.as_str()));
Self {
state,
baseline: content,
command_line: None,
status_message: None,
close_after_save: false,
first_draw_done: false,
}
}
#[must_use]
pub fn is_dirty(&self) -> bool {
extract_text(&self.state) != self.baseline
}
}
#[must_use]
pub fn extract_text(state: &EditorState) -> String {
state
.lines
.iter_row()
.map(|row| row.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandOutcome {
Handled,
Save,
Close,
SaveThenClose,
}
pub fn dispatch_command(editor: &mut TabEditor, cmd: &str) -> CommandOutcome {
match cmd.trim() {
"w" => CommandOutcome::Save,
"q" => {
if editor.is_dirty() {
editor.status_message = Some("unsaved changes — use :q! to discard".to_string());
CommandOutcome::Handled
} else {
CommandOutcome::Close
}
}
"q!" => CommandOutcome::Close,
"wq" => CommandOutcome::SaveThenClose,
other => {
editor.status_message = Some(format!("unknown command: :{other}"));
CommandOutcome::Handled
}
}
}
#[must_use]
pub fn theme_from_palette(p: &Palette) -> EditorTheme<'static> {
EditorTheme::default()
.base(Style::default().fg(p.foreground).bg(p.background))
.cursor_style(
Style::default()
.fg(p.background)
.bg(p.accent)
.add_modifier(Modifier::BOLD),
)
.selection_style(Style::default().fg(p.selection_fg).bg(p.selection_bg))
.line_numbers_style(Style::default().fg(p.gutter).bg(p.background))
.hide_status_line()
}
pub fn draw(f: &mut Frame, app: &mut App, viewer_area: Rect) {
let palette = app.palette;
let Some(tab) = app.tabs.active_tab_mut() else {
return;
};
let Some(editor) = tab.editor.as_mut() else {
return;
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(viewer_area);
let editor_area = chunks[0];
let footer_area = chunks[1];
if !editor.first_draw_done {
let first_pass = EditorView::new(&mut editor.state).theme(theme_from_palette(&palette));
f.render_widget(first_pass, editor_area);
editor.first_draw_done = true;
}
let editor_view = EditorView::new(&mut editor.state).theme(theme_from_palette(&palette));
f.render_widget(editor_view, editor_area);
let cursor_pos = editor
.state
.cursor_screen_position()
.unwrap_or(Position::new(editor_area.x, editor_area.y));
f.set_cursor_position(cursor_pos);
let mode_label = match editor.state.mode {
EditorMode::Normal | EditorMode::Search => "-- NORMAL --",
EditorMode::Insert => "-- INSERT --",
EditorMode::Visual => "-- VISUAL --",
};
let dirty_marker = if editor.is_dirty() { " [+]" } else { "" };
let right_text = if let Some(ref cmd) = editor.command_line {
format!(":{cmd}")
} else if let Some(ref msg) = editor.status_message {
msg.clone()
} else {
String::new()
};
let footer_line = Line::from(vec![
Span::styled(
format!("{mode_label}{dirty_marker}"),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(right_text, Style::default().fg(palette.accent_alt)),
]);
f.render_widget(
Paragraph::new(footer_line).style(Style::default().bg(palette.background)),
footer_area,
);
}
pub fn forward_key_to_edtui(key: crossterm::event::KeyEvent, state: &mut EditorState) {
let mut handler = EditorEventHandler::default();
handler.on_key_event(key, state);
}
#[cfg(test)]
mod tests {
use super::*;
fn make_editor(content: &str) -> TabEditor {
TabEditor::new(content.to_string())
}
#[test]
fn extract_text_roundtrip() {
let editor = make_editor("hello\nworld");
assert_eq!(extract_text(&editor.state), "hello\nworld");
}
#[test]
fn extract_text_single_line() {
let editor = make_editor("single line");
assert_eq!(extract_text(&editor.state), "single line");
}
#[test]
fn is_dirty_false_on_new_editor() {
let editor = make_editor("content");
assert!(!editor.is_dirty());
}
#[test]
fn command_unknown_sets_status_message() {
let mut editor = make_editor("text");
let outcome = dispatch_command(&mut editor, "xyz");
assert_eq!(outcome, CommandOutcome::Handled);
assert!(editor.status_message.is_some());
assert!(editor.status_message.unwrap().contains("xyz"));
}
#[test]
fn command_q_clean_returns_close() {
let mut editor = make_editor("clean");
let outcome = dispatch_command(&mut editor, "q");
assert_eq!(outcome, CommandOutcome::Close);
}
#[test]
fn command_q_dirty_returns_handled_and_sets_message() {
let mut editor = make_editor("original");
editor.baseline = "different".to_string();
let outcome = dispatch_command(&mut editor, "q");
assert_eq!(outcome, CommandOutcome::Handled);
assert!(editor.status_message.is_some());
}
#[test]
fn command_q_bang_always_closes() {
let mut editor = make_editor("original");
editor.baseline = "different".to_string();
let outcome = dispatch_command(&mut editor, "q!");
assert_eq!(outcome, CommandOutcome::Close);
}
#[test]
fn command_wq_returns_save_then_close() {
let mut editor = make_editor("content");
let outcome = dispatch_command(&mut editor, "wq");
assert_eq!(outcome, CommandOutcome::SaveThenClose);
}
#[test]
fn command_w_returns_save() {
let mut editor = make_editor("content");
let outcome = dispatch_command(&mut editor, "w");
assert_eq!(outcome, CommandOutcome::Save);
}
#[test]
fn close_after_save_field_defaults_false() {
let editor = make_editor("content");
assert!(!editor.close_after_save);
}
#[test]
fn close_after_save_is_independent_of_status_message() {
let mut editor = make_editor("content");
editor.close_after_save = true;
editor.status_message = Some("saved".into());
assert!(editor.close_after_save);
assert_eq!(editor.status_message.as_deref(), Some("saved"));
}
}