Skip to main content

clankerdiff_ratatui/markdown_review/
input.rs

1use super::{MarkdownFocusPane, MarkdownReviewEvent, MarkdownReviewState};
2use crate::{
3    InputOutcome, InteractionPhase, KeyBinding, KeyEvent, MarkdownReviewCommand, MouseEvent,
4    MouseEventKind, NavigationPane, ReviewCommand, ReviewInput,
5    interaction::{self, ReviewWidget},
6    keybindings,
7    theme_picker::ThemePicker,
8};
9use clankerdiff_core::{CommandContext, ReviewCapabilities};
10use clankerdiff_markdown::{MarkdownCommentDraft, MarkdownReviewError};
11#[cfg(feature = "crossterm-backend")]
12use crossterm::event::Event;
13use ratatui::layout::Position;
14use std::sync::Arc;
15
16#[cfg(feature = "crossterm-backend")]
17pub fn handle_crossterm_event(
18    state: &mut MarkdownReviewState,
19    event: Event,
20) -> Result<InputOutcome<MarkdownReviewEvent>, MarkdownReviewError> {
21    crate::crossterm_adapter::handle_event(state, event)
22}
23
24impl MarkdownReviewState {
25    #[must_use]
26    pub fn interaction_phase(&self) -> InteractionPhase {
27        if self.theme_picker.is_some() {
28            InteractionPhase::ThemePicker
29        } else if self.help {
30            InteractionPhase::Help
31        } else if self.session.draft().is_some() {
32            InteractionPhase::Draft
33        } else {
34            InteractionPhase::Browse
35        }
36    }
37
38    pub fn handle_input(
39        &mut self,
40        input: ReviewInput,
41    ) -> Result<InputOutcome<MarkdownReviewEvent>, MarkdownReviewError> {
42        interaction::handle_input(self, input)
43    }
44
45    #[must_use]
46    pub fn command_for_key(&self, key: KeyEvent) -> Option<MarkdownReviewCommand> {
47        if self.interaction_phase() != InteractionPhase::Browse {
48            return None;
49        }
50        keybindings::binding_for_key(
51            &self.keybindings,
52            key,
53            self.focus == MarkdownFocusPane::Document,
54            false,
55        )
56        .map(|binding| binding.command)
57    }
58
59    pub(crate) fn help_bindings(&self) -> impl Iterator<Item = &KeyBinding<MarkdownReviewCommand>> {
60        let context = CommandContext {
61            phase: InteractionPhase::Browse,
62            ..self.command_context()
63        };
64        keybindings::help_bindings(
65            &self.keybindings,
66            context.navigation_available,
67            move |command| command.enabled(&context),
68        )
69    }
70
71    pub(crate) fn footer_hint(&self, width: usize) -> String {
72        keybindings::footer_hint(
73            &self.keybindings,
74            self.focus == MarkdownFocusPane::Document,
75            false,
76            |command| self.command_enabled(command),
77            &ReviewCommand::ShowHelp.into(),
78            width,
79        )
80    }
81
82    #[must_use]
83    pub fn command_context(&self) -> CommandContext {
84        CommandContext {
85            phase: self.interaction_phase(),
86            capabilities: self.capabilities,
87            navigation_available: !matches!(
88                self.options.navigation,
89                NavigationPane::Hidden | NavigationPane::Width(0)
90            ),
91            themes_available: !self.theme_choices.is_empty(),
92            ..CommandContext::default()
93        }
94    }
95
96    pub fn set_capabilities(&mut self, capabilities: ReviewCapabilities) {
97        self.capabilities = capabilities;
98        self.mark_dirty();
99    }
100
101    #[must_use]
102    pub fn command_enabled(&self, command: &MarkdownReviewCommand) -> bool {
103        command.enabled(&self.command_context())
104    }
105
106    #[expect(
107        clippy::too_many_lines,
108        reason = "Exhaustive command dispatch keeps routing in one place"
109    )]
110    pub fn handle_command(
111        &mut self,
112        command: impl Into<MarkdownReviewCommand>,
113    ) -> Result<InputOutcome<MarkdownReviewEvent>, MarkdownReviewError> {
114        use MarkdownReviewCommand as C;
115        use ReviewCommand as R;
116        let command = command.into();
117        if !self.command_enabled(&command) {
118            return Ok(InputOutcome::Ignored);
119        }
120        let previous = (self.focus, self.session.selected_target());
121        match command {
122            C::Focus(pane) => self.focus = pane,
123            C::ToggleFocus => {
124                self.focus = match self.focus {
125                    MarkdownFocusPane::Document => MarkdownFocusPane::Outline,
126                    MarkdownFocusPane::Outline => MarkdownFocusPane::Document,
127                }
128            }
129            C::MoveSelection(delta) => self.move_selection(delta),
130            C::Page(delta) => self
131                .move_selection(delta.saturating_mul(
132                    isize::try_from(self.last_height.max(1)).unwrap_or(isize::MAX),
133                )),
134            C::First | C::Last => {
135                let last = command == C::Last;
136                if self.focus == MarkdownFocusPane::Outline {
137                    self.select_heading(if last {
138                        self.document().outline().len().saturating_sub(1)
139                    } else {
140                        0
141                    });
142                } else {
143                    self.session.select_boundary(last);
144                }
145            }
146            C::OpenSelected => {
147                if self.focus == MarkdownFocusPane::Outline {
148                    self.select_heading(self.outline_selected);
149                    self.focus = MarkdownFocusPane::Document;
150                }
151            }
152            C::Scroll { pane, lines } => {
153                match pane {
154                    MarkdownFocusPane::Document => {
155                        self.scroll = self.scroll.saturating_add_signed(lines);
156                        self.follow_pending = false;
157                    }
158                    MarkdownFocusPane::Outline => {
159                        self.outline_scroll = self
160                            .outline_scroll
161                            .saturating_add_signed(lines)
162                            .min(self.document().outline().len().saturating_sub(1));
163                    }
164                }
165                self.mark_dirty();
166                return Ok(InputOutcome::Consumed);
167            }
168            C::SelectTarget(target) => {
169                if !self.session.select_target(target) {
170                    return Ok(InputOutcome::Ignored);
171                }
172            }
173            C::SelectHeading(index) => {
174                if !self.select_heading(index) {
175                    return Ok(InputOutcome::Ignored);
176                }
177            }
178            C::NextHeading => {
179                self.session.next_heading();
180            }
181            C::PreviousHeading => {
182                self.session.previous_heading();
183            }
184            C::Approve => return self.session.approve().map(InputOutcome::Emitted),
185            C::RequestChanges => return self.session.request_changes().map(InputOutcome::Emitted),
186            C::CopyReview(decision) => {
187                return Ok(InputOutcome::Emitted(self.session.copy_formatted(decision)));
188            }
189            C::Review(R::BeginComment | R::EditComment) => {
190                let started = if command == C::Review(R::BeginComment) {
191                    self.session.begin_draft(None)
192                } else {
193                    self.session.edit_comment_at_selection()
194                };
195                if !started {
196                    return Ok(InputOutcome::Ignored);
197                }
198                self.request_follow();
199            }
200            C::Review(R::DeleteComment | R::UndoComment) => {
201                let changed = if command == C::Review(R::DeleteComment) {
202                    self.session.delete_comment_at_selection()
203                } else {
204                    self.session.undo_last_comment()
205                };
206                if !changed {
207                    return Ok(InputOutcome::Ignored);
208                }
209                self.request_follow();
210            }
211            C::Review(R::SubmitComment) => {
212                self.session.submit_draft();
213                self.cursor_position = None;
214                self.request_follow();
215            }
216            C::Review(R::Cancel) => {
217                match self.interaction_phase() {
218                    InteractionPhase::Browse => {
219                        return Ok(InputOutcome::Emitted(MarkdownReviewEvent::Cancel));
220                    }
221                    InteractionPhase::Draft => {
222                        self.session.cancel_draft();
223                        self.cursor_position = None;
224                        self.request_follow();
225                    }
226                    InteractionPhase::Help => self.help = false,
227                    InteractionPhase::ThemePicker => {
228                        if let Some(picker) = self.theme_picker.take() {
229                            self.apply_theme(picker.cancel());
230                        }
231                    }
232                    InteractionPhase::RepositoryPrompt => return Ok(InputOutcome::Ignored),
233                }
234                self.mark_dirty();
235            }
236            C::Review(R::ShowHelp) => {
237                self.help = true;
238                self.help_scroll = 0;
239                self.mark_dirty();
240            }
241            C::Review(R::ScrollHelp(lines)) => {
242                self.help_scroll = self
243                    .help_scroll
244                    .saturating_add_signed(lines)
245                    .min(self.help_bindings().count().saturating_sub(1));
246                self.mark_dirty();
247            }
248            C::Review(R::OpenThemePicker) => {
249                self.theme_picker = ThemePicker::new(&self.theme, Arc::clone(&self.theme_choices));
250                self.mark_dirty();
251            }
252            C::Review(R::SelectTheme(index)) => {
253                let Some(theme) = self
254                    .theme_picker
255                    .as_mut()
256                    .and_then(|picker| picker.select(index))
257                else {
258                    return Ok(InputOutcome::Ignored);
259                };
260                self.apply_theme(theme);
261            }
262            C::Review(R::MoveTheme(delta)) => {
263                if let Some(picker) = self.theme_picker.as_mut() {
264                    let theme = picker.select_relative(delta);
265                    self.apply_theme(theme);
266                }
267            }
268            C::Review(R::CommitTheme) => {
269                let Some(picker) = self.theme_picker.take() else {
270                    return Ok(InputOutcome::Ignored);
271                };
272                let theme = picker.commit();
273                let id = theme.id().clone();
274                self.apply_theme(theme);
275                return Ok(InputOutcome::ThemeSelected(id));
276            }
277        }
278        if previous != (self.focus, self.session.selected_target()) {
279            self.sync_outline_selection();
280            self.request_follow();
281        }
282        Ok(InputOutcome::Consumed)
283    }
284
285    fn select_heading(&mut self, index: usize) -> bool {
286        let Some(heading) = self.document().outline().get(index) else {
287            return false;
288        };
289        let target = heading.target_id;
290        self.outline_selected = index;
291        self.session.select_target(target)
292    }
293
294    fn move_selection(&mut self, delta: isize) {
295        if self.focus == MarkdownFocusPane::Outline {
296            self.select_heading(
297                self.outline_selected
298                    .saturating_add_signed(delta)
299                    .min(self.document().outline().len().saturating_sub(1)),
300            );
301        } else {
302            self.session.move_target(delta);
303        }
304    }
305
306    fn sync_outline_selection(&mut self) {
307        if let Some(selected) = self.selected_target()
308            && let Some(index) = self
309                .document()
310                .outline()
311                .iter()
312                .rposition(|heading| heading.target_id.index() <= selected.index())
313        {
314            self.outline_selected = index;
315        }
316    }
317
318    fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<MarkdownReviewEvent> {
319        let position = Position::new(mouse.column, mouse.row);
320        match mouse.kind {
321            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
322                self.session
323                    .move_target(if mouse.kind == MouseEventKind::ScrollUp {
324                        -1
325                    } else {
326                        1
327                    });
328            }
329            MouseEventKind::Down(_) => {
330                if let Some(region) = self
331                    .hit_regions
332                    .iter()
333                    .rev()
334                    .find(|region| region.area.contains(position))
335                    .copied()
336                {
337                    self.focus = if region.outline {
338                        MarkdownFocusPane::Outline
339                    } else {
340                        MarkdownFocusPane::Document
341                    };
342                    if let Some(target) = region.target {
343                        self.session.select_target(target);
344                    }
345                }
346            }
347            _ => return InputOutcome::Ignored,
348        }
349        self.sync_outline_selection();
350        self.request_follow();
351        InputOutcome::Consumed
352    }
353}
354
355impl ReviewWidget for MarkdownReviewState {
356    type Event = MarkdownReviewEvent;
357    type Error = MarkdownReviewError;
358    type Draft = MarkdownCommentDraft;
359
360    fn phase(&self) -> InteractionPhase {
361        self.interaction_phase()
362    }
363    fn handle_review_command(
364        &mut self,
365        command: ReviewCommand,
366    ) -> Result<InputOutcome<MarkdownReviewEvent>, MarkdownReviewError> {
367        self.handle_command(command)
368    }
369    fn contains(&self, position: Position) -> bool {
370        self.hit_regions
371            .iter()
372            .any(|region| region.area.contains(position))
373    }
374    fn mark_dirty(&mut self) {
375        Self::mark_dirty(self);
376    }
377    fn draft_mut(&mut self) -> Option<&mut MarkdownCommentDraft> {
378        self.session.draft_mut()
379    }
380    fn draft_changed(&mut self) {
381        self.request_follow();
382    }
383    fn handle_browse_key(
384        &mut self,
385        key: KeyEvent,
386    ) -> Result<InputOutcome<MarkdownReviewEvent>, MarkdownReviewError> {
387        match self.command_for_key(key) {
388            Some(command) => self.handle_command(command),
389            None => Ok(InputOutcome::Ignored),
390        }
391    }
392    fn handle_mouse(&mut self, mouse: MouseEvent) -> InputOutcome<MarkdownReviewEvent> {
393        self.handle_mouse(mouse)
394    }
395}