vorto 0.4.0

A terminal text editor with tree-sitter syntax highlighting and LSP support
mod action;
mod app;
mod buffer_ref;
mod config;
mod editor;
mod effect;
mod event;
mod finder;
mod format;
mod grammar;
mod lsp;
mod mode;
mod prompt;
mod syntax;
mod ui;
mod vcs;

use std::io::{self, Stdout, Write};
use std::sync::mpsc;
use std::thread;

use anyhow::Result;
use crossterm::event::{self as crossterm_event, Event};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;

use crate::app::App;
use crate::config::CursorShape;

fn main() -> Result<()> {
    let argv: Vec<String> = std::env::args().collect();
    // `vorto grammar …` is a one-shot CLI that builds and installs
    // tree-sitter `.so` libraries; it never enters the TUI, so handle
    // it before we touch the terminal.
    if argv.get(1).map(String::as_str) == Some("grammar") {
        return grammar::cli::run(&argv[2..]);
    }
    // `--version` / `--help` are likewise one-shots — print and exit
    // before any terminal setup.
    match argv.get(1).map(String::as_str) {
        Some("-V" | "--version") => {
            println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
            return Ok(());
        }
        Some("-h" | "--help") => {
            print_usage();
            return Ok(());
        }
        _ => {}
    }

    let path = argv.into_iter().nth(1);
    // Anchor for LSP workspace root discovery — captured once here so the
    // value can't shift mid-session if anything changes the process's
    // cwd. Every later `:e` resolves against the same directory.
    let startup_cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let cfg = config::Config::load(config::default_path().as_deref())?;
    let loader = syntax::Loader::new(cfg.grammar_dir.clone(), cfg.query_dir.clone());

    // Unified event channel. Terminal input runs on a dedicated thread
    // that pushes `Event::Term`; LSP reader threads push `Event::Lsp`.
    let (event_tx, event_rx) = mpsc::channel::<event::AppEvent>();
    let input_tx = event_tx.clone();
    thread::spawn(move || {
        loop {
            match crossterm_event::read() {
                Ok(ev) => {
                    if input_tx.send(event::AppEvent::Term(ev)).is_err() {
                        return;
                    }
                }
                Err(_) => return,
            }
        }
    });

    let mut app = App::new(cfg, loader, event_tx, startup_cwd);
    if let Some(p) = path {
        app.open_path(std::path::Path::new(&p))?;
    }

    let result = run(&mut terminal, &mut app, &event_rx);

    disable_raw_mode()?;
    // `\x1b[0 q` = DECSCUSR Ps=0 → restore the user's configured shape.
    let _ = io::stdout().write_all(b"\x1b[0 q");
    let _ = io::stdout().flush();
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;

    result
}

fn run(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    app: &mut App,
    event_rx: &mpsc::Receiver<event::AppEvent>,
) -> Result<()> {
    let mut last_shape: Option<CursorShape> = None;
    while !app.should_quit {
        app.buffer.refresh_highlights();
        terminal.draw(|f| ui::draw(f, app))?;
        let shape = app.config.cursor_shapes.for_mode(app.mode);
        if last_shape != Some(shape) {
            let mut out = io::stdout();
            out.write_all(cursor_ansi(shape, app.config.cursor_shapes.blinking))?;
            out.flush()?;
            last_shape = Some(shape);
        }
        // Block on the next event. Both terminal input and LSP reader
        // threads feed this channel, so we wake on whichever comes first
        // and only redraw once after we drain the burst.
        //
        // When a toast is on screen, fall back to `recv_timeout` so the
        // loop wakes when the TTL expires and the next redraw can drop
        // the toast — otherwise it would linger until the user happens
        // to press a key.
        let first = match app.toast_remaining() {
            Some(rem) => match event_rx.recv_timeout(rem) {
                Ok(ev) => Some(ev),
                Err(mpsc::RecvTimeoutError::Timeout) => None,
                Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
            },
            None => match event_rx.recv() {
                Ok(ev) => Some(ev),
                Err(_) => return Ok(()),
            },
        };
        if let Some(ev) = first {
            dispatch(app, ev)?;
        }
        // Drain any events that piled up while we were blocked so we
        // don't redraw between a Term+Lsp pair (e.g. didChange burst).
        while let Ok(ev) = event_rx.try_recv() {
            dispatch(app, ev)?;
        }
        app.sync_buffer_if_dirty();
    }
    Ok(())
}

fn dispatch(app: &mut App, ev: event::AppEvent) -> Result<()> {
    match ev {
        event::AppEvent::Term(Event::Key(key)) => app.handle_key(key)?,
        event::AppEvent::Term(_) => {}
        event::AppEvent::Lsp(lsp_ev) => app.handle_lsp_event(lsp_ev),
        event::AppEvent::HighlighterReady { generation, result } => {
            app.handle_highlighter_ready(generation, result);
        }
        event::AppEvent::LspReady {
            generation,
            client_key,
            lang,
            path,
            result,
        } => {
            app.handle_lsp_ready(generation, client_key, lang, path, result);
        }
        event::AppEvent::PreviewReady(entry) => app.handle_preview_ready(entry),
    }
    Ok(())
}

fn print_usage() {
    println!(
        "{name} {version}

Usage:
    vorto [FILE]
    vorto grammar <list|install|remove> [args]
    vorto -h | --help
    vorto -V | --version",
        name = env!("CARGO_PKG_NAME"),
        version = env!("CARGO_PKG_VERSION"),
    );
}

/// DECSCUSR escape sequence — `CSI Ps SP q`, where Ps picks the shape.
/// Written directly to stdout from the main loop so the terminal
/// switches shape as the user changes mode.
fn cursor_ansi(shape: CursorShape, blinking: bool) -> &'static [u8] {
    match (shape, blinking) {
        (CursorShape::Terminal, _) => b"\x1b[0 q",
        (CursorShape::Block, true) => b"\x1b[1 q",
        (CursorShape::Block, false) => b"\x1b[2 q",
        (CursorShape::Underbar, true) => b"\x1b[3 q",
        (CursorShape::Underbar, false) => b"\x1b[4 q",
        (CursorShape::Bar, true) => b"\x1b[5 q",
        (CursorShape::Bar, false) => b"\x1b[6 q",
    }
}