Skip to main content

agg_gui/widgets/
markdown.rs

1//! `MarkdownView` — render a Markdown string as formatted text with images.
2//!
3//! Uses `pulldown-cmark` for parsing, then converts the event stream into a
4//! flat list of styled lines and image placeholders.  Word-wrapping is
5//! computed in `layout()` using the standalone `measure_text_metrics` function
6//! so no `DrawCtx` is needed at layout time.
7//!
8//! # Image support
9//!
10//! Pass an `image_provider` closure via [`MarkdownView::with_image_provider`].
11//! It receives the image URL/path string and should return
12//! `Some((rgba_bytes, width, height))` or `None` for unknown images.  The data
13//! must be tightly-packed RGBA8 in row-major order, **top-row first**.
14//!
15//! Images are decoded and cached on the first `layout()` call and then drawn
16//! via `DrawCtx::draw_image_rgba()` on every `paint()`.
17//!
18//! # Supported Markdown features
19//!
20//! - Headings H1–H4 (larger font sizes)
21//! - Paragraphs (word-wrapped)
22//! - Bullet lists (`-`/`*`) with "• " prefix
23//! - Ordered lists with "N. " prefix
24//! - Inline code `` `x` `` (highlight)
25//! - Fenced code blocks (background box)
26//! - Horizontal rules (thin separator line)
27//! - Images via `image_provider` callback; placeholder box when unavailable
28//! - Links (coloured text, URL is not opened — add `on_link_click` if needed)
29
30use std::sync::Arc;
31
32use pulldown_cmark::{Event as MdEvent, Options, Parser, Tag, TagEnd};
33
34use crate::color::Color;
35use crate::draw_ctx::DrawCtx;
36use crate::event::{Event, EventResult};
37use crate::geometry::{Rect, Size};
38use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
39use crate::text::{measure_text_metrics, Font};
40use crate::widget::Widget;
41
42// ── Styled line representation ─────────────────────────────────────────────────
43
44#[derive(Clone, Copy, Debug, PartialEq)]
45enum LineStyle {
46    Body,
47    H1,
48    H2,
49    H3,
50    H4,
51    Code,
52    Rule,
53}
54
55impl LineStyle {
56    fn font_size(self, base: f64) -> f64 {
57        match self {
58            LineStyle::H1   => base * 1.8,
59            LineStyle::H2   => base * 1.5,
60            LineStyle::H3   => base * 1.25,
61            LineStyle::H4   => base * 1.1,
62            LineStyle::Body => base,
63            LineStyle::Code => base * 0.9,
64            LineStyle::Rule => base,
65        }
66    }
67}
68
69// ── Layout item ────────────────────────────────────────────────────────────────
70
71/// A single item in the laid-out view.
72#[derive(Clone)]
73enum LayoutItem {
74    /// A text row (including blank spacing rows and horizontal rules).
75    Line {
76        text:   String,
77        style:  LineStyle,
78        indent: f64,
79        y:      f64,
80        height: f64,
81    },
82    /// An image row — draws cached pixel data or a placeholder box.
83    Image {
84        /// URL/path originally specified in the Markdown.
85        #[allow(dead_code)]
86        url:    String,
87        alt:    String,
88        /// Index into `MarkdownView::image_cache`.
89        cache_idx: usize,
90        /// Displayed rect in local Y-up coordinates.
91        x:      f64,
92        y:      f64,
93        width:  f64,
94        height: f64,
95    },
96}
97
98// ── Intermediate paragraph item (before layout) ────────────────────────────────
99
100enum ParagraphItem {
101    Text(String, LineStyle, f64),
102    Image { url: String, alt: String },
103}
104
105// ── Image cache entry ──────────────────────────────────────────────────────────
106
107struct ImageEntry {
108    url:    String,
109    /// `None` = provider returned nothing, `Some(...)` = decoded image.
110    data:   Option<(Vec<u8>, u32, u32)>,
111}
112
113// ── MarkdownView widget ────────────────────────────────────────────────────────
114
115/// A widget that renders a Markdown string as formatted, word-wrapped text
116/// with optional image support.
117pub struct MarkdownView {
118    bounds:    Rect,
119    children:  Vec<Box<dyn Widget>>,
120    base:      WidgetBase,
121
122    markdown:  String,
123    font:      Arc<Font>,
124    font_size: f64,
125    padding:   f64,
126
127    /// Optional image decoder.  Receives a URL/path, returns RGBA8 pixel data
128    /// (top-row first) + (width, height), or `None` if unavailable.
129    image_provider: Option<Box<dyn Fn(&str) -> Option<(Vec<u8>, u32, u32)>>>,
130
131    /// Cached image data, indexed by `LayoutItem::Image::cache_idx`.
132    image_cache: Vec<ImageEntry>,
133
134    /// Laid-out items (populated by `layout()`).
135    items:     Vec<LayoutItem>,
136    /// Total content height from the last layout pass.
137    content_h: f64,
138}
139
140impl MarkdownView {
141    pub fn new(markdown: impl Into<String>, font: Arc<Font>) -> Self {
142        Self {
143            bounds:         Rect::default(),
144            children:       Vec::new(),
145            base:           WidgetBase::new(),
146            markdown:       markdown.into(),
147            font,
148            font_size:      14.0,
149            padding:        8.0,
150            image_provider: None,
151            image_cache:    Vec::new(),
152            items:          Vec::new(),
153            content_h:      0.0,
154        }
155    }
156
157    pub fn with_font_size(mut self, size: f64) -> Self { self.font_size = size; self }
158    pub fn with_padding(mut self, p: f64) -> Self { self.padding = p; self }
159
160    /// Currently-active font — honours the thread-local system-font override
161    /// (`font_settings::current_system_font`) so system-font changes propagate
162    /// live without rebuilding the markdown view.
163    fn active_font(&self) -> Arc<Font> {
164        crate::font_settings::current_system_font()
165            .unwrap_or_else(|| Arc::clone(&self.font))
166    }
167
168    /// Supply an image provider closure.
169    ///
170    /// The closure receives a URL/path string from the Markdown source and must
171    /// return `Some((rgba_bytes, width, height))` or `None`.
172    pub fn with_image_provider(
173        mut self,
174        provider: impl Fn(&str) -> Option<(Vec<u8>, u32, u32)> + 'static,
175    ) -> Self {
176        self.image_provider = Some(Box::new(provider));
177        self
178    }
179
180    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
181    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
182    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
183
184    // ── Markdown → paragraph items ────────────────────────────────────────────
185
186    fn parse_paragraphs(&self) -> Vec<ParagraphItem> {
187        let mut out: Vec<ParagraphItem> = Vec::new();
188
189        let opts = Options::ENABLE_STRIKETHROUGH
190            | Options::ENABLE_TASKLISTS
191            | Options::ENABLE_TABLES;
192        let parser = Parser::new_ext(&self.markdown, opts);
193
194        let mut cur_text   = String::new();
195        let mut cur_style  = LineStyle::Body;
196        let mut cur_indent = 0.0_f64;
197        let mut list_depth = 0u32;
198        let mut list_ordinal: Vec<u64> = Vec::new();
199        // When inside an image tag, collect the alt text and suppress normal text.
200        let mut in_image: Option<String> = None; // Some(url) while parsing image
201
202        let flush = |out: &mut Vec<ParagraphItem>, text: &mut String, style: LineStyle, indent: f64| {
203            let t = text.trim().to_string();
204            if !t.is_empty() {
205                out.push(ParagraphItem::Text(t, style, indent));
206            }
207            text.clear();
208        };
209
210        for ev in parser {
211            match ev {
212                MdEvent::Start(Tag::Image { dest_url, .. }) => {
213                    // Flush any pending text, then start collecting alt text.
214                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
215                    in_image = Some(dest_url.to_string());
216                }
217                MdEvent::End(TagEnd::Image) => {
218                    if let Some(url) = in_image.take() {
219                        let alt = cur_text.trim().to_string();
220                        cur_text.clear();
221                        out.push(ParagraphItem::Image { url, alt });
222                        out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0)); // spacing
223                    }
224                }
225                // While parsing an image, Text events are alt text — collect separately.
226                MdEvent::Text(t) if in_image.is_some() => {
227                    cur_text.push_str(&t);
228                }
229                MdEvent::Start(Tag::Heading { level, .. }) => {
230                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
231                    cur_style  = match level as u8 { 1 => LineStyle::H1, 2 => LineStyle::H2, 3 => LineStyle::H3, _ => LineStyle::H4 };
232                    cur_indent = 0.0;
233                }
234                MdEvent::End(TagEnd::Heading(_)) => {
235                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
236                    out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
237                    cur_style  = LineStyle::Body;
238                    cur_indent = 0.0;
239                }
240                MdEvent::Start(Tag::Paragraph) => {
241                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
242                }
243                MdEvent::End(TagEnd::Paragraph) => {
244                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
245                    out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
246                }
247                MdEvent::Start(Tag::List(first)) => {
248                    list_depth += 1;
249                    list_ordinal.push(first.unwrap_or(1));
250                    cur_indent = list_depth as f64 * 16.0;
251                }
252                MdEvent::End(TagEnd::List(_)) => {
253                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
254                    list_depth = list_depth.saturating_sub(1);
255                    list_ordinal.pop();
256                    cur_indent = list_depth as f64 * 16.0;
257                    if list_depth == 0 {
258                        out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
259                    }
260                }
261                MdEvent::Start(Tag::Item) => {
262                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
263                    if let Some(n) = list_ordinal.last_mut() {
264                        cur_text = format!("{}. ", n);
265                        *n += 1;
266                    } else {
267                        cur_text = "• ".to_string();
268                    }
269                }
270                MdEvent::End(TagEnd::Item) => {
271                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
272                }
273                MdEvent::Start(Tag::CodeBlock(_)) => {
274                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
275                    cur_style = LineStyle::Code;
276                }
277                MdEvent::End(TagEnd::CodeBlock) => {
278                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
279                    out.push(ParagraphItem::Text("".to_string(), LineStyle::Body, 0.0));
280                    cur_style = LineStyle::Body;
281                }
282                MdEvent::Rule => {
283                    flush(&mut out, &mut cur_text, cur_style, cur_indent);
284                    out.push(ParagraphItem::Text("".to_string(), LineStyle::Rule, 0.0));
285                }
286                MdEvent::Text(t) => {
287                    if !cur_text.is_empty() && !cur_text.ends_with(' ') && !cur_text.ends_with('\n') {
288                        cur_text.push(' ');
289                    }
290                    cur_text.push_str(&t);
291                }
292                MdEvent::Code(t) => {
293                    if !cur_text.is_empty() && !cur_text.ends_with(' ') { cur_text.push(' '); }
294                    cur_text.push('`');
295                    cur_text.push_str(&t);
296                    cur_text.push('`');
297                }
298                MdEvent::SoftBreak | MdEvent::HardBreak => { cur_text.push(' '); }
299                MdEvent::Start(Tag::Link { .. }) | MdEvent::End(TagEnd::Link) => {}
300                _ => {}
301            }
302        }
303        flush(&mut out, &mut cur_text, cur_style, cur_indent);
304        out
305    }
306
307    // ── Word-wrapping ─────────────────────────────────────────────────────────
308
309    fn wrap_paragraph(&self, text: &str, style: LineStyle, indent: f64, max_w: f64) -> Vec<(String, f64)> {
310        let font_size = style.font_size(self.font_size);
311        let avail     = (max_w - indent).max(1.0);
312        if text.is_empty() { return vec![("".to_string(), indent)]; }
313
314        let font = self.active_font();
315        let mut lines: Vec<(String, f64)> = Vec::new();
316        let mut current = String::new();
317
318        for word in text.split_whitespace() {
319            let candidate = if current.is_empty() { word.to_string() } else { format!("{} {}", current, word) };
320            let w = measure_text_metrics(&font, &candidate, font_size).width;
321            if w <= avail || current.is_empty() {
322                current = candidate;
323            } else {
324                lines.push((current, indent));
325                current = word.to_string();
326            }
327        }
328        if !current.is_empty() { lines.push((current, indent)); }
329        lines
330    }
331
332    // ── Image cache management ────────────────────────────────────────────────
333
334    /// Return the cache index for `url`, loading it via the provider if not yet cached.
335    fn get_or_load_image(&mut self, url: &str) -> usize {
336        // Check if already cached.
337        if let Some(idx) = self.image_cache.iter().position(|e| e.url == url) {
338            return idx;
339        }
340        // Load via provider.
341        let data = self.image_provider.as_ref().and_then(|p| p(url));
342        let idx = self.image_cache.len();
343        self.image_cache.push(ImageEntry { url: url.to_string(), data });
344        idx
345    }
346}
347
348impl Widget for MarkdownView {
349    fn type_name(&self) -> &'static str { "MarkdownView" }
350    fn bounds(&self) -> Rect { self.bounds }
351    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
352    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
353    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
354
355    fn margin(&self)   -> Insets  { self.base.margin }
356    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
357    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
358
359    fn layout(&mut self, available: Size) -> Size {
360        let pad   = self.padding;
361        let max_w = (available.width - pad * 2.0).max(1.0);
362
363        let paragraphs = self.parse_paragraphs();
364
365        // Build intermediate list: (text/image, style, indent, line_h), top-to-bottom.
366        struct RawItem {
367            text:    String,
368            style:   LineStyle,
369            indent:  f64,
370            height:  f64,
371            // Image-specific fields.
372            is_image:  bool,
373            image_url: String,
374            image_alt: String,
375            cache_idx: usize,
376            img_disp_w: f64,
377        }
378
379        let mut raw: Vec<RawItem> = Vec::new();
380
381        for item in &paragraphs {
382            match item {
383                ParagraphItem::Text(text, style, indent) => {
384                    if *style == LineStyle::Rule {
385                        raw.push(RawItem { text: String::new(), style: LineStyle::Rule, indent: 0.0,
386                            height: 8.0, is_image: false, image_url: String::new(), image_alt: String::new(),
387                            cache_idx: 0, img_disp_w: 0.0 });
388                        continue;
389                    }
390                    let font_size = style.font_size(self.font_size);
391                    let metrics   = measure_text_metrics(&self.active_font(), "", font_size);
392                    let line_h    = metrics.line_height * 1.3;
393
394                    if text.is_empty() {
395                        raw.push(RawItem { text: String::new(), style: *style, indent: *indent,
396                            height: line_h * 0.5, is_image: false, image_url: String::new(),
397                            image_alt: String::new(), cache_idx: 0, img_disp_w: 0.0 });
398                        continue;
399                    }
400                    let wrapped = self.wrap_paragraph(text, *style, *indent, max_w);
401                    for (wl, ind) in wrapped {
402                        raw.push(RawItem { text: wl, style: *style, indent: ind,
403                            height: line_h, is_image: false, image_url: String::new(),
404                            image_alt: String::new(), cache_idx: 0, img_disp_w: 0.0 });
405                    }
406                }
407                ParagraphItem::Image { url, alt } => {
408                    let cache_idx = self.get_or_load_image(url);
409                    let (disp_w, disp_h) = if let Some((_, iw, ih)) = self.image_cache[cache_idx].data.as_ref() {
410                        // Scale to fit available width, preserve aspect.
411                        let scale = (max_w / *iw as f64).min(1.0); // never upscale beyond natural size
412                        (*iw as f64 * scale, *ih as f64 * scale)
413                    } else {
414                        // Placeholder: full-width × 60px box.
415                        (max_w, 60.0)
416                    };
417                    raw.push(RawItem { text: alt.clone(), style: LineStyle::Body, indent: 0.0,
418                        height: disp_h, is_image: true, image_url: url.clone(),
419                        image_alt: alt.clone(), cache_idx, img_disp_w: disp_w });
420                }
421            }
422        }
423
424        // Assign Y positions (Y-up: cursor starts at top and decrements).
425        let total_h: f64 = raw.iter().map(|r| r.height).sum::<f64>() + pad * 2.0;
426        let mut y = total_h - pad;
427
428        self.items.clear();
429        for r in raw {
430            y -= r.height;
431            if r.is_image {
432                self.items.push(LayoutItem::Image {
433                    url:       r.image_url,
434                    alt:       r.image_alt,
435                    cache_idx: r.cache_idx,
436                    x:         pad,
437                    y,
438                    width:     r.img_disp_w,
439                    height:    r.height,
440                });
441            } else {
442                self.items.push(LayoutItem::Line {
443                    text:   r.text,
444                    style:  r.style,
445                    indent: r.indent,
446                    y,
447                    height: r.height,
448                });
449            }
450        }
451
452        self.content_h = total_h;
453        self.bounds = Rect::new(0.0, 0.0, available.width, total_h);
454        Size::new(available.width, total_h)
455    }
456
457    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
458        let v   = ctx.visuals();
459        let pad = self.padding;
460        let w   = self.bounds.width;
461        let font = self.active_font();
462        ctx.set_font(Arc::clone(&font));
463
464        for item in &self.items {
465            match item {
466                LayoutItem::Line { text, style, indent, y, height } => {
467                    let fs = style.font_size(self.font_size);
468                    ctx.set_font_size(fs);
469
470                    let tx = pad + indent;
471                    let ty = y + height * 0.5;
472                    let metrics = measure_text_metrics(&font, text.as_str(), fs);
473                    let text_y  = ty - (metrics.ascent - metrics.descent) * 0.5;
474
475                    match style {
476                        LineStyle::Rule => {
477                            ctx.set_fill_color(v.separator);
478                            ctx.begin_path();
479                            ctx.rect(pad, ty, w - pad * 2.0, 1.0);
480                            ctx.fill();
481                        }
482                        LineStyle::Code => {
483                            ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.15));
484                            ctx.begin_path();
485                            ctx.rounded_rect(pad, *y, w - pad * 2.0, *height, 3.0);
486                            ctx.fill();
487                            ctx.set_fill_color(v.accent);
488                            ctx.fill_text(text, tx + 4.0, text_y);
489                        }
490                        _ => {
491                            ctx.set_fill_color(v.text_color);
492                            if !text.is_empty() {
493                                ctx.fill_text(text, tx, text_y);
494                            }
495                        }
496                    }
497                }
498                LayoutItem::Image { url: _, alt, cache_idx, x, y, width, height } => {
499                    if let Some(entry) = self.image_cache.get(*cache_idx) {
500                        if let Some((data, iw, ih)) = &entry.data {
501                            ctx.draw_image_rgba(data.as_slice(), *iw, *ih, *x, *y, *width, *height);
502                        } else {
503                            // Placeholder box when image unavailable.
504                            ctx.set_fill_color(Color::rgba(0.5, 0.5, 0.5, 0.15));
505                            ctx.begin_path();
506                            ctx.rounded_rect(*x, *y, *width, *height, 4.0);
507                            ctx.fill();
508                            ctx.set_fill_color(v.text_dim);
509                            ctx.set_font_size(self.font_size * 0.85);
510                            let label = format!("[image: {}]", alt);
511                            ctx.fill_text(&label, x + 8.0, y + height * 0.5);
512                        }
513                    }
514                }
515            }
516        }
517    }
518
519    fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
520}