clankerdiff-ratatui 0.1.1

Embeddable Ratatui diff review widget
Documentation
use crate::{
    DiffReviewCommand, DiffReviewState, FocusPane, InputOutcome, InteractionPhase, KeyCode,
    KeyEvent, MouseEvent, MouseEventKind, ReviewCommand, ReviewInput,
    interaction::{self, ReviewWidget},
    state::{RepositoryOperationStatus, RepositoryPrompt},
};
use clankerdiff_core::{CommentDraft, DiffReviewEvent, RepositoryAction};
#[cfg(feature = "crossterm-backend")]
use crossterm::event::Event;
use ratatui::layout::Position;
use std::convert::Infallible;

#[cfg(feature = "crossterm-backend")]
#[must_use]
pub fn handle_crossterm_event(
    state: &mut DiffReviewState,
    event: Event,
) -> InputOutcome<DiffReviewEvent> {
    let Ok(outcome) = crate::crossterm_adapter::handle_event(state, event);
    outcome
}

impl DiffReviewState {
    #[must_use]
    pub fn interaction_phase(&self) -> InteractionPhase {
        if self.theme_picker.is_some() {
            InteractionPhase::ThemePicker
        } else if self.repository_prompt.is_some() {
            InteractionPhase::RepositoryPrompt
        } else if self.help {
            InteractionPhase::Help
        } else if self.session.draft().is_some() {
            InteractionPhase::Draft
        } else {
            InteractionPhase::Browse
        }
    }

    #[must_use]
    pub fn handle_input(&mut self, input: ReviewInput) -> InputOutcome<DiffReviewEvent> {
        let Ok(outcome) = interaction::handle_input(self, input);
        outcome
    }
}

impl ReviewWidget for DiffReviewState {
    type Event = DiffReviewEvent;
    type Error = Infallible;
    type Draft = CommentDraft;

    fn phase(&self) -> InteractionPhase {
        self.interaction_phase()
    }
    fn handle_review_command(
        &mut self,
        command: ReviewCommand,
    ) -> Result<InputOutcome<DiffReviewEvent>, Infallible> {
        Ok(self.handle_command(command))
    }
    fn contains(&self, position: Position) -> bool {
        self.hit_layout.drawer.contains(position) || self.hit_layout.patch.contains(position)
    }
    fn mark_dirty(&mut self) {
        Self::mark_dirty(self);
    }
    fn draft_mut(&mut self) -> Option<&mut CommentDraft> {
        self.session.draft_mut()
    }
    fn draft_changed(&mut self) {
        self.request_follow();
    }
    fn paste_prompt(&mut self, text: &str) {
        if let Some(RepositoryPrompt::Commit { message }) = &mut self.repository_prompt {
            message.push_str(text);
        }
    }
    fn handle_browse_key(
        &mut self,
        key: KeyEvent,
    ) -> Result<InputOutcome<DiffReviewEvent>, Infallible> {
        Ok(self
            .command_for_key(key)
            .map_or(InputOutcome::Ignored, |command| {
                self.handle_command(command)
            }))
    }
    fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<DiffReviewEvent> {
        self.handle_mouse(mouse)
    }
    fn handle_prompt_key(&mut self, key: KeyEvent) -> InputOutcome<DiffReviewEvent> {
        self.handle_repository_prompt_key(key)
    }
}

impl DiffReviewState {
    fn handle_repository_prompt_key(&mut self, key: KeyEvent) -> InputOutcome<DiffReviewEvent> {
        if key.code == KeyCode::Esc {
            return self.handle_command(ReviewCommand::Cancel);
        }
        if !interaction::is_plain_key(key) {
            return InputOutcome::Consumed;
        }
        let Some(prompt) = self.repository_prompt.as_mut() else {
            return InputOutcome::Consumed;
        };
        let action = match prompt {
            RepositoryPrompt::Commit { message } => match key.code {
                KeyCode::Enter => {
                    let message = message.trim().to_owned();
                    if message.is_empty() {
                        self.set_repository_error("Commit message cannot be empty");
                        None
                    } else {
                        Some(RepositoryAction::Commit { message })
                    }
                }
                KeyCode::Backspace => {
                    message.pop();
                    None
                }
                KeyCode::Char(character) => {
                    message.push(character);
                    None
                }
                _ => None,
            },
            RepositoryPrompt::Discard { path, status } => match key.code {
                KeyCode::Char('y' | 'Y') => Some(RepositoryAction::Discard {
                    path: path.clone(),
                    status: *status,
                }),
                KeyCode::Char('n' | 'N') => {
                    self.repository_prompt = None;
                    None
                }
                _ => None,
            },
        };
        if let Some(action) = action {
            self.repository_prompt = None;
            return self.handle_command(DiffReviewCommand::RepositoryAction(action));
        }
        InputOutcome::Consumed
    }

    fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<DiffReviewEvent> {
        let position = Position::new(mouse.column, mouse.row);
        match mouse.kind {
            MouseEventKind::ScrollUp => self.scroll_at(position, -1),
            MouseEventKind::ScrollDown => self.scroll_at(position, 1),
            MouseEventKind::Down(_) => {
                if self.hit_layout.drawer.contains(position) {
                    self.focus = FocusPane::Files;
                    let relative = usize::from(mouse.row.saturating_sub(self.hit_layout.drawer.y));
                    self.select_drawer_entry(self.drawer_scroll.saturating_add(relative));
                    if self.hit_layout.drawer_stage_column == Some(mouse.column)
                        && !matches!(self.repository_status, RepositoryOperationStatus::Pending)
                    {
                        return self.handle_command(DiffReviewCommand::ToggleStage);
                    }
                } else if self.hit_layout.patch.contains(position) {
                    self.focus = FocusPane::Diff;
                    self.select_clicked_row(mouse.row);
                }
            }
            _ => {}
        }
        InputOutcome::Consumed
    }

    fn scroll_at(&mut self, position: Position, direction: isize) {
        let pane = if self.hit_layout.patch.contains(position) {
            FocusPane::Diff
        } else if self.hit_layout.drawer.contains(position) {
            FocusPane::Files
        } else {
            self.focus
        };
        match pane {
            FocusPane::Diff => self.move_row(direction),
            FocusPane::Files => self.scroll_drawer(direction),
        }
    }
}