clankerdiff-ratatui 0.1.8

Embeddable Ratatui diff review widget
Documentation
use clankerdiff_core::{DiffReviewEvent, ViewMode, testing::DocumentBuilder};
use clankerdiff_markdown::MarkdownDocument;
use clankerdiff_ratatui::{
    DiffReviewCommand, DiffReviewState, DiffReviewWidget, InputOutcome, InteractionPhase, KeyCode,
    KeyEvent, KeyModifiers, MarkdownReviewState, MouseEvent, MouseEventKind, NavigationPane,
    ReviewInput, ReviewOptions, ThemeChoice,
};
use ratatui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use std::{error::Error, fmt::Write, sync::Arc};

#[test]
fn portable_review_routes_input_without_a_terminal() -> Result<(), Box<dyn Error>> {
    let mut state = DiffReviewState::new(
        DocumentBuilder::new()
            .changed("a.rs", "old\n", "new\n")
            .build(),
    );
    assert!(matches!(
        state.handle_input(key(KeyCode::F(1))),
        InputOutcome::Ignored
    ));
    assert_eq!(state.interaction_phase(), InteractionPhase::Browse);
    let _ = state.handle_input(key(KeyCode::Tab));
    let _ = state.handle_input(key(KeyCode::Char('c')));
    assert_eq!(state.interaction_phase(), InteractionPhase::Draft);
    let _ = state.handle_input(ReviewInput::Paste("héllo".to_owned()));
    let _ = state.handle_input(key(KeyCode::Left));
    let _ = state.handle_input(key(KeyCode::Backspace));
    let _ = state.handle_input(key(KeyCode::Char('p')));
    assert_eq!(state.session().draft().ok_or("no draft")?.body(), "hélpo");
    let _ = state.handle_input(key(KeyCode::Enter));
    assert_eq!(state.review().len(), 1);
    assert!(matches!(
        state.handle_input(key(KeyCode::Esc)),
        InputOutcome::Emitted(DiffReviewEvent::Cancel)
    ));
    Ok(())
}

#[test]
fn modals_consume_keys_and_outside_mouse_is_ignored() {
    let mut state =
        DiffReviewState::new(DocumentBuilder::new().changed("a.rs", "old", "new").build());
    let area = Rect::new(10, 5, 60, 15);
    let mut buffer = Buffer::empty(Rect::new(0, 0, 90, 30));
    for cell in &mut buffer.content {
        cell.set_symbol("~");
    }
    DiffReviewWidget::new().render(area, &mut buffer, &mut state);
    for y in 0..30 {
        for x in 0..90 {
            if !area.contains((x, y).into()) {
                assert_eq!(buffer[(x, y)].symbol(), "~");
            }
        }
    }
    assert!(matches!(
        state.handle_input(ReviewInput::Mouse(MouseEvent {
            kind: MouseEventKind::ScrollDown,
            column: 0,
            row: 0,
            modifiers: KeyModifiers::NONE
        })),
        InputOutcome::Ignored
    ));
    let _ = state.handle_input(key(KeyCode::Char('?')));
    assert_eq!(state.interaction_phase(), InteractionPhase::Help);
    assert!(matches!(
        state.handle_input(key(KeyCode::F(1))),
        InputOutcome::Consumed
    ));
    let _ = state.handle_input(key(KeyCode::Esc));
    state.set_theme_choices(vec![ThemeChoice::new("Current", state.theme().clone())]);
    let _ = state.handle_input(key(KeyCode::Char('t')));
    assert_eq!(state.interaction_phase(), InteractionPhase::ThemePicker);
    let _ = state.handle_input(key(KeyCode::Esc));
    assert_eq!(state.interaction_phase(), InteractionPhase::Browse);
}

#[test]
fn markdown_drafts_use_portable_input() -> Result<(), Box<dyn Error>> {
    let mut state = MarkdownReviewState::new(Arc::new(MarkdownDocument::parse("# Plan\n\nText")));
    let _ = state.handle_input(key(KeyCode::Char('c')))?;
    let _ = state.handle_input(ReviewInput::Paste("A comment".to_owned()))?;
    assert_eq!(state.interaction_phase(), InteractionPhase::Draft);
    let _ = state.handle_input(key(KeyCode::Enter))?;
    assert_eq!(state.review().len(), 1);
    assert!(matches!(
        state.handle_input(key(KeyCode::F(1)))?,
        InputOutcome::Ignored
    ));
    Ok(())
}

