clankerdiff_ratatui/markdown_review/
input.rs1use 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}