clankerdiff_ratatui/
input.rs1use 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
14const DRAWER_WHEEL_ROWS: isize = 1;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum DiffReviewInput {
21 Key(KeyEvent),
23 Paste(String),
25 Mouse(MouseEvent),
27}
28
29#[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 _ => {}
339 }
340 None
341 }
342
343 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}