feat: implement modular modal editor and isolated Ruby plugins

This commit is contained in:
Valdemar Lorenzen
2026-10-05 13:48:01 +02:00
parent 6e1de421f1
commit 396ffc4f3a
11 changed files with 3450 additions and 2 deletions
+238
View File
@@ -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}");
}
}