clankerdiff-ratatui 0.1.2

Embeddable Ratatui diff review widget
Documentation
use crate::ReviewCommand;
use bitflags::bitflags;
use clankerdiff_core::CommentDraft;
pub use clankerdiff_core::InteractionPhase;
use clankerdiff_markdown::MarkdownCommentDraft;
use clankerdiff_theme::ThemeId;
use ratatui::layout::Position;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputOutcome<T> {
    Ignored,
    Consumed,
    Emitted(T),
    ThemeSelected(ThemeId),
}

impl<T> InputOutcome<T> {
    #[must_use]
    pub const fn is_consumed(&self) -> bool {
        !matches!(self, Self::Ignored)
    }
    #[must_use]
    pub fn into_event(self) -> Option<T> {
        match self {
            Self::Emitted(event) => Some(event),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyCode {
    Char(char),
    Enter,
    Esc,
    Tab,
    BackTab,
    Backspace,
    Delete,
    Insert,
    Left,
    Right,
    Up,
    Down,
    Home,
    End,
    PageUp,
    PageDown,
    F(u8),
    Null,
}

impl KeyCode {
    #[must_use]
    pub fn ctrl(key: impl Into<Self>) -> KeyEvent {
        KeyEvent::new(key.into(), KeyModifiers::CONTROL)
    }

    #[must_use]
    pub fn alt(key: impl Into<Self>) -> KeyEvent {
        KeyEvent::new(key.into(), KeyModifiers::ALT)
    }

    #[must_use]
    pub fn shift(key: impl Into<Self>) -> KeyEvent {
        KeyEvent::new(key.into(), KeyModifiers::SHIFT)
    }
}

impl From<char> for KeyCode {
    fn from(key: char) -> Self {
        Self::Char(key)
    }
}

bitflags! {
    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
    pub struct KeyModifiers: u8 {
        const NONE = 0;
        const SHIFT = 1;
        const CONTROL = 2;
        const ALT = 4;
        const SUPER = 8;
        const HYPER = 16;
        const META = 32;
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyEvent {
    pub code: KeyCode,
    pub modifiers: KeyModifiers,
}
impl KeyEvent {
    #[must_use]
    pub const fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
        Self { code, modifiers }
    }
}

impl From<KeyCode> for KeyEvent {
    fn from(code: KeyCode) -> Self {
        Self::new(code, KeyModifiers::NONE)
    }
}

impl From<char> for KeyEvent {
    fn from(key: char) -> Self {
        KeyCode::from(key).into()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseButton {
    Left,
    Right,
    Middle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseEventKind {
    Down(MouseButton),
    Up(MouseButton),
    Drag(MouseButton),
    Moved,
    ScrollUp,
    ScrollDown,
    ScrollLeft,
    ScrollRight,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MouseEvent {
    pub kind: MouseEventKind,
    pub column: u16,
    pub row: u16,
    pub modifiers: KeyModifiers,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewInput {
    Key(KeyEvent),
    Paste(String),
    Mouse(MouseEvent),
}

impl From<KeyEvent> for ReviewInput {
    fn from(key: KeyEvent) -> Self {
        Self::Key(key)
    }
}

impl From<KeyCode> for ReviewInput {
    fn from(key: KeyCode) -> Self {
        Self::Key(key.into())
    }
}

impl From<char> for ReviewInput {
    fn from(key: char) -> Self {
        Self::Key(key.into())
    }
}

impl From<MouseEvent> for ReviewInput {
    fn from(mouse: MouseEvent) -> Self {
        Self::Mouse(mouse)
    }
}

pub(crate) trait DraftEditor {
    fn insert(&mut self, text: &str);
    fn edit(&mut self, code: KeyCode);
}

impl DraftEditor for CommentDraft {
    fn insert(&mut self, text: &str) {
        Self::insert(self, text);
    }
    fn edit(&mut self, code: KeyCode) {
        match code {
            KeyCode::Left => self.move_cursor_left(),
            KeyCode::Right => self.move_cursor_right(),
            KeyCode::Home => self.move_cursor_to_start(),
            KeyCode::End => self.move_cursor_to_end(),
            KeyCode::Backspace => self.delete_before_cursor(),
            KeyCode::Delete => self.delete_at_cursor(),
            _ => {}
        }
    }
}

impl DraftEditor for MarkdownCommentDraft {
    fn insert(&mut self, text: &str) {
        Self::insert(self, text);
    }
    fn edit(&mut self, code: KeyCode) {
        match code {
            KeyCode::Left => self.move_cursor_left(),
            KeyCode::Right => self.move_cursor_right(),
            KeyCode::Home => self.move_cursor_to_start(),
            KeyCode::End => self.move_cursor_to_end(),
            KeyCode::Backspace => self.delete_before_cursor(),
            KeyCode::Delete => self.delete_at_cursor(),
            _ => {}
        }
    }
}

pub(crate) trait ReviewWidget {
    type Event;
    type Error;
    type Draft: DraftEditor;
    fn phase(&self) -> InteractionPhase;
    fn handle_review_command(
        &mut self,
        command: ReviewCommand,
    ) -> Result<InputOutcome<Self::Event>, Self::Error>;
    fn contains(&self, position: Position) -> bool;
    fn mark_dirty(&mut self);
    fn draft_mut(&mut self) -> Option<&mut Self::Draft>;
    fn draft_changed(&mut self);
    fn handle_browse_key(
        &mut self,
        key: KeyEvent,
    ) -> Result<InputOutcome<Self::Event>, Self::Error>;
    fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<Self::Event>;
    fn handle_prompt_key(&mut self, _key: KeyEvent) -> InputOutcome<Self::Event> {
        InputOutcome::Consumed
    }
    fn paste_prompt(&mut self, _text: &str) {}
}

pub(crate) fn handle_input<T: ReviewWidget>(
    state: &mut T,
    input: ReviewInput,
) -> Result<InputOutcome<T::Event>, T::Error> {
    let phase = state.phase();
    let outcome = match input {
        ReviewInput::Key(key) => handle_key(state, key)?,
        ReviewInput::Paste(text) => {
            if let Some(draft) = state.draft_mut() {
                draft.insert(&text);
                state.draft_changed();
                state.mark_dirty();
                InputOutcome::Consumed
            } else if phase != InteractionPhase::Browse {
                if phase == InteractionPhase::RepositoryPrompt {
                    state.paste_prompt(&text);
                    state.mark_dirty();
                }
                InputOutcome::Consumed
            } else {
                InputOutcome::Ignored
            }
        }
        ReviewInput::Mouse(mouse) => {
            if phase != InteractionPhase::Browse {
                InputOutcome::Consumed
            } else if !state.contains(Position::new(mouse.column, mouse.row))
                || !matches!(
                    mouse.kind,
                    MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
                )
            {
                InputOutcome::Ignored
            } else {
                let outcome = state.handle_mouse(mouse);
                if outcome.is_consumed() {
                    state.mark_dirty();
                }
                outcome
            }
        }
    };
    Ok(outcome)
}

fn handle_key<T: ReviewWidget>(
    state: &mut T,
    key: KeyEvent,
) -> Result<InputOutcome<T::Event>, T::Error> {
    let command = match state.phase() {
        InteractionPhase::ThemePicker => {
            if !is_plain_key(key) {
                return Ok(InputOutcome::Consumed);
            }
            match key.code {
                KeyCode::Esc | KeyCode::Char('q') => ReviewCommand::Cancel,
                KeyCode::Enter => ReviewCommand::CommitTheme,
                KeyCode::Up | KeyCode::Char('k') => ReviewCommand::MoveTheme(-1),
                KeyCode::Down | KeyCode::Char('j') => ReviewCommand::MoveTheme(1),
                KeyCode::Home | KeyCode::Char('g') => ReviewCommand::SelectTheme(0),
                KeyCode::End | KeyCode::Char('G') => ReviewCommand::MoveTheme(isize::MAX),
                _ => return Ok(InputOutcome::Consumed),
            }
        }
        InteractionPhase::RepositoryPrompt => {
            let outcome = state.handle_prompt_key(key);
            if outcome.is_consumed() {
                state.mark_dirty();
            }
            return Ok(outcome);
        }
        InteractionPhase::Help => {
            if !is_plain_key(key) {
                return Ok(InputOutcome::Consumed);
            }
            match key.code {
                KeyCode::Esc | KeyCode::Char('?') => ReviewCommand::Cancel,
                KeyCode::Down | KeyCode::Char('j') => ReviewCommand::ScrollHelp(1),
                KeyCode::Up | KeyCode::Char('k') => ReviewCommand::ScrollHelp(-1),
                KeyCode::PageDown => ReviewCommand::ScrollHelp(10),
                KeyCode::PageUp => ReviewCommand::ScrollHelp(-10),
                KeyCode::Home => ReviewCommand::ScrollHelp(isize::MIN),
                KeyCode::End => ReviewCommand::ScrollHelp(isize::MAX),
                _ => return Ok(InputOutcome::Consumed),
            }
        }
        InteractionPhase::Draft => return handle_draft_key(state, key),
        InteractionPhase::Browse => return state.handle_browse_key(key),
    };
    state.handle_review_command(command)
}

pub(crate) fn is_plain_key(key: KeyEvent) -> bool {
    key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT
}

fn handle_draft_key<T: ReviewWidget>(
    state: &mut T,
    key: KeyEvent,
) -> Result<InputOutcome<T::Event>, T::Error> {
    if key.code == KeyCode::Esc {
        return state.handle_review_command(ReviewCommand::Cancel);
    } else if key.code == KeyCode::Enter && !key.modifiers.contains(KeyModifiers::SHIFT) {
        return state.handle_review_command(ReviewCommand::SubmitComment);
    } else if let Some(draft) = state.draft_mut() {
        match key.code {
            KeyCode::Enter => draft.insert("\n"),
            KeyCode::Char(character) if is_plain_key(key) => {
                let mut buffer = [0; 4];
                draft.insert(character.encode_utf8(&mut buffer));
            }
            code => draft.edit(code),
        }
        state.draft_changed();
        state.mark_dirty();
    }
    Ok(InputOutcome::Consumed)
}