feat: implement modular modal editor and isolated Ruby plugins
This commit is contained in:
+238
@@ -0,0 +1,238 @@
|
||||
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, Invocation, Mode};
|
||||
|
||||
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<Self> {
|
||||
enable_raw_mode()?;
|
||||
let session = Self;
|
||||
execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalSession {
|
||||
fn drop(&mut self) {
|
||||
restore_terminal();
|
||||
}
|
||||
}
|
||||
|
||||
/// The frontend only translates input and renders backend state.
|
||||
/// Plugin dispatch is supplied by the executable, not the terminal adapter.
|
||||
pub fn run(editor: &mut Editor, mut invoke: impl FnMut(&mut Editor, Invocation)) -> 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<CrosstermBackend<Stdout>> =
|
||||
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
let (mut top, mut left) = (0, 0);
|
||||
while !editor.should_quit() {
|
||||
terminal.draw(|frame| draw(frame, editor, &mut top, &mut left))?;
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if let Some(input) = translate(key)
|
||||
&& let Some(invocation) = editor.handle(input)
|
||||
{
|
||||
invoke(editor, invocation);
|
||||
}
|
||||
}
|
||||
// Bracketed paste must never execute pasted Ex commands.
|
||||
Event::Paste(text) if editor.mode() == Mode::Insert => {
|
||||
if let Err(error) = editor.apply(vec![Effect::Insert(text)]) {
|
||||
editor.set_message(error);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn translate(key: KeyEvent) -> Option<Input> {
|
||||
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<Line> = 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user