238 lines
7.1 KiB
Rust
238 lines
7.1 KiB
Rust
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<Self> {
|
|
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<CrosstermBackend<Stdout>> =
|
|
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<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}");
|
|
}
|
|
}
|