Skip to main content

vtcode_tui/core_tui/app/session/
impl_events.rs

1use 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                // Only process Press events to avoid duplicate character insertion
345                // Repeat events can cause characters to be inserted multiple times
346                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                // No-op: focus tracking is host/application concern.
493            }
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}