clankerdiff-ratatui 0.1.5

Embeddable Ratatui diff review widget
Documentation
use crate::{
    DiffReviewCommand, DiffReviewEvent, DiffReviewState, DiffReviewStatus, FocusPane, InputOutcome,
    InteractionPhase, KeyBinding, KeyEvent, NavigationPane, ReviewCommand, keybindings,
    theme_picker::ThemePicker,
};
use clankerdiff_core::{CommandContext, RepositoryAction, ReviewCapabilities};
use std::sync::Arc;

impl DiffReviewState {
    #[must_use]
    pub fn command_for_key(&self, key: KeyEvent) -> Option<DiffReviewCommand> {
        if self.interaction_phase() != InteractionPhase::Browse {
            return None;
        }
        keybindings::binding_for_key(
            &self.keybindings,
            key,
            self.focus == FocusPane::Diff,
            self.layout().is_split(),
        )
        .map(|binding| binding.command.clone())
    }

    pub(crate) fn help_bindings(&self) -> impl Iterator<Item = &KeyBinding<DiffReviewCommand>> {
        let context = CommandContext {
            phase: InteractionPhase::Browse,
            ..self.command_context()
        };
        keybindings::help_bindings(
            &self.keybindings,
            context.navigation_available,
            move |command| self.command_enabled_in(command, &context),
        )
    }

    pub(crate) fn footer_hint(&self, width: usize) -> String {
        keybindings::footer_hint(
            &self.keybindings,
            self.focus == FocusPane::Diff,
            self.layout().is_split(),
            |command| self.command_enabled(command),
            &ReviewCommand::ShowHelp.into(),
            width,
        )
    }

    #[must_use]
    pub fn command_context(&self) -> CommandContext {
        CommandContext {
            phase: self.interaction_phase(),
            capabilities: self.capabilities,
            repository_pending: self.repository_pending(),
            document_ready: matches!(self.status, DiffReviewStatus::Ready),
            navigation_available: !matches!(
                self.options.navigation,
                NavigationPane::Hidden | NavigationPane::Width(0)
            ),
            themes_available: !self.theme_choices.is_empty(),
        }
    }

    pub fn set_capabilities(&mut self, capabilities: ReviewCapabilities) {
        self.capabilities = capabilities;
        if !capabilities.repository {
            self.repository_prompt = None;
        }
        self.mark_dirty();
    }

    #[must_use]
    pub fn command_enabled(&self, command: &DiffReviewCommand) -> bool {
        self.command_enabled_in(command, &self.command_context())
    }

    fn command_enabled_in(&self, command: &DiffReviewCommand, context: &CommandContext) -> bool {
        command.enabled(context)
            && (!matches!(command, DiffReviewCommand::CopyReview) || !self.review().is_empty())
    }

