Skip to main content

vtcode_ui/tui/core_tui/session/
impl_events.rs

1use super::transcript_links::TranscriptLinkClickAction;
2use super::*;
3use std::time::Instant;
4
5impl Session {
6    fn input_area_contains(&self, column: u16, row: u16) -> bool {
7        self.input_area.is_some_and(|area| {
8            row >= area.y
9                && row < area.y.saturating_add(area.height)
10                && column >= area.x
11                && column < area.x.saturating_add(area.width)
12        })
13    }
14
15    fn handle_modal_list_result(
16        &mut self,
17        result: modal::ModalListKeyResult,
18        events: &UnboundedSender<InlineEvent>,
19        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
20    ) -> bool {
21        match result {
22            modal::ModalListKeyResult::NotHandled => false,
23            modal::ModalListKeyResult::HandledNoRedraw => true,
24            modal::ModalListKeyResult::Redraw => {
25                self.mark_dirty();
26                true
27            }
28            modal::ModalListKeyResult::Emit(event) => {
29                self.mark_dirty();
30                self.emit_inline_event(&event, events, callback);
31                true
32            }
33            modal::ModalListKeyResult::Submit(event) | modal::ModalListKeyResult::Cancel(event) => {
34                self.close_overlay();
35                self.mark_dirty();
36                self.emit_inline_event(&event, events, callback);
37                true
38            }
39        }
40    }
41
42    fn handle_link_click_action(
43        &mut self,
44        action: TranscriptLinkClickAction,
45        clear_drag_target: bool,
46        events: &UnboundedSender<InlineEvent>,
47        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
48    ) -> bool {
49        match action {
50            TranscriptLinkClickAction::Open(outbound) => {
51                if clear_drag_target {
52                    self.mouse_drag_target = MouseDragTarget::None;
53                }
54                self.mark_dirty();
55                self.emit_inline_event(&outbound, events, callback);
56                self.mouse_selection.clear_click_history();
57                true
58            }
59            TranscriptLinkClickAction::Consume => {
60                if clear_drag_target {
61                    self.mouse_drag_target = MouseDragTarget::None;
62                }
63                self.mouse_selection.clear_click_history();
64                true
65            }
66            TranscriptLinkClickAction::Ignore => false,
67        }
68    }
69
70    fn modal_visible_index_at(&self, row: u16) -> Option<usize> {
71        let area = self.modal_list_area?;
72        if row < area.y || row >= area.y.saturating_add(area.height) {
73            return None;
74        }
75
76        let styles = render::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                    false,
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                false,
122            );
123            let height = usize::from(inline_list::row_height(&lines));
124            if relative_row < consumed_rows + height {
125                return Some(visible_index);
126            }
127            consumed_rows += height;
128            if consumed_rows >= usize::from(area.height) {
129                break;
130            }
131        }
132
133        None
134    }
135
136    fn mouse_in_modal_area(&self, column: u16, row: u16) -> bool {
137        self.modal_list_area.is_some_and(|area| {
138            row >= area.y
139                && row < area.y.saturating_add(area.height)
140                && column >= area.x
141                && column < area.x.saturating_add(area.width)
142        })
143    }
144
145    fn modal_text_area_contains(&self, column: u16, row: u16) -> bool {
146        self.modal_text_areas().iter().any(|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    }
153
154    fn handle_active_overlay_click(
155        &mut self,
156        mouse_event: MouseEvent,
157        events: &UnboundedSender<InlineEvent>,
158        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
159    ) -> bool {
160        if !self.mouse_in_modal_area(mouse_event.column, mouse_event.row) {
161            return false;
162        }
163
164        let Some(visible_index) = self.modal_visible_index_at(mouse_event.row) else {
165            return true;
166        };
167
168        if let Some(wizard) = self.wizard_overlay_mut() {
169            let result = wizard.handle_mouse_click(visible_index);
170            return self.handle_modal_list_result(result, events, callback);
171        }
172
173        if let Some(modal) = self.modal_state_mut() {
174            let result = modal.handle_list_mouse_click(visible_index);
175            return self.handle_modal_list_result(result, events, callback);
176        }
177
178        true
179    }
180
181    fn handle_active_overlay_scroll(
182        &mut self,
183        mouse_event: MouseEvent,
184        down: bool,
185        events: &UnboundedSender<InlineEvent>,
186        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
187    ) -> bool {
188        if !self.has_active_overlay() {
189            return false;
190        }
191
192        if !self.mouse_in_modal_area(mouse_event.column, mouse_event.row) {
193            return false;
194        }
195
196        if let Some(wizard) = self.wizard_overlay_mut() {
197            let result = wizard.handle_mouse_scroll(down);
198            return self.handle_modal_list_result(result, events, callback);
199        }
200
201        if let Some(modal) = self.modal_state_mut() {
202            let result = modal.handle_list_mouse_scroll(down);
203            return self.handle_modal_list_result(result, events, callback);
204        }
205
206        true
207    }
208
209    fn handle_bottom_panel_scroll(&mut self, down: bool) -> bool {
210        let _ = down;
211        false
212    }
213
214    fn handle_bottom_panel_click(&mut self, mouse_event: MouseEvent) -> bool {
215        let _ = mouse_event;
216        false
217    }
218
219    pub fn handle_event(
220        &mut self,
221        event: CrosstermEvent,
222        events: &UnboundedSender<InlineEvent>,
223        callback: Option<&(dyn Fn(&InlineEvent) + Send + Sync + 'static)>,
224    ) {
225        match event {
226            CrosstermEvent::Key(key) => {
227                self.update_held_key_modifiers(&key);
228                // Only process Press events to avoid duplicate character insertion
229                // Repeat events can cause characters to be inserted multiple times
230                if matches!(key.kind, KeyEventKind::Press)
231                    && let Some(outbound) = events::process_key(self, key)
232                {
233                    self.emit_inline_event(&outbound, events, callback);
234                }
235            }
236            CrosstermEvent::Mouse(mouse_event) => match mouse_event.kind {
237                MouseEventKind::Moved
238                    if self
239                        .update_transcript_file_link_hover(mouse_event.column, mouse_event.row) =>
240                {
241                    self.mark_dirty();
242                }
243                MouseEventKind::ScrollDown => {
244                    self.clear_pending_link_click();
245                    self.mouse_selection.clear_click_history();
246                    if !self.handle_active_overlay_scroll(mouse_event, true, events, callback)
247                        && !self.handle_bottom_panel_scroll(true)
248                    {
249                        self.scroll_line_down();
250                        self.mark_dirty();
251                    }
252                }
253                MouseEventKind::ScrollUp => {
254                    self.clear_pending_link_click();
255                    self.mouse_selection.clear_click_history();
256                    if !self.handle_active_overlay_scroll(mouse_event, false, events, callback)
257                        && !self.handle_bottom_panel_scroll(false)
258                    {
259                        self.scroll_line_up();
260                        self.mark_dirty();
261                    }
262                }
263                MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
264                    self.clear_pending_link_click();
265                    if self.queue_link_click_action(self.transcript_file_link_click_action(
266                        mouse_event.column,
267                        mouse_event.row,
268                        mouse_event.modifiers,
269                    )) {
270                        self.mouse_selection.clear_click_history();
271                        return;
272                    }
273
274                    if self.has_active_overlay() {
275                        let in_modal_list =
276                            self.mouse_in_modal_area(mouse_event.column, mouse_event.row);
277                        if self.queue_link_click_action(self.modal_link_click_action(
278                            mouse_event.column,
279                            mouse_event.row,
280                            mouse_event.modifiers,
281                        )) {
282                            self.mouse_selection.clear_click_history();
283                            return;
284                        }
285
286                        if self.modal_text_area_contains(mouse_event.column, mouse_event.row)
287                            && !in_modal_list
288                        {
289                            let is_double_click = self.mouse_selection.register_click(
290                                mouse_event.column,
291                                mouse_event.row,
292                                Instant::now(),
293                            );
294                            if is_double_click {
295                                let modal_double_click_action = self.throttle_link_click_action(
296                                    self.modal_link_double_click_action(
297                                        mouse_event.column,
298                                        mouse_event.row,
299                                    ),
300                                );
301                                if !matches!(
302                                    modal_double_click_action,
303                                    TranscriptLinkClickAction::Ignore
304                                ) {
305                                    self.clear_pending_link_click();
306                                }
307                                if self.handle_link_click_action(
308                                    modal_double_click_action,
309                                    true,
310                                    events,
311                                    callback,
312                                ) {
313                                    return;
314                                }
315                            }
316
317                            self.mouse_drag_target = MouseDragTarget::ModalText;
318                            self.mouse_selection
319                                .start_selection(mouse_event.column, mouse_event.row);
320                            self.mark_dirty();
321                            return;
322                        }
323                    }
324
325                    if self.has_active_overlay()
326                        && self.handle_active_overlay_click(mouse_event, events, callback)
327                    {
328                        self.mouse_selection.clear_click_history();
329                        return;
330                    }
331
332                    if self.handle_bottom_panel_click(mouse_event) {
333                        self.mouse_selection.clear_click_history();
334                        return;
335                    }
336
337                    if self.handle_input_click(mouse_event) {
338                        self.mouse_drag_target = MouseDragTarget::Input;
339                        self.mouse_selection.clear();
340                        return;
341                    }
342
343                    let is_double_click = self.mouse_selection.register_click(
344                        mouse_event.column,
345                        mouse_event.row,
346                        Instant::now(),
347                    );
348                    if is_double_click {
349                        let transcript_double_click_action = self.throttle_link_click_action(
350                            self.transcript_file_link_double_click_action(
351                                mouse_event.column,
352                                mouse_event.row,
353                            ),
354                        );
355                        if !matches!(
356                            transcript_double_click_action,
357                            TranscriptLinkClickAction::Ignore
358                        ) {
359                            self.clear_pending_link_click();
360                        }
361                        if self.handle_link_click_action(
362                            transcript_double_click_action,
363                            true,
364                            events,
365                            callback,
366                        ) {
367                            return;
368                        }
369
370                        self.mouse_drag_target = MouseDragTarget::None;
371                        let _ = self.handle_transcript_click(mouse_event);
372                        if self.select_transcript_word_at(mouse_event.column, mouse_event.row) {
373                            self.mark_dirty();
374                        } else {
375                            self.mouse_selection.clear();
376                        }
377                        self.mouse_selection.clear_click_history();
378                        return;
379                    }
380
381                    self.mouse_drag_target = MouseDragTarget::Transcript;
382                    self.mouse_selection
383                        .start_selection(mouse_event.column, mouse_event.row);
384                    self.mark_dirty();
385                    self.handle_transcript_click(mouse_event);
386                }
387                MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
388                    self.clear_pending_link_click();
389                    match self.mouse_drag_target {
390                        MouseDragTarget::Input => {
391                            if let Some(cursor) = self
392                                .cursor_index_for_input_point(mouse_event.column, mouse_event.row)
393                                && self.input_manager.cursor() != cursor
394                            {
395                                self.input_manager.set_cursor_with_selection(cursor);
396                                self.mark_dirty();
397                            }
398                        }
399                        MouseDragTarget::Transcript => {
400                            self.mouse_selection
401                                .update_selection(mouse_event.column, mouse_event.row);
402                            self.mark_dirty();
403                        }
404                        MouseDragTarget::ModalText => {
405                            self.mouse_selection
406                                .update_selection(mouse_event.column, mouse_event.row);
407                            self.mark_dirty();
408                        }
409                        MouseDragTarget::None => {}
410                    }
411                }
412                MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
413                    let transcript_link_action =
414                        self.pending_link_click_action(self.transcript_file_link_click_action(
415                            mouse_event.column,
416                            mouse_event.row,
417                            mouse_event.modifiers,
418                        ));
419                    let modal_link_action =
420                        self.pending_link_click_action(self.modal_link_click_action(
421                            mouse_event.column,
422                            mouse_event.row,
423                            mouse_event.modifiers,
424                        ));
425                    match self.mouse_drag_target {
426                        MouseDragTarget::Input => {
427                            if let Some(cursor) = self
428                                .cursor_index_for_input_point(mouse_event.column, mouse_event.row)
429                                && self.input_manager.cursor() != cursor
430                            {
431                                self.input_manager.set_cursor_with_selection(cursor);
432                                self.mark_dirty();
433                            }
434                        }
435                        MouseDragTarget::Transcript => {
436                            self.mouse_selection
437                                .finish_selection(mouse_event.column, mouse_event.row);
438                            self.mark_dirty();
439                        }
440                        MouseDragTarget::ModalText => {
441                            self.mouse_selection
442                                .finish_selection(mouse_event.column, mouse_event.row);
443                            self.mark_dirty();
444                        }
445                        MouseDragTarget::None => {}
446                    }
447                    self.mouse_drag_target = MouseDragTarget::None;
448                    self.clear_pending_link_click();
449                    if self.handle_link_click_action(
450                        transcript_link_action,
451                        false,
452                        events,
453                        callback,
454                    ) {
455                        return;
456                    }
457                    if self.handle_link_click_action(modal_link_action, false, events, callback) {}
458                }
459                _ => {}
460            },
461            CrosstermEvent::Paste(content) => {
462                events::handle_paste(self, &content);
463            }
464            CrosstermEvent::Resize(_, rows) => {
465                self.apply_view_rows(rows);
466                self.mark_dirty();
467            }
468            CrosstermEvent::FocusGained => {
469                // No-op: focus tracking is host/application concern.
470            }
471            CrosstermEvent::FocusLost => {
472                self.clear_held_key_modifiers();
473            }
474        }
475    }
476
477    pub(crate) fn handle_transcript_click(&mut self, mouse_event: MouseEvent) -> bool {
478        if !matches!(
479            mouse_event.kind,
480            MouseEventKind::Down(crossterm::event::MouseButton::Left)
481        ) {
482            return false;
483        }
484
485        let Some(area) = self.transcript_area else {
486            return false;
487        };
488
489        if mouse_event.row < area.y
490            || mouse_event.row >= area.y.saturating_add(area.height)
491            || mouse_event.column < area.x
492            || mouse_event.column >= area.x.saturating_add(area.width)
493        {
494            return false;
495        }
496
497        if self.transcript_width == 0 || self.transcript_rows == 0 {
498            return false;
499        }
500
501        let row_in_view = (mouse_event.row - area.y) as usize;
502        if row_in_view >= self.transcript_rows as usize {
503            return false;
504        }
505
506        let viewport_rows = self.transcript_rows.max(1) as usize;
507        let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
508        let effective_padding = padding.min(viewport_rows.saturating_sub(1));
509        let total_rows = self.total_transcript_rows(self.transcript_width) + effective_padding;
510        let (top_offset, _clamped_total_rows) =
511            self.prepare_transcript_scroll(total_rows, viewport_rows);
512        let view_top = top_offset.min(self.scroll_manager.max_offset());
513        self.transcript_view_top = view_top;
514
515        let clicked_row = view_top.saturating_add(row_in_view);
516        let expanded = self.expand_collapsed_paste_at_row(self.transcript_width, clicked_row);
517        if expanded {
518            self.mark_dirty();
519        }
520        expanded
521    }
522
523    pub(crate) fn transcript_word_selection_range(
524        &mut self,
525        column: u16,
526        row: u16,
527    ) -> Option<((u16, u16), (u16, u16))> {
528        let area = self.transcript_area?;
529        if row < area.y
530            || row >= area.y.saturating_add(area.height)
531            || column < area.x
532            || column >= area.x.saturating_add(area.width)
533        {
534            return None;
535        }
536
537        if self.transcript_width == 0 || self.transcript_rows == 0 {
538            return None;
539        }
540
541        let row_in_view = usize::from(row.saturating_sub(area.y));
542        if row_in_view >= self.transcript_rows as usize {
543            return None;
544        }
545
546        let viewport_rows = self.transcript_rows.max(1) as usize;
547        let visible_lines = self.collect_transcript_window_cached(
548            self.transcript_width,
549            self.transcript_view_top,
550            viewport_rows,
551        );
552        let line = visible_lines.get(row_in_view)?;
553
554        let text = transcript_links::transcript_line_text(&line.line);
555        let local_column = column.saturating_sub(area.x);
556        let (start_col, end_col) = mouse_selection::word_selection_range(&text, local_column)?;
557
558        let start = (area.x.saturating_add(start_col), row);
559        let end = (area.x.saturating_add(end_col), row);
560        (start != end).then_some((start, end))
561    }
562
563    pub(crate) fn select_transcript_word_at(&mut self, column: u16, row: u16) -> bool {
564        let Some((start, end)) = self.transcript_word_selection_range(column, row) else {
565            return false;
566        };
567
568        self.mouse_selection.set_selection(start, end);
569        true
570    }
571
572    pub(crate) fn handle_input_click(&mut self, mouse_event: MouseEvent) -> bool {
573        if !matches!(
574            mouse_event.kind,
575            MouseEventKind::Down(crossterm::event::MouseButton::Left)
576        ) {
577            return false;
578        }
579
580        if !self.input_area_contains(mouse_event.column, mouse_event.row) {
581            return false;
582        }
583
584        let cursor_at_end = self.input_manager.cursor() == self.input_manager.content().len();
585        if self.input_compact_mode && cursor_at_end && self.input_compact_placeholder().is_some() {
586            self.input_compact_mode = false;
587            self.mark_dirty();
588            return true;
589        }
590
591        if let Some(cursor) = self.cursor_index_for_input_point(mouse_event.column, mouse_event.row)
592        {
593            if self.input_manager.cursor() != cursor {
594                self.input_manager.set_cursor(cursor);
595                self.mark_dirty();
596            }
597            return true;
598        }
599
600        false
601    }
602}