quinjet 0.0.74

A fast, live, keyboard-first Git source-control interface for the terminal
use std::time::Instant;

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;

use super::*;

fn hover_at(app: &mut App, column: u16, row: u16) {
    drop(app.handle_mouse(
        MouseEvent {
            kind: MouseEventKind::Moved,
            column,
            row,
            modifiers: KeyModifiers::NONE,
        },
        Instant::now(),
    ));
}

fn diff_app(layout: DiffLayout, wrap: bool) -> App {
    let mut app = App::new("/example/project", "example");
    app.sidebar_hidden = true;
    app.diff_layout = layout;
    app.wrap_code = wrap;
    app.set_document(DiffDocument {
        lines: vec![
            test_file_header("example.rs", 1, 1),
            test_line(DiffLineKind::Removed, &"old 界 text ".repeat(8)),
            test_line(DiffLineKind::Added, &"new 界 text ".repeat(24)),
            test_line(DiffLineKind::Context, "untouched"),
            test_line(DiffLineKind::FileFooter, ""),
        ],
        ..DiffDocument::default()
    });
    app
}

#[test]
fn diff_hover_tracks_pairs_and_every_wrapped_continuation() {
    for layout in [DiffLayout::Unified, DiffLayout::SideBySide] {
        for wrap in [false, true] {
            for appearance in [
                crate::theme::Appearance::Dark,
                crate::theme::Appearance::Light,
            ] {
                let theme = Theme::new(ThemeName::Quinjet, appearance);
                let mut app = diff_app(layout, wrap);
                let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
                terminal
                    .draw(|frame| draw(frame, &mut app, &theme))
                    .unwrap();
                let viewport = app.geometry.diff_viewport.unwrap();
                let index = if layout == DiffLayout::Unified { 2 } else { 1 };
                let row = if wrap { index + 1 } else { index };
                let column = viewport.area.right() - 4;
                let before = terminal.backend().buffer().clone();
                hover_at(&mut app, column, viewport.area.y + row);
                terminal
                    .draw(|frame| draw(frame, &mut app, &theme))
                    .unwrap();
                let after = terminal.backend().buffer();
                let mut tinted_rows = 0;
                for y in viewport.area.y..viewport.area.bottom() {
                    if after[(column, y)].bg != before[(column, y)].bg {
                        tinted_rows += 1;
                    }
                }
                assert!(
                    tinted_rows >= if wrap { 3 } else { 1 },
                    "{layout:?} wrap={wrap}"
                );
                for (before, after) in before.content().iter().zip(after.content()) {
                    assert_eq!(before.symbol(), after.symbol());
                    assert_eq!(before.fg, after.fg);
                }
                if layout == DiffLayout::SideBySide {
                    let y = viewport.area.y + row;
                    assert_ne!(
                        before[(viewport.area.x + 2, y)].bg,
                        after[(viewport.area.x + 2, y)].bg
                    );
                }
                let context_y = (viewport.area.y..viewport.area.bottom())
                    .find(|y| {
                        (viewport.area.x..viewport.area.right())
                            .any(|x| after[(x, *y)].symbol() == "u")
                    })
                    .unwrap();
                assert_eq!(
                    before[(column, context_y)].bg,
                    after[(column, context_y)].bg
                );
            }
        }
    }
}

