ratatui_toolkit/widgets/markdown_widget/widget/methods/
handle_mouse_event.rs

1//! Handle mouse events for the markdown widget.
2
3use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
4use ratatui::layout::Rect;
5
6use crate::widgets::markdown_widget::extensions::scrollbar::{
7    click_to_offset, is_in_scrollbar_area,
8};
9use crate::widgets::markdown_widget::extensions::selection::should_render_line;
10use crate::widgets::markdown_widget::foundation::elements::render;
11use crate::widgets::markdown_widget::foundation::elements::ElementKind;
12use crate::widgets::markdown_widget::foundation::events::MarkdownEvent;
13use crate::widgets::markdown_widget::foundation::helpers::is_in_area;
14use crate::widgets::markdown_widget::foundation::parser::render_markdown_to_elements;
15use crate::widgets::markdown_widget::foundation::types::SelectionPos;
16use crate::widgets::markdown_widget::widget::enums::MarkdownWidgetMode;
17use crate::widgets::markdown_widget::widget::MarkdownWidget;
18
19impl<'a> MarkdownWidget<'a> {
20    /// Handle a mouse event for all interactions.
21    ///
22    /// This method handles:
23    /// - Click-to-focus: Sets the current line on click (highlights it)
24    /// - Double-click: Returns event with line info
25    /// - Text selection: Drag to select, auto-copy on release
26    /// - Heading collapse: Click on heading to toggle
27    /// - Scrolling: Mouse wheel to scroll
28    ///
29    /// Returns a `MarkdownEvent` indicating what action was taken.
30    ///
31    /// # Mouse Capture Requirement
32    ///
33    /// This method requires `EnableMouseCapture` to be enabled for click events.
34    /// Scroll events may work without it (terminal-dependent).
35    ///
36    /// # Arguments
37    ///
38    /// * `event` - The mouse event
39    /// * `area` - The area the widget occupies (for bounds checking)
40    pub fn handle_mouse_event(&mut self, event: &MouseEvent, area: Rect) -> MarkdownEvent {
41        if !is_in_area(event.column, event.row, area) {
42            // Click outside area exits selection mode
43            if self.selection.is_active() {
44                self.selection.exit();
45                return MarkdownEvent::SelectionEnded;
46            }
47            return MarkdownEvent::None;
48        }
49
50        let relative_y = event.row.saturating_sub(area.y) as usize;
51        let relative_x = event.column.saturating_sub(area.x) as usize;
52        let width = area.width as usize;
53
54        // Document coordinates (accounting for scroll)
55        let document_y = (relative_y + self.scroll.scroll_offset) as i32;
56        let document_x = relative_x as i32;
57
58        // Check if mouse is over TOC area - handle TOC scrolling if so
59        if self.show_toc {
60            if let Some(toc_area) = self.calculate_toc_area(area) {
61                let is_over_toc = event.column >= toc_area.x
62                    && event.column < toc_area.x + toc_area.width
63                    && event.row >= toc_area.y
64                    && event.row < toc_area.y + toc_area.height;
65
66                if is_over_toc {
67                    // Handle scroll events for TOC scrolling
68                    match event.kind {
69                        MouseEventKind::ScrollUp => {
70                            self.toc_scroll_offset = self.toc_scroll_offset.saturating_sub(1);
71                            // Recalculate hovered entry after scroll
72                            self.update_toc_hovered_entry(event.column, event.row, toc_area);
73                            return MarkdownEvent::None;
74                        }
75                        MouseEventKind::ScrollDown => {
76                            // Get entry count to limit scrolling
77                            let entry_count = self.toc_state.map(|s| s.entry_count()).unwrap_or(0);
78                            let visible_height = toc_area.height as usize;
79                            let max_offset = entry_count.saturating_sub(visible_height);
80                            if self.toc_scroll_offset < max_offset {
81                                self.toc_scroll_offset += 1;
82                            }
83                            // Recalculate hovered entry after scroll
84                            self.update_toc_hovered_entry(event.column, event.row, toc_area);
85                            return MarkdownEvent::None;
86                        }
87                        MouseEventKind::Down(MouseButton::Left) => {
88                            // TOC click - navigation is handled by handle_toc_click
89                            // Don't change the highlighted line for TOC clicks
90                            return MarkdownEvent::None;
91                        }
92                        _ => {}
93                    }
94                }
95            }
96        }
97
98        // Check if click is on scrollbar area (rightmost column(s) of content area)
99        if let Some(scrollbar_area) = self.calculate_scrollbar_area(area) {
100            if is_in_scrollbar_area(event.column, event.row, scrollbar_area) {
101                match event.kind {
102                    MouseEventKind::Down(MouseButton::Left)
103                    | MouseEventKind::Drag(MouseButton::Left) => {
104                        // Click or drag on scrollbar - jump to position
105                        let new_offset = click_to_offset(event.row, scrollbar_area, self.scroll);
106                        self.scroll.scroll_offset = new_offset;
107                        return MarkdownEvent::Scrolled {
108                            offset: new_offset,
109                            direction: 0,
110                        };
111                    }
112                    MouseEventKind::ScrollUp => {
113                        let old_offset = self.scroll.scroll_offset;
114                        self.scroll.scroll_up(5);
115                        return MarkdownEvent::Scrolled {
116                            offset: self.scroll.scroll_offset,
117                            direction: -(old_offset.saturating_sub(self.scroll.scroll_offset)
118                                as i32),
119                        };
120                    }
121                    MouseEventKind::ScrollDown => {
122                        let old_offset = self.scroll.scroll_offset;
123                        self.scroll.scroll_down(5);
124                        return MarkdownEvent::Scrolled {
125                            offset: self.scroll.scroll_offset,
126                            direction: (self.scroll.scroll_offset.saturating_sub(old_offset)
127                                as i32),
128                        };
129                    }
130                    _ => {}
131                }
132            }
133        }
134
135        match event.kind {
136            MouseEventKind::Down(MouseButton::Left) => {
137                // Exit active selection on new click
138                if self.selection.is_active() {
139                    self.selection.exit();
140                }
141
142                // Process click for double-click detection
143                // Pass current scroll_offset so it can be stored for accurate line calculation later
144                let (is_double, _should_process_pending) = self.double_click.process_click(
145                    event.column,
146                    event.row,
147                    self.scroll.scroll_offset,
148                );
149
150                if is_double {
151                    // Double-click: store info for app to retrieve, return None
152                    if let Some(evt) = self.get_line_info_at_position(relative_y, width) {
153                        self.last_double_click = Some((evt.0, evt.1, evt.2));
154                    }
155                    return MarkdownEvent::None;
156                }
157
158                // Single click: highlight the clicked line (set as current line)
159                let clicked_line = self.scroll.scroll_offset + relative_y + 1; // 1-indexed
160                if clicked_line <= self.scroll.total_lines {
161                    self.scroll.set_current_line(clicked_line);
162                }
163
164                MarkdownEvent::FocusedLine { line: clicked_line }
165            }
166            MouseEventKind::Drag(MouseButton::Left) => {
167                let event_result = if !self.selection.is_active() {
168                    // Start selection on drag
169                    self.selection.enter(
170                        document_x,
171                        document_y,
172                        self.rendered_lines.clone(),
173                        width,
174                    );
175                    self.selection.anchor = Some(SelectionPos::new(document_x, document_y));
176                    self.mode = MarkdownWidgetMode::Drag;
177                    MarkdownEvent::SelectionStarted
178                } else {
179                    MarkdownEvent::None
180                };
181
182                // Update cursor position during drag
183                self.selection.update_cursor(document_x, document_y);
184
185                event_result
186            }
187            MouseEventKind::Up(MouseButton::Left) => {
188                // Selection complete - auto-copy to clipboard
189                if self.selection.is_active() && self.selection.has_selection() {
190                    // Update frozen lines with current rendered lines
191                    self.selection.frozen_lines = Some(self.rendered_lines.clone());
192                    self.selection.frozen_width = width;
193
194                    // Auto-copy to clipboard
195                    if let Some(text) = self.selection.get_selected_text() {
196                        if !text.is_empty() {
197                            if let Ok(mut clipboard) = arboard::Clipboard::new() {
198                                if clipboard.set_text(&text).is_ok() {
199                                    // Store in selection state for app to retrieve (shows toast)
200                                    self.selection.last_copied_text = Some(text.clone());
201                                    return MarkdownEvent::Copied { text };
202                                }
203                            }
204                        }
205                    }
206                }
207                MarkdownEvent::None
208            }
209            MouseEventKind::ScrollUp => {
210                let old_offset = self.scroll.scroll_offset;
211                self.scroll.scroll_up(5);
212                MarkdownEvent::Scrolled {
213                    offset: self.scroll.scroll_offset,
214                    direction: -(old_offset.saturating_sub(self.scroll.scroll_offset) as i32),
215                }
216            }
217            MouseEventKind::ScrollDown => {
218                let old_offset = self.scroll.scroll_offset;
219                self.scroll.scroll_down(5);
220                MarkdownEvent::Scrolled {
221                    offset: self.scroll.scroll_offset,
222                    direction: (self.scroll.scroll_offset.saturating_sub(old_offset) as i32),
223                }
224            }
225            _ => MarkdownEvent::None,
226        }
227    }
228
229    /// Check for pending single-click timeout and process if needed.
230    ///
231    /// Call this method periodically (e.g., each frame) to handle deferred
232    /// single-click actions like heading collapse and focus line changes.
233    ///
234    /// Returns a `MarkdownEvent` if a pending click was processed.
235    ///
236    /// # Arguments
237    ///
238    /// * `area` - The area the widget occupies (for position calculations)
239    pub fn check_pending_click(&mut self, area: Rect) -> MarkdownEvent {
240        if let Some((x, y, click_scroll_offset)) = self.double_click.check_pending_timeout() {
241            // Calculate relative position
242            let relative_y = y.saturating_sub(area.y) as usize;
243            let relative_x = x.saturating_sub(area.x) as usize;
244            let width = area.width as usize;
245
246            // Set focused line based on click position (1-indexed)
247            // Use the scroll_offset from when the click happened, not the current scroll_offset
248            let clicked_line = click_scroll_offset + relative_y + 1;
249            if clicked_line <= self.scroll.total_lines {
250                self.scroll.set_current_line(clicked_line);
251            }
252
253            // Try to handle heading collapse (uses current scroll offset for content lookup)
254            if self.handle_click_collapse(relative_x, relative_y, width) {
255                // Heading was toggled - get info for the event
256                if let Some((_, line_kind, text)) =
257                    self.get_line_info_at_position(relative_y, width)
258                {
259                    if line_kind == "Heading" {
260                        return MarkdownEvent::HeadingToggled {
261                            level: 1, // We don't have easy access to level here
262                            text,
263                            collapsed: true, // We toggled, but don't know new state
264                        };
265                    }
266                }
267            }
268
269            return MarkdownEvent::FocusedLine { line: clicked_line };
270        }
271
272        MarkdownEvent::None
273    }
274
275    /// Handle click for collapse/expand functionality.
276    ///
277    /// Returns `true` if a collapsible element was toggled.
278    fn handle_click_collapse(&mut self, _x: usize, y: usize, width: usize) -> bool {
279        use crate::widgets::markdown_widget::foundation::elements::ElementKind;
280
281        let elements = render_markdown_to_elements(self.content, true);
282
283        // Account for scroll offset - y is relative to visible area
284        let document_y = y + self.scroll.scroll_offset;
285        let mut line_idx = 0;
286
287        for (idx, element) in elements.iter().enumerate() {
288            // Skip elements that shouldn't be rendered (collapsed sections)
289            if !should_render_line(element, idx, self.collapse) {
290                continue;
291            }
292
293            let rendered = render(element, width);
294            let line_count = rendered.len();
295
296            if document_y >= line_idx && document_y < line_idx + line_count {
297                match &element.kind {
298                    ElementKind::Heading { section_id, .. } => {
299                        // Only collapse headings if show_heading_collapse is enabled
300                        if self.display.show_heading_collapse {
301                            self.collapse.toggle_section(*section_id);
302                            self.cache.invalidate();
303                            return true;
304                        }
305                    }
306                    ElementKind::Frontmatter { .. } => {
307                        self.collapse.toggle_section(0);
308                        self.cache.invalidate();
309                        return true;
310                    }
311                    ElementKind::FrontmatterStart { .. } => {
312                        self.collapse.toggle_section(0);
313                        self.cache.invalidate();
314                        return true;
315                    }
316                    ElementKind::ExpandToggle { content_id, .. } => {
317                        self.expandable.toggle(content_id);
318                        self.cache.invalidate();
319                        return true;
320                    }
321                    _ => {}
322                }
323            }
324
325            line_idx += line_count;
326        }
327
328        false
329    }
330
331    /// Get line information at a given screen position.
332    ///
333    /// Returns (line_number, line_kind, content) if found.
334    pub fn get_line_info_at_position(
335        &self,
336        y: usize,
337        width: usize,
338    ) -> Option<(usize, String, String)> {
339        use crate::widgets::markdown_widget::foundation::elements::ElementKind;
340
341        let elements = render_markdown_to_elements(self.content, true);
342        let document_y = y + self.scroll.scroll_offset;
343        let mut visual_line_idx = 0;
344        let mut logical_line_num = 0;
345
346        for (idx, element) in elements.iter().enumerate() {
347            if !should_render_line(element, idx, self.collapse) {
348                continue;
349            }
350
351            logical_line_num += 1;
352
353            let rendered = render(element, width);
354            let line_count = rendered.len();
355
356            if document_y >= visual_line_idx && document_y < visual_line_idx + line_count {
357                let line_kind = match &element.kind {
358                    ElementKind::Heading { .. } => "Heading",
359                    ElementKind::Paragraph(_) => "Paragraph",
360                    ElementKind::CodeBlockHeader { .. } => "CodeBlockHeader",
361                    ElementKind::CodeBlockContent { .. } => "CodeBlockContent",
362                    ElementKind::CodeBlockBorder { .. } => "CodeBlockBorder",
363                    ElementKind::ListItem { .. } => "ListItem",
364                    ElementKind::Blockquote { .. } => "Blockquote",
365                    ElementKind::Empty => "Empty",
366                    ElementKind::HorizontalRule => "HorizontalRule",
367                    ElementKind::Frontmatter { .. } => "Frontmatter",
368                    ElementKind::FrontmatterStart { .. } => "FrontmatterStart",
369                    ElementKind::FrontmatterField { .. } => "FrontmatterField",
370                    ElementKind::FrontmatterEnd => "FrontmatterEnd",
371                    ElementKind::Expandable { .. } => "Expandable",
372                    ElementKind::ExpandToggle { .. } => "ExpandToggle",
373                    ElementKind::TableRow { .. } => "TableRow",
374                    ElementKind::TableBorder(_) => "TableBorder",
375                    ElementKind::HeadingBorder { .. } => "HeadingBorder",
376                };
377
378                let text_content = self.get_element_text(&element.kind);
379
380                return Some((logical_line_num, line_kind.to_string(), text_content));
381            }
382
383            visual_line_idx += line_count;
384        }
385
386        None
387    }
388
389    /// Extract plain text from an ElementKind.
390    fn get_element_text(
391        &self,
392        kind: &crate::widgets::markdown_widget::foundation::elements::ElementKind,
393    ) -> String {
394        use crate::widgets::markdown_widget::foundation::elements::{ElementKind, TextSegment};
395
396        fn segment_to_text(seg: &TextSegment) -> &str {
397            match seg {
398                TextSegment::Plain(s) => s,
399                TextSegment::Bold(s) => s,
400                TextSegment::Italic(s) => s,
401                TextSegment::BoldItalic(s) => s,
402                TextSegment::InlineCode(s) => s,
403                TextSegment::Link { text, .. } => text,
404                TextSegment::Strikethrough(s) => s,
405                TextSegment::Html(s) => s,
406                TextSegment::Checkbox(_) => "",
407            }
408        }
409
410        match kind {
411            ElementKind::Heading { text, .. } => text.iter().map(segment_to_text).collect(),
412            ElementKind::Paragraph(segments) => segments.iter().map(segment_to_text).collect(),
413            ElementKind::CodeBlockContent { content, .. } => content.clone(),
414            ElementKind::CodeBlockHeader { language, .. } => language.clone(),
415            ElementKind::ListItem { content, .. } => content.iter().map(segment_to_text).collect(),
416            ElementKind::Blockquote { content, .. } => {
417                content.iter().map(segment_to_text).collect()
418            }
419            ElementKind::Frontmatter { fields, .. } => fields
420                .iter()
421                .map(|(k, v)| format!("{}: {}", k, v))
422                .collect::<Vec<_>>()
423                .join(", "),
424            ElementKind::FrontmatterField { key, value } => format!("{}: {}", key, value),
425            ElementKind::TableRow { cells, .. } => cells.join(" | "),
426            _ => String::new(),
427        }
428    }
429
430    /// Set the rendered lines for selection text extraction.
431    ///
432    /// Call this after rendering to update the cached lines.
433    pub fn set_rendered_lines(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
434        self.rendered_lines = lines;
435    }
436
437    /// Check if selection mode is active.
438    pub fn is_selection_active(&self) -> bool {
439        self.selection.is_active()
440    }
441
442    /// Get the current selection state (for rendering).
443    pub fn selection(
444        &self,
445    ) -> &crate::widgets::markdown_widget::state::selection_state::SelectionState {
446        self.selection
447    }
448
449    /// Get line information at the current highlighted line.
450    ///
451    /// Returns (line_number, line_kind, content) if found.
452    pub fn get_current_line_info(&self, width: usize) -> Option<(usize, String, String)> {
453        // current_line is 1-indexed document line, get_line_info_at_position expects
454        // a relative viewport position, so we need to convert.
455        // The document position of current_line is current_line - 1 (0-indexed).
456        // Since get_line_info_at_position adds scroll_offset, we pass (current_line - 1).
457        let document_y = self.scroll.current_line.saturating_sub(1);
458        let elements = render_markdown_to_elements(self.content, true);
459        let mut visual_line_idx = 0;
460        let mut logical_line_num = 0;
461
462        for (idx, element) in elements.iter().enumerate() {
463            if !should_render_line(element, idx, self.collapse) {
464                continue;
465            }
466
467            logical_line_num += 1;
468
469            let rendered = render(element, width);
470            let line_count = rendered.len();
471
472            if document_y >= visual_line_idx && document_y < visual_line_idx + line_count {
473                let line_kind = match &element.kind {
474                    ElementKind::Heading { .. } => "Heading",
475                    ElementKind::Paragraph(_) => "Paragraph",
476                    ElementKind::CodeBlockHeader { .. } => "CodeBlockHeader",
477                    ElementKind::CodeBlockContent { .. } => "CodeBlockContent",
478                    ElementKind::CodeBlockBorder { .. } => "CodeBlockBorder",
479                    ElementKind::ListItem { .. } => "ListItem",
480                    ElementKind::Blockquote { .. } => "Blockquote",
481                    ElementKind::Empty => "Empty",
482                    ElementKind::HorizontalRule => "HorizontalRule",
483                    ElementKind::Frontmatter { .. } => "Frontmatter",
484                    ElementKind::FrontmatterStart { .. } => "FrontmatterStart",
485                    ElementKind::FrontmatterField { .. } => "FrontmatterField",
486                    ElementKind::FrontmatterEnd => "FrontmatterEnd",
487                    ElementKind::Expandable { .. } => "Expandable",
488                    ElementKind::ExpandToggle { .. } => "ExpandToggle",
489                    ElementKind::TableRow { .. } => "TableRow",
490                    ElementKind::TableBorder(_) => "TableBorder",
491                    ElementKind::HeadingBorder { .. } => "HeadingBorder",
492                };
493
494                let text_content = self.get_element_text(&element.kind);
495
496                return Some((logical_line_num, line_kind.to_string(), text_content));
497            }
498
499            visual_line_idx += line_count;
500        }
501
502        None
503    }
504}