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 `` 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;