ratatui_toolkit/widgets/markdown_widget/widget/traits/
widget.rs1use 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
22const CURRENT_LINE_BG: Color = Color::Rgb(38, 52, 63);
24const 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 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 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 let inner = block.inner(area);
65
66 block.render(area, buf);
68
69 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 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 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 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 let overlay_area = if self.show_toc {
126 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 let toc_height = if self.toc_hovered {
136 Toc::required_height(self.content, self.toc_config.show_border)
138 .min(main_area.height.saturating_sub(1))
139 } else {
140 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 let line_num_width = if self.display.show_document_line_numbers {
168 6
169 } else {
170 0
171 };
172
173 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 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 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 let (all_lines, line_boundaries): (Vec<Line<'static>>, Vec<(usize, usize)>) =
215 if render_cache_valid {
216 let cache = self.cache.render.as_ref().unwrap();
218 (cache.lines.clone(), cache.line_boundaries.clone())
219 } else {
220 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 self.cache.parsed.as_ref().unwrap().elements.clone()
231 } else {
232 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 let filter_lower = self
250 .filter_mode
251 .then(|| self.filter.as_deref().unwrap_or("").to_lowercase());
252
253 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 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 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 self.scroll.update_total_lines(all_lines.len());
296
297 self.rendered_lines = all_lines.clone();
299
300 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 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 let current_visual_line = self.scroll.current_line.saturating_sub(1);
314
315 let final_lines: Vec<Line<'_>> = if self.display.show_document_line_numbers {
317 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 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 let (num_str, border_str) = if is_first {
348 (format!("{:>3} ", logical_num), "│ ".to_string())
349 } else {
350 (" ".to_string(), "│ ".to_string()) };
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 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 new_spans.push(span);
371 } else {
372 new_spans
373 .push(Span::styled(span.content, span.style.bg(highlight_bg)));
374 }
375 }
376 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 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 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 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 if let Some(ov_area) = overlay_area {
448 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 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 if let Some(sl_area) = statusline_area {
474 self.render_statusline(sl_area, buf);
475 }
476
477 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}