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_active {
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_state.active {
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_active {
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_state.active {
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                    self.history_picker_state
303                        .accept(&mut self.core.input_manager);
304                    self.update_input_triggers();
305                    self.mark_dirty();
306                } else if self.history_picker_state.select_index(actual_index) {
307                    self.mark_dirty();
308                }
309            }
310            return true;
311        }
312
313        if slash::slash_navigation_available(self) {
314            let Some(layout) = slash::slash_panel_layout(self) else {
315                return true;
316            };
317            if let Some(local_index) = self.panel_row_index(&layout, column, row) {
318                let actual_index = self
319                    .slash_palette
320                    .scroll_offset()
321                    .saturating_add(local_index);
322                if self.slash_palette.selected_index() == Some(actual_index) {
323                    slash::apply_selected_slash_suggestion(self);
324                } else {
325                    slash::select_slash_suggestion_index(self, actual_index);
326                }
327            }
328            return true;
329        }
330
331        true
332    }
333
334    pub fn handle_event(
335        &mut self,
336        event: CrosstermEvent,
337        events: &UnboundedSender<InlineEvent>,
338        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
339    ) {
340        match event {
341            CrosstermEvent::Key(key) => {
342                self.update_held_key_modifiers(&key);
343                // Only process Press events to avoid duplicate character insertion
344                // Repeat events can cause characters to be inserted multiple times
345                if matches!(key.kind, KeyEventKind::Press)
346                    && let Some(outbound) = events::process_key(self, key)
347                {
348                    events::emit_inline_event(&outbound, events, callback);
349                }
350            }
351            CrosstermEvent::Mouse(mouse_event) => match mouse_event.kind {
352                MouseEventKind::Moved => {
353                    if self.update_transcript_file_link_hover(mouse_event.column, mouse_event.row) {
354                        self.mark_dirty();
355                    }
356                }
357                MouseEventKind::ScrollDown => {
358                    self.core.mouse_selection.clear_click_history();
359                    if !self.handle_active_overlay_scroll(mouse_event, true, events, callback)
360                        && !self.handle_bottom_panel_scroll(true)
361                    {
362                        self.scroll_line_down();
363                        self.mark_dirty();
364                    }
365                }
366                MouseEventKind::ScrollUp => {
367                    self.core.mouse_selection.clear_click_history();
368                    if !self.handle_active_overlay_scroll(mouse_event, false, events, callback)
369                        && !self.handle_bottom_panel_scroll(false)
370                    {
371                        self.scroll_line_up();
372                        self.mark_dirty();
373                    }
374                }
375                MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
376                    match self.transcript_file_link_click_action(
377                        mouse_event.column,
378                        mouse_event.row,
379                        mouse_event.modifiers,
380                    ) {
381                        TranscriptLinkClickAction::Open(outbound) => {
382                            self.mark_dirty();
383                            let outbound: InlineEvent = outbound.into();
384                            events::emit_inline_event(&outbound, events, callback);
385                            self.core.mouse_selection.clear_click_history();
386                            return;
387                        }
388                        TranscriptLinkClickAction::Consume => {
389                            self.core.mouse_selection.clear_click_history();
390                            return;
391                        }
392                        TranscriptLinkClickAction::Ignore => {}
393                    }
394
395                    if self.has_active_overlay()
396                        && self.handle_active_overlay_click(mouse_event, events, callback)
397                    {
398                        self.core.mouse_selection.clear_click_history();
399                        return;
400                    }
401
402                    if self.handle_bottom_panel_click(mouse_event) {
403                        self.core.mouse_selection.clear_click_history();
404                        return;
405                    }
406
407                    if self.handle_input_click(mouse_event) {
408                        self.core.mouse_drag_target = MouseDragTarget::Input;
409                        self.core.mouse_selection.clear();
410                        return;
411                    }
412
413                    let is_double_click = self.core.mouse_selection.register_click(
414                        mouse_event.column,
415                        mouse_event.row,
416                        Instant::now(),
417                    );
418                    if is_double_click {
419                        self.core.mouse_drag_target = MouseDragTarget::None;
420                        let _ = self.handle_transcript_click(mouse_event);
421                        if self
422                            .core
423                            .select_transcript_word_at(mouse_event.column, mouse_event.row)
424                        {
425                            self.mark_dirty();
426                        } else {
427                            self.core.mouse_selection.clear();
428                        }
429                        self.core.mouse_selection.clear_click_history();
430                        return;
431                    }
432
433                    self.core.mouse_drag_target = MouseDragTarget::Transcript;
434                    self.core
435                        .mouse_selection
436                        .start_selection(mouse_event.column, mouse_event.row);
437                    self.mark_dirty();
438                    self.handle_transcript_click(mouse_event);
439                }
440                MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
441                    match self.core.mouse_drag_target {
442                        MouseDragTarget::Input => {
443                            if let Some(cursor) = self
444                                .cursor_index_for_input_point(mouse_event.column, mouse_event.row)
445                                && self.core.input_manager.cursor() != cursor
446                            {
447                                self.core.input_manager.set_cursor_with_selection(cursor);
448                                self.mark_dirty();
449                            }
450                        }
451                        MouseDragTarget::Transcript => {
452                            self.core
453                                .mouse_selection
454                                .update_selection(mouse_event.column, mouse_event.row);
455                            self.mark_dirty();
456                        }
457                        MouseDragTarget::None => {}
458                    }
459                }
460                MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
461                    match self.core.mouse_drag_target {
462                        MouseDragTarget::Input => {
463                            if let Some(cursor) = self
464                                .cursor_index_for_input_point(mouse_event.column, mouse_event.row)
465                                && self.core.input_manager.cursor() != cursor
466                            {
467                                self.core.input_manager.set_cursor_with_selection(cursor);
468                                self.mark_dirty();
469                            }
470                        }
471                        MouseDragTarget::Transcript => {
472                            self.core
473                                .mouse_selection
474                                .finish_selection(mouse_event.column, mouse_event.row);
475                            self.mark_dirty();
476                        }
477                        MouseDragTarget::None => {}
478                    }
479                    self.core.mouse_drag_target = MouseDragTarget::None;
480                }
481                _ => {}
482            },
483            CrosstermEvent::Paste(content) => {
484                events::handle_paste(self, &content);
485            }
486            CrosstermEvent::Resize(_, rows) => {
487                self.apply_view_rows(rows);
488                self.mark_dirty();
489            }
490            CrosstermEvent::FocusGained => {
491                // No-op: focus tracking is host/application concern.
492            }
493            CrosstermEvent::FocusLost => {
494                self.clear_held_key_modifiers();
495            }
496        }
497    }
498
499    pub(crate) fn handle_transcript_click(&mut self, mouse_event: MouseEvent) -> bool {
500        if !matches!(
501            mouse_event.kind,
502            MouseEventKind::Down(crossterm::event::MouseButton::Left)
503        ) {
504            return false;
505        }
506
507        let Some(area) = self.core.transcript_area() else {
508            return false;
509        };
510
511        if mouse_event.row < area.y
512            || mouse_event.row >= area.y.saturating_add(area.height)
513            || mouse_event.column < area.x
514            || mouse_event.column >= area.x.saturating_add(area.width)
515        {
516            return false;
517        }
518
519        if self.core.transcript_width == 0 || self.core.transcript_rows == 0 {
520            return false;
521        }
522
523        let row_in_view = (mouse_event.row - area.y) as usize;
524        if row_in_view >= self.core.transcript_rows as usize {
525            return false;
526        }
527
528        let viewport_rows = self.core.transcript_rows.max(1) as usize;
529        let transcript_width = self.core.transcript_width;
530        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
531        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
532        let total_rows = self.total_transcript_rows(transcript_width) + effective_padding;
533        let (top_offset, _clamped_total_rows) =
534            self.prepare_transcript_scroll(total_rows, viewport_rows);
535        let view_top = top_offset.min(self.core.scroll_manager.max_offset());
536        self.core.transcript_view_top = view_top;
537
538        let clicked_row = view_top.saturating_add(row_in_view);
539        let expanded = self.expand_collapsed_paste_at_row(transcript_width, clicked_row);
540        if expanded {
541            self.mark_dirty();
542        }
543        expanded
544    }
545
546    pub(crate) fn handle_input_click(&mut self, mouse_event: MouseEvent) -> bool {
547        if !matches!(
548            mouse_event.kind,
549            MouseEventKind::Down(crossterm::event::MouseButton::Left)
550        ) {
551            return false;
552        }
553
554        if !self.input_area_contains(mouse_event.column, mouse_event.row) {
555            return false;
556        }
557
558        let cursor_at_end =
559            self.core.input_manager.cursor() == self.core.input_manager.content().len();
560        if self.core.input_compact_mode()
561            && cursor_at_end
562            && self.input_compact_placeholder().is_some()
563        {
564            self.core.set_input_compact_mode(false);
565            self.mark_dirty();
566            return true;
567        }
568
569        if let Some(cursor) = self.cursor_index_for_input_point(mouse_event.column, mouse_event.row)
570        {
571            if self.core.input_manager.cursor() != cursor {
572                self.core.input_manager.set_cursor(cursor);
573                self.mark_dirty();
574            }
575            return true;
576        }
577
578        false
579    }
580}