lib.rs raw

//! A markdown view: a CommonMark document, rendered.
//!
//! ```lisp
//! (markdown :source page-text :on-link (navigate payload))
//! ```
//!
//! **This crate is not part of the framework.** It depends on `guiduck-core`
//! for the [`Widget`] contract and on `pulldown-cmark` for what markdown means,
//! and on nothing else guiduck ships — no facade, no interpreter, no build
//! script. It authors its own interface in `widgets/markdown.gdw` and registers
//! the Rust side from it with `guiduck_core::register_widget!` (below), so an
//! application that depends on this crate writes `(markdown …)` in a `.gdc`
//! exactly as it writes `(text …)`, with no configuration. If this ever had to
//! move into `guiduck-core` to work, the open widget registry would have
//! failed; it did not have to.
//!
//! # The document is the widget's business
//!
//! `:source` is a `String`. The [`Block`](block::Block)s it parses to never
//! cross the `.gdc` boundary and there is no way to write one from a `.gdc`,
//! because a document is not a declaration — the same reason a button's label
//! becomes a parley layout without anyone declaring glyph runs.
//!
//! A [`Widget`] cannot own child widgets (the tree owns children), so the view
//! measures and paints the whole document itself: one
//! [`Paragraph`](guiduck_core::text::Paragraph) per block, stacked. That is a
//! `guiduck-core` type, and it is the *same* one `Text` is built from — the
//! link hit testing, the span styling, the decoration drawing and the
//! accessibility nodes here are calls into it, not a second copy of it.
//!
//! # Appearance is theme
//!
//! Nothing below paints a literal color or size: see [`tokens`]. The `.gdw`
//! declares the same names as `(token …)`s, so `(rule (markdown .doc)
//! :heading-1-font-size 32)` works with no code involved.
//!
//! # Images
//!
//! An image's `src` is opaque to the widget — an application-supplied resolver
//! ([`MarkdownView::set_image_resolver`]) turns it into a [`Graphic`], exactly
//! as a link handler turns a target into a navigation, because the framework no
//! more knows how to fetch `logo.png` than it knows what `faq` means. The
//! resolver returns an [`ImageResult`]: a [`Graphic`](ImageResult::Graphic)
//! (raster or vector) when it has one, [`None`](ImageResult::None) to decline
//! it for good, or [`DeferredImage`](ImageResult::DeferredImage) while a fetch
//! is in flight — the alt text stands in and the widget re-asks on a timer
//! until the image arrives or is declined (the poll runs only while something
//! is loading, like the caret's blink runs only while focused). The resolver
//! may load however the app likes: an embedded map, a local file, a cache a
//! worker thread fills.
//!
//! A `.gdc` wires it like a link — `(markdown :on-link (navigate payload)
//! :get-image (load payload))`. `:get-image` is a *query* (declared in the
//! manifest as `(query …)`) whose handler returns the [`ImageResult`], which
//! codegen installs as the resolver: `fn load(&mut self, cx, url: &str) ->
//! ImageResult`. It works under hot reload as well as in a compiled build — the
//! interpreter installs the same compiled handler as the resolver.
//! [`set_image_resolver`](MarkdownView::set_image_resolver) is the same thing
//! for a Rust-mounted widget.
//!
//! An `![alt](src)` that is the whole of its paragraph is a **block** image,
//! placed at the image's natural size (scaled down to fit the width). An image
//! *inside* running text renders its alt text: a true inline image is a parley
//! `InlineBox`, which wants a generic inline-box facility on
//! [`Paragraph`](guiduck_core::text::Paragraph) this crate cannot add from
//! outside — a clearly-scoped extension, deferred honestly rather than
//! half-built.
//!
//! # What is not here
//!
//! - **Footnotes, task lists, raw HTML.** Not enabled in the parser; additive.
//! - **Inline code has no background**, only `code-color` and
//!   `code-font-family`. A background behind a run that wraps mid-paragraph is
//!   a per-line box, which is a decoration `Paragraph` does not draw. A code
//!   *block* does have one, because it is a block.

pub mod block;
pub mod parse;
pub mod tokens;

use std::rc::Rc;

use guiduck_core::accesskit;
use guiduck_core::event::{EventData, EventKind, PointerButton, PointerEvent, UserValue};
use guiduck_core::graphic::{Graphic, GraphicPaint};
use guiduck_core::style::ComputedStyle;
use guiduck_core::text::TextContext;
use guiduck_core::{Dirt, Widget, taffy};
use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect, Size};
use guiduck_scene::paint::Color;

