Skip to main content

clankerdiff_ratatui/
interaction.rs

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}