Skip to main content

foundry_tui_app/controller/
input.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind};
2use foundry_tui_foundry::ToolEvent;
3use tokio::sync::mpsc::UnboundedSender;
4
5use crate::{
6    model::{CustomCommandModal, CustomModalStep},
7    parsing::{anvil_prompt_field_mut, custom_form_placeholders, initial_placeholder_value},
8};
9
10use super::AppController;
11
12impl AppController {
13    pub fn handle_key(&mut self, key: KeyEvent, tool_events: &UnboundedSender<ToolEvent>) {
14        if key.kind != KeyEventKind::Press {
15            return;
16        }
17
18        if self.model.custom_modal.is_some() {
19            self.handle_custom_modal_key(key, tool_events);
20            return;
21        }
22
23        if self.model.anvil_prompt.is_some() {
24            self.handle_anvil_prompt_key(key, tool_events);
25            return;
26        }
27
28        if self.model.palette_open {
29            self.handle_palette_key(key, tool_events);
30            return;
31        }
32
33        match key.code {
34            KeyCode::Left => {
35                if self.scroll_focused_section_horizontal(false) {
36                    return;
37                }
38            }
39            KeyCode::Right => {
40                if self.scroll_focused_section_horizontal(true) {
41                    return;
42                }
43            }
44            _ => {}
45        }
46
47        if let Some(action) = self.resolve_action(key) {
48            self.execute_action(action, tool_events);
49        }
50    }
51
52    pub fn handle_mouse(&mut self, mouse: MouseEvent) {
53        if self.model.custom_modal.is_some() || self.model.anvil_prompt.is_some() {
54            return;
55        }
56
57        match mouse.kind {
58            MouseEventKind::ScrollDown => {
59                if self.model.palette_open {
60                    if !self.model.palette_actions.is_empty() {
61                        self.model.palette_index =
62                            (self.model.palette_index + 1) % self.model.palette_actions.len();
63                    }
64                    return;
65                }
66                self.scroll_focused_section(true);
67            }
68            MouseEventKind::ScrollUp => {
69                if self.model.palette_open {
70                    if self.model.palette_index == 0 {
71                        self.model.palette_index =
72                            self.model.palette_actions.len().saturating_sub(1);
73                    } else {
74                        self.model.palette_index -= 1;
75                    }
76                    return;
77                }
78                self.scroll_focused_section(false);
79            }
80            _ => {}
81        }
82    }
83
84    fn handle_palette_key(&mut self, key: KeyEvent, tool_events: &UnboundedSender<ToolEvent>) {
85        match key.code {
86            KeyCode::Esc => {
87                self.model.palette_open = false;
88            }
89            KeyCode::Up | KeyCode::Char('k') => {
90                if self.model.palette_index == 0 {
91                    self.model.palette_index = self.model.palette_actions.len().saturating_sub(1);
92                } else {
93                    self.model.palette_index -= 1;
94                }
95            }
96            KeyCode::Down | KeyCode::Char('j') => {
97                self.model.palette_index =
98                    (self.model.palette_index + 1) % self.model.palette_actions.len();
99            }
100            KeyCode::Enter => {
101                if let Some(action) = self
102                    .model
103                    .palette_actions
104                    .get(self.model.palette_index)
105                    .copied()
106                {
107                    self.execute_action(action, tool_events);
108                }
109                self.model.palette_open = false;
110            }
111            _ => {}
112        }
113    }
114
115    fn handle_custom_modal_key(&mut self, key: KeyEvent, tool_events: &UnboundedSender<ToolEvent>) {
116        let Some(mut modal) = self.model.custom_modal.take() else {
117            return;
118        };
119
120        let close_modal = match modal.step {
121            CustomModalStep::TemplatePicker => self.handle_custom_picker_key(&mut modal, key),
122            CustomModalStep::Editor => self.handle_custom_editor_key(&mut modal, key),
123            CustomModalStep::Preview => {
124                self.handle_custom_preview_key(&mut modal, key, tool_events)
125            }
126        };
127
128        if close_modal {
129            self.model.custom_modal = None;
130            return;
131        }
132
133        self.model.custom_modal = Some(modal);
134    }
135
136    fn handle_custom_picker_key(&mut self, modal: &mut CustomCommandModal, key: KeyEvent) -> bool {
137        if modal.paste_mode {
138            match key.code {
139                KeyCode::Esc => {
140                    modal.paste_mode = false;
141                    modal.error = None;
142                }
143                KeyCode::Backspace => {
144                    modal.paste_input.pop();
145                    modal.error = None;
146                }
147                KeyCode::Enter => match self.parse_pasted_template(&modal.paste_input) {
148                    Ok(template) => {
149                        modal.draft = Some(self.new_custom_draft(template));
150                        modal.step = CustomModalStep::Editor;
151                        modal.editor_index = 0;
152                        modal.paste_mode = false;
153                        modal.error = None;
154                        self.model.notification =
155                            Some("pasted command parsed. adjust options and continue".to_string());
156                    }
157                    Err(error) => modal.error = Some(error),
158                },
159                KeyCode::Char(ch) => {
160                    modal.paste_input.push(ch);
161                    modal.error = None;
162                }
163                _ => {}
164            }
165            return false;
166        }
167
168        let total_rows = self.model.custom_templates.len() + 1;
169        match key.code {
170            KeyCode::Esc => return true,
171            KeyCode::Up | KeyCode::Char('k') => {
172                if modal.picker_index == 0 {
173                    modal.picker_index = total_rows.saturating_sub(1);
174                } else {
175                    modal.picker_index -= 1;
176                }
177            }
178            KeyCode::Down | KeyCode::Char('j') => {
179                if total_rows > 0 {
180                    modal.picker_index = (modal.picker_index + 1) % total_rows;
181                }
182            }
183            KeyCode::Enter => {
184                if modal.picker_index == 0 {
185                    modal.paste_mode = true;
186                    modal.paste_input.clear();
187                    modal.error = None;
188                    self.model.notification =
189                        Some("paste full foundry command and press Enter to parse it".to_string());
190                } else if let Some(template) = self
191                    .model
192                    .custom_templates
193                    .get(modal.picker_index - 1)
194                    .cloned()
195                {
196                    modal.draft = Some(self.new_custom_draft(template));
197                    modal.step = CustomModalStep::Editor;
198                    modal.editor_index = 0;
199                    modal.error = None;
200                }
201            }
202            KeyCode::Char('p') => {
203                modal.paste_mode = true;
204                modal.paste_input.clear();
205                modal.error = None;
206            }
207            _ => {}
208        }
209        false
210    }
211
212    fn handle_custom_editor_key(&mut self, modal: &mut CustomCommandModal, key: KeyEvent) -> bool {
213        let Some(draft) = modal.draft.as_mut() else {
214            modal.step = CustomModalStep::TemplatePicker;
215            return false;
216        };
217
218        let placeholders = custom_form_placeholders(draft);
219        let field_count = 2 + placeholders.len();
220        if field_count == 0 || modal.editor_index >= field_count {
221            modal.editor_index = 0;
222        }
223
224        match key.code {
225            KeyCode::Esc => {
226                modal.step = CustomModalStep::TemplatePicker;
227                modal.draft = None;
228                modal.error = None;
229            }
230            KeyCode::Tab | KeyCode::Down => {
231                if field_count > 0 {
232                    modal.editor_index = (modal.editor_index + 1) % field_count;
233                }
234                modal.error = None;
235            }
236            KeyCode::BackTab | KeyCode::Up => {
237                if field_count > 0 {
238                    modal.editor_index = if modal.editor_index == 0 {
239                        field_count - 1
240                    } else {
241                        modal.editor_index - 1
242                    };
243                }
244                modal.error = None;
245            }
246            KeyCode::Left => {
247                if modal.editor_index == 0 {
248                    let previous_rpc_url = draft
249                        .rpc_url
250                        .clone()
251                        .or_else(|| self.rpc_url_for_preset(&draft.rpc_preset));
252                    self.cycle_rpc_preset(&mut draft.rpc_preset, false);
253                    self.sync_draft_rpc_url_with_preset(draft, previous_rpc_url);
254                }
255                modal.error = None;
256            }
257            KeyCode::Right => {
258                if modal.editor_index == 0 {
259                    let previous_rpc_url = draft
260                        .rpc_url
261                        .clone()
262                        .or_else(|| self.rpc_url_for_preset(&draft.rpc_preset));
263                    self.cycle_rpc_preset(&mut draft.rpc_preset, true);
264                    self.sync_draft_rpc_url_with_preset(draft, previous_rpc_url);
265                }
266                modal.error = None;
267            }
268            KeyCode::Backspace => {
269                if modal.editor_index == 1 {
270                    draft.raw_args.pop();
271                } else if modal.editor_index >= 2 {
272                    let placeholder = placeholders[modal.editor_index - 2].clone();
273                    let initial = initial_placeholder_value(
274                        &draft.template,
275                        &draft.param_values,
276                        &placeholder,
277                        draft
278                            .rpc_url
279                            .clone()
280                            .or_else(|| self.rpc_url_for_preset(&draft.rpc_preset)),
281                    );
282                    let entry = draft.param_values.entry(placeholder).or_insert(initial);
283                    entry.pop();
284                }
285                modal.error = None;
286            }
287            KeyCode::Char(ch) => {
288                if modal.editor_index == 1 {
289                    draft.raw_args.push(ch);
290                } else if modal.editor_index >= 2 {
291                    let placeholder = placeholders[modal.editor_index - 2].clone();
292                    let initial = initial_placeholder_value(
293                        &draft.template,
294                        &draft.param_values,
295                        &placeholder,
296                        draft
297                            .rpc_url
298                            .clone()
299                            .or_else(|| self.rpc_url_for_preset(&draft.rpc_preset)),
300                    );
301                    let entry = draft.param_values.entry(placeholder).or_insert(initial);
302                    entry.push(ch);
303                }
304                modal.error = None;
305            }
306            KeyCode::Enter => {
307                if let Err(error) = self.prepare_custom_preview(modal) {
308                    modal.error = Some(error);
309                } else {
310                    modal.error = None;
311                }
312            }
313            _ => {}
314        }
315
316        false
317    }
318
319    fn handle_custom_preview_key(
320        &mut self,
321        modal: &mut CustomCommandModal,
322        key: KeyEvent,
323        tool_events: &UnboundedSender<ToolEvent>,
324    ) -> bool {
325        match key.code {
326            KeyCode::Esc => {
327                modal.step = CustomModalStep::Editor;
328                modal.error = None;
329                false
330            }
331            KeyCode::Enter => {
332                let Some(draft) = modal.draft.as_ref() else {
333                    modal.error = Some("missing draft state".to_string());
334                    return false;
335                };
336
337                match self.run_custom_draft(draft, tool_events) {
338                    Ok(_) => true,
339                    Err(error) => {
340                        modal.error = Some(error);
341                        false
342                    }
343                }
344            }
345            _ => false,
346        }
347    }
348
349    fn handle_anvil_prompt_key(&mut self, key: KeyEvent, tool_events: &UnboundedSender<ToolEvent>) {
350        match key.code {
351            KeyCode::Enter => {
352                self.submit_anvil_prompt(tool_events);
353                return;
354            }
355            KeyCode::Esc => {
356                self.model.anvil_prompt = None;
357                self.model.notification = Some("anvil launch prompt dismissed".to_string());
358                return;
359            }
360            _ => {}
361        }
362
363        let Some(prompt) = self.model.anvil_prompt.as_mut() else {
364            return;
365        };
366
367        match key.code {
368            KeyCode::Tab | KeyCode::Down => {
369                prompt.focus = prompt.focus.next();
370                prompt.error = None;
371            }
372            KeyCode::BackTab | KeyCode::Up => {
373                prompt.focus = prompt.focus.prev();
374                prompt.error = None;
375            }
376            KeyCode::Backspace => {
377                anvil_prompt_field_mut(prompt).pop();
378                prompt.error = None;
379            }
380            KeyCode::Char(ch) => {
381                if matches!(prompt.focus, crate::model::AnvilPromptField::Port)
382                    && !ch.is_ascii_digit()
383                {
384                    return;
385                }
386
387                anvil_prompt_field_mut(prompt).push(ch);
388                prompt.error = None;
389            }
390            _ => {}
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use std::path::PathBuf;
398
399    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
400    use foundry_tui_config::{ActionId, AppConfig};
401    use foundry_tui_foundry::ToolEvent;
402    use tokio::sync::mpsc::unbounded_channel;
403
404    use crate::model::{LogLine, LogStream, SectionFocus};
405
406    use super::AppController;
407
408    #[test]
409    fn mouse_wheel_scrolls_focused_logs_panel() {
410        let config = AppConfig::default();
411        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
412        controller.model.focused_section = SectionFocus::LogsPanel;
413        controller.model.logs_scroll = 2;
414
415        controller.handle_mouse(MouseEvent {
416            kind: MouseEventKind::ScrollDown,
417            column: 0,
418            row: 0,
419            modifiers: KeyModifiers::NONE,
420        });
421        assert_eq!(controller.model.logs_scroll, 1);
422
423        controller.handle_mouse(MouseEvent {
424            kind: MouseEventKind::ScrollUp,
425            column: 0,
426            row: 0,
427            modifiers: KeyModifiers::NONE,
428        });
429        assert_eq!(controller.model.logs_scroll, 2);
430    }
431
432    #[test]
433    fn mouse_wheel_navigates_palette() {
434        let config = AppConfig::default();
435        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
436        controller.model.palette_open = true;
437        controller.model.palette_index = 0;
438
439        controller.handle_mouse(MouseEvent {
440            kind: MouseEventKind::ScrollDown,
441            column: 0,
442            row: 0,
443            modifiers: KeyModifiers::NONE,
444        });
445        assert_eq!(controller.model.palette_index, 1);
446
447        controller.handle_mouse(MouseEvent {
448            kind: MouseEventKind::ScrollUp,
449            column: 0,
450            row: 0,
451            modifiers: KeyModifiers::NONE,
452        });
453        assert_eq!(controller.model.palette_index, 0);
454    }
455
456    #[test]
457    fn question_mark_is_unbound_by_default() {
458        let config = AppConfig::default();
459        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
460        let (events_tx, _events_rx) = unbounded_channel::<ToolEvent>();
461
462        assert!(controller.model.show_build_onboarding);
463        controller.handle_key(
464            KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
465            &events_tx,
466        );
467        assert!(controller.model.show_build_onboarding);
468    }
469
470    #[test]
471    fn toggle_onboarding_action_keeps_onboarding_enabled() {
472        let config = AppConfig::default();
473        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
474        let (events_tx, _events_rx) = unbounded_channel::<ToolEvent>();
475        controller.model.show_build_onboarding = false;
476        assert!(!controller.model.show_build_onboarding);
477        controller.execute_action(
478            foundry_tui_config::ActionId::ToggleBuildOnboarding,
479            &events_tx,
480        );
481        assert!(controller.model.show_build_onboarding);
482    }
483
484    #[test]
485    fn w_toggles_log_wrap_mode() {
486        let config = AppConfig::default();
487        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
488        let (events_tx, _events_rx) = unbounded_channel::<ToolEvent>();
489
490        assert_eq!(controller.model.log_text_mode.label(), "horizontal");
491        controller.handle_key(
492            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
493            &events_tx,
494        );
495        assert_eq!(controller.model.log_text_mode.label(), "wrapped");
496        controller.handle_key(
497            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
498            &events_tx,
499        );
500        assert_eq!(controller.model.log_text_mode.label(), "horizontal");
501    }
502
503    #[test]
504    fn right_left_scrolls_logs_panel_horizontally_when_horizontal_mode() {
505        let config = AppConfig::default();
506        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
507        let (events_tx, _events_rx) = unbounded_channel::<ToolEvent>();
508        controller.model.focused_section = SectionFocus::LogsPanel;
509        controller.model.logs.push(LogLine {
510            ts: chrono::Local::now(),
511            job_id: None,
512            stream: LogStream::Stdout,
513            message: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
514                .to_string(),
515        });
516
517        controller.handle_key(
518            KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
519            &events_tx,
520        );
521        assert_eq!(controller.model.logs_hscroll, 8);
522
523        controller.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &events_tx);
524        assert_eq!(controller.model.logs_hscroll, 0);
525    }
526
527    #[test]
528    fn right_left_noop_in_wrapped_mode() {
529        let mut config = AppConfig::default();
530        config
531            .keys
532            .bindings
533            .insert(ActionId::ToggleLogWrapMode, "w".to_string());
534        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
535        let (events_tx, _events_rx) = unbounded_channel::<ToolEvent>();
536        controller.model.focused_section = SectionFocus::LogsPanel;
537        controller.model.logs.push(LogLine {
538            ts: chrono::Local::now(),
539            job_id: None,
540            stream: LogStream::Stdout,
541            message: "0xabcdef".to_string(),
542        });
543
544        controller.handle_key(
545            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
546            &events_tx,
547        );
548        assert_eq!(controller.model.log_text_mode.label(), "wrapped");
549
550        controller.handle_key(
551            KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
552            &events_tx,
553        );
554        assert_eq!(controller.model.logs_hscroll, 0);
555    }
556
557    #[test]
558    fn right_left_scrolls_selected_anvil_logs_horizontally() {
559        let config = AppConfig::default();
560        let mut controller = AppController::new(config, PathBuf::from("."), PathBuf::from("cfg"));
561        let (events_tx, _events_rx) = unbounded_channel::<ToolEvent>();
562        controller.model.focused_section = SectionFocus::AnvilInstanceLogsPanel;
563        controller
564            .model
565            .anvil_instances
566            .push(crate::model::AnvilInstance {
567                job_id: 1,
568                name: "anvil-1".to_string(),
569                port: 8545,
570                fork_url: None,
571                status: crate::model::AnvilInstanceStatus::Running,
572                logs: vec![LogLine {
573                    ts: chrono::Local::now(),
574                    job_id: Some(1),
575                    stream: LogStream::Stdout,
576                    message: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
577                        .to_string(),
578                }],
579            });
580
581        controller.handle_key(
582            KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
583            &events_tx,
584        );
585        assert_eq!(controller.model.anvil_logs_hscroll, 8);
586        controller.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &events_tx);
587        assert_eq!(controller.model.anvil_logs_hscroll, 0);
588    }
589}