use crate::block::{Block, BlockKind, CellAlign, ImageContent, Table, TableCell};
use crate::tokens::MarkdownTokens;

// The Rust side of `widgets/markdown.gdw`: the `set_source` setter and a
// constructor, submitted to the link-time registry so the interpreter builds a
// `markdown` the way it builds any registered widget. The manifest's query and
// events are wired by generated code, not here.
guiduck_core::register_widget!(MarkdownView, "widgets/markdown.gdw");

/// What resolving an image `src` produced.
///
/// Three answers rather than two, because a resolver that fetches over the
/// network has a third thing to say beyond "here it is" and "there is none":
/// "it is coming". [`DeferredImage`](Self::DeferredImage) is that state — the
/// alt text stands in and the widget keeps asking — where [`None`](Self::None)
/// is final and the alt text is the answer for good.
pub enum ImageResult {
    /// Resolved: draw this graphic. A [`Graphic`] rather than raw pixels, so a
    /// resolver may hand back vector geometry (an SVG turned to a path) as
    /// readily as a raster.
    Graphic(Graphic),
    /// Still loading (a fetch in flight): not ready yet, but expected. The alt
    /// text stands in and the widget asks again until it resolves or is
    /// declined.
    DeferredImage,
    /// No graphic: the source was declined or could not be decoded. The alt
    /// text is the final answer.
    None,
}

impl Default for ImageResult {
    /// [`None`](Self::None): the fallback a query wire returns when the dev
    /// interpreter cannot reassemble the handler's context, which is exactly
    /// "no image, show the alt text".
    fn default() -> Self {
        Self::None
    }
}

impl ImageResult {
    /// The resolved graphic, if one is ready.
    pub fn graphic(&self) -> Option<&Graphic> {
        match self {
            Self::Graphic(graphic) => Some(graphic),
            _ => None,
        }
    }

    /// Whether a graphic is resolved and ready to draw.
    pub fn ready(&self) -> bool {
        matches!(self, Self::Graphic(_))
    }
}

/// Turns an image `src` into a graphic. The application supplies it — the widget
/// no more knows how to fetch `logo.png` than it knows what a link target means
/// — and it resolves however the app likes: an embedded map, a local file, a
/// cache filled from the network on another thread (returning
/// [`ImageResult::DeferredImage`] while that is in flight).
pub type ImageResolver = Rc<dyn Fn(&str) -> ImageResult>;

/// How often the widget re-asks the resolver about images it reported as still
/// loading. Active only while some image is deferred, exactly as the caret's
/// wake is active only while focused.
const IMAGE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);

/// The event a clicked link reports, as `markdown.gdw` declares it.
const ON_LINK: &str = "on-link";

pub struct MarkdownView {
    source: String,
    tokens: MarkdownTokens,
    blocks: Vec<Block>,
    /// Whether `blocks` still reflects `source` and `tokens`.
    blocks_stale: bool,
    /// The wrapping width the blocks are currently placed for; `None` means
    /// they are placed for nothing yet. The inner `Option` is parley's:
    /// unbounded is a width.
    placed_at: Option<Option<f32>>,
    content_size: Size,
    /// The application's image resolver, if it set one. Without it, images are
    /// their alt text.
    image_resolver: Option<ImageResolver>,
    /// A pending wake to re-ask the resolver about deferred images, collected
    /// by the tree beside dirt. `Some` only while an image is loading.
    wake: Option<std::time::Duration>,
    emitted: Vec<EventData>,
    dirt: Dirt,
}

impl Default for MarkdownView {
    fn default() -> Self {
        Self {
            source: String::new(),
            tokens: MarkdownTokens::default(),
            blocks: Vec::new(),
            blocks_stale: true,
            placed_at: None,
            content_size: Size::ZERO,
            image_resolver: None,
            wake: None,
            emitted: Vec::new(),
            dirt: Dirt::CLEAN,
        }
    }
}

impl MarkdownView {
    pub fn new(source: impl Into<String>) -> Self {
        let mut view = Self::default();
        view.set_source(source.into());
        view
    }

    /// `(prop source String)` — the document. Layout-affecting, equality-gated.
    pub fn set_source(&mut self, source: String) {
        if self.source == source {
            return;
        }
        self.source = source;
        self.rebuild();
        self.dirt.mark_layout();
    }

