[tui] Add edtui editor for basic vim emulation.
This commit is contained in:
@@ -41,7 +41,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
true => gui::run(root_path),
|
||||
false => match args.tui {
|
||||
// Open the TUI editor if requested, otherwise use the QML GUI by default.
|
||||
true => Ok(tui::start(root_path)?),
|
||||
true => Ok(tui::Tui::new(root_path).start()?),
|
||||
false => {
|
||||
// Relaunch the CLIDE GUI in a separate process.
|
||||
Command::new(std::env::current_exe()?)
|
||||
|
||||
60
src/tui.rs
60
src/tui.rs
@@ -1,16 +1,58 @@
|
||||
pub mod app;
|
||||
mod component;
|
||||
mod explorer;
|
||||
mod editor;
|
||||
mod explorer;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
};
|
||||
use ratatui::crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use std::io::{Stdout, stdout};
|
||||
|
||||
pub fn start(root_path: std::path::PathBuf) -> Result<()> {
|
||||
println!("Starting the TUI editor at {:?}", root_path);
|
||||
let terminal = ratatui::init();
|
||||
let app_result = app::App::new(&root_path)
|
||||
.run(terminal)
|
||||
.context("Failed to start the TUI editor.");
|
||||
ratatui::restore();
|
||||
app_result
|
||||
pub struct Tui {
|
||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
root_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
pub fn new(root_path: std::path::PathBuf) -> Self {
|
||||
Self {
|
||||
terminal: Terminal::new(CrosstermBackend::new(stdout()))
|
||||
.expect("Failed to initialize terminal"),
|
||||
root_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(self) -> Result<()> {
|
||||
println!("Starting the TUI editor at {:?}", self.root_path);
|
||||
ratatui::crossterm::execute!(
|
||||
stdout(),
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste
|
||||
)?;
|
||||
enable_raw_mode()?;
|
||||
|
||||
let app_result = app::App::new(&self.root_path)
|
||||
.run(self.terminal)
|
||||
.context("Failed to start the TUI editor.");
|
||||
Self::stop()?;
|
||||
app_result
|
||||
}
|
||||
|
||||
fn stop() -> Result<()> {
|
||||
disable_raw_mode()?;
|
||||
ratatui::crossterm::execute!(
|
||||
stdout(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture,
|
||||
DisableBracketedPaste
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user