Skip to main content

clankerdiff_ratatui/markdown_review/
input.rs

1use super::{MarkdownFocusPane, MarkdownReviewEvent, MarkdownReviewState};
2use crate::theme_picker::{ThemePicker, ThemePickerAction};
3use clankerdiff_markdown::MarkdownReviewError;
4use crossterm::event::{
5    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
6};
7use ratatui::layout::Position;
8
9/// Framework-neutral input accepted by [`MarkdownReviewState::handle_input`].
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum MarkdownReviewInput {
12    /// A Crossterm key event.
13    Key(KeyEvent),
14    /// Text pasted into the active draft.
15    Paste(String),
16    /// A Crossterm mouse event.
17    Mouse(MouseEvent),
18}
19
20/// Converts one Crossterm event and applies it to Markdown review state.
21///
22/// Key releases and unrelated terminal events are ignored. Submissions can
23/// fail when a draft/comment body is blank, so validation is returned to the
24/// host instead of being silently discarded.
25///
26/// # Errors
27///
28/// Returns [`MarkdownReviewError::BlankComment`] when an approval or
29/// request-changes action encounters a blank comment body.
30pub fn handle_crossterm_event(
31    state: &mut MarkdownReviewState,
32    event: Event,
33) -> Result<Option<MarkdownReviewEvent>, MarkdownReviewError> {
34    match event {
35        Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
36            state.handle_input(MarkdownReviewInput::Key(key))
37        }
38        Event::Paste(text) => state.handle_input(MarkdownReviewInput::Paste(text)),
39        Event::Mouse(mouse) => state.handle_input(MarkdownReviewInput::Mouse(mouse)),
40        Event::Resize(..) => {
41            state.mark_dirty();
42            Ok(None)
43        }
44        _ => Ok(None),
45    }
46}
47
48impl MarkdownReviewState {
49    /// Applies one input event.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`MarkdownReviewError::BlankComment`] when an approval or
54    /// request-changes action encounters a blank comment body.
55    pub fn handle_input(
56        &mut self,
57        input: MarkdownReviewInput,
58    ) -> Result<Option<MarkdownReviewEvent>, MarkdownReviewError> {
59        match input {
60            MarkdownReviewInput::Key(key) => {
61                self.mark_dirty();
62                self.handle_key(key)
63            }
64            MarkdownReviewInput::Paste(text) => {
65                if let Some(draft) = self.session.draft_mut() {
66                    draft.insert(&text);
67                    self.request_follow();
68                }
69                Ok(None)
70            }
71            MarkdownReviewInput::Mouse(mouse) => {
72                self.handle_mouse(mouse);
73                Ok(None)
74            }
75        }
76    }
77
78    fn handle_key(
79        &mut self,
80        key: KeyEvent,
81    ) -> Result<Option<MarkdownReviewEvent>, MarkdownReviewError> {
82        if let Some(picker) = self.theme_picker.as_mut() {
83            let action = picker.handle_key(key);
84            match action {
85                ThemePickerAction::Preview(theme) => self.set_theme(theme),
86                ThemePickerAction::Restore(theme) => {
87                    self.set_theme(theme);
88                    self.theme_picker = None;
89                }
90                ThemePickerAction::Commit => self.theme_picker = None,
91                ThemePickerAction::None => {}
92            }
93            return Ok(None);
94        }
95        if self.help {
96            if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
97                self.help = false;
98            }
99            return Ok(None);
100        }
101        if self.session.draft().is_some() {
102            return Ok(self.handle_draft_key(key));
103        }
104        if key.code == KeyCode::Esc
105            || (key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::CONTROL))
106        {
107            return Ok(Some(MarkdownReviewEvent::Cancel));
108        }
109        if key
110            .modifiers
111            .intersects(KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::CONTROL)
112        {
113            return Ok(None);
114        }
115        self.handle_browse_key(key)
116    }
117
118    fn handle_browse_key(
119        &mut self,
120        key: KeyEvent,
121    ) -> Result<Option<MarkdownReviewEvent>, MarkdownReviewError> {
122        match key.code {
123            KeyCode::Tab => {
124                self.focus = match self.focus {
125                    MarkdownFocusPane::Document => MarkdownFocusPane::Outline,
126                    MarkdownFocusPane::Outline => MarkdownFocusPane::Document,
127                }
128            }
129            KeyCode::Up | KeyCode::Char('k') => self.move_target(-1),
130            KeyCode::Down | KeyCode::Char('j') => self.move_target(1),
131            KeyCode::Home | KeyCode::Char('g') => {
132                self.session.select_first_target();
133            }
134            KeyCode::End | KeyCode::Char('G') => {
135                self.session.select_last_target();
136            }
137            KeyCode::Char('n') => {
138                self.session.next_heading();
139            }
140            KeyCode::Char('p') => {
141                self.session.previous_heading();
142            }
143            KeyCode::Left | KeyCode::Char('h') => self.focus = MarkdownFocusPane::Outline,
144            KeyCode::Right | KeyCode::Char('l') => self.focus = MarkdownFocusPane::Document,
145            KeyCode::Enter => {
146                if self.focus == MarkdownFocusPane::Outline {
147                    if let Some(target) = self.selected_outline_target() {
148                        self.session.select_target(target);
149                    }
150                    self.focus = MarkdownFocusPane::Document;
151                }
152            }
153            KeyCode::Char('c') => {
154                self.session.begin_draft(None);
155                self.request_follow();
156            }
157            KeyCode::Char('e') => {
158                if self.session.comment_id_at_selection().is_some() {
159                    self.session.edit_comment_at_selection();
160                    self.request_follow();
161                }
162            }
163            KeyCode::Char('x') => {
164                self.session.delete_comment_at_selection();
165                self.request_follow();
166            }
167            KeyCode::Char('u') => {
168                self.session.undo_last_comment();
169                self.request_follow();
170            }
171            KeyCode::Char('a') => return self.session.approve().map(Some),
172            KeyCode::Char('r') => return self.session.request_changes().map(Some),
173            KeyCode::Char('t') => {
174                self.theme_picker = Some(ThemePicker::new(&self.theme));
175            }
176            KeyCode::Char('?') => self.help = true,
177            _ => {}
178        }
179        self.sync_outline_selection();
180        self.request_follow();
181        Ok(None)
182    }
183
184    fn handle_draft_key(&mut self, key: KeyEvent) -> Option<MarkdownReviewEvent> {
185        if key.code == KeyCode::Esc {
186            self.session.cancel_draft();
187            self.request_follow();
188            return None;
189        }
190        if key.code == KeyCode::Enter && !key.modifiers.contains(KeyModifiers::SHIFT) {
191            self.session.submit_draft();
192            self.request_follow();
193            return None;
194        }
195        let draft = self.session.draft_mut()?;
196        match key.code {
197            KeyCode::Enter => draft.insert("\n"),
198            KeyCode::Left => draft.move_cursor_left(),
199            KeyCode::Right => draft.move_cursor_right(),
200            KeyCode::Home => draft.move_cursor_to_start(),
201            KeyCode::End => draft.move_cursor_to_end(),
202            KeyCode::Backspace => draft.delete_before_cursor(),
203            KeyCode::Delete => draft.delete_at_cursor(),
204            KeyCode::Char(character)
205                if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
206            {
207                let mut buffer = [0_u8; 4];
208                draft.insert(character.encode_utf8(&mut buffer));
209            }
210            _ => {}
211        }
212        self.request_follow();
213        None
214    }
215
216    fn move_target(&mut self, delta: isize) {
217        self.session.move_target(delta);
218        self.sync_outline_selection();
219    }
220
221    fn sync_outline_selection(&mut self) {
222        if let Some(selected) = self.selected_target()
223            && let Some(index) = self
224                .document()
225                .outline()
226                .iter()
227                .position(|heading| heading.target_id == selected)
228        {
229            self.outline_selected = index;
230        }
231    }
232
233    fn handle_mouse(&mut self, mouse: MouseEvent) {
234        let position = Position::new(mouse.column, mouse.row);
235        match mouse.kind {
236            MouseEventKind::ScrollUp => self.move_target_with_follow(-1),
237            MouseEventKind::ScrollDown => self.move_target_with_follow(1),
238            MouseEventKind::Down(_) => {
239                if let Some(region) = self
240                    .hit_regions
241                    .iter()
242                    .rev()
243                    .find(|region| region.area.contains(position))
244                    .copied()
245                {
246                    if region.outline {
247                        self.focus = MarkdownFocusPane::Outline;
248                        if let Some(index) = self
249                            .document()
250                            .outline()
251                            .iter()
252                            .position(|heading| Some(heading.target_id) == region.target)
253                        {
254                            self.outline_selected = index;
255                            let target = self.document().outline()[index].target_id;
256                            self.session.select_target(target);
257                        }
258                    } else if let Some(target) = region.target {
259                        self.focus = MarkdownFocusPane::Document;
260                        self.session.select_target(target);
261                    }
262                    self.request_follow();
263                }
264            }
265            _ => {}
266        }
267    }
268
269    fn move_target_with_follow(&mut self, delta: isize) {
270        let previous = self.selected_target();
271        self.move_target(delta);
272        if self.selected_target() != previous {
273            self.request_follow();
274        }
275    }
276}