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