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_prompt_key(&mut self, _key: KeyEvent) -> InputOutcome<Self::Event> {
226        InputOutcome::Consumed
227    }
228    fn paste_prompt(&mut self, _text: &str) {}
229}
230
231pub(crate) fn handle_input<T: ReviewWidget>(
232    state: &mut T,
233    input: ReviewInput,
234) -> Result<InputOutcome<T::Event>, T::Error> {
235    let phase = state.phase();
236    let outcome = match input {
237        ReviewInput::Key(key) => handle_key(state, key)?,
238        ReviewInput::Paste(text) => {
239            if let Some(draft) = state.draft_mut() {
240                draft.insert(&text);
241                state.draft_changed();
242                state.mark_dirty();
243                InputOutcome::Consumed
244            } else if phase != InteractionPhase::Browse {
245                if phase == InteractionPhase::RepositoryPrompt {
246                    state.paste_prompt(&text);
247                    state.mark_dirty();
248                }
249                InputOutcome::Consumed
250            } else {
251                InputOutcome::Ignored
252            }
253        }
254        ReviewInput::Mouse(mouse) => {
255            if phase != InteractionPhase::Browse {
256                InputOutcome::Consumed
257            } else if !state.contains(Position::new(mouse.column, mouse.row))
258                || !matches!(
259                    mouse.kind,
260                    MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
261                )
262            {
263                InputOutcome::Ignored
264            } else {
265                let outcome = state.handle_mouse(mouse);
266                if outcome.is_consumed() {
267                    state.mark_dirty();
268                }
269                outcome
270            }
271        }
272    };
273    Ok(outcome)
274}
275
276fn handle_key<T: ReviewWidget>(
277    state: &mut T,
278    key: KeyEvent,
279) -> Result<InputOutcome<T::Event>, T::Error> {
280    let command = match state.phase() {
281        InteractionPhase::ThemePicker => {
282            if !is_plain_key(key) {
283                return Ok(InputOutcome::Consumed);
284            }
285            match key.code {
286                KeyCode::Esc | KeyCode::Char('q') => ReviewCommand::Cancel,
287                KeyCode::Enter => ReviewCommand::CommitTheme,
288                KeyCode::Up | KeyCode::Char('k') => ReviewCommand::MoveTheme(-1),
289                KeyCode::Down | KeyCode::Char('j') => ReviewCommand::MoveTheme(1),
290                KeyCode::Home | KeyCode::Char('g') => ReviewCommand::SelectTheme(0),
291                KeyCode::End | KeyCode::Char('G') => ReviewCommand::MoveTheme(isize::MAX),
292                _ => return Ok(InputOutcome::Consumed),
293            }
294        }
295        InteractionPhase::RepositoryPrompt => {
296            let outcome = state.handle_prompt_key(key);
297            if outcome.is_consumed() {
298                state.mark_dirty();
299            }
300            return Ok(outcome);
301        }
302        InteractionPhase::Help => {
303            if !is_plain_key(key) {
304                return Ok(InputOutcome::Consumed);
305            }
306            match key.code {
307                KeyCode::Esc | KeyCode::Char('?') => ReviewCommand::Cancel,
308                KeyCode::Down | KeyCode::Char('j') => ReviewCommand::ScrollHelp(1),
309                KeyCode::Up | KeyCode::Char('k') => ReviewCommand::ScrollHelp(-1),
310                KeyCode::PageDown => ReviewCommand::ScrollHelp(10),
311                KeyCode::PageUp => ReviewCommand::ScrollHelp(-10),
312                KeyCode::Home => ReviewCommand::ScrollHelp(isize::MIN),
313                KeyCode::End => ReviewCommand::ScrollHelp(isize::MAX),
314                _ => return Ok(InputOutcome::Consumed),
315            }
316        }
317        InteractionPhase::Draft => return handle_draft_key(state, key),
318        InteractionPhase::Browse => return state.handle_browse_key(key),
319    };
320    state.handle_review_command(command)
321}
322
323pub(crate) fn is_plain_key(key: KeyEvent) -> bool {
324    key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT
325}
326
327fn handle_draft_key<T: ReviewWidget>(
328    state: &mut T,
329    key: KeyEvent,
330) -> Result<InputOutcome<T::Event>, T::Error> {
331    if key.code == KeyCode::Esc {
332        return state.handle_review_command(ReviewCommand::Cancel);
333    } else if key.code == KeyCode::Enter && !key.modifiers.contains(KeyModifiers::SHIFT) {
334        return state.handle_review_command(ReviewCommand::SubmitComment);
335    } else if let Some(draft) = state.draft_mut() {
336        match key.code {
337            KeyCode::Enter => draft.insert("\n"),
338            KeyCode::Char(character) if is_plain_key(key) => {
339                let mut buffer = [0; 4];
340                draft.insert(character.encode_utf8(&mut buffer));
341            }
342            code => draft.edit(code),
343        }
344        state.draft_changed();
345        state.mark_dirty();
346    }
347    Ok(InputOutcome::Consumed)
348}