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}