foundry_tui_app/controller/
input.rs1use 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}