clankerdiff_ratatui/
input.rs1use 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}