clankerdiff_ratatui/markdown_review/
input.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum MarkdownReviewInput {
12 Key(KeyEvent),
14 Paste(String),
16 Mouse(MouseEvent),
18}
19
20pub 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 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}