#[test]
fn hover_uses_the_scrolled_viewport_and_reflows_on_resize() {
    let theme = Theme::default();
    for layout in [DiffLayout::Unified, DiffLayout::SideBySide] {
        let mut app = diff_app(layout, true);
        app.document.lines.splice(
            3..3,
            std::iter::repeat_n(test_line(DiffLineKind::Context, "other"), 100),
        );
        app.content_scroll = 2;
        let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
        terminal
            .draw(|frame| draw(frame, &mut app, &theme))
            .unwrap();
        let viewport = app.geometry.diff_viewport.unwrap();
        let before = terminal.backend().buffer().clone();
        hover_at(&mut app, viewport.area.x + 15, viewport.area.y);
        terminal
            .draw(|frame| draw(frame, &mut app, &theme))
            .unwrap();
        assert_ne!(
            before[(viewport.area.x + 15, viewport.area.y)].bg,
            terminal.backend().buffer()[(viewport.area.x + 15, viewport.area.y)].bg
        );
        app.sidebar_hidden = false;
        let mut narrow = Terminal::new(TestBackend::new(80, 24)).unwrap();
        narrow.draw(|frame| draw(frame, &mut app, &theme)).unwrap();
        assert!(!app.geometry.diff_viewport.unwrap().side_by_side);
        assert!(rendered_text(narrow.backend().buffer()).contains("example.rs"));
    }
}

#[test]
fn conversation_hover_does_not_change_navigation_or_links() {
    let mut app = overview_app();
    app.pull_request_section = PullRequestSection::Overview;
    let theme = Theme::default();
    let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    let before = terminal.backend().buffer().clone();
    let content = app.geometry.content;
    let y = content.y + 3;
    let links = app.geometry.link_hits.len();
    let focus = app.focus;
    hover_at(&mut app, content.x + 10, y);
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    assert_ne!(
        before[(content.x + 10, y)].bg,
        terminal.backend().buffer()[(content.x + 10, y)].bg
    );
    assert_eq!(app.focus, focus);
    assert_eq!(app.content_scroll, 0);
    assert_eq!(app.geometry.link_hits.len(), links);
    assert!(app.geometry.diff_viewport.is_none());
    drop(app.handle_key(
        KeyEvent::new(KeyCode::Null, KeyModifiers::NONE),
        Instant::now(),
    ));
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    assert_eq!(before, *terminal.backend().buffer());
}

#[test]
fn hovering_a_commit_highlights_both_rows_without_selecting_it() {
    let mut app = App::new("/example/project", "example");
    app.view = View::History;
    app.history = (0..3)
        .map(|index| crate::git::history::Commit {
            id: format!("{index:040x}"),
            short_id: format!("{index:07x}"),
            parent_ids: Vec::new(),
            author: "Example".to_owned(),
            author_email: "example@example.invalid".to_owned(),
            authored_at: String::new(),
            committer: "Example".to_owned(),
            committer_email: "example@example.invalid".to_owned(),
            committed_at: String::new(),
            relative_date: "now".to_owned(),
            subject: format!("example commit {index}"),
            decorations: Vec::new(),
        })
        .collect();
    let theme = Theme::default();
    let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    let hit = app
        .geometry
        .sidebar_hits
        .iter()
        .find(|hit| matches!(hit.target, SidebarHit::Commit(1)))
        .unwrap()
        .area;
    let before = terminal.backend().buffer().clone();
    hover_at(&mut app, hit.x + 10, hit.y + 1);
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    assert_eq!(app.history_cursor, 0);
    assert!(!app.document_loading);
    for y in hit.y..hit.bottom() {
        assert_ne!(
            before[(hit.x + 10, y)].bg,
            terminal.backend().buffer()[(hit.x + 10, y)].bg
        );
    }
}

#[test]
fn selection_and_modals_take_precedence_over_hover() {
    let theme = Theme::default();
    let mut app = diff_app(DiffLayout::Unified, false);
    let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    let viewport = app.geometry.diff_viewport.unwrap();
    let point = (viewport.area.x + 12, viewport.area.y + 2);
    hover_at(&mut app, point.0, point.1);
    app.text_selection = Some(crate::app::TextSelection {
        pane: viewport.area,
        anchor: point,
        head: (point.0 + 5, point.1),
    });
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    assert_eq!(terminal.backend().buffer()[point].bg, theme.selected);
    app.text_selection = None;
    app.modal = Some(Modal::Help {
        selected: 0,
        scroll: 0,
        hover: None,
    });
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    let hovered = terminal.backend().buffer().clone();
    app.mouse_hover = None;
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    assert_eq!(hovered, *terminal.backend().buffer());
}