    pub fn source(&self) -> &str {
        &self.source
    }

    /// Supply the resolver that turns an image `src` into a graphic — the
    /// widget's counterpart to wiring a link handler, and the only way images
    /// appear. It resolves the document's images at once and re-lays it out.
    pub fn set_image_resolver(&mut self, resolver: impl Fn(&str) -> ImageResult + 'static) {
        self.image_resolver = Some(Rc::new(resolver));
        if self.resolve_images() {
            self.placed_at = None;
            self.dirt.mark_layout();
        }
    }

    /// Ask the resolver about every image block, reporting whether any block's
    /// resolved state changed (so a caller knows to re-lay-out). A declined
    /// source keeps its alt text; a deferred one keeps the alt but schedules a
    /// re-ask.
    fn resolve_images(&mut self) -> bool {
        let Some(resolver) = self.image_resolver.clone() else {
            return false;
        };
        let mut changed = false;
        for block in &mut self.blocks {
            if let Some(image) = &mut block.image {
                let was_ready = image.graphic.is_some();
                match resolver(&image.src) {
                    ImageResult::Graphic(graphic) => {
                        image.graphic = Some(graphic);
                        image.deferred = false;
                    }
                    ImageResult::DeferredImage => {
                        image.graphic = None;
                        image.deferred = true;
                    }
                    ImageResult::None => {
                        image.graphic = None;
                        image.deferred = false;
                    }
                }
                changed |= image.graphic.is_some() != was_ready;
            }
        }
        // Keep the poll alive exactly while something is still loading.
        self.wake = self
            .blocks
            .iter()
            .any(|block| block.image.as_ref().is_some_and(|image| image.deferred))
            .then_some(IMAGE_POLL_INTERVAL);
        changed
    }

    /// The document's prose, blocks joined by newlines — what a screen reader
    /// is read, and the plain-text truth of what was parsed.
    pub fn plain_text(&self) -> String {
        let mut lines: Vec<String> = Vec::new();
        for block in &self.blocks {
            if let Some(body) = &block.body {
                lines.push(body.text().to_owned());
            } else if let Some(table) = &block.table {
                for row in &table.rows {
                    let cells: Vec<&str> = row.iter().map(|cell| cell.body.text()).collect();
                    lines.push(cells.join("\t"));
                }
            } else if let Some(image) = &block.image {
                // The alt text is the image to a screen reader.
                lines.push(image.alt.text().to_owned());
            }
        }
        lines.join("\n")
    }

    /// The blocks the source parsed to. The document's shape is the widget's
    /// business, so this is here for the widget's own tests and for an
    /// application that wants to ask; nothing in the `.gdc` vocabulary reaches
    /// it.
    pub fn blocks(&self) -> &[Block] {
        &self.blocks
    }

    /// The target of the link under `local`, if the point is on one.
    ///
    /// Blocks do not overlap vertically, so which paragraph the point is in is
    /// settled before any of them is asked — and then it is
    /// [`Paragraph::link_at`](guiduck_core::text::Paragraph::link_at), the same
    /// one a `Text` asks, in the block's own coordinates.
    pub fn link_at(&self, local: Point) -> Option<&str> {
        let block = self.blocks.iter().find(|block| block.contains_y(local.y))?;
        if let Some(table) = &block.table {
            // A table's links live in its cells, in the cell's own coordinates.
            let cell = table_cell_at(table, local)?;
            return cell.body.link_at(local - cell.origin.to_vec2());
        }
        let body = block.body.as_ref()?;
        body.link_at(local - block.body_origin.to_vec2())
    }

    fn rebuild(&mut self) {
        self.blocks_stale = true;
        self.placed_at = None;
    }

