//! 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 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, /// 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>, content_size: Size, /// The application's image resolver, if it set one. Without it, images are /// their alt text. image_resolver: Option, /// 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, emitted: Vec, 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) -> 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 = 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) -> 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, ) -> (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 = 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) { 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>, available: taffy::Size, ) -> taffy::Size { 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 { std::mem::take(&mut self.emitted) } fn take_wake(&mut self) -> Option { 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;