vtcode_tui/core_tui/app/session/
impl_events.rs1use super::*;
2use crate::config::constants::ui;
3use crate::core_tui::session::MouseDragTarget;
4use crate::core_tui::session::render::modal_render_styles;
5use crate::core_tui::session::{TranscriptLinkClickAction, inline_list, list_panel, modal};
6use std::time::Instant;
7
8impl Session {
9 #[cfg(test)]
10 pub(crate) fn process_key(&mut self, key: KeyEvent) -> Option<InlineEvent> {
11 events::process_key(self, key)
12 }
13
14 fn input_area_contains(&self, column: u16, row: u16) -> bool {
15 self.core.input_area().is_some_and(|area| {
16 row >= area.y
17 && row < area.y.saturating_add(area.height)
18 && column >= area.x
19 && column < area.x.saturating_add(area.width)
20 })
21 }
22
23 fn bottom_panel_contains(&self, column: u16, row: u16) -> bool {
24 self.core.bottom_panel_area().is_some_and(|area| {
25 row >= area.y
26 && row < area.y.saturating_add(area.height)
27 && column >= area.x
28 && column < area.x.saturating_add(area.width)
29 })
30 }
31
32 fn panel_row_index(
33 &self,
34 layout: &list_panel::ListPanelLayout,
35 column: u16,
36 row: u16,
37 ) -> Option<usize> {
38 let area = self.core.bottom_panel_area()?;
39 layout.row_index(area, column, row)
40 }
41
42 fn handle_modal_list_result(
43 &mut self,
44 result: modal::ModalListKeyResult,
45 events: &UnboundedSender<InlineEvent>,
46 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
47 ) -> bool {
48 match result {
49 modal::ModalListKeyResult::NotHandled => false,
50 modal::ModalListKeyResult::HandledNoRedraw => true,
51 modal::ModalListKeyResult::Redraw => {
52 self.mark_dirty();
53 true
54 }
55 modal::ModalListKeyResult::Emit(event) => {
56 self.mark_dirty();
57 let outbound: InlineEvent = event.into();
58 events::emit_inline_event(&outbound, events, callback);
59 true
60 }
61 modal::ModalListKeyResult::Submit(event) | modal::ModalListKeyResult::Cancel(event) => {
62 self.close_overlay();
63 self.mark_dirty();
64 let outbound: InlineEvent = event.into();
65 events::emit_inline_event(&outbound, events, callback);
66 true
67 }
68 }
69 }
70
71 fn modal_visible_index_at(&self, row: u16) -> Option<usize> {
72 let area = self.core.modal_list_area()?;
73 if row < area.y || row >= area.y.saturating_add(area.height) {
74 return None;
75 }
76
77 let styles = modal_render_styles(self);
78 let content_width =
79 area.width
80 .saturating_sub(inline_list::selection_padding_width() as u16) as usize;
81 let relative_row = usize::from(row.saturating_sub(area.y));
82
83 if let Some(wizard) = self.wizard_overlay() {
84 let step = wizard.steps.get(wizard.current_step)?;
85 let offset = step.list.list_state.offset();
86 let visible_indices = &step.list.visible_indices;
87 let mut consumed_rows = 0usize;
88 for (visible_index, &item_index) in visible_indices.iter().enumerate().skip(offset) {
89 let lines = modal::modal_list_item_lines(
90 &step.list,
91 visible_index,
92 item_index,
93 &styles,
94 content_width,
95 None,
96 );
97 let height = usize::from(inline_list::row_height(&lines));
98 if relative_row < consumed_rows + height {
99 return Some(visible_index);
100 }
101 consumed_rows += height;
102 if consumed_rows >= usize::from(area.height) {
103 break;
104 }
105 }
106 return None;
107 }
108
109 let modal = self.modal_state()?;
110 let list = modal.list.as_ref()?;
111 let offset = list.list_state.offset();
112 let mut consumed_rows = 0usize;
113 for (visible_index, &item_index) in list.visible_indices.iter().enumerate().skip(offset) {
114 let lines = modal::modal_list_item_lines(
115 list,
116 visible_index,
117 item_index,
118 &styles,
119 content_width,
120 None,
121 );
122 let height = usize::from(inline_list::row_height(&lines));
123 if relative_row < consumed_rows + height {
124 return Some(visible_index);
125 }
126 consumed_rows += height;
127 if consumed_rows >= usize::from(area.height) {
128 break;
129 }
130 }
131
132 None
133 }
134
135 fn handle_active_overlay_click(
136 &mut self,
137 mouse_event: MouseEvent,
138 events: &UnboundedSender<InlineEvent>,
139 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
140 ) -> bool {
141 let column = mouse_event.column;
142 let row = mouse_event.row;
143 let in_modal_list = self.core.modal_list_area().is_some_and(|area| {
144 row >= area.y
145 && row < area.y.saturating_add(area.height)
146 && column >= area.x
147 && column < area.x.saturating_add(area.width)
148 });
149 if !in_modal_list {
150 return self.has_active_overlay();
151 }
152
153 let Some(visible_index) = self.modal_visible_index_at(row) else {
154 return true;
155 };
156
157 if let Some(wizard) = self.wizard_overlay_mut() {
158 let result = wizard.handle_mouse_click(visible_index);
159 return self.handle_modal_list_result(result, events, callback);
160 }
161
162 if let Some(modal) = self.modal_state_mut() {
163 let result = modal.handle_list_mouse_click(visible_index);
164 return self.handle_modal_list_result(result, events, callback);
165 }
166
167 true
168 }
169
170 fn handle_active_overlay_scroll(
171 &mut self,
172 mouse_event: MouseEvent,
173 down: bool,
174 events: &UnboundedSender<InlineEvent>,
175 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
176 ) -> bool {
177 if !self.has_active_overlay() {
178 return false;
179 }
180
181 let column = mouse_event.column;
182 let row = mouse_event.row;
183 let in_modal_list = self.core.modal_list_area().is_some_and(|area| {
184 row >= area.y
185 && row < area.y.saturating_add(area.height)
186 && column >= area.x
187 && column < area.x.saturating_add(area.width)
188 });
189
190 if !in_modal_list {
191 return true;
192 }
193
194 if let Some(wizard) = self.wizard_overlay_mut() {
195 let result = wizard.handle_mouse_scroll(down);
196 return self.handle_modal_list_result(result, events, callback);
197 }
198
199 if let Some(modal) = self.modal_state_mut() {
200 let result = modal.handle_list_mouse_scroll(down);
201 return self.handle_modal_list_result(result, events, callback);
202 }
203
204 true
205 }
206
207 fn handle_bottom_panel_scroll(&mut self, down: bool) -> bool {
208 if self.core.bottom_panel_area().is_none() {
209 return false;
210 }
211
212 if self.file_palette_visible() {
213 let Some(palette) = self.file_palette.as_mut() else {
214 return true;
215 };
216 if down {
217 palette.move_selection_down();
218 } else {
219 palette.move_selection_up();
220 }
221 self.mark_dirty();
222 return true;
223 }
224
225 if self.history_picker_visible() {
226 if down {
227 self.history_picker_state.move_down();
228 } else {
229 self.history_picker_state.move_up();
230 }
231 self.mark_dirty();
232 return true;
233 }
234
235 if slash::slash_navigation_available(self) {
236 if down {
237 slash::move_slash_selection_down(self);
238 } else {
239 slash::move_slash_selection_up(self);
240 }
241 return true;
242 }
243
244 false
245 }
246
247 fn handle_bottom_panel_click(&mut self, mouse_event: MouseEvent) -> bool {
248 let column = mouse_event.column;
249 let row = mouse_event.row;
250 if !self.bottom_panel_contains(column, row) {
251 return false;
252 }
253
254 if self.file_palette_visible() {
255 let Some(layout) = render::file_palette_panel_layout(self) else {
256 return true;
257 };
258 let bottom_area = self.core.bottom_panel_area();
259 let Some(palette) = self.file_palette.as_mut() else {
260 return true;
261 };
262 let local_index = bottom_area.and_then(|area| layout.row_index(area, column, row));
263 let mut apply_path = None;
264 let mut should_mark_dirty = false;
265 if !palette.has_files() {
266 return true;
267 }
268
269 let page_items = palette.current_page_items();
270 if let Some(local_index) = local_index
271 && let Some((global_index, entry, selected)) = page_items.get(local_index)
272 {
273 if *selected {
274 apply_path = Some(entry.relative_path.clone());
275 } else if palette.select_index(*global_index) {
276 should_mark_dirty = true;
277 }
278 }
279
280 if let Some(path) = apply_path {
281 self.insert_file_reference(&path);
282 self.close_file_palette();
283 self.mark_dirty();
284 } else if should_mark_dirty {
285 self.mark_dirty();
286 }
287 return true;
288 }
289
290 if self.history_picker_visible() {
291 let Some(layout) = render::history_picker_panel_layout(self) else {
292 return true;
293 };
294 if let Some(local_index) = self.panel_row_index(&layout, column, row)
295 && !self.history_picker_state.matches.is_empty()
296 {
297 let actual_index = self
298 .history_picker_state
299 .scroll_offset()
300 .saturating_add(local_index);
301 if self.history_picker_state.selected_index() == Some(actual_index) {
302 let was_active = self.history_picker_visible();
303 self.history_picker_state
304 .accept(&mut self.core.input_manager);
305 self.finish_history_picker_interaction(was_active);
306 self.mark_dirty();
307 } else if self.history_picker_state.select_index(actual_index) {
308 self.mark_dirty();
309 }
310 }
311 return true;
312 }
313
314 if slash::slash_navigation_available(self) {
315 let Some(layout) = slash::slash_panel_layout(self) else {
316 return true;
317 };
318 if let Some(local_index) = self.panel_row_index(&layout, column, row) {
319 let actual_index = self
320 .slash_palette
321 .scroll_offset()
322 .saturating_add(local_index);
323 if self.slash_palette.selected_index() == Some(actual_index) {
324 slash::apply_selected_slash_suggestion(self);
325 } else {
326 slash::select_slash_suggestion_index(self, actual_index);
327 }
328 }
329 return true;
330 }
331
332 true
333 }
334
335 pub fn handle_event(
336 &mut self,
337 event: CrosstermEvent,
338 events: &UnboundedSender<InlineEvent>,
339 callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
340 ) {
341 match event {
342 CrosstermEvent::Key(key) => {
343 self.update_held_key_modifiers(&key);
344 if matches!(key.kind, KeyEventKind::Press)
347 && let Some(outbound) = events::process_key(self, key)
348 {
349 events::emit_inline_event(&outbound, events, callback);
350 }
351 }
352 CrosstermEvent::Mouse(mouse_event) => match mouse_event.kind {
353 MouseEventKind::Moved => {
354 if self.update_transcript_file_link_hover(mouse_event.column, mouse_event.row) {
355 self.mark_dirty();
356 }
357 }
358 MouseEventKind::ScrollDown => {
359 self.core.mouse_selection.clear_click_history();
360 if !self.handle_active_overlay_scroll(mouse_event, true, events, callback)
361 && !self.handle_bottom_panel_scroll(true)
362 {
363 self.scroll_line_down();
364 self.mark_dirty();
365 }
366 }
367 MouseEventKind::ScrollUp => {
368 self.core.mouse_selection.clear_click_history();
369 if !self.handle_active_overlay_scroll(mouse_event, false, events, callback)
370 && !self.handle_bottom_panel_scroll(false)
371 {
372 self.scroll_line_up();
373 self.mark_dirty();
374 }
375 }
376 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
377 match self.transcript_file_link_click_action(
378 mouse_event.column,
379 mouse_event.row,
380 mouse_event.modifiers,
381 ) {
382 TranscriptLinkClickAction::Open(outbound) => {
383 self.mark_dirty();
384 let outbound: InlineEvent = outbound.into();
385 events::emit_inline_event(&outbound, events, callback);
386 self.core.mouse_selection.clear_click_history();
387 return;
388 }
389 TranscriptLinkClickAction::Consume => {
390 self.core.mouse_selection.clear_click_history();
391 return;
392 }
393 TranscriptLinkClickAction::Ignore => {}
394 }
395
396 if self.has_active_overlay()
397 && self.handle_active_overlay_click(mouse_event, events, callback)
398 {
399 self.core.mouse_selection.clear_click_history();
400 return;
401 }
402
403 if self.handle_bottom_panel_click(mouse_event) {
404 self.core.mouse_selection.clear_click_history();
405 return;
406 }
407
408 if self.handle_input_click(mouse_event) {
409 self.core.mouse_drag_target = MouseDragTarget::Input;
410 self.core.mouse_selection.clear();
411 return;
412 }
413
414 let is_double_click = self.core.mouse_selection.register_click(
415 mouse_event.column,
416 mouse_event.row,
417 Instant::now(),
418 );
419 if is_double_click {
420 self.core.mouse_drag_target = MouseDragTarget::None;
421 let _ = self.handle_transcript_click(mouse_event);
422 if self
423 .core
424 .select_transcript_word_at(mouse_event.column, mouse_event.row)
425 {
426 self.mark_dirty();
427 } else {
428 self.core.mouse_selection.clear();
429 }
430 self.core.mouse_selection.clear_click_history();
431 return;
432 }
433
434 self.core.mouse_drag_target = MouseDragTarget::Transcript;
435 self.core
436 .mouse_selection
437 .start_selection(mouse_event.column, mouse_event.row);
438 self.mark_dirty();
439 self.handle_transcript_click(mouse_event);
440 }
441 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
442 match self.core.mouse_drag_target {
443 MouseDragTarget::Input => {
444 if let Some(cursor) = self
445 .cursor_index_for_input_point(mouse_event.column, mouse_event.row)
446 && self.core.input_manager.cursor() != cursor
447 {
448 self.core.input_manager.set_cursor_with_selection(cursor);
449 self.mark_dirty();
450 }
451 }
452 MouseDragTarget::Transcript => {
453 self.core
454 .mouse_selection
455 .update_selection(mouse_event.column, mouse_event.row);
456 self.mark_dirty();
457 }
458 MouseDragTarget::None => {}
459 }
460 }
461 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
462 match self.core.mouse_drag_target {
463 MouseDragTarget::Input => {
464 if let Some(cursor) = self
465 .cursor_index_for_input_point(mouse_event.column, mouse_event.row)
466 && self.core.input_manager.cursor() != cursor
467 {
468 self.core.input_manager.set_cursor_with_selection(cursor);
469 self.mark_dirty();
470 }
471 }
472 MouseDragTarget::Transcript => {
473 self.core
474 .mouse_selection
475 .finish_selection(mouse_event.column, mouse_event.row);
476 self.mark_dirty();
477 }
478 MouseDragTarget::None => {}
479 }
480 self.core.mouse_drag_target = MouseDragTarget::None;
481 }
482 _ => {}
483 },
484 CrosstermEvent::Paste(content) => {
485 events::handle_paste(self, &content);
486 }
487 CrosstermEvent::Resize(_, rows) => {
488 self.apply_view_rows(rows);
489 self.mark_dirty();
490 }
491 CrosstermEvent::FocusGained => {
492 }
494 CrosstermEvent::FocusLost => {
495 self.clear_held_key_modifiers();
496 }
497 }
498 }
499
500 pub(crate) fn handle_transcript_click(&mut self, mouse_event: MouseEvent) -> bool {
501 if !matches!(
502 mouse_event.kind,
503 MouseEventKind::Down(crossterm::event::MouseButton::Left)
504 ) {
505 return false;
506 }
507
508 let Some(area) = self.core.transcript_area() else {
509 return false;
510 };
511
512 if mouse_event.row < area.y
513 || mouse_event.row >= area.y.saturating_add(area.height)
514 || mouse_event.column < area.x
515 || mouse_event.column >= area.x.saturating_add(area.width)
516 {
517 return false;
518 }
519
520 if self.core.transcript_width == 0 || self.core.transcript_rows == 0 {
521 return false;
522 }
523
524 let row_in_view = (mouse_event.row - area.y) as usize;
525 if row_in_view >= self.core.transcript_rows as usize {
526 return false;
527 }
528
529 let viewport_rows = self.core.transcript_rows.max(1) as usize;
530 let transcript_width = self.core.transcript_width;
531 let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
532 let effective_padding = padding.min(viewport_rows.saturating_sub(1));
533 let total_rows = self.total_transcript_rows(transcript_width) + effective_padding;
534 let (top_offset, _clamped_total_rows) =
535 self.prepare_transcript_scroll(total_rows, viewport_rows);
536 let view_top = top_offset.min(self.core.scroll_manager.max_offset());
537 self.core.transcript_view_top = view_top;
538
539 let clicked_row = view_top.saturating_add(row_in_view);
540 let expanded = self.expand_collapsed_paste_at_row(transcript_width, clicked_row);
541 if expanded {
542 self.mark_dirty();
543 }
544 expanded
545 }
546
547 pub(crate) fn handle_input_click(&mut self, mouse_event: MouseEvent) -> bool {
548 if !matches!(
549 mouse_event.kind,
550 MouseEventKind::Down(crossterm::event::MouseButton::Left)
551 ) {
552 return false;
553 }
554
555 if !self.input_area_contains(mouse_event.column, mouse_event.row) {
556 return false;
557 }
558
559 let cursor_at_end =
560 self.core.input_manager.cursor() == self.core.input_manager.content().len();
561 if self.core.input_compact_mode()
562 && cursor_at_end
563 && self.input_compact_placeholder().is_some()
564 {
565 self.core.set_input_compact_mode(false);
566 self.mark_dirty();
567 return true;
568 }
569
570 if let Some(cursor) = self.cursor_index_for_input_point(mouse_event.column, mouse_event.row)
571 {
572 if self.core.input_manager.cursor() != cursor {
573 self.core.input_manager.set_cursor(cursor);
574 self.mark_dirty();
575 }
576 return true;
577 }
578
579 false
580 }
581}