    /// Parse if needed, then place every block, and report the space they take.
    ///
    /// `max_width` is parley's: `None` is unbounded, which is what taffy's
    /// max-content probe means.
    fn place(&mut self, text_cx: &mut TextContext, max_width: Option<f32>) -> Size {
        if self.blocks_stale {
            self.blocks = parse::parse(&self.source, &self.tokens);
            self.resolve_images();
            self.blocks_stale = false;
            self.placed_at = None;
        }
        if self.placed_at == Some(max_width) {
            return self.content_size;
        }

        let tokens = &self.tokens;
        let mut y = 0.0_f64;
        let mut width = 0.0_f64;
        for index in 0..self.blocks.len() {
            if index > 0 {
                y += tokens.block_gap;
            }
            let block = &mut self.blocks[index];
            let quote_inset = f64::from(block.quote_depth) * tokens.quote_indent;
            block.indent = quote_inset + block.list_depth as f64 * tokens.list_indent;
            block.top = y;

            match block.kind {
                BlockKind::Rule => {
                    // A break is its own thickness tall and as wide as it is
                    // given; nothing to measure.
                    block.height = tokens.rule_width;
                    width = width.max(block.indent);
                }
                BlockKind::Code => {
                    let inset = block.indent + 2.0 * tokens.code_padding;
                    let avail = max_width.map(|w| (f64::from(w) - inset).max(0.0) as f32);
                    let body = block.body.as_mut().expect("a code block has text");
                    let layout = body.layout_at(text_cx, avail);
                    let (text_width, text_height) =
                        (f64::from(layout.width()), f64::from(layout.height()));
                    block.body_origin =
                        Point::new(block.indent + tokens.code_padding, y + tokens.code_padding);
                    block.height = text_height + 2.0 * tokens.code_padding;
                    width = width.max(inset + text_width);
                }
                BlockKind::Table => {
                    let table = block.table.as_mut().expect("a table block has a grid");
                    let (table_width, table_height) =
                        place_table(table, tokens, text_cx, block.indent, y, max_width);
                    block.height = table_height;
                    width = width.max(block.indent + table_width);
                }
                BlockKind::Image => {
                    let avail = max_width.map(|w| (f64::from(w) - block.indent).max(0.0));
                    let image = block.image.as_mut().expect("an image block has content");
                    let (image_width, image_height) = place_image(image, text_cx, avail);
                    block.body_origin = Point::new(block.indent, y);
                    block.height = image_height;
                    width = width.max(block.indent + image_width);
                }
                BlockKind::Heading(_) | BlockKind::Paragraph | BlockKind::ListItem => {
                    let avail = max_width.map(|w| (f64::from(w) - block.indent).max(0.0) as f32);
                    let body = block.body.as_mut().expect("a prose block has text");
                    let layout = body.layout_at(text_cx, avail);
                    let (text_width, text_height) =
                        (f64::from(layout.width()), f64::from(layout.height()));
                    block.body_origin = Point::new(block.indent, y);
                    block.height = text_height;
                    width = width.max(block.indent + text_width);

                    if let Some(marker) = &mut block.marker {
                        // Unbounded: a marker that wrapped would not be a
                        // marker. It sits in the gutter the body's own
                        // `list-indent` opened, which is what makes the item a
                        // hanging indent rather than a first-line one.
                        let marker_layout = marker.layout_at(text_cx, None);
                        let marker_height = f64::from(marker_layout.height());
                        block.marker_origin =
                            Point::new((block.indent - tokens.list_indent).max(quote_inset), y);
                        block.height = block.height.max(marker_height);
                    }
                }
            }
            y += block.height;
        }

        self.content_size = Size::new(width, y);
        self.placed_at = Some(max_width);
        self.content_size
    }
}