    #[expect(
        clippy::too_many_lines,
        reason = "Exhaustive command dispatch keeps routing in one place"
    )]
    pub fn handle_command(
        &mut self,
        command: impl Into<DiffReviewCommand>,
    ) -> InputOutcome<DiffReviewEvent> {
        use DiffReviewCommand as C;
        use ReviewCommand as R;
        let command = command.into();
        if !self.command_enabled(&command) {
            return InputOutcome::Ignored;
        }
        match command {
            C::Focus(pane) => self.focus = pane,
            C::ToggleFocus => {
                self.focus = match self.focus {
                    FocusPane::Files => FocusPane::Diff,
                    FocusPane::Diff => FocusPane::Files,
                }
            }
            C::SelectFile(index) => {
                if !self.select_file(index) {
                    return InputOutcome::Ignored;
                }
            }
            C::MoveSelection(delta) => match self.focus {
                FocusPane::Files => self.move_drawer_entry(delta),
                FocusPane::Diff => self.move_row(delta),
            },
            C::Scroll { pane, lines } => {
                match pane {
                    FocusPane::Files => self.scroll_drawer(lines),
                    FocusPane::Diff => self.scroll_patch(lines),
                }
                return InputOutcome::Consumed;
            }
            C::Page(delta) => self.page(delta),
            C::First => self.select_boundary(false),
            C::Last => self.select_boundary(true),
            C::OpenSelected => {
                if !self.expand_or_open_drawer_entry() {
                    self.focus = FocusPane::Diff;
                }
            }
            C::CollapseSelected => self.collapse_drawer_entry(),
            C::ToggleStage => {
                let Some(action) = self.toggle_stage_action() else {
                    return InputOutcome::Ignored;
                };
                return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(action));
            }
            C::BeginCommit => self.begin_commit(),
            C::BeginDiscard => self.begin_discard(),
            C::SelectRow(index) => {
                let previous = (self.session.selected_row(), self.session.selected_side());
                if !self
                    .session
                    .selected_file_range()
                    .is_some_and(|range| range.contains(&index))
                    || !self.session.select_row(index)
                {
                    return InputOutcome::Ignored;
                }
                if previous == (self.session.selected_row(), self.session.selected_side()) {
                    return InputOutcome::Consumed;
                }
                self.request_follow();
            }
            C::SelectSide(side) => {
                let previous = self.session.selected_side();
                self.session.set_selected_side(side);
                if previous == self.session.selected_side() {
                    return InputOutcome::Consumed;
                }
                self.request_follow();
            }
            C::RevealGap(amount) => {
                self.reveal_selected_gap(amount);
                return InputOutcome::Consumed;
            }
            C::ToggleFullFile => {
                self.toggle_full_file();
                return InputOutcome::Consumed;
            }
            C::SetViewMode(mode) => {
                self.set_view_mode(mode);
                return InputOutcome::Consumed;
            }
            C::CycleViewMode => {
                if self.session.cycle_view_mode() {
                    self.scroll_to_selected_file();
                }
                return InputOutcome::Consumed;
            }
            C::SubmitReview => {
                return InputOutcome::Emitted(DiffReviewEvent::SubmitReview(
                    self.session.submission(),
                ));
            }
            C::CopyReview => {
                return InputOutcome::Emitted(DiffReviewEvent::CopyFormattedReview(
                    self.session.submission().formatted,
                ));
            }
            C::SetScope(scope) => return InputOutcome::Emitted(DiffReviewEvent::SetScope(scope)),
            C::CycleScope => {
                return InputOutcome::Emitted(DiffReviewEvent::SetScope(self.scope.next()));
            }
            C::Refresh => return InputOutcome::Emitted(DiffReviewEvent::Refresh),
            C::StageAll => {
                return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(
                    RepositoryAction::StageAll,
                ));
            }
            C::UnstageAll => {
                return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(
                    RepositoryAction::UnstageAll,
                ));
            }
            C::RepositoryAction(action) => {
                return InputOutcome::Emitted(DiffReviewEvent::RepositoryAction(action));
            }
            C::Review(R::BeginComment | R::EditComment) => {
                let started = if command == C::Review(R::BeginComment) {
                    self.session.begin_draft(None)
                } else {
                    self.session.edit_comment_at_selection()
                };
                if !started {
                    return InputOutcome::Ignored;
                }
                self.request_follow();
            }
            C::Review(R::DeleteComment | R::UndoComment) => {
                let changed = if command == C::Review(R::DeleteComment) {
                    self.session.delete_comment_at_selection()
                } else {
                    self.session.undo_last_comment()
                };
                if !changed {
                    return InputOutcome::Ignored;
                }
                self.request_follow();
            }
            C::Review(R::SubmitComment) => {
                self.session.submit_draft();
                self.cursor_position = None;
                self.request_follow();
            }
            C::Review(R::Cancel) => match self.interaction_phase() {
                InteractionPhase::Browse => return InputOutcome::Emitted(DiffReviewEvent::Cancel),
                InteractionPhase::Draft => {
                    self.session.cancel_draft();
                    self.cursor_position = None;
                    self.request_follow();
                }
                InteractionPhase::Help => self.help = false,
                InteractionPhase::RepositoryPrompt => self.repository_prompt = None,
                InteractionPhase::ThemePicker => {
                    if let Some(picker) = self.theme_picker.take() {
                        self.apply_theme(picker.cancel());
                    }
                }
            },
            C::Review(R::ShowHelp) => {
                self.help = true;
                self.help_scroll = 0;
            }
            C::Review(R::ScrollHelp(lines)) => {
                self.help_scroll = self
                    .help_scroll
                    .saturating_add_signed(lines)
                    .min(self.help_bindings().count().saturating_sub(1));
            }
            C::Review(R::OpenThemePicker) => {
                self.theme_picker = ThemePicker::new(&self.theme, Arc::clone(&self.theme_choices));
            }
            C::Review(R::SelectTheme(index)) => {
                let Some(theme) = self
                    .theme_picker
                    .as_mut()
                    .and_then(|picker| picker.select(index))
                else {
                    return InputOutcome::Ignored;
                };
                self.apply_theme(theme);
            }
            C::Review(R::MoveTheme(delta)) => {
                if let Some(picker) = self.theme_picker.as_mut() {
                    let theme = picker.select_relative(delta);
                    self.apply_theme(theme);
                }
            }
            C::Review(R::CommitTheme) => {
                let Some(picker) = self.theme_picker.take() else {
                    return InputOutcome::Ignored;
                };
                let theme = picker.commit();
                let id = theme.id().clone();
                self.apply_theme(theme);
                return InputOutcome::ThemeSelected(id);
            }
        }
        self.mark_dirty();
        InputOutcome::Consumed
    }
}