[tui] Add edtui editor for basic vim emulation.

This commit is contained in:
2026-01-18 10:09:28 -05:00
parent a8de77f370
commit fe6390c1cd
8 changed files with 494 additions and 411 deletions

View File

@@ -1,34 +1,63 @@
use crate::tui::component::{Action, ClideComponent};
use crate::tui::editor::Editor;
use crate::tui::explorer::Explorer;
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::crossterm::event;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::{Color, Style, Widget};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Tabs, Wrap};
use ratatui::{DefaultTerminal, symbols};
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct App<'a> {
explorer: Explorer<'a>,
editor: Editor,
}
impl<'a> App<'a> {
pub(crate) fn new(root_path: &'a std::path::Path) -> Self {
Self {
explorer: Explorer::new(root_path),
editor: Editor::new(),
}
}
fn get_event(&mut self) -> Option<Event> {
if !event::poll(Duration::from_millis(250)).expect("event poll failed") {
return None;
}
event::read().ok()
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> anyhow::Result<()> {
loop {
terminal.draw(|f| {
f.render_widget(&self, f.area());
f.render_widget(&mut self, f.area());
})?;
// TODO: Handle events based on which component is active.
// match self.explorer.handle_events() { ... }
match self.handle_events() {
Action::Quit => break,
_ => {}
if let Some(event) = self.get_event() {
self.editor
.event_handler
.on_event(event.clone(), &mut self.editor.state);
match event {
Event::FocusGained => {}
Event::FocusLost => {}
Event::Key(key_event) => {
// Handle main application key events.
match self.handle_key_events(key_event) {
Action::Noop => {}
Action::Quit => break,
Action::Pass => {}
}
}
Event::Mouse(_) => {}
Event::Paste(_) => {}
Event::Resize(_, _) => {}
}
}
}
Ok(())
@@ -55,25 +84,6 @@ impl<'a> App<'a> {
.render(area, buf);
}
fn draw_editor(&self, area: Rect, buf: &mut Buffer) {
// TODO: Title should be detected programming language name
// TODO: Content should be file contents
// TODO: Contents should use vim in rendered TTY
// TODO: Vimrc should be used
Paragraph::new("This is an example of the TUI interface (press 'q' to quit)")
.style(Style::default())
.block(
Block::default()
.title("Rust")
.title_style(Style::default().fg(Color::Yellow))
.title_alignment(Alignment::Right)
.borders(Borders::ALL)
.padding(Padding::new(0, 0, 0, 1)),
)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn draw_terminal(&self, area: Rect, buf: &mut Buffer) {
// TODO: Title should be detected shell name
// TODO: Contents should be shell output
@@ -91,7 +101,7 @@ impl<'a> App<'a> {
}
// TODO: Separate complex components into their own widgets.
impl<'a> Widget for &App<'a> {
impl<'a> Widget for &mut App<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
@@ -127,8 +137,23 @@ impl<'a> Widget for &App<'a> {
self.explorer.render(horizontal[0], buf);
self.draw_tabs(editor_layout[0], buf);
self.draw_editor(editor_layout[1], buf);
self.editor.render(editor_layout[1], buf);
}
}
impl<'a> ClideComponent for App<'a> {}
impl<'a> ClideComponent for App<'a> {
fn handle_key_events(&mut self, key: KeyEvent) -> Action {
match key {
KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: _state,
} => Action::Quit,
key_event => {
// Pass the key event to each component that can handle it.
self.explorer.handle_key_events(key_event)
}
}
}
}

View File

@@ -1,38 +1,23 @@
use ratatui::crossterm::event;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, MouseEvent};
use std::time::Duration;
use ratatui::crossterm::event::{KeyEvent, MouseEvent};
pub enum Action {
Noop,
Quit,
Pass, // Pass input to another component.
}
pub trait ClideComponent {
fn handle_events(&mut self) -> Action {
if !event::poll(Duration::from_millis(250)).expect("event poll failed") {
return Action::Noop;
}
let key_event = event::read().expect("event read failed");
match key_event {
Event::Key(key_event) => self.handle_key_events(key_event),
Event::Mouse(mouse_event) => self.handle_mouse_events(mouse_event),
_ => Action::Noop,
}
}
fn handle_key_events(&mut self, key: KeyEvent) -> Action {
match key.code {
KeyCode::Char('q') => Action::Quit,
_ => Action::Noop,
}
}
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Action {
fn handle_key_events(&mut self, _key: KeyEvent) -> Action {
Action::Noop
}
fn update(&mut self, action: Action) -> Action {
#[allow(dead_code)]
fn handle_mouse_events(&mut self, _mouse: MouseEvent) -> Action {
Action::Noop
}
#[allow(dead_code)]
fn update(&mut self, _action: Action) -> Action {
Action::Noop
}
}

View File

@@ -1,156 +1,59 @@
use crate::tui::component::ClideComponent;
use anyhow::Result;
use nvim_rs::compat::tokio::Compat;
use nvim_rs::{Handler, Neovim, UiAttachOptions, Value};
use crate::tui::component::{Action, ClideComponent};
use edtui::{
EditorEventHandler, EditorState, EditorTheme, EditorView, LineNumbers, SyntaxHighlighter,
};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tokio::process::Command;
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::{Color, Style};
use ratatui::widgets::{Block, Borders, Padding, Widget};
struct Editor {
ui: Arc<NvimUI>,
height: usize,
width: usize,
// TODO: Consider using editor-command https://docs.rs/editor-command/latest/editor_command/
// TODO: Title should be detected programming language name
// TODO: Content should be file contents
// TODO: Vimrc should be used
pub struct Editor {
pub state: EditorState,
pub event_handler: EditorEventHandler,
}
impl Editor {
fn new(height: usize, width: usize) -> Self {
let editor = Editor {
ui: Arc::new(NvimUI::default()),
height,
width,
};
editor
.ui
.grid
.lock()
.unwrap()
.resize(height, vec![' '; width]);
editor
pub fn new() -> Self {
Editor {
state: EditorState::default(),
event_handler: EditorEventHandler::default(),
}
}
}
impl<'a> Widget for &Editor {
impl Widget for &mut Editor {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let grid = self.ui.grid.lock().unwrap();
let lines: Vec<Line> = grid
.iter()
.map(|row| Line::from(row.iter().collect::<String>()))
.collect();
Paragraph::new(lines).render(area, buf);
// TODO: Use current file extension for syntax highlighting here.
EditorView::new(&mut self.state)
.wrap(true)
.theme(
EditorTheme::default().block(
Block::default()
.title("Rust")
.title_style(Style::default().fg(Color::Yellow))
.title_alignment(Alignment::Right)
.borders(Borders::ALL)
.padding(Padding::new(0, 0, 0, 1)),
),
)
.syntax_highlighter(SyntaxHighlighter::new("dracula", "rs").ok())
.tab_width(2)
.line_numbers(LineNumbers::Absolute)
.render(area, buf);
}
}
impl ClideComponent for Editor {}
#[derive(Default, Clone)]
pub struct NvimUI {
pub grid: Arc<Mutex<Vec<Vec<char>>>>,
pub cursor: Arc<Mutex<(usize, usize)>>,
}
impl Handler for NvimUI {
type Writer = Compat<tokio::process::ChildStdin>;
async fn handle_notify(
&self,
_name: String,
_args: Vec<Value>,
_neovim: Neovim<Compat<tokio::process::ChildStdin>>,
) -> Result<()> {
if _name != "redraw" {
return Ok(());
}
for event in _args {
if let Value::Array(items) = event {
if items.is_empty() {
continue;
}
let event_name = items[0].as_str().unwrap_or("");
match event_name {
"grid_line" => self.handle_grid_line(&items),
"cursor_goto" => self.handle_cursor(&items),
_ => {}
}
}
}
Ok(())
}
}
impl NvimUI {
fn handle_grid_line(&self, items: &[Value]) {
// ["grid_line", grid, row, col, cells]
let row = items[2].as_u64().unwrap() as usize;
let col = items[3].as_u64().unwrap() as usize;
let cells = items[4].as_array().unwrap();
let mut grid = self.grid.lock().unwrap();
let mut c = col;
for cell in cells {
let cell_arr = cell.as_array().unwrap();
let text = cell_arr[0].as_str().unwrap();
let repeat = cell_arr.get(2).and_then(|v| v.as_u64()).unwrap_or(1);
for _ in 0..repeat {
if let Some(ch) = text.chars().next() {
grid[row][c] = ch;
}
c += 1;
}
}
}
fn handle_cursor(&self, items: &[Value]) {
let row = items[2].as_u64().unwrap() as usize;
let col = items[3].as_u64().unwrap() as usize;
*self.cursor.lock().unwrap() = (row, col);
}
pub async fn spawn_nvim<H: Handler + Send + 'static>(
handler: H,
) -> Result<Neovim<Compat<tokio::process::ChildStdin>>> {
let mut child = Command::new("nvim")
.arg("--embed")
.arg("--headless")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take().unwrap();
let mut stdout = child.stdout.take().unwrap();
let (nvim, io_handler) = Neovim::new(stdout, stdin, handler);
tokio::spawn(async move {
nvim_rs::compat::tokio::spawn(stdout.compat(), io_handler).await;
});
Ok(nvim)
}
pub async fn init_ui(
nvim: &Neovim<nvim_rs::compat::tokio::Compat<tokio::process::ChildStdin>>,
w: i64,
h: i64,
) -> anyhow::Result<()> {
let mut opts = UiAttachOptions::default();
opts.set_rgb(true).set_linegrid_external(true);
nvim.ui_attach(w, h, &opts).await?;
Ok(())
impl ClideComponent for Editor {
fn handle_key_events(&mut self, key: KeyEvent) -> Action {
self.event_handler.on_key_event(key, &mut self.state);
Action::Pass
}
}

View File

@@ -17,15 +17,13 @@ pub struct Explorer<'a> {
impl<'a> Explorer<'a> {
pub fn new(path: &'a std::path::Path) -> Self {
let mut explorer = Explorer {
let explorer = Explorer {
root_path: path,
tree_items: Self::build_tree_from_path(path.into()),
};
explorer
}
pub fn draw(&self, area: Rect, buf: &mut Buffer) {}
fn build_tree_from_path(path: std::path::PathBuf) -> TreeItem<'static, String> {
let mut children = vec![];
if let Ok(entries) = fs::read_dir(&path) {