/// Place a table's grid: resolve column widths to the available space, wrap
/// each cell to its column, and settle the grid lines. Returns the table's own
/// (width, height). The block layout owns the vertical position; this owns
/// everything inside the box.
fn place_table(
    table: &mut Table,
    tokens: &MarkdownTokens,
    text_cx: &mut TextContext,
    indent: f64,
    top: f64,
    max_width: Option<f32>,
) -> (f64, f64) {
    let cols = table.columns();
    let border = tokens.table_border_width;
    let pad = tokens.table_cell_padding;
    if cols == 0 || table.rows.is_empty() {
        table.col_edges = vec![indent];
        table.row_edges = vec![top];
        return (0.0, 0.0);
    }

    // Each column's max-content width: the widest its cells want, laid out
    // unbounded. A column is never given less than it wants unless the table as
    // a whole does not fit.
    let mut natural = vec![0.0_f64; cols];
    for row in &mut table.rows {
        for (c, cell) in row.iter_mut().enumerate() {
            let cell_width = f64::from(cell.body.layout_at(text_cx, None).width());
            natural[c] = natural[c].max(cell_width);
        }
    }

    // The chrome — borders and cell padding — is fixed; the content shares
    // what is left. When the natural widths overflow a bounded width, shrink
    // them proportionally, but not past a readable minimum.
    let chrome = (cols as f64 + 1.0) * border + cols as f64 * 2.0 * pad;
    let avail = max_width.map(|w| (f64::from(w) - indent - chrome).max(0.0));
    let total: f64 = natural.iter().sum();
    let min_content = (tokens.table_min_column_width - 2.0 * pad).max(0.0);
    let widths: Vec<f64> = match avail {
        Some(avail) if total > avail && total > 0.0 => natural
            .iter()
            .map(|n| (n / total * avail).max(min_content.min(*n)))
            .collect(),
        // Unbounded (the max-content probe) or already fitting: natural widths.
        _ => natural,
    };

    let mut col_edges = Vec::with_capacity(cols + 1);
    col_edges.push(indent);
    for c in 0..cols {
        col_edges.push(col_edges[c] + border + 2.0 * pad + widths[c]);
    }

    // Wrap each cell to its column to learn the row's height, then place every
    // cell — offset within its column for a centered or end-aligned column.
    let mut row_edges = Vec::with_capacity(table.rows.len() + 1);
    row_edges.push(top);
    for (r, row) in table.rows.iter_mut().enumerate() {
        let mut content_height = 0.0_f64;
        for (c, cell) in row.iter_mut().enumerate() {
            let layout = cell.body.layout_at(text_cx, Some(widths[c] as f32));
            content_height = content_height.max(f64::from(layout.height()));
        }
        let row_top = row_edges[r];
        for (c, cell) in row.iter_mut().enumerate() {
            let content_width = cell.body.layout().map_or(0.0, |l| f64::from(l.width()));
            let slack = (widths[c] - content_width).max(0.0);
            let dx = match table.aligns[c] {
                CellAlign::Start => 0.0,
                CellAlign::Center => slack / 2.0,
                CellAlign::End => slack,
            };
            cell.origin = Point::new(col_edges[c] + border + pad + dx, row_top + border + pad);
        }
        row_edges.push(row_top + border + 2.0 * pad + content_height);
    }

    let width = col_edges[cols] + border - indent;
    let height = row_edges[table.rows.len()] + border - top;
    table.col_edges = col_edges;
    table.row_edges = row_edges;
    (width, height)
}

/// Place an image block: a resolved image at its natural size, scaled down to
/// fit the available width (aspect preserved); an unresolved one at the size of
/// its alt text. Returns the drawn (width, height).
fn place_image(
    image: &mut ImageContent,
    text_cx: &mut TextContext,
    avail: Option<f64>,
) -> (f64, f64) {
    if let Some(natural) = image.graphic.as_ref().and_then(Graphic::natural_size) {
        let mut width = natural.width;
        let mut height = natural.height;
        if let Some(avail) = avail
            && width > avail
            && width > 0.0
        {
            height *= avail / width;
            width = avail;
        }
        return (width, height);
    }
    // No image: the alt text stands in, laid out like any prose.
    let layout = image.alt.layout_at(text_cx, avail.map(|a| a as f32));
    (f64::from(layout.width()), f64::from(layout.height()))
}

/// Paint an image block: the picture scaled into its box, or the alt text if it
/// never resolved. The width is reconstructed from the height and the image's
/// aspect ratio, which [`place_image`] preserved.
fn paint_image(fragment: &mut Fragment, image: &ImageContent, origin: Point, height: f64) {
    if let Some(graphic) = &image.graphic
        && let Some(natural) = graphic.natural_size()
        && natural.height > 0.0
    {
        let width = height * natural.width / natural.height;
        let rect = Rect::from_origin_size(origin, Size::new(width, height));
        // A raster ignores the paint; the fill brush is only for a vector
        // graphic, which an image resolver does not produce.
        graphic.paint_into(
            fragment,
            rect,
            &GraphicPaint::Fill(Color::TRANSPARENT.into()),
        );
        return;
    }
    image.alt.paint(fragment, origin);
}

/// The cell of `table` at `local` (in the widget's coordinates), if the point
/// is on the grid.
fn table_cell_at(table: &Table, local: Point) -> Option<&TableCell> {
    let col = table
        .col_edges
        .windows(2)
        .position(|edges| local.x >= edges[0] && local.x < edges[1])?;
    let row = table
        .row_edges
        .windows(2)
        .position(|edges| local.y >= edges[0] && local.y < edges[1])?;
    table.rows.get(row)?.get(col)
}

