clankerdiff-ratatui 0.1.7

Embeddable Ratatui diff review widget
Documentation
//! Interactive review of a real Git worktree using `clankerdiff-ratatui`.

use clankerdiff_core::{DiffDocument, DiffScope, ReviewCapabilities, ReviewSubmission};
use clankerdiff_git::GitRepository;
use clankerdiff_ratatui::{
    DiffReviewEvent, DiffReviewState, DiffReviewWidget, ThemeChoice, handle_crossterm_event,
};
use crossterm::{
    event::{self, Event},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::{
    env,
    error::Error,
    io::{self, Write, stdout},
    path::PathBuf,
    sync::Arc,
    time::Duration,
};

const USAGE: &str = "Usage: cargo run -p clankerdiff-ratatui --example review -- [PATH] [--scope SCOPE]\n\nScopes:\n  both       staged and unstaged changes (default)\n  unstaged   worktree changes only\n  staged     index changes only\n\nKeys:\n  j/k        move through files or lines\n  h/l, Tab   switch between file and diff panes\n  c          add a comment to the selected line\n  e/x/u      edit, delete, or undo a comment\n  v          cycle automatic, unified, and split views\n  s          submit the review and print it\n  y          copy event (printed after leaving the TUI)\n  ?          show all shortcuts\n  Esc        exit";

#[derive(Debug)]
struct Options {
    path: PathBuf,
    scope: DiffScope,
    help: bool,
}

#[derive(Debug)]
enum Outcome {
    Cancelled,
    Submitted(ReviewSubmission),
    CopyRequested(String),
}

/// Events applied between two frames. A trackpad flick queues far more wheel
/// notches than there are frames worth drawing, so the loop drains what the
/// terminal already has before it draws once.
const MAX_COALESCED_EVENTS: usize = 256;

/// Button presses (`?1000h`) reported as SGR (`?1006h`), and nothing else.
///
/// Crossterm's `EnableMouseCapture` also asks for `?1002h`/`?1003h`, which
/// report every pointer step. That is one event, and one frame, per pixel of
/// mouse movement, for input the widget ignores.
const ENABLE_MOUSE_BUTTONS: &[u8] = b"\x1b[?1000h\x1b[?1006h";
const DISABLE_MOUSE_BUTTONS: &[u8] = b"\x1b[?1006l\x1b[?1000l";

struct TerminalSession;

impl TerminalSession {
    fn enter() -> io::Result<Self> {
        enable_raw_mode()?;
        if let Err(error) = execute!(stdout(), EnterAlternateScreen) {
            let _ = disable_raw_mode();
            return Err(error);
        }
        if let Err(error) = write_all_flushed(ENABLE_MOUSE_BUTTONS) {
            let _ = execute!(stdout(), LeaveAlternateScreen);
            let _ = disable_raw_mode();
            return Err(error);
        }
        Ok(Self)
    }
}

impl Drop for TerminalSession {
    fn drop(&mut self) {
        let _ = write_all_flushed(DISABLE_MOUSE_BUTTONS);
        let _ = disable_raw_mode();
        let _ = execute!(stdout(), LeaveAlternateScreen);
    }
}

fn write_all_flushed(bytes: &[u8]) -> io::Result<()> {
    let mut out = stdout();
    out.write_all(bytes)?;
    out.flush()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let options = parse_options(env::args().skip(1))?;
    if options.help {
        println!("{USAGE}");
        return Ok(());
    }

    let repository = GitRepository::discover(&options.path).await?;
    let document = repository.snapshot(options.scope).await?;
    let outcome = run_tui(options.scope, Arc::new(document))?;

    match outcome {
        Outcome::Cancelled => {}
        Outcome::Submitted(submission) => println!("{}", submission.formatted),
        Outcome::CopyRequested(formatted) => {
            println!("CopyFormattedReview event:\n\n{formatted}");
        }
    }
    Ok(())
}

fn run_tui(scope: DiffScope, document: Arc<DiffDocument>) -> io::Result<Outcome> {
    let _session = TerminalSession::enter()?;
    let backend = CrosstermBackend::new(stdout());
    let mut terminal = Terminal::new(backend)?;
    let mut state = DiffReviewState::new(document);
    state.set_scope(scope);
    state.set_theme_choices(ThemeChoice::catalog());
    state.set_capabilities(ReviewCapabilities {
        repository: false,
        refresh: false,
        scope: false,
        ..ReviewCapabilities::default()
    });

    loop {
        if state.is_dirty() {
            terminal.draw(|frame| {
                frame.render_stateful_widget(DiffReviewWidget::new(), frame.area(), &mut state);
                if let Some(position) = state.cursor_position() {
                    frame.set_cursor_position(position);
                }
            })?;
        }

        // Block for the next event, then apply everything already queued behind
        // it. Drawing once per event instead makes a burst of wheel notches one
        // full repaint each, which is more than a terminal can absorb.
        if let Some(outcome) = apply_event(&mut state, event::read()?) {
            return Ok(outcome);
        }
        for _ in 1..MAX_COALESCED_EVENTS {
            if !event::poll(Duration::ZERO)? {
                break;
            }
            if let Some(outcome) = apply_event(&mut state, event::read()?) {
                return Ok(outcome);
            }
        }
    }
}

fn apply_event(state: &mut DiffReviewState, event: Event) -> Option<Outcome> {
    match handle_crossterm_event(state, event).into_event() {
        Some(DiffReviewEvent::SetScope(scope)) => {
            state.set_scope(scope);
            None
        }
        Some(DiffReviewEvent::RepositoryAction(_) | DiffReviewEvent::Refresh) | None => None,
        Some(DiffReviewEvent::Cancel) => Some(Outcome::Cancelled),
        Some(DiffReviewEvent::SubmitReview(submission)) => Some(Outcome::Submitted(submission)),
        Some(DiffReviewEvent::CopyFormattedReview(formatted)) => {
            Some(Outcome::CopyRequested(formatted))
        }
    }
}

fn parse_options(args: impl IntoIterator<Item = String>) -> io::Result<Options> {
    let mut path = None;
    let mut scope = DiffScope::Both;
    let mut help = false;
    let mut args = args.into_iter();

    while let Some(argument) = args.next() {
        match argument.as_str() {
            "-h" | "--help" => help = true,
            "--scope" => {
                let value = args
                    .next()
                    .ok_or_else(|| invalid_input(&"--scope needs a value"))?;
                scope = value.parse().map_err(|error| invalid_input(&error))?;
            }
            value if value.starts_with('-') => {
                return Err(invalid_input(&format!("unknown option: {value}")));
            }
            value if path.is_none() => path = Some(PathBuf::from(value)),
            value => return Err(invalid_input(&format!("unexpected argument: {value}"))),
        }
    }

    Ok(Options {
        path: path.unwrap_or_else(|| PathBuf::from(".")),
        scope,
        help,
    })
}

fn invalid_input(message: &impl ToString) -> io::Error {
    io::Error::new(io::ErrorKind::InvalidInput, message.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_defaults_and_explicit_scope() {
        let defaults = parse_options(Vec::new()).expect("default options");
        assert_eq!(defaults.path, PathBuf::from("."));
        assert_eq!(defaults.scope, DiffScope::Both);

        let explicit = parse_options([
            "/repo".to_owned(),
            "--scope".to_owned(),
            "staged".to_owned(),
        ])
        .expect("explicit options");
        assert_eq!(explicit.path, PathBuf::from("/repo"));
        assert_eq!(explicit.scope, DiffScope::Staged);
    }

    #[test]
    fn rejects_invalid_arguments() {
        assert!(parse_options(["--scope".to_owned()]).is_err());
        assert!(parse_options(["--scope".to_owned(), "unknown".to_owned()]).is_err());
        assert!(parse_options(["--wat".to_owned()]).is_err());
        assert!(parse_options(["one".to_owned(), "two".to_owned()]).is_err());
    }
}