Skip to main content

clankerdiff_ratatui/
input.rs

1use crate::{
2    DiffReviewCommand, DiffReviewState, FocusPane, InputOutcome, InteractionPhase, KeyCode,
3    KeyEvent, MouseEvent, MouseEventKind, ReviewCommand, ReviewInput,
4    interaction::{self, ReviewWidget},
5    state::{RepositoryOperationStatus, RepositoryPrompt},
6};
7use clankerdiff_core::{CommentDraft, DiffReviewEvent, RepositoryAction};
8#[cfg(feature = "crossterm-backend")]
9use crossterm::event::Event;
10use ratatui::layout::Position;
11use std::convert::Infallible;
12
13#[cfg(feature = "crossterm-backend")]
14#[must_use]
15pub fn handle_crossterm_event(
16    state: &mut DiffReviewState,
17    event: Event,
18) -> InputOutcome<DiffReviewEvent> {
19    let Ok(outcome) = crate::crossterm_adapter::handle_event(state, event);
20    outcome
21}
22
23impl DiffReviewState {
24    #[must_use]
25    pub fn interaction_phase(&self) -> InteractionPhase {
26        if self.theme_picker.is_some() {
27            InteractionPhase::ThemePicker
28        } else if self.repository_prompt.is_some() {
29            InteractionPhase::RepositoryPrompt
30        } else if self.help {
31            InteractionPhase::Help
32        } else if self.session.draft().is_some() {
33            InteractionPhase::Draft
34        } else {
35            InteractionPhase::Browse
36        }
37    }
38
39    #[must_use]
40    pub fn handle_input(&mut self, input: ReviewInput) -> InputOutcome<DiffReviewEvent> {
41        let Ok(outcome) = interaction::handle_input(self, input);
42        self.install_deferred();
43        outcome
44    }
45}
46
47impl ReviewWidget for DiffReviewState {
48    type Event = DiffReviewEvent;
49    type Error = Infallible;
50    type Draft = CommentDraft;
51
52    fn phase(&self) -> InteractionPhase {
53        self.interaction_phase()
54    }
55    fn handle_review_command(
56        &mut self,
57        command: ReviewCommand,
58    ) -> Result<InputOutcome<DiffReviewEvent>, Infallible> {
59        Ok(self.handle_command(command))
60    }
61    fn contains(&self, position: Position) -> bool {
62        self.hit_layout.drawer.contains(position) || self.hit_layout.patch.contains(position)
63    }
64    fn mark_dirty(&mut self) {
65        Self::mark_dirty(self);
66    }
67    fn draft_mut(&mut self) -> Option<&mut CommentDraft> {
68        self.session.draft_mut()
69    }
70    fn draft_changed(&mut self) {
71        self.request_follow();
72    }
73    fn paste_prompt(&mut self, text: &str) {
74        if let Some(RepositoryPrompt::Commit { message }) = &mut self.repository_prompt {
75            message.push_str(text);
76        }
77    }
78    fn handle_browse_key(
79        &mut self,
80        key: KeyEvent,
81    ) -> Result<InputOutcome<DiffReviewEvent>, Infallible> {
82        Ok(self
83            .command_for_key(key)
84            .map_or(InputOutcome::Ignored, |command| {
85                self.handle_command(command)
86            }))
87    }
88    fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<DiffReviewEvent> {
89        self.handle_mouse(mouse)
90    }
91    fn handle_draft_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<DiffReviewEvent> {
92        if !matches!(mouse.kind, MouseEventKind::Down(_))
93            || !self
94                .hit_layout
95                .patch
96                .contains(Position::new(mouse.column, mouse.row))
97            || !self
98                .session
99                .draft()
100                .is_some_and(|draft| draft.body().is_empty())
101        {
102            return InputOutcome::Consumed;
103        }
104        let clicked = self
105            .visible_rows
106            .iter()
107            .find_map(|(row, index)| (*row == mouse.row).then_some(*index));
108        if let Some(index) = clicked
109            && self.session.presentation().gap_info(index).is_none()
110            && self.session.select_row(index)
111            && self.session.begin_draft(None)
112        {
113            self.focus = FocusPane::Diff;
114            self.request_follow();
115            self.mark_dirty();
116        }
117        InputOutcome::Consumed
118    }
119    fn handle_prompt_key(&mut self, key: KeyEvent) -> InputOutcome<DiffReviewEvent> {
120        self.handle_repository_prompt_key(key)
121    }
122}
123
124impl DiffReviewState {
125    fn handle_repository_prompt_key(&mut self, key: KeyEvent) -> InputOutcome<DiffReviewEvent> {
126        if key.code == KeyCode::Esc {
127            return self.handle_command(ReviewCommand::Cancel);
128        }
129        if !interaction::is_plain_key(key) {
130            return InputOutcome::Consumed;
131        }
132        let Some(prompt) = self.repository_prompt.as_mut() else {
133            return InputOutcome::Consumed;
134        };
135        let action = match prompt {
136            RepositoryPrompt::Commit { message } => match key.code {
137                KeyCode::Enter => {
138                    let message = message.trim().to_owned();
139                    if message.is_empty() {
140                        self.set_repository_error("Commit message cannot be empty");
141                        None
142                    } else {
143                        Some(RepositoryAction::Commit { message })
144                    }
145                }
146                KeyCode::Backspace => {
147                    message.pop();
148                    None
149                }
150                KeyCode::Char(character) => {
151                    message.push(character);
152                    None
153                }
154                _ => None,
155            },
156            RepositoryPrompt::Discard { path, status } => match key.code {
157                KeyCode::Char('y' | 'Y') => Some(RepositoryAction::Discard {
158                    path: path.clone(),
159                    status: *status,
160                }),
161                KeyCode::Char('n' | 'N') => {
162                    self.repository_prompt = None;
163                    None
164                }
165                _ => None,
166            },
167        };
168        if let Some(action) = action {
169            self.repository_prompt = None;
170            return self.handle_command(DiffReviewCommand::RepositoryAction(action));
171        }
172        InputOutcome::Consumed
173    }
174
175    fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<DiffReviewEvent> {
176        let position = Position::new(mouse.column, mouse.row);
177        match mouse.kind {
178            MouseEventKind::ScrollUp => self.scroll_at(position, -1),
179            MouseEventKind::ScrollDown => self.scroll_at(position, 1),
180            MouseEventKind::Down(_) => {
181                if self.hit_layout.drawer.contains(position) {
182                    self.focus = FocusPane::Files;
183                    let relative = usize::from(mouse.row.saturating_sub(self.hit_layout.drawer.y));
184                    self.select_drawer_entry(self.drawer_scroll.saturating_add(relative));
185                    if self.hit_layout.drawer_stage_column == Some(mouse.column)
186                        && !matches!(self.repository_status, RepositoryOperationStatus::Pending)
187                    {
188                        return self.handle_command(DiffReviewCommand::ToggleStage);
189                    }
190                } else if self.hit_layout.patch.contains(position) {
191                    self.focus = FocusPane::Diff;
192                    if self.select_clicked_row(mouse.row) {
193                        return self.handle_command(ReviewCommand::BeginComment);
194                    }
195                }
196            }
197            _ => {}
198        }
199        InputOutcome::Consumed
200    }
201
202    fn scroll_at(&mut self, position: Position, direction: isize) {
203        let pane = if self.hit_layout.patch.contains(position) {
204            FocusPane::Diff
205        } else if self.hit_layout.drawer.contains(position) {
206            FocusPane::Files
207        } else {
208            self.focus
209        };
210        match pane {
211            FocusPane::Diff => self.scroll_patch(direction),
212            FocusPane::Files => self.scroll_drawer(direction),
213        }
214    }
215}