/// Paint a table's header shading, grid lines, and cell text.
fn paint_table(fragment: &mut Fragment, table: &Table, tokens: &MarkdownTokens) {
    let cols = table.columns();
    let rows = table.rows.len();
    if cols == 0 || rows == 0 {
        return;
    }
    let border = tokens.table_border_width;
    let left = table.col_edges[0];
    let right = table.col_edges[cols] + border;
    let top = table.row_edges[0];
    let bottom = table.row_edges[rows] + border;

    // Header shading, behind the grid, from the top down to the last header
    // row's boundary.
    let header_rows = table.header_rows.min(rows);
    if header_rows > 0 {
        fragment.fill(
            Rect::new(left, top, right, table.row_edges[header_rows]),
            tokens.table_header_background.clone(),
        );
    }

    // The grid: a vertical rule at each column boundary, a horizontal at each
    // row boundary, each the border's own thickness.
    for &x in &table.col_edges {
        fragment.fill(
            Rect::new(x, top, x + border, bottom),
            tokens.table_border_color.clone(),
        );
    }
    for &y in &table.row_edges {
        fragment.fill(
            Rect::new(left, y, right, y + border),
            tokens.table_border_color.clone(),
        );
    }

    for row in &table.rows {
        for cell in row {
            cell.body.paint(fragment, cell.origin);
        }
    }
}

impl Widget for MarkdownView {
    fn measure(
        &mut self,
        text_cx: &mut TextContext,
        known: taffy::Size<Option<f32>>,
        available: taffy::Size<taffy::AvailableSpace>,
    ) -> taffy::Size<f32> {
        let max_width = known.width.or(match available.width {
            taffy::AvailableSpace::Definite(width) => Some(width),
            taffy::AvailableSpace::MinContent => Some(0.0),
            taffy::AvailableSpace::MaxContent => None,
        });
        let size = self.place(text_cx, max_width);
        taffy::Size {
            width: size.width.ceil() as f32,
            height: size.height.ceil() as f32,
        }
    }

    fn finalize_layout(&mut self, text_cx: &mut TextContext, size: Size, _content_size: Size) {
        self.place(text_cx, Some(size.width as f32));
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        let tokens = &self.tokens;
        for (index, block) in self.blocks.iter().enumerate() {
            // A quote's rule, one per level, drawn through the gap to the next
            // block at the same depth or deeper — a quote of two paragraphs has
            // one bar down it, not two with a notch between them.
            for depth in 0..block.quote_depth {
                let carries_on = self.blocks[index + 1..]
                    .first()
                    .is_some_and(|next| next.quote_depth > depth);
                let x = f64::from(depth) * tokens.quote_indent;
                let bottom =
                    block.top + block.height + if carries_on { tokens.block_gap } else { 0.0 };
                fragment.fill(
                    Rect::new(x, block.top, x + tokens.quote_width, bottom),
                    tokens.quote_color.clone(),
                );
            }
            // The full assigned width, not the measured one: a code block's
            // background and a thematic break run to the edge of the view.
            let right = size.width.max(block.indent);
            match block.kind {
                BlockKind::Code => fragment.fill(
                    Rect::new(block.indent, block.top, right, block.top + block.height),
                    tokens.code_background.clone(),
                ),
                BlockKind::Rule => fragment.fill(
                    Rect::new(block.indent, block.top, right, block.top + block.height),
                    tokens.rule_color.clone(),
                ),
                BlockKind::Table => {
                    if let Some(table) = &block.table {
                        paint_table(fragment, table, tokens);
                    }
                }
                BlockKind::Image => {
                    if let Some(image) = &block.image {
                        paint_image(fragment, image, block.body_origin, block.height);
                    }
                }
                _ => {}
            }
            if let Some(marker) = &block.marker {
                marker.paint(fragment, block.marker_origin);
            }
            if let Some(body) = &block.body {
                body.paint(fragment, block.body_origin);
            }
        }
    }

    /// A left click on a link reports its target through `:on-link`. The view
    /// reports; the application acts — nothing here knows what a target names,
    /// which is why an `(a href …)` and a `(markdown …)` can mean entirely
    /// different things by the same string.
    fn on_pointer(
        &mut self,
        kind: EventKind,
        event: &PointerEvent,
        _text: &mut TextContext,
        _clipboard: &mut dyn guiduck_core::Clipboard,
    ) -> bool {
        // A link follows only on a *confirmed* single click (the burst settled
        // at one), never on the first press of a double — so a double-click on a
        // link does not navigate. Waiting for the settled count is why the view
        // opts into counted clicks below.
        if kind != EventKind::CountedClick
            || event.button != Some(PointerButton::Left)
            || event.click_count != 1
        {
            return false;
        }
        let Some(target) = self.link_at(event.local) else {
            return false;
        };
        let target = target.to_owned();
        self.emitted.push(EventData::User {
            name: ON_LINK.to_owned(),
            payload: Some(UserValue::Str(target)),
        });
        true
    }

