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