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, inline image runs, 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; compact inline placeholder when unavailable
28//! - Links (coloured text, URL is not opened — add `on_link_click` if needed)
29
30use std::sync::{Arc, Mutex};
31
32use crate::draw_ctx::DrawCtx;
33use crate::event::{Event, EventResult};
34use crate::geometry::{Point, Rect, Size};
35use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
36use crate::text::Font;
37use crate::widget::Widget;
38
39mod event;
40mod image_context;
41mod image_loader;
42mod layout;
43mod paint;
44mod parse;
45mod rich_html;
46mod selection;
47
48// ── Styled line representation ─────────────────────────────────────────────────
49
50#[derive(Clone, Copy, Debug, PartialEq)]
51enum LineStyle {
52    Body,
53    H1,
54    H2,
55    H3,
56    H4,
57    Code,
58    Rule,
59}
60
61impl LineStyle {
62    fn font_size(self, base: f64) -> f64 {
63        match self {
64            LineStyle::H1 => base * 1.8,
65            LineStyle::H2 => base * 1.5,
66            LineStyle::H3 => base * 1.25,
67            LineStyle::H4 => base * 1.1,
68            LineStyle::Body => base,
69            LineStyle::Code => base * 0.9,
70            LineStyle::Rule => base,
71        }
72    }
73}
74
75// ── Layout item ────────────────────────────────────────────────────────────────
76
77/// A single item in the laid-out view.
78#[derive(Clone)]
79enum LayoutItem {
80    /// A text row (including blank spacing rows and horizontal rules).
81    Line {
82        runs: Vec<LineRun>,
83        style: LineStyle,
84        indent: f64,
85        quote: bool,
86        y: f64,
87        height: f64,
88    },
89    Table {
90        block_idx: usize,
91        rows: Vec<Vec<String>>,
92        y: f64,
93        height: f64,
94        row_h: f64,
95        col_widths: Vec<f64>,
96        viewport_width: f64,
97        content_width: f64,
98    },
99    CodeBlock {
100        block_idx: usize,
101        lines: Vec<String>,
102        y: f64,
103        height: f64,
104        line_h: f64,
105        viewport_width: f64,
106        content_width: f64,
107    },
108}
109
110#[derive(Clone)]
111enum LineRun {
112    Text {
113        text: String,
114        link: Option<String>,
115        code: bool,
116        x: f64,
117        width: f64,
118    },
119    Image {
120        url: String,
121        alt: String,
122        link: Option<String>,
123        cache_idx: usize,
124        x: f64,
125        y_offset: f64,
126        width: f64,
127        height: f64,
128    },
129}
130
131// ── Intermediate paragraph item (before layout) ────────────────────────────────
132
133#[derive(Clone)]
134enum InlineItem {
135    Text {
136        text: String,
137        link: Option<String>,
138        code: bool,
139    },
140    Image {
141        url: String,
142        alt: String,
143        link: Option<String>,
144    },
145}
146
147enum ParagraphItem {
148    Flow {
149        items: Vec<InlineItem>,
150        style: LineStyle,
151        indent: f64,
152        quote: bool,
153    },
154    Table(Vec<Vec<String>>),
155    CodeBlock(Vec<String>),
156    Spacer,
157    Rule,
158}
159
160// ── Image cache entry ──────────────────────────────────────────────────────────
161
162struct ImageEntry {
163    url: String,
164    state: Arc<Mutex<ImageState>>,
165}
166
167#[derive(Clone, Copy, Debug, Default)]
168struct BlockScroll {
169    offset: f64,
170    dragging: bool,
171    drag_thumb_offset: f64,
172}
173
174#[derive(Clone)]
175struct ImagePixels {
176    data: Arc<Vec<u8>>,
177    width: u32,
178    height: u32,
179}
180
181enum ImageState {
182    RemotePending,
183    Loading,
184    Ready { image: ImagePixels, seen: bool },
185    Failed,
186}
187
188// ── MarkdownView widget ────────────────────────────────────────────────────────
189
190/// A widget that renders a Markdown string as formatted, word-wrapped text
191/// with optional image support.
192pub struct MarkdownView {
193    bounds: Rect,
194    children: Vec<Box<dyn Widget>>,
195    base: WidgetBase,
196
197    markdown: String,
198    font: Arc<Font>,
199    font_size: f64,
200    padding: f64,
201
202    /// Optional image decoder.  Receives a URL/path, returns RGBA8 pixel data
203    /// (top-row first) + (width, height), or `None` if unavailable.
204    image_provider: Option<Box<dyn Fn(&str) -> Option<(Vec<u8>, u32, u32)>>>,
205
206    /// Cached image data, indexed by `LineRun::Image::cache_idx`.
207    image_cache: Vec<ImageEntry>,
208
209    /// Laid-out items (populated by `layout()`).
210    items: Vec<LayoutItem>,
211    /// Total content height from the last layout pass.
212    content_h: f64,
213    on_link_click: Option<Box<dyn FnMut(&str)>>,
214    on_image_open: Option<Box<dyn FnMut(&str)>>,
215    block_scrolls: Vec<BlockScroll>,
216    focused: bool,
217    selecting_drag: bool,
218    selection_anchor: Option<usize>,
219    selection_cursor: Option<usize>,
220    selection_drag_start: Option<Point>,
221    selection_dragged: bool,
222    selectable_text: String,
223    selectable_fragments: Vec<selection::SelectableFragment>,
224    context_menu: Option<image_context::MarkdownContextMenuState>,
225    suppress_next_left_mouse_up: bool,
226}
227
228impl MarkdownView {
229    pub fn new(markdown: impl Into<String>, font: Arc<Font>) -> Self {
230        Self {
231            bounds: Rect::default(),
232            children: Vec::new(),
233            base: WidgetBase::new(),
234            markdown: markdown.into(),
235            font,
236            font_size: 14.0,
237            padding: 8.0,
238            image_provider: None,
239            image_cache: Vec::new(),
240            items: Vec::new(),
241            content_h: 0.0,
242            on_link_click: None,
243            on_image_open: None,
244            block_scrolls: Vec::new(),
245            focused: false,
246            selecting_drag: false,
247            selection_anchor: None,
248            selection_cursor: None,
249            selection_drag_start: None,
250            selection_dragged: false,
251            selectable_text: String::new(),
252            selectable_fragments: Vec::new(),
253            context_menu: None,
254            suppress_next_left_mouse_up: false,
255        }
256    }
257
258    pub fn with_font_size(mut self, size: f64) -> Self {
259        self.font_size = size;
260        self
261    }
262    pub fn with_padding(mut self, p: f64) -> Self {
263        self.padding = p;
264        self
265    }
266
267    /// Currently-active font — honours the thread-local system-font override
268    /// (`font_settings::current_system_font`) so system-font changes propagate
269    /// live without rebuilding the markdown view.
270    fn active_font(&self) -> Arc<Font> {
271        crate::font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font))
272    }
273
274    /// Supply an image provider closure.
275    ///
276    /// The closure receives a URL/path string from the Markdown source and must
277    /// return `Some((rgba_bytes, width, height))` or `None`.
278    pub fn with_image_provider(
279        mut self,
280        provider: impl Fn(&str) -> Option<(Vec<u8>, u32, u32)> + 'static,
281    ) -> Self {
282        self.image_provider = Some(Box::new(provider));
283        self
284    }
285
286    pub fn on_link_click(mut self, cb: impl FnMut(&str) + 'static) -> Self {
287        self.on_link_click = Some(Box::new(cb));
288        self
289    }
290
291    pub fn on_image_open(mut self, cb: impl FnMut(&str) + 'static) -> Self {
292        self.on_image_open = Some(Box::new(cb));
293        self
294    }
295
296    pub fn with_margin(mut self, m: Insets) -> Self {
297        self.base.margin = m;
298        self
299    }
300    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
301        self.base.h_anchor = h;
302        self
303    }
304    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
305        self.base.v_anchor = v;
306        self
307    }
308
309    // ── Image cache management ────────────────────────────────────────────────
310
311    /// Return the cache index for `url`, loading it via the provider if not yet cached.
312    fn get_or_load_image(&mut self, url: &str) -> usize {
313        // Check if already cached.
314        if let Some(idx) = self.image_cache.iter().position(|e| e.url == url) {
315            return idx;
316        }
317
318        let state = Arc::new(Mutex::new(
319            if let Some((data, width, height)) = self.image_provider.as_ref().and_then(|p| p(url)) {
320                ImageState::Ready {
321                    image: ImagePixels {
322                        data: Arc::new(data),
323                        width,
324                        height,
325                    },
326                    seen: false,
327                }
328            } else if is_fetchable_url(url) {
329                ImageState::RemotePending
330            } else {
331                ImageState::Failed
332            },
333        ));
334
335        let idx = self.image_cache.len();
336        self.image_cache.push(ImageEntry {
337            url: url.to_string(),
338            state,
339        });
340        idx
341    }
342
343    fn link_at(&self, pos: Point) -> Option<&str> {
344        let pad = self.padding;
345        for item in &self.items {
346            if let LayoutItem::Line {
347                runs,
348                indent,
349                y,
350                height,
351                ..
352            } = item
353            {
354                let tx = pad + indent;
355                for run in runs {
356                    match run {
357                        LineRun::Text {
358                            link: Some(url),
359                            x,
360                            width,
361                            ..
362                        } => {
363                            if point_in_rect(pos, tx + x, *y, *width, *height) {
364                                return Some(url);
365                            }
366                        }
367                        LineRun::Image {
368                            url: _,
369                            link: Some(url),
370                            x,
371                            y_offset,
372                            width,
373                            height,
374                            ..
375                        } => {
376                            if point_in_rect(pos, tx + x, y + y_offset, *width, *height) {
377                                return Some(url);
378                            }
379                        }
380                        _ => {}
381                    }
382                }
383            }
384        }
385        None
386    }
387
388    fn block_scroll_mut(&mut self, block_idx: usize) -> &mut BlockScroll {
389        if block_idx >= self.block_scrolls.len() {
390            self.block_scrolls
391                .resize(block_idx + 1, BlockScroll::default());
392        }
393        &mut self.block_scrolls[block_idx]
394    }
395
396    fn block_scroll_offset(&self, block_idx: usize) -> f64 {
397        self.block_scrolls
398            .get(block_idx)
399            .map(|s| s.offset)
400            .unwrap_or(0.0)
401    }
402
403    fn hit_scrollbar(&self, pos: Point) -> Option<BlockHit> {
404        for item in &self.items {
405            match item {
406                LayoutItem::Table {
407                    block_idx,
408                    y,
409                    height,
410                    viewport_width,
411                    content_width,
412                    ..
413                }
414                | LayoutItem::CodeBlock {
415                    block_idx,
416                    y,
417                    height,
418                    viewport_width,
419                    content_width,
420                    ..
421                } if *content_width > *viewport_width => {
422                    let bar = scrollbar_rect(*y, *viewport_width);
423                    if point_in_rect(pos, self.padding + bar.x, bar.y, bar.width, bar.height) {
424                        let offset = self.block_scroll_offset(*block_idx);
425                        let thumb = scrollbar_thumb(bar, *viewport_width, *content_width, offset);
426                        let thumb_hit = point_in_rect(
427                            pos,
428                            self.padding + thumb.x,
429                            thumb.y,
430                            thumb.width,
431                            thumb.height,
432                        );
433                        return Some(BlockHit {
434                            block_idx: *block_idx,
435                            viewport_width: *viewport_width,
436                            content_width: *content_width,
437                            bar,
438                            thumb,
439                            on_thumb: thumb_hit,
440                        });
441                    }
442                    let _ = height;
443                }
444                _ => {}
445            }
446        }
447        None
448    }
449
450    fn point_over_scrollable_block(&self, pos: Point) -> Option<(usize, f64, f64)> {
451        for item in &self.items {
452            match item {
453                LayoutItem::Table {
454                    block_idx,
455                    y,
456                    height,
457                    viewport_width,
458                    content_width,
459                    ..
460                }
461                | LayoutItem::CodeBlock {
462                    block_idx,
463                    y,
464                    height,
465                    viewport_width,
466                    content_width,
467                    ..
468                } if *content_width > *viewport_width
469                    && point_in_rect(pos, self.padding, *y, *viewport_width, *height) =>
470                {
471                    return Some((*block_idx, *viewport_width, *content_width));
472                }
473                _ => {}
474            }
475        }
476        None
477    }
478
479    fn block_metrics(&self, block_idx: usize) -> Option<(Rect, f64, f64)> {
480        self.items.iter().find_map(|item| match item {
481            LayoutItem::Table {
482                block_idx: idx,
483                y,
484                viewport_width,
485                content_width,
486                ..
487            }
488            | LayoutItem::CodeBlock {
489                block_idx: idx,
490                y,
491                viewport_width,
492                content_width,
493                ..
494            } if *idx == block_idx && *content_width > *viewport_width => Some((
495                scrollbar_rect(*y, *viewport_width),
496                *viewport_width,
497                *content_width,
498            )),
499            _ => None,
500        })
501    }
502
503    fn dragging_block(&self) -> Option<usize> {
504        self.block_scrolls
505            .iter()
506            .enumerate()
507            .find_map(|(idx, scroll)| scroll.dragging.then_some(idx))
508    }
509
510    fn scroll_block_to(
511        &mut self,
512        block_idx: usize,
513        offset: f64,
514        viewport: f64,
515        content: f64,
516    ) -> bool {
517        let scroll = self.block_scroll_mut(block_idx);
518        let next = clamp_block_offset(offset, viewport, content);
519        let changed = (next - scroll.offset).abs() > 1e-6;
520        scroll.offset = next;
521        changed
522    }
523
524    fn drag_block_scrollbar(&mut self, block_idx: usize, pos: Point) -> bool {
525        let Some((bar, viewport, content)) = self.block_metrics(block_idx) else {
526            return false;
527        };
528        let offset = self.block_scroll_offset(block_idx);
529        let thumb = scrollbar_thumb(bar, viewport, content, offset);
530        let drag_thumb_offset = self
531            .block_scrolls
532            .get(block_idx)
533            .map(|scroll| scroll.drag_thumb_offset)
534            .unwrap_or(0.0);
535        let travel = (bar.width - thumb.width).max(1.0);
536        let raw_start = pos.x - self.padding - drag_thumb_offset;
537        let frac = ((raw_start - bar.x) / travel).clamp(0.0, 1.0);
538        self.scroll_block_to(
539            block_idx,
540            frac * (content - viewport).max(0.0),
541            viewport,
542            content,
543        )
544    }
545}
546
547fn point_in_rect(pos: Point, x: f64, y: f64, w: f64, h: f64) -> bool {
548    pos.x >= x && pos.x <= x + w && pos.y >= y && pos.y <= y + h
549}
550
551#[derive(Clone, Copy)]
552struct BlockHit {
553    block_idx: usize,
554    viewport_width: f64,
555    content_width: f64,
556    bar: Rect,
557    thumb: Rect,
558    on_thumb: bool,
559}
560
561pub(super) const BLOCK_SCROLLBAR_H: f64 = 10.0;
562pub(super) const BLOCK_SCROLLBAR_GAP: f64 = 4.0;
563const BLOCK_SCROLLBAR_MIN_THUMB: f64 = 24.0;
564
565fn scrollbar_rect(block_y: f64, viewport_width: f64) -> Rect {
566    Rect::new(0.0, block_y + 1.0, viewport_width, BLOCK_SCROLLBAR_H)
567}
568
569fn scrollbar_thumb(bar: Rect, viewport_width: f64, content_width: f64, offset: f64) -> Rect {
570    let ratio = (viewport_width / content_width).clamp(0.0, 1.0);
571    let thumb_w = (bar.width * ratio)
572        .max(BLOCK_SCROLLBAR_MIN_THUMB)
573        .min(bar.width);
574    let travel = (bar.width - thumb_w).max(0.0);
575    let max_scroll = (content_width - viewport_width).max(0.0);
576    let x = if max_scroll > 0.0 {
577        bar.x + travel * (offset / max_scroll).clamp(0.0, 1.0)
578    } else {
579        bar.x
580    };
581    Rect::new(x, bar.y, thumb_w, bar.height)
582}
583
584fn clamp_block_offset(offset: f64, viewport_width: f64, content_width: f64) -> f64 {
585    offset
586        .clamp(0.0, (content_width - viewport_width).max(0.0))
587        .round()
588}
589
590fn is_rect_visible_in_root(ctx: &dyn DrawCtx, x: f64, y: f64, w: f64, h: f64) -> bool {
591    let mut points = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)];
592    let transform = ctx.root_transform();
593    for (px, py) in &mut points {
594        transform.transform(px, py);
595    }
596    let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
597    let max_x = points
598        .iter()
599        .map(|(x, _)| *x)
600        .fold(f64::NEG_INFINITY, f64::max);
601    let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
602    let max_y = points
603        .iter()
604        .map(|(_, y)| *y)
605        .fold(f64::NEG_INFINITY, f64::max);
606    let viewport = crate::widget::current_viewport();
607    let root_visible =
608        max_x >= 0.0 && min_x <= viewport.width && max_y >= 0.0 && min_y <= viewport.height;
609    if !root_visible {
610        return false;
611    }
612
613    if let Some(clip) = crate::widget::current_paint_clip() {
614        max_x >= clip.x
615            && min_x <= clip.x + clip.width
616            && max_y >= clip.y
617            && min_y <= clip.y + clip.height
618    } else {
619        true
620    }
621}
622
623fn is_fetchable_url(url: &str) -> bool {
624    !url.is_empty()
625        && !url.starts_with('#')
626        && !url.starts_with("file://")
627        && !url.starts_with("data:")
628}
629
630impl Widget for MarkdownView {
631    fn type_name(&self) -> &'static str {
632        "MarkdownView"
633    }
634    fn bounds(&self) -> Rect {
635        self.bounds
636    }
637    fn set_bounds(&mut self, b: Rect) {
638        self.bounds = b;
639    }
640    fn children(&self) -> &[Box<dyn Widget>] {
641        &self.children
642    }
643    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
644        &mut self.children
645    }
646
647    fn margin(&self) -> Insets {
648        self.base.margin
649    }
650    fn widget_base(&self) -> Option<&WidgetBase> {
651        Some(&self.base)
652    }
653    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
654        Some(&mut self.base)
655    }
656    fn h_anchor(&self) -> HAnchor {
657        self.base.h_anchor
658    }
659    fn v_anchor(&self) -> VAnchor {
660        self.base.v_anchor
661    }
662
663    fn layout(&mut self, available: Size) -> Size {
664        self.layout_markdown(available)
665    }
666
667    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
668        self.paint_markdown(ctx);
669    }
670
671    fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
672        self.context_menu_contains(local_pos)
673    }
674
675    fn has_active_modal(&self) -> bool {
676        self.context_menu.is_some()
677    }
678
679    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
680        self.paint_context_menu(ctx);
681    }
682
683    fn needs_draw(&self) -> bool {
684        if !self.is_visible() {
685            return false;
686        }
687        self.image_cache.iter().any(|entry| {
688            entry
689                .state
690                .lock()
691                .map(|state| {
692                    matches!(
693                        *state,
694                        ImageState::Loading | ImageState::Ready { seen: false, .. }
695                    )
696                })
697                .unwrap_or(false)
698        }) || self.children().iter().any(|c| c.needs_draw())
699    }
700
701    fn on_event(&mut self, event: &Event) -> EventResult {
702        self.handle_markdown_event(event)
703    }
704
705    fn is_focusable(&self) -> bool {
706        true
707    }
708}