1use crate::ReviewCommand;
2use bitflags::bitflags;
3use clankerdiff_core::CommentDraft;
4pub use clankerdiff_core::InteractionPhase;
5use clankerdiff_markdown::MarkdownCommentDraft;
6use clankerdiff_theme::ThemeId;
7use ratatui::layout::Position;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum InputOutcome<T> {
11 Ignored,
12 Consumed,
13 Emitted(T),
14 ThemeSelected(ThemeId),
15}
16
17impl<T> InputOutcome<T> {
18 #[must_use]
19 pub const fn is_consumed(&self) -> bool {
20 !matches!(self, Self::Ignored)
21 }
22 #[must_use]
23 pub fn into_event(self) -> Option<T> {
24 match self {
25 Self::Emitted(event) => Some(event),
26 _ => None,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum KeyCode {
33 Char(char),
34 Enter,
35 Esc,
36 Tab,
37 BackTab,
38 Backspace,
39 Delete,
40 Insert,
41 Left,
42 Right,
43 Up,
44 Down,
45 Home,
46 End,
47 PageUp,
48 PageDown,
49 F(u8),
50 Null,
51}
52
53impl KeyCode {
54 #[must_use]
55 pub fn ctrl(key: impl Into<Self>) -> KeyEvent {
56 KeyEvent::new(key.into(), KeyModifiers::CONTROL)
57 }
58
59 #[must_use]
60 pub fn alt(key: impl Into<Self>) -> KeyEvent {
61 KeyEvent::new(key.into(), KeyModifiers::ALT)
62 }
63
64 #[must_use]
65 pub fn shift(key: impl Into<Self>) -> KeyEvent {
66 KeyEvent::new(key.into(), KeyModifiers::SHIFT)
67 }
68}
69
70impl From<char> for KeyCode {
71 fn from(key: char) -> Self {
72 Self::Char(key)
73 }
74}
75
76bitflags! {
77 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78 pub struct KeyModifiers: u8 {
79 const NONE = 0;
80 const SHIFT = 1;
81 const CONTROL = 2;
82 const ALT = 4;
83 const SUPER = 8;
84 const HYPER = 16;
85 const META = 32;
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct KeyEvent {
91 pub code: KeyCode,
92 pub modifiers: KeyModifiers,
93}
94impl KeyEvent {
95 #[must_use]
96 pub const fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
97 Self { code, modifiers }
98 }
99}
100
101impl From<KeyCode> for KeyEvent {
102 fn from(code: KeyCode) -> Self {
103 Self::new(code, KeyModifiers::NONE)
104 }
105}
106
107impl From<char> for KeyEvent {
108 fn from(key: char) -> Self {
109 KeyCode::from(key).into()
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum MouseButton {
115 Left,
116 Right,
117 Middle,
118}
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum MouseEventKind {
121 Down(MouseButton),
122 Up(MouseButton),
123 Drag(MouseButton),
124 Moved,
125 ScrollUp,
126 ScrollDown,
127 ScrollLeft,
128 ScrollRight,
129}
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct MouseEvent {
132 pub kind: MouseEventKind,
133 pub column: u16,
134 pub row: u16,
135 pub modifiers: KeyModifiers,
136}
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum ReviewInput {
139 Key(KeyEvent),
140 Paste(String),
141 Mouse(MouseEvent),
142}
143
144impl From<KeyEvent> for ReviewInput {
145 fn from(key: KeyEvent) -> Self {
146 Self::Key(key)
147 }
148}
149
150impl From<KeyCode> for ReviewInput {
151 fn from(key: KeyCode) -> Self {
152 Self::Key(key.into())
153 }
154}
155
156impl From<char> for ReviewInput {
157 fn from(key: char) -> Self {
158 Self::Key(key.into())
159 }
160}
161
162impl From<MouseEvent> for ReviewInput {
163 fn from(mouse: MouseEvent) -> Self {
164 Self::Mouse(mouse)
165 }
166}
167
168pub(crate) trait DraftEditor {
169 fn insert(&mut self, text: &str);
170 fn edit(&mut self, code: KeyCode);
171}
172
173impl DraftEditor for CommentDraft {
174 fn insert(&mut self, text: &str) {
175 Self::insert(self, text);
176 }
177 fn edit(&mut self, code: KeyCode) {
178 match code {
179 KeyCode::Left => self.move_cursor_left(),
180 KeyCode::Right => self.move_cursor_right(),
181 KeyCode::Home => self.move_cursor_to_start(),
182 KeyCode::End => self.move_cursor_to_end(),
183 KeyCode::Backspace => self.delete_before_cursor(),
184 KeyCode::Delete => self.delete_at_cursor(),
185 _ => {}
186 }
187 }
188}
189
190impl DraftEditor for MarkdownCommentDraft {
191 fn insert(&mut self, text: &str) {
192 Self::insert(self, text);
193 }
194 fn edit(&mut self, code: KeyCode) {
195 match code {
196 KeyCode::Left => self.move_cursor_left(),
197 KeyCode::Right => self.move_cursor_right(),
198 KeyCode::Home => self.move_cursor_to_start(),
199 KeyCode::End => self.move_cursor_to_end(),
200 KeyCode::Backspace => self.delete_before_cursor(),
201 KeyCode::Delete => self.delete_at_cursor(),
202 _ => {}
203 }
204 }
205}
206
207pub(crate) trait ReviewWidget {
208 type Event;
209 type Error;
210 type Draft: DraftEditor;
211 fn phase(&self) -> InteractionPhase;
212 fn handle_review_command(
213 &mut self,
214 command: ReviewCommand,
215 ) -> Result<InputOutcome<Self::Event>, Self::Error>;
216 fn contains(&self, position: Position) -> bool;
217 fn mark_dirty(&mut self);
218 fn draft_mut(&mut self) -> Option<&mut Self::Draft>;
219 fn draft_changed(&mut self);
220 fn handle_browse_key(
221 &mut self,
222 key: KeyEvent,
223 ) -> Result<InputOutcome<Self::Event>, Self::Error>;
224 fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<Self::Event>;
225 fn handle_draft_mouse(&mut self, _mouse: MouseEvent) -> InputOutcome<Self::Event> {
226 InputOutcome::Consumed
227 }
228 fn handle_prompt_key(&mut self, _key: KeyEvent) -> InputOutcome<Self::Event> {
229 InputOutcome::Consumed
230 }
231 fn paste_prompt(&mut self, _text: &str) {}
232}
233
234pub(crate) fn handle_input<T: ReviewWidget>(
235 state: &mut T,
236 input: ReviewInput,
237) -> Result<InputOutcome<T::Event>, T::Error> {
238 let phase = state.phase();
239 let outcome = match input {
240 ReviewInput::Key(key) => handle_key(state, key)?,
241 ReviewInput::Paste(text) => {
242 if let Some(draft) = state.draft_mut() {
243 draft.insert(&text);
244 state.draft_changed();
245 state.mark_dirty();
246 InputOutcome::Consumed
247 } else if phase != InteractionPhase::Browse {
248 if phase == InteractionPhase::RepositoryPrompt {
249 state.paste_prompt(&text);
250 state.mark_dirty();
251 }
252 InputOutcome::Consumed
253 } else {
254 InputOutcome::Ignored
255 }
256 }
257 ReviewInput::Mouse(mouse) => {
258 if phase == InteractionPhase::Draft {
259 state.handle_draft_mouse(mouse)
260 } else if phase != InteractionPhase::Browse {
261 InputOutcome::Consumed
262 } else if !state.contains(Position::new(mouse.column, mouse.row))
263 || !matches!(
264 mouse.kind,
265 MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
266 )
267 {
268 InputOutcome::Ignored
269 } else {
270 let outcome = state.handle_mouse(mouse);
271 if outcome.is_consumed() {
272 state.mark_dirty();
273 }
274 outcome
275 }
276 }
277 };
278 Ok(outcome)
279}
280
281fn handle_key<T: ReviewWidget>(
282 state: &mut T,
283 key: KeyEvent,
284) -> Result<InputOutcome<T::Event>, T::Error> {
285 let command = match state.phase() {
286 InteractionPhase::ThemePicker => {
287 if !is_plain_key(key) {
288 return Ok(InputOutcome::Consumed);
289 }
290 match key.code {
291 KeyCode::Esc | KeyCode::Char('q') => ReviewCommand::Cancel,
292 KeyCode::Enter => ReviewCommand::CommitTheme,
293 KeyCode::Up | KeyCode::Char('k') => ReviewCommand::MoveTheme(-1),
294 KeyCode::Down | KeyCode::Char('j') => ReviewCommand::MoveTheme(1),
295 KeyCode::Home | KeyCode::Char('g') => ReviewCommand::SelectTheme(0),
296 KeyCode::End | KeyCode::Char('G') => ReviewCommand::MoveTheme(isize::MAX),
297 _ => return Ok(InputOutcome::Consumed),
298 }
299 }
300 InteractionPhase::RepositoryPrompt => {
301 let outcome = state.handle_prompt_key(key);
302 if outcome.is_consumed() {
303 state.mark_dirty();
304 }
305 return Ok(outcome);
306 }
307 InteractionPhase::Help => {
308 if !is_plain_key(key) {
309 return Ok(InputOutcome::Consumed);
310 }
311 match key.code {
312 KeyCode::Esc | KeyCode::Char('?') => ReviewCommand::Cancel,
313 KeyCode::Down | KeyCode::Char('j') => ReviewCommand::ScrollHelp(1),
314 KeyCode::Up | KeyCode::Char('k') => ReviewCommand::ScrollHelp(-1),
315 KeyCode::PageDown => ReviewCommand::ScrollHelp(10),
316 KeyCode::PageUp => ReviewCommand::ScrollHelp(-10),
317 KeyCode::Home => ReviewCommand::ScrollHelp(isize::MIN),
318 KeyCode::End => ReviewCommand::ScrollHelp(isize::MAX),
319 _ => return Ok(InputOutcome::Consumed),
320 }
321 }
322 InteractionPhase::Draft => return handle_draft_key(state, key),
323 InteractionPhase::Browse => return state.handle_browse_key(key),
324 };
325 state.handle_review_command(command)
326}
327
328pub(crate) fn is_plain_key(key: KeyEvent) -> bool {
329 key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT
330}
331
332fn handle_draft_key<T: ReviewWidget>(
333 state: &mut T,
334 key: KeyEvent,
335) -> Result<InputOutcome<T::Event>, T::Error> {
336 if key.code == KeyCode::Esc {
337 return state.handle_review_command(ReviewCommand::Cancel);
338 } else if key.code == KeyCode::Enter && !key.modifiers.contains(KeyModifiers::SHIFT) {
339 return state.handle_review_command(ReviewCommand::SubmitComment);
340 } else if let Some(draft) = state.draft_mut() {
341 match key.code {
342 KeyCode::Enter => draft.insert("\n"),
343 KeyCode::Char(character) if is_plain_key(key) => {
344 let mut buffer = [0; 4];
345 draft.insert(character.encode_utf8(&mut buffer));
346 }
347 code => draft.edit(code),
348 }
349 state.draft_changed();
350 state.mark_dirty();
351 }
352 Ok(InputOutcome::Consumed)
353}