Skip to main content

vtcode_tui/core_tui/session/
impl_events.rs

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