Skip to main content

clankerdiff_ratatui/
input.rs

1//! Crossterm keyboard, mouse, and paste input helpers.
2
3use crate::{
4    DiffReviewState, FocusPane,
5    state::{RepositoryOperationStatus, RepositoryPrompt},
6    theme_picker::{ThemePicker, ThemePickerAction},
7};
8use clankerdiff_core::{DiffReviewEvent, DiffSide, RepositoryAction, RevealAmount};
9use crossterm::event::{
10    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
11};
12use ratatui::layout::Position;
13
14/// Files one wheel notch scrolls the drawer. A notch is a line, not a file, so
15/// the drawer moves one entry at a time.
16const DRAWER_WHEEL_ROWS: isize = 1;
17
18/// Framework-neutral input accepted by [`DiffReviewState::handle_input`].
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum DiffReviewInput {
21    /// A Crossterm key event.
22    Key(KeyEvent),
23    /// Pasted text.
24    Paste(String),
25    /// A Crossterm mouse event.
26    Mouse(MouseEvent),
27}
28
29/// Converts one Crossterm event and applies it to state.
30///
31/// Non-input events and key releases are ignored.
32#[must_use]
33pub fn handle_crossterm_event(
34    state: &mut DiffReviewState,
35    event: Event,
36) -> Option<DiffReviewEvent> {
37    match event {
38        Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
39            state.handle_input(DiffReviewInput::Key(key))
40        }
41        Event::Paste(text) => state.handle_input(DiffReviewInput::Paste(text)),
42        Event::Mouse(mouse) => state.handle_input(DiffReviewInput::Mouse(mouse)),
43        Event::Resize(..) => {
44            state.mark_dirty();
45            None
46        }
47        _ => None,
48    }
49}
50
51impl DiffReviewState {
52    #[must_use]
53    pub fn handle_input(&mut self, input: DiffReviewInput) -> Option<DiffReviewEvent> {
54        match input {
55            DiffReviewInput::Key(key) => {
56                self.mark_dirty();
57                self.handle_key(key)
58            }
59            DiffReviewInput::Paste(text) => {
60                if let Some(draft) = self.session.draft_mut() {
61                    draft.insert(&text);
62                    self.request_follow();
63                }
64                None
65            }
66            DiffReviewInput::Mouse(mouse) => self.handle_mouse(mouse),
67        }
68    }
69
70    fn handle_key(&mut self, key: KeyEvent) -> Option<DiffReviewEvent> {
71        if let Some(picker) = self.theme_picker.as_mut() {
72            let action = picker.handle_key(key);
73            match action {
74                ThemePickerAction::Preview(theme) => self.set_theme(theme),
75                ThemePickerAction::Restore(theme) => {
76                    self.set_theme(theme);
77                    self.theme_picker = None;
78                }
79                ThemePickerAction::Commit => self.theme_picker = None,
80                ThemePickerAction::None => {}
81            }
82            return None;
83        }
84        if self.repository_prompt.is_some() {
85            return self.handle_repository_prompt_key(key);
86        }
87        if self.help {
88            if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
89                self.help = false;
90            }
91            return None;
92        }
93        if self.session.draft().is_some() {
94            self.handle_draft_key(key);
95            return None;
96        }
97        let cancels = key.code == KeyCode::Esc
98            || (key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::CONTROL));
99        if cancels {
100            return Some(DiffReviewEvent::Cancel);
101        }
102        if key
103            .modifiers
104            .intersects(KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::CONTROL)
105        {
106            return None;
107        }
108        self.handle_browse_key(key)
109    }
110
111    #[allow(clippy::too_many_lines)]
112    fn handle_browse_key(&mut self, key: KeyEvent) -> Option<DiffReviewEvent> {
113        if matches!(self.repository_status, RepositoryOperationStatus::Pending)
114            && matches!(
115                key.code,
116                KeyCode::Char(' ' | 'a' | 'A' | 'C' | 'S' | 'd' | 'r')
117            )
118        {
119            return None;
120        }
121        let in_diff = self.focus == FocusPane::Diff;
122        match key.code {
123            KeyCode::Tab => {
124                self.focus = match self.focus {
125                    FocusPane::Files => FocusPane::Diff,
126                    FocusPane::Diff => FocusPane::Files,
127                };
128            }
129            KeyCode::Left if in_diff && self.layout().is_split() => {
130                self.session.set_selected_side(DiffSide::Old);
131            }
132            KeyCode::Right if in_diff && self.layout().is_split() => {
133                self.session.set_selected_side(DiffSide::New);
134            }
135            KeyCode::Left | KeyCode::Char('h') => {
136                if in_diff {
137                    self.focus = FocusPane::Files;
138                } else {
139                    self.collapse_drawer_entry();
140                }
141            }
142            KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter if !in_diff => {
143                if !self.expand_or_open_drawer_entry() {
144                    self.focus = FocusPane::Diff;
145                }
146            }
147            KeyCode::Up | KeyCode::Char('k') => self.move_focused(-1),
148            KeyCode::Down | KeyCode::Char('j') => self.move_focused(1),
149            KeyCode::PageUp => self.page(-1),
150            KeyCode::PageDown => self.page(1),
151            KeyCode::Home => self.select_boundary(false),
152            KeyCode::End => self.select_boundary(true),
153            KeyCode::Char('c') if in_diff => {
154                self.session.begin_draft(None);
155                self.request_follow();
156            }
157            KeyCode::Char('e') if in_diff => {
158                let editing = self.session.comment_id_at_selection();
159                if editing.is_some() {
160                    self.session.begin_draft(editing);
161                    self.request_follow();
162                }
163            }
164            KeyCode::Char('x') if in_diff => {
165                self.session.delete_comment_at_selection();
166            }
167            KeyCode::Char('u') if in_diff => {
168                if let Some(id) = self.session.last_comment_id() {
169                    self.session.review_mut().remove_comment(id);
170                }
171            }
172            KeyCode::Char('s') if in_diff => {
173                return Some(DiffReviewEvent::SubmitReview(self.session.submission()));
174            }
175            KeyCode::Char('y') if in_diff => {
176                return Some(DiffReviewEvent::CopyFormattedReview(
177                    self.session.submission().formatted,
178                ));
179            }
180            KeyCode::Char(' ') if self.focus == FocusPane::Files => {
181                if let Some(action) = self.toggle_stage_action() {
182                    return Some(DiffReviewEvent::RepositoryAction(action));
183                }
184            }
185            KeyCode::Char('a') if self.focus == FocusPane::Files => {
186                return Some(DiffReviewEvent::RepositoryAction(
187                    RepositoryAction::StageAll,
188                ));
189            }
190            KeyCode::Char('A') if self.focus == FocusPane::Files => {
191                return Some(DiffReviewEvent::RepositoryAction(
192                    RepositoryAction::UnstageAll,
193                ));
194            }
195            KeyCode::Char('C') => self.begin_commit(),
196            KeyCode::Char('d') => self.begin_discard(),
197            KeyCode::Char('t') => {
198                self.theme_picker = Some(ThemePicker::new(&self.theme));
199            }
200            KeyCode::Enter
201                if in_diff
202                    && self
203                        .session
204                        .selected_row()
205                        .is_some_and(|row| self.presentation().gap_info(row).is_some()) =>
206            {
207                self.reveal_selected_gap(RevealAmount::Step);
208            }
209            KeyCode::Char('o') if in_diff => {
210                self.reveal_selected_gap(RevealAmount::Step);
211            }
212            KeyCode::Char('O') if in_diff => {
213                self.reveal_selected_gap(RevealAmount::All);
214            }
215            KeyCode::Char('f') if in_diff => {
216                self.toggle_full_file();
217            }
218            KeyCode::Char('v') => {
219                if self.session.cycle_view_mode() {
220                    self.scroll_to_selected_file();
221                }
222            }
223            KeyCode::Char('S') => {
224                return Some(DiffReviewEvent::SetScope(self.scope.next()));
225            }
226            KeyCode::Char('?') => self.help = true,
227            _ => {}
228        }
229        None
230    }
231
232    fn move_focused(&mut self, delta: isize) {
233        match self.focus {
234            FocusPane::Files => self.move_drawer_entry(delta),
235            FocusPane::Diff => self.move_row(delta),
236        }
237    }
238
239    fn handle_repository_prompt_key(&mut self, key: KeyEvent) -> Option<DiffReviewEvent> {
240        match self.repository_prompt.as_mut()? {
241            RepositoryPrompt::Commit { message } => match key.code {
242                KeyCode::Esc => self.repository_prompt = None,
243                KeyCode::Enter => {
244                    let message = message.trim().to_owned();
245                    if message.is_empty() {
246                        self.set_repository_error("Commit message cannot be empty");
247                    } else {
248                        self.repository_prompt = None;
249                        return Some(DiffReviewEvent::RepositoryAction(
250                            RepositoryAction::Commit { message },
251                        ));
252                    }
253                }
254                KeyCode::Backspace => {
255                    message.pop();
256                }
257                KeyCode::Char(character)
258                    if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
259                {
260                    message.push(character);
261                }
262                _ => {}
263            },
264            RepositoryPrompt::Discard { path, status } => match key.code {
265                KeyCode::Char('y' | 'Y') => {
266                    let action = RepositoryAction::Discard {
267                        path: path.clone(),
268                        status: *status,
269                    };
270                    self.repository_prompt = None;
271                    return Some(DiffReviewEvent::RepositoryAction(action));
272                }
273                KeyCode::Char('n' | 'N') | KeyCode::Esc => self.repository_prompt = None,
274                _ => {}
275            },
276        }
277        None
278    }
279
280    fn handle_draft_key(&mut self, key: KeyEvent) {
281        if key.code == KeyCode::Esc {
282            self.session.cancel_draft();
283            self.cursor_position = None;
284            return;
285        }
286        if key.code == KeyCode::Enter && !key.modifiers.contains(KeyModifiers::SHIFT) {
287            self.session.submit_draft();
288            self.cursor_position = None;
289            return;
290        }
291        let Some(draft) = self.session.draft_mut() else {
292            return;
293        };
294        match key.code {
295            KeyCode::Enter => draft.insert("\n"),
296            KeyCode::Left => draft.move_cursor_left(),
297            KeyCode::Right => draft.move_cursor_right(),
298            KeyCode::Home => draft.move_cursor_to_start(),
299            KeyCode::End => draft.move_cursor_to_end(),
300            KeyCode::Backspace => draft.delete_before_cursor(),
301            KeyCode::Delete => draft.delete_at_cursor(),
302            KeyCode::Char(character)
303                if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
304            {
305                let mut buffer = [0_u8; 4];
306                draft.insert(character.encode_utf8(&mut buffer));
307            }
308            _ => {}
309        }
310        self.request_follow();
311    }
312
313    fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<DiffReviewEvent> {
314        let position = Position::new(mouse.column, mouse.row);
315        match mouse.kind {
316            MouseEventKind::ScrollUp => self.scroll_at(position, -1),
317            MouseEventKind::ScrollDown => self.scroll_at(position, 1),
318            MouseEventKind::Down(_) => {
319                if self.hit_layout.drawer.contains(position) {
320                    self.focus = FocusPane::Files;
321                    let relative = usize::from(mouse.row.saturating_sub(self.hit_layout.drawer.y));
322                    self.select_drawer_entry(self.drawer_scroll.saturating_add(relative));
323                    self.mark_dirty();
324                    if self.hit_layout.drawer_stage_column == Some(mouse.column)
325                        && !matches!(self.repository_status, RepositoryOperationStatus::Pending)
326                        && let Some(action) = self.toggle_stage_action()
327                    {
328                        return Some(DiffReviewEvent::RepositoryAction(action));
329                    }
330                } else if self.hit_layout.patch.contains(position) {
331                    self.focus = FocusPane::Diff;
332                    self.select_clicked_row(mouse.row);
333                    self.mark_dirty();
334                }
335            }
336            // Motion, drag, and button releases change nothing that is drawn,
337            // and a terminal in all-motion mode reports one per pointer step.
338            _ => {}
339        }
340        None
341    }
342
343    /// Navigates the pane under the pointer, falling back to the focused pane
344    /// when the pointer is over neither. The wheel moves one diff selection or
345    /// one drawer entry per notch without changing which pane has focus.
346    fn scroll_at(&mut self, position: Position, direction: isize) {
347        let pane = if self.hit_layout.patch.contains(position) {
348            FocusPane::Diff
349        } else if self.hit_layout.drawer.contains(position) {
350            FocusPane::Files
351        } else {
352            self.focus
353        };
354        match pane {
355            FocusPane::Diff => self.move_row(direction),
356            FocusPane::Files => self.scroll_drawer(direction * DRAWER_WHEEL_ROWS),
357        }
358    }
359}