#[test]
fn deep_scrolling_preserves_source_content_and_logical_selection() -> Result<(), Box<dyn Error>> {
    let mut source = String::new();
    for index in 0..100 {
        write!(source, "{index:04}_")?;
    }
    let document = DocumentBuilder::new()
        .changed(
            "long.rs",
            "context\nshort\n",
            &format!("context\n{source}\n"),
        )
        .build();
    let make_state = || {
        let mut state = DiffReviewState::new(document.clone());
        state.set_options(ReviewOptions {
            navigation: NavigationPane::Hidden,
            footer: false,
            ..Default::default()
        });
        state.set_view_mode(ViewMode::Split);
        state
    };
    let mut state = make_state();
    let mut reference_state = make_state();
    let tall_area = Rect::new(0, 0, 40, 100);
    let mut reference = Buffer::empty(tall_area);
    DiffReviewWidget::new()
        .borders(false)
        .render(tall_area, &mut reference, &mut reference_state);
    let area = Rect::new(0, 0, 40, 6);
    let mut buffer = Buffer::empty(area);
    DiffReviewWidget::new()
        .borders(false)
        .render(area, &mut buffer, &mut state);
    let selected = state.selected_row();
    for _ in 0..3 {
        let _ = state.handle_input(key(KeyCode::PageDown));
        buffer.reset();
        DiffReviewWidget::new()
            .borders(false)
            .render(area, &mut buffer, &mut state);
    }
    let offset = u16::try_from(state.scroll_offset())?;
    assert!(offset > area.height);
    assert_eq!(state.selected_row(), selected);
    for y in 0..area.height {
        for x in 26..39 {
            assert_eq!(
                buffer[(x, y)].symbol(),
                reference[(x, y + offset)].symbol(),
                "at {x},{y}"
            );
        }
    }
    let expected = state
        .presentation()
        .rows(0..state.presentation().row_count())
        .iter()
        .position(|row| row.cells().any(|cell| cell.text.as_ref() == source))
        .ok_or("missing logical row")?;
    let _ = state.handle_input(ReviewInput::Mouse(MouseEvent {
        kind: MouseEventKind::Down(clankerdiff_ratatui::MouseButton::Left),
        column: 2,
        row: 2,
        modifiers: KeyModifiers::NONE,
    }));
    assert_eq!(state.selected_row(), Some(expected));
    assert_eq!(state.scroll_offset(), usize::from(offset));
    Ok(())
}

#[test]
fn viewport_starting_inside_a_comment_keeps_source_hit_mapping() -> Result<(), Box<dyn Error>> {
    let mut state = DiffReviewState::new(
        DocumentBuilder::new()
            .changed("note.rs", "", "source\nnext\n")
            .build(),
    );
    state.set_options(ReviewOptions {
        navigation: NavigationPane::Hidden,
        footer: false,
        ..Default::default()
    });
    let source = state.selected_row().ok_or("missing selected source")?;
    let anchor = state.session().selected_anchor().ok_or("missing anchor")?;
    let mut comment = String::new();
    for line in 0..20 {
        writeln!(comment, "note {line}")?;
    }
    state.review_mut().add_comment(anchor, comment);
    state.handle_command(DiffReviewCommand::SelectRow(source + 1));
    let area = Rect::new(0, 0, 30, 6);
    let mut buffer = Buffer::empty(area);
    DiffReviewWidget::new()
        .borders(false)
        .render(area, &mut buffer, &mut state);
    let _ = state.handle_input(key(KeyCode::PageUp));
    buffer.reset();
    DiffReviewWidget::new()
        .borders(false)
        .render(area, &mut buffer, &mut state);
    let top: String = (0..area.width).map(|x| buffer[(x, 0)].symbol()).collect();
    assert!(top.contains("note"), "{top}");
    let _ = state.handle_input(ReviewInput::Mouse(MouseEvent {
        kind: MouseEventKind::Down(clankerdiff_ratatui::MouseButton::Left),
        column: 3,
        row: 0,
        modifiers: KeyModifiers::NONE,
    }));
    assert_eq!(state.selected_row(), Some(source));
    Ok(())
}

#[cfg(feature = "crossterm-backend")]
#[test]
fn crossterm_adapter_matches_portable_input_and_ignores_releases() {
    use clankerdiff_ratatui::handle_crossterm_event;
    use crossterm::event::{
        Event, KeyCode as TerminalCode, KeyEvent as TerminalKey, KeyEventKind,
        KeyModifiers as TerminalModifiers,
    };
    let document = DocumentBuilder::new().changed("a.rs", "old", "new").build();
    let mut portable = DiffReviewState::new(document.clone());
    let mut terminal = DiffReviewState::new(document);
    for (code, terminal_code) in [
        (KeyCode::Tab, TerminalCode::Tab),
        (KeyCode::Down, TerminalCode::Down),
        (KeyCode::Char('c'), TerminalCode::Char('c')),
    ] {
        assert_eq!(
            portable.handle_input(key(code)).is_consumed(),
            handle_crossterm_event(
                &mut terminal,
                Event::Key(TerminalKey::new(terminal_code, TerminalModifiers::NONE))
            )
            .is_consumed()
        );
        assert_eq!(portable.selected_row(), terminal.selected_row());
        assert_eq!(portable.interaction_phase(), terminal.interaction_phase());
    }
    let release = TerminalKey::new_with_kind(
        TerminalCode::Esc,
        TerminalModifiers::NONE,
        KeyEventKind::Release,
    );
    assert!(matches!(
        handle_crossterm_event(&mut terminal, Event::Key(release)),
        InputOutcome::Ignored
    ));
    assert_eq!(terminal.interaction_phase(), InteractionPhase::Draft);
}

fn key(code: KeyCode) -> ReviewInput {
    ReviewInput::Key(KeyEvent::new(code, KeyModifiers::NONE))
}