    /// A document with links distinguishes single from multi-clicks (a link
    /// follows only on a confirmed single), so its clicks accumulate into a
    /// burst; a document with none has nothing to follow and its clicks are
    /// immediate.
    fn handles_counted_click(&self) -> bool {
        self.blocks.iter().any(|block| {
            block
                .body
                .as_ref()
                .is_some_and(|p| p.links().next().is_some())
                || block.table.as_ref().is_some_and(|table| {
                    table
                        .rows
                        .iter()
                        .flatten()
                        .any(|cell| cell.body.links().next().is_some())
                })
        })
    }

    /// The hand over a link, the default over prose — the only thing that says
    /// a word in a document is a link before you click it.
    fn cursor(&self, local: Point) -> guiduck_core::CursorShape {
        match self.link_at(local) {
            Some(_) => guiduck_core::CursorShape::Pointer,
            None => guiduck_core::CursorShape::Default,
        }
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::Document
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_value(self.plain_text());
    }

    /// Every link in the document, as a node an assistive technology can find,
    /// name, and click — through
    /// [`Paragraph::push_link_nodes`](guiduck_core::text::Paragraph::push_link_nodes),
    /// which is the same code that gives a `Text` its. The only thing this
    /// widget adds is where each paragraph sits, because it has more than one.
    fn accessibility_extended(
        &mut self,
        node: &mut accesskit::Node,
        update: &mut accesskit::TreeUpdate,
        next_id: &mut dyn FnMut() -> accesskit::NodeId,
        _origin: Point,
        _text: &mut TextContext,
    ) {
        node.set_value(self.plain_text());
        for block in &mut self.blocks {
            let body_origin = block.body_origin;
            if let Some(body) = &mut block.body {
                body.push_link_nodes(node, update, next_id, body_origin);
            } else if let Some(table) = &mut block.table {
                for row in &mut table.rows {
                    for cell in row {
                        let origin = cell.origin;
                        cell.body.push_link_nodes(node, update, next_id, origin);
                    }
                }
            }
        }
    }

    fn accessibility_action(&mut self, node: accesskit::NodeId, action: accesskit::Action) {
        if action != accesskit::Action::Click {
            return;
        }
        let target = self
            .blocks
            .iter()
            .flat_map(|block| {
                let cells = block
                    .table
                    .iter()
                    .flat_map(|table| table.rows.iter().flatten().map(|cell| &cell.body));
                block.body.iter().chain(cells)
            })
            .find_map(|body| body.link_target_for_node(node))
            .map(str::to_owned);
        if let Some(target) = target {
            self.emitted.push(EventData::User {
                name: ON_LINK.to_owned(),
                payload: Some(UserValue::Str(target)),
            });
        }
    }

    /// The name a theme selector and a `.gdc` write. The style engine matches
    /// on this string, so a document is themed through the one mechanism every
    /// builtin uses.
    fn type_name(&self) -> &'static str {
        "markdown"
    }

    /// Every token this widget has is baked into the blocks — a heading's size
    /// into its paragraph, a code block's padding into its box — so a token
    /// that really moved rebuilds the document.
    ///
    /// That makes every token layout dirt, including `link-color`, which for a
    /// `text` is paint-only. It is honest rather than pessimistic: nearly all of
    /// this widget's tokens *are* metrics (six heading sizes, three indents, two
    /// paddings, a gap), so a scheduler told "a markdown token changed, expect
    /// a relayout" is being told the truth in almost every case.
    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.rebuild();
            self.dirt.mark_layout();
        }
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn take_wake(&mut self) -> Option<std::time::Duration> {
        self.wake.take()
    }

    /// Re-ask the resolver about deferred images. When one has arrived, the
    /// document is placed again at the image's real size; while any is still
    /// loading, `resolve_images` leaves the next wake scheduled.
    fn on_timer(&mut self) {
        if self.resolve_images() {
            self.placed_at = None;
            self.dirt.mark_layout();
        }
    }
}

#[cfg(test)]
mod tests;