use std::io::{self, Stdout}; use crossterm::{ cursor::Show, event::{ self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, layout::Rect, style::{Color, Style}, text::Line, widgets::Paragraph, }; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ editor::{Editor, Effect, Input, Mode}, session::EditorSession, }; pub const CAPABILITIES: &[&str] = &["text", "styled_text"]; const TAB_WIDTH: usize = 4; struct TerminalSession; fn restore_terminal() { let _ = disable_raw_mode(); let _ = execute!( io::stdout(), DisableBracketedPaste, LeaveAlternateScreen, Show ); } impl TerminalSession { fn enter() -> io::Result { enable_raw_mode()?; let session = Self; execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?; Ok(session) } } impl Drop for TerminalSession { fn drop(&mut self) { restore_terminal(); } } /// Renders session state and translates terminal input; the session owns plugin /// execution and retirement, so the adapter needs no lifecycle callback. pub fn run(session: &mut EditorSession) -> io::Result<()> { let previous_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { restore_terminal(); previous_hook(info); })); let _session = TerminalSession::enter()?; let mut terminal: Terminal> = Terminal::new(CrosstermBackend::new(io::stdout()))?; let (mut top, mut left) = (0, 0); while !session.editor().should_quit() { terminal.draw(|frame| draw(frame, session.editor(), &mut top, &mut left))?; match event::read()? { Event::Key(key) if key.kind != KeyEventKind::Release => { if let Some(input) = translate(key) { session.handle(input); } } // Bracketed paste must never execute pasted Ex commands. Event::Paste(text) if session.editor().mode() == Mode::Insert => { session.apply(vec![Effect::Insert(text)]); } _ => {} } } Ok(()) } fn translate(key: KeyEvent) -> Option { if key .modifiers .intersects(KeyModifiers::ALT | KeyModifiers::SUPER) { return None; } Some(match key.code { KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl(c), KeyCode::Char(c) => Input::Char(c), KeyCode::Esc => Input::Escape, KeyCode::Enter => Input::Enter, KeyCode::Backspace => Input::Backspace, KeyCode::Delete => Input::Delete, KeyCode::Left => Input::Left, KeyCode::Right => Input::Right, KeyCode::Up => Input::Up, KeyCode::Down => Input::Down, KeyCode::Home => Input::Home, KeyCode::End => Input::End, KeyCode::Tab => Input::Char('\t'), _ => return None, }) } fn display_column(line: &str, grapheme_column: usize) -> usize { line.graphemes(true) .take(grapheme_column) .fold(0, |column, grapheme| { column + if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) } }) } fn visible_line(line: &str, left: usize, width: usize) -> String { let mut visible = String::new(); let mut column = 0; let right = left.saturating_add(width); for grapheme in line.graphemes(true) { let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let end = column + cells; if column >= right { break; } if end > left || (cells == 0 && column >= left) { if grapheme == "\t" || column < left || end > right { for _ in column.max(left)..end.min(right) { visible.push(' '); } } else { visible.push_str(grapheme); } } column = end; } visible } fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { let area = frame.area(); if area.width == 0 || area.height < 3 { return; } let height = usize::from(area.height - 2); let width = usize::from(area.width); let cursor = editor.cursor(); if cursor.row < *top { *top = cursor.row; } else if cursor.row >= top.saturating_add(height) { *top = cursor.row + 1 - height; } let column = display_column(&editor.lines()[cursor.row], cursor.column); if column < *left { *left = column; } else if column >= left.saturating_add(width) { *left = column + 1 - width; } let lines: Vec = editor .lines() .iter() .skip(*top) .take(height) .map(|line| Line::from(visible_line(line, *left, width))) .collect(); frame.render_widget( Paragraph::new(lines), Rect::new(area.x, area.y, area.width, area.height - 2), ); let path = editor .path() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|| "[No Name]".into()); let path: String = path .chars() .map(|c| if c.is_control() { '�' } else { c }) .collect(); let status = format!( " {} {}{} {}:{}", editor.mode(), path, if editor.dirty() { " [+]" } else { "" }, cursor.row + 1, cursor.column + 1 ); frame.render_widget( Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)), Rect::new(area.x, area.y + area.height - 2, area.width, 1), ); let bottom = area.y + area.height - 1; if editor.mode() == Mode::Command { let command = format!(":{}", editor.command_line()); let command_width = UnicodeWidthStr::width(command.as_str()); let offset = command_width.saturating_sub(width - 1); frame.render_widget( Paragraph::new(visible_line(&command, offset, width)), Rect::new(area.x, bottom, area.width, 1), ); frame.set_cursor_position((area.x + (command_width - offset) as u16, bottom)); } else { frame.render_widget( Paragraph::new(editor.message()), Rect::new(area.x, bottom, area.width, 1), ); frame.set_cursor_position(( area.x + (column - *left) as u16, area.y + (cursor.row - *top) as u16, )); } } #[cfg(test)] mod tests { use super::*; #[test] fn tabs_and_wide_graphemes_scroll_in_terminal_cells() { let text = "a\t界e\u{301}z"; assert_eq!(display_column(text, 4), 7); assert_eq!(visible_line(text, 2, 5), " 界e\u{301}"); assert_eq!(visible_line(text, 5, 2), " e\u{301}"); } }