#[test]
fn scrollbar_drag_takes_precedence_over_hover_feedback() {
    use super::scrollbars::mouse;

    let theme = Theme::default();
    let mut app = diff_app(DiffLayout::Unified, false);
    app.document.lines.splice(
        3..3,
        std::iter::repeat_n(test_line(DiffLineKind::Context, "example text"), 100),
    );
    let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    let scrollbar = app.geometry.content_scrollbar.unwrap();
    let viewport = app.geometry.diff_viewport.unwrap();
    hover_at(&mut app, viewport.area.x + 15, viewport.area.y + 2);
    app.link_hover = app.mouse_hover;
    drop(mouse(
        &mut app,
        MouseEventKind::Down(crossterm::event::MouseButton::Left),
        scrollbar.thumb.x,
        scrollbar.thumb.y,
    ));
    assert!(app.scrollbar_drag.is_some());
    assert!(app.mouse_hover.is_none());
    assert!(app.link_hover.is_none());
    hover_at(&mut app, viewport.area.x + 15, viewport.area.y + 2);
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    let dragging = terminal.backend().buffer().clone();
    app.mouse_hover = None;
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    assert_eq!(dragging, *terminal.backend().buffer());
    let effects = mouse(
        &mut app,
        MouseEventKind::Up(crossterm::event::MouseButton::Left),
        scrollbar.thumb.x,
        scrollbar.thumb.y,
    );
    assert!(effects.is_empty());
    assert!(app.scrollbar_drag.is_none());
}

#[test]
fn scrollbar_click_remains_available_after_momentum_crosses_panes() {
    use super::scrollbars::mouse;

    let theme = Theme::default();
    let mut app = diff_app(DiffLayout::Unified, false);
    app.sidebar_hidden = false;
    app.document.lines.splice(
        3..3,
        std::iter::repeat_n(test_line(DiffLineKind::Context, "example text"), 100),
    );
    app.status.changes = (0..100)
        .map(|index| Change {
            path: format!("example-{index}.txt").into(),
            original_path: None,
            area: ChangeArea::Unstaged,
            status: ChangeStatus::Modified,
        })
        .collect();
    let mut terminal = Terminal::new(TestBackend::new(160, 32)).unwrap();
    terminal
        .draw(|frame| draw(frame, &mut app, &theme))
        .unwrap();
    let content = app.geometry.content;
    let sidebar = app.geometry.sidebar;
    drop(mouse(
        &mut app,
        MouseEventKind::ScrollDown,
        content.x + 10,
        content.y + 5,
    ));
    hover_at(&mut app, sidebar.x + 10, sidebar.y + 5);
    drop(mouse(
        &mut app,
        MouseEventKind::ScrollDown,
        sidebar.x + 10,
        sidebar.y + 5,
    ));
    assert_eq!(app.sidebar_offset, 0);
    let scrollbar = app.geometry.sidebar_scrollbar.unwrap();
    drop(mouse(
        &mut app,
        MouseEventKind::Down(crossterm::event::MouseButton::Left),
        scrollbar.thumb.x,
        scrollbar.thumb.y,
    ));
    drop(mouse(
        &mut app,
        MouseEventKind::Drag(crossterm::event::MouseButton::Left),
        scrollbar.thumb.x,
        scrollbar.track.y + 10,
    ));
    assert!(app.sidebar_offset > 0);
    let effects = mouse(
        &mut app,
        MouseEventKind::Up(crossterm::event::MouseButton::Left),
        scrollbar.thumb.x,
        scrollbar.track.y + 10,
    );
    assert!(effects.is_empty());
    let offset = app.sidebar_offset;
    drop(mouse(
        &mut app,
        MouseEventKind::ScrollUp,
        sidebar.x + 10,
        sidebar.y + 5,
    ));
    assert_eq!(app.sidebar_offset, offset - 2);
}