ratatui_toolkit/widgets/markdown_widget/widget/traits/
widget.rs

1//! Widget trait implementation for MarkdownWidget.
2
3use ratatui::{
4    layout::Rect,
5    style::{Color, Style},
6    text::{Line, Span},
7    widgets::{Block, Borders, Widget},
8};
9
10use crate::primitives::pane::Pane;
11use crate::widgets::markdown_widget::extensions::scrollbar::CustomScrollbar;
12use crate::widgets::markdown_widget::extensions::selection::should_render_line;
13use crate::widgets::markdown_widget::extensions::toc::Toc;
14use crate::widgets::markdown_widget::foundation::elements::{render_with_options, RenderOptions};
15use crate::widgets::markdown_widget::foundation::helpers::hash_content;
16use crate::widgets::markdown_widget::foundation::parser::render_markdown_to_elements;
17use crate::widgets::markdown_widget::state::toc_state::TocState;
18use crate::widgets::markdown_widget::state::{ParsedCache, RenderCache};
19use crate::widgets::markdown_widget::widget::helpers::apply_selection_highlighting;
20use crate::widgets::markdown_widget::widget::MarkdownWidget;
21
22/// Current line highlight color (normal - dark blue-gray)
23const CURRENT_LINE_BG: Color = Color::Rgb(38, 52, 63);
24/// Current line highlight color when dragging (selection is active)
25const CURRENT_LINE_DRAG_BG: Color = Color::Rgb(70, 80, 100);
26
27impl<'a> Widget for MarkdownWidget<'a> {
28    fn render(mut self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
29        // Handle pane wrapping if enabled
30        let (area, _pane_footer_area) = if self.has_pane {
31            let title = self
32                .pane_title
33                .clone()
34                .unwrap_or_else(|| "Markdown".to_string());
35            let pane = self.pane.take().unwrap_or_else(|| {
36                let mut p = Pane::new(title);
37                if let Some(color) = self.pane_color {
38                    p = p.border_style(ratatui::style::Style::default().fg(color));
39                }
40                p
41            });
42
43            // Build the block
44            let mut block = Block::default()
45                .borders(Borders::ALL)
46                .border_type(pane.border_type)
47                .border_style(pane.border_style)
48                .title(pane.title);
49
50            if let Some(icon) = &pane.icon {
51                use ratatui::text::Span;
52                let title = format!(" {} ", icon);
53                block = block.title(Line::from(vec![Span::styled(
54                    title,
55                    pane.title_style.clone(),
56                )]));
57            }
58
59            if let Some(ref footer) = pane.text_footer {
60                block = block.title_bottom(footer.clone().style(pane.footer_style));
61            }
62
63            // Get the inner area (inside the border) BEFORE rendering
64            let inner = block.inner(area);
65
66            // Render the block
67            block.render(area, buf);
68
69            // Calculate footer area if needed
70            let (inner, pane_footer) = if pane.footer_height > 0 {
71                let chunks = ratatui::layout::Layout::default()
72                    .direction(ratatui::layout::Direction::Vertical)
73                    .constraints([
74                        ratatui::layout::Constraint::Min(0),
75                        ratatui::layout::Constraint::Length(pane.footer_height),
76                    ])
77                    .split(inner);
78                (chunks[0], Some(chunks[1]))
79            } else {
80                (inner, None)
81            };
82
83            // Calculate padded inner area
84            let padded = Rect {
85                x: inner.x + pane.padding.3,
86                y: inner.y + pane.padding.0,
87                width: inner.width.saturating_sub(pane.padding.1 + pane.padding.3),
88                height: inner.height.saturating_sub(pane.padding.0 + pane.padding.2),
89            };
90
91            // Render footer if exists
92            if let Some(footer_area) = pane_footer {
93                if let Some(ref footer) = pane.text_footer {
94                    footer.render(footer_area, buf);
95                }
96            }
97
98            (padded, None::<Rect>)
99        } else {
100            (area, None::<Rect>)
101        };
102
103        // Reserve space for statusline if enabled
104        let (main_area, statusline_area) = if self.show_statusline && area.height > 1 {
105            (
106                Rect {
107                    height: area.height.saturating_sub(1),
108                    ..area
109                },
110                Some(Rect {
111                    y: area.y + area.height.saturating_sub(1),
112                    height: 1,
113                    ..area
114                }),
115            )
116        } else {
117            (area, None)
118        };
119
120        let padding_right: u16 = 2;
121        let padding_top: u16 = 1;
122        let content_area = main_area;
123
124        // Calculate overlay area for TOC
125        let overlay_area = if self.show_toc {
126            // TOC: compact when not hovered, expanded when hovered
127            // Dynamic width based on content for expanded mode
128            let toc_width = if self.toc_hovered {
129                Toc::required_expanded_width(self.content, self.toc_config.show_border)
130                    .min(main_area.width.saturating_sub(padding_right + 4))
131            } else {
132                self.toc_config.compact_width
133            };
134            // Dynamic height based on content
135            let toc_height = if self.toc_hovered {
136                // Expanded: one row per entry
137                Toc::required_height(self.content, self.toc_config.show_border)
138                    .min(main_area.height.saturating_sub(1))
139            } else {
140                // Compact: based on entries and line_spacing
141                Toc::required_compact_height(
142                    self.content,
143                    self.toc_config.line_spacing,
144                    self.toc_config.show_border,
145                )
146                .min(main_area.height.saturating_sub(1))
147            };
148
149            if main_area.width > toc_width + padding_right + 2 {
150                Some(Rect {
151                    x: main_area.x + main_area.width.saturating_sub(toc_width + padding_right),
152                    y: main_area.y + padding_top,
153                    width: toc_width,
154                    height: toc_height,
155                })
156            } else {
157                None
158            }
159        } else {
160            None
161        };
162
163        self.scroll.update_viewport(content_area);
164
165        // Calculate line number width if document line numbers are enabled
166        // Fixed width of 6 chars: "  1 │ " to "999 │ " covers most documents
167        let line_num_width = if self.display.show_document_line_numbers {
168            6
169        } else {
170            0
171        };
172
173        // Render markdown content (subtract line number width from available width)
174        let width = (content_area.width as usize).saturating_sub(line_num_width);
175        let content_hash = hash_content(self.content);
176        let show_line_numbers = self.display.show_line_numbers;
177        let theme = self.display.code_block_theme;
178
179        // Hash app theme for cache invalidation
180        let app_theme_hash = self
181            .app_theme
182            .map(|t| {
183                use std::collections::hash_map::DefaultHasher;
184                use std::hash::{Hash, Hasher};
185                let mut hasher = DefaultHasher::new();
186                format!(
187                    "{:?}{:?}{:?}{:?}{:?}",
188                    t.primary, t.text, t.background, t.markdown.heading, t.markdown.code
189                )
190                .hash(&mut hasher);
191                hasher.finish()
192            })
193            .unwrap_or(0);
194
195        // Check if we can use fully cached rendered lines
196        // Note: Never use cache when in filter mode (filter affects what's shown)
197        let show_heading_collapse = self.display.show_heading_collapse;
198        let render_cache_valid = !self.filter_mode
199            && self
200                .cache
201                .render
202                .as_ref()
203                .map(|c| {
204                    c.content_hash == content_hash
205                        && c.width == width
206                        && c.show_line_numbers == show_line_numbers
207                        && c.theme == theme
208                        && c.app_theme_hash == app_theme_hash
209                        && c.show_heading_collapse == show_heading_collapse
210                })
211                .unwrap_or(false);
212
213        // Get rendered lines and boundaries (from cache or fresh render)
214        let (all_lines, line_boundaries): (Vec<Line<'static>>, Vec<(usize, usize)>) =
215            if render_cache_valid {
216                // Use fully cached rendered lines
217                let cache = self.cache.render.as_ref().unwrap();
218                (cache.lines.clone(), cache.line_boundaries.clone())
219            } else {
220                // Check if we can use cached parsed elements
221                let parsed_cache_valid = self
222                    .cache
223                    .parsed
224                    .as_ref()
225                    .map(|c| c.content_hash == content_hash)
226                    .unwrap_or(false);
227
228                let elements = if parsed_cache_valid {
229                    // Use cached parsed elements
230                    self.cache.parsed.as_ref().unwrap().elements.clone()
231                } else {
232                    // Parse markdown and cache
233                    let parsed = render_markdown_to_elements(self.content, true);
234                    self.cache.parsed = Some(ParsedCache {
235                        content_hash,
236                        elements: parsed.clone(),
237                    });
238                    parsed
239                };
240
241                let render_options = RenderOptions {
242                    show_line_numbers,
243                    theme,
244                    app_theme: self.app_theme,
245                    show_heading_collapse: self.display.show_heading_collapse,
246                };
247
248                // Get filter text if in filter mode
249                let filter_lower = self
250                    .filter_mode
251                    .then(|| self.filter.as_deref().unwrap_or("").to_lowercase());
252
253                // Build all rendered lines and track line boundaries
254                let mut lines: Vec<Line<'static>> = Vec::new();
255                let mut boundaries: Vec<(usize, usize)> = Vec::new();
256
257                for (idx, element) in elements.iter().enumerate() {
258                    if !should_render_line(element, idx, self.collapse) {
259                        continue;
260                    }
261
262                    // Check if element matches filter (skip if not matching)
263                    if let Some(ref filter) = filter_lower {
264                        let text =
265                            super::super::helpers::element_to_plain_text_for_filter(&element.kind)
266                                .to_lowercase();
267                        if !text.contains(filter) {
268                            continue;
269                        }
270                    }
271
272                    let start_idx = lines.len();
273                    let rendered = render_with_options(element, width, render_options);
274                    let line_count = rendered.len();
275                    lines.extend(rendered);
276                    boundaries.push((start_idx, line_count));
277                }
278
279                // Cache the rendered lines
280                self.cache.render = Some(RenderCache {
281                    content_hash,
282                    width,
283                    show_line_numbers,
284                    theme,
285                    app_theme_hash,
286                    show_heading_collapse,
287                    lines: lines.clone(),
288                    line_boundaries: boundaries.clone(),
289                });
290
291                (lines, boundaries)
292            };
293
294        // Update total lines
295        self.scroll.update_total_lines(all_lines.len());
296
297        // Update cache for selection text extraction
298        self.rendered_lines = all_lines.clone();
299
300        // Extract visible portion
301        let start = self.scroll.scroll_offset.min(all_lines.len());
302        let end = (self.scroll.scroll_offset + content_area.height as usize).min(all_lines.len());
303        let visible_lines: Vec<Line<'static>> = all_lines[start..end].to_vec();
304
305        // Apply selection highlighting if selection is active
306        let visible_lines = if self.selection_active {
307            apply_selection_highlighting(visible_lines, self.selection, self.scroll.scroll_offset)
308        } else {
309            visible_lines
310        };
311
312        // Current line for highlighting (0-indexed visual line)
313        let current_visual_line = self.scroll.current_line.saturating_sub(1);
314
315        // Add document line numbers if enabled and apply current line highlighting
316        let final_lines: Vec<Line<'_>> = if self.display.show_document_line_numbers {
317            // Get colors from the code block theme for consistency
318            let theme_colors = self.display.code_block_theme.colors();
319            let line_num_style = Style::default()
320                .fg(theme_colors.line_number)
321                .bg(theme_colors.background);
322            let border_style = Style::default()
323                .fg(theme_colors.border)
324                .bg(theme_colors.background);
325
326            // Build a map: visual_line_idx -> (logical_line_num, is_first_line_of_logical)
327            let mut visual_to_logical: Vec<(usize, bool)> = Vec::with_capacity(all_lines.len());
328            for (logical_idx, (_start_idx, count)) in line_boundaries.iter().enumerate() {
329                for offset in 0..*count {
330                    let is_first = offset == 0;
331                    visual_to_logical.push((logical_idx + 1, is_first));
332                }
333            }
334
335            visible_lines
336                .into_iter()
337                .enumerate()
338                .map(|(i, mut line)| {
339                    let visual_idx = start + i;
340                    let is_current = visual_idx == current_visual_line;
341                    let (logical_num, is_first) = visual_to_logical
342                        .get(visual_idx)
343                        .copied()
344                        .unwrap_or((visual_idx + 1, true));
345
346                    // Fixed width of 3 digits + " │ " = 6 chars total
347                    let (num_str, border_str) = if is_first {
348                        (format!("{:>3} ", logical_num), "│ ".to_string())
349                    } else {
350                        ("    ".to_string(), "│ ".to_string()) // Continuation line
351                    };
352
353                    let num_span = Span::styled(num_str, line_num_style);
354                    let border_span = Span::styled(border_str, border_style);
355
356                    let mut new_spans = vec![num_span, border_span];
357
358                    // Apply current line highlighting to content
359                    let highlight_bg = if self.selection_active {
360                        CURRENT_LINE_DRAG_BG
361                    } else {
362                        CURRENT_LINE_BG
363                    };
364                    if is_current {
365                        let mut content_width = 0usize;
366                        for span in line.spans.drain(..) {
367                            content_width += span.content.chars().count();
368                            if span.content.contains('▋') {
369                                // Keep blockquote marker without highlight
370                                new_spans.push(span);
371                            } else {
372                                new_spans
373                                    .push(Span::styled(span.content, span.style.bg(highlight_bg)));
374                            }
375                        }
376                        // Add padding to fill the rest of the line
377                        let total_content_width = line_num_width + content_width;
378                        if total_content_width < content_area.width as usize {
379                            let padding =
380                                " ".repeat(content_area.width as usize - total_content_width);
381                            new_spans
382                                .push(Span::styled(padding, Style::default().bg(highlight_bg)));
383                        }
384                    } else {
385                        new_spans.extend(line.spans.drain(..));
386                    }
387
388                    Line::from(new_spans)
389                })
390                .collect()
391        } else {
392            // No line numbers, but still apply current line highlighting
393            let highlight_bg = if self.selection_active {
394                CURRENT_LINE_DRAG_BG
395            } else {
396                CURRENT_LINE_BG
397            };
398            visible_lines
399                .into_iter()
400                .enumerate()
401                .map(|(i, mut line)| {
402                    let visual_idx = start + i;
403                    let is_current = visual_idx == current_visual_line;
404
405                    if is_current {
406                        let mut new_spans = Vec::new();
407                        let mut content_width = 0usize;
408                        for span in line.spans.drain(..) {
409                            content_width += span.content.chars().count();
410                            if span.content.contains('▋') {
411                                new_spans.push(span);
412                            } else {
413                                new_spans
414                                    .push(Span::styled(span.content, span.style.bg(highlight_bg)));
415                            }
416                        }
417                        // Add padding to fill the rest of the line
418                        if content_width < content_area.width as usize {
419                            let padding = " ".repeat(content_area.width as usize - content_width);
420                            new_spans
421                                .push(Span::styled(padding, Style::default().bg(highlight_bg)));
422                        }
423                        Line::from(new_spans)
424                    } else {
425                        line
426                    }
427                })
428                .collect()
429        };
430
431        // Render markdown content to buffer
432        for (i, line) in final_lines.iter().enumerate() {
433            if i < content_area.height as usize {
434                let y = content_area.y + i as u16;
435                let mut x = content_area.x;
436                for span in line.spans.iter() {
437                    let span_width = span.content.chars().count() as u16;
438                    if x.saturating_sub(content_area.x) < content_area.width {
439                        buf.set_string(x, y, &span.content, span.style);
440                        x = x.saturating_add(span_width);
441                    }
442                }
443            }
444        }
445
446        // Render TOC overlay
447        if let Some(ov_area) = overlay_area {
448            // Create state from content with widget's hover state
449            let mut auto_state = TocState::from_content(self.content);
450            auto_state.hovered = self.toc_hovered;
451            auto_state.hovered_entry = self.toc_hovered_entry;
452            auto_state.scroll_offset = self.toc_scroll_offset;
453
454            // Use provided state if it has entries, otherwise use auto-extracted
455            let final_state = if let Some(provided) = self.toc_state {
456                if provided.entries.is_empty() {
457                    &auto_state
458                } else {
459                    provided
460                }
461            } else {
462                &auto_state
463            };
464
465            let toc = Toc::new(final_state)
466                .expanded(self.toc_hovered)
467                .config(self.toc_config.clone());
468
469            toc.render(ov_area, buf);
470        }
471
472        // Render statusline
473        if let Some(sl_area) = statusline_area {
474            self.render_statusline(sl_area, buf);
475        }
476
477        // Render scrollbar LAST so it's on top of everything
478        if self.show_scrollbar && self.scroll.total_lines > content_area.height as usize {
479            let scrollbar_width = self.scrollbar_config.width;
480            let scrollbar_area = Rect {
481                x: content_area.x + content_area.width.saturating_sub(scrollbar_width),
482                y: content_area.y,
483                width: scrollbar_width,
484                height: content_area.height,
485            };
486
487            let scrollbar = CustomScrollbar::new(self.scroll)
488                .config(self.scrollbar_config.clone())
489                .show_percentage(false);
490
491            scrollbar.render(scrollbar_area, buf);
492        }
493    }
494}