paragraph.rs raw

//! A paragraph: characters, the styled runs over them, and the one parley
//! layout they shape to.
//!
//! This is the whole of "text with links in it", and it lives here rather than
//! in the [`Text`](crate::widget::Text) widget because it is not the widget's:
//! a `Widget` cannot own child widgets, so anything that renders a *document* —
//! a markdown view, say — has to stack several of these itself. Both need the
//! same answers to the same questions (which span is under this point, where
//! did this byte land, what does a link look like), and there is exactly one
//! implementation of each here. `Text` is a `Paragraph` plus a widget's dirt
//! and events; nothing about hit testing, span styling, or decoration drawing
//! is duplicated in either consumer.
//!
//! The interesting method is [`Paragraph::link_at`]: wrapping "See the home
//! page for more" across a line break has to be free to break between `home`
//! and `page`, so the pointer has to reach *inside* the layout. Everything else
//! a consumer says about a point — the cursor, the click, the accessibility
//! nodes — is a reading of that one answer.

use std::ops::Range;

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect};
use guiduck_scene::paint::{Brush, color::palette::css};
use parley::{Alignment, AlignmentOptions, Cluster, Layout, PositionedLayoutItem, StyleProperty};

use crate::style::{ComputedStyle, StyleReader};
use crate::text::{TextContext, font_family};

/// A styled run within a paragraph: what differs from the paragraph's own
/// font, over some byte range of it.
///
/// Every field is optional and only the set ones apply, so a span says just
/// what it changes and inherits the rest — which is what lets spans overlap
/// and nest without either having to know about the other.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TextSpan {
    font_size: Option<f32>,
    family: Option<String>,
    brush: Option<Brush>,
    /// A fill painted behind the span's glyphs, per line — inline code's
    /// background. Not a parley run style (parley draws no backgrounds); the
    /// paragraph paints it from the span's own geometry.
    background: Option<Brush>,
    weight: Option<f32>,
    italic: bool,
    underline: bool,
    strikethrough: bool,
    /// What this span links to, if it links anywhere. A target, not an
    /// appearance: what a link *looks* like is the theme's `link-color` and
    /// `link-underline-width`, and what it *means* is the application's.
    target: Option<String>,
}

impl TextSpan {
    pub fn new() -> Self {
        Self::default()
    }

    /// 400 is regular, 700 bold.
    ///
    /// A weight only shows if the font collection has a face for it: text
    /// resolves through real faces, and nothing here synthesizes one. A
    /// single-face collection renders bold as regular rather than smearing
    /// the glyphs.
    pub fn weight(mut self, weight: f32) -> Self {
        self.weight = Some(weight);
        self
    }

    pub fn bold(self) -> Self {
        self.weight(700.0)
    }

    /// Shows only if the collection has an italic face (see
    /// [`weight`](Self::weight)); no oblique is synthesized.
    pub fn italic(mut self) -> Self {
        self.italic = true;
        self
    }

    pub fn underline(mut self) -> Self {
        self.underline = true;
        self
    }

    pub fn strikethrough(mut self) -> Self {
        self.strikethrough = true;
        self
    }

    pub fn color(mut self, brush: impl Into<Brush>) -> Self {
        self.brush = Some(brush.into());
        self
    }

    /// A fill painted behind the span, per line it crosses — inline code's
    /// background box.
    pub fn background(mut self, brush: impl Into<Brush>) -> Self {
        self.background = Some(brush.into());
        self
    }

    pub fn font_size(mut self, size: f32) -> Self {
        self.font_size = Some(size);
        self
    }

    pub fn family(mut self, family: impl Into<String>) -> Self {
        self.family = Some(family.into());
        self
    }

    /// Make this span a link to `target`: clicking it names the string, and the
    /// pointer over it is the hand.
    ///
    /// The span takes the theme's link appearance under whatever else it sets,
    /// so `(link "home" (color "#f00" "Home"))` is a red link — an explicit
    /// color is an instruction, and the theme's is a default.
    pub fn link(mut self, target: impl Into<String>) -> Self {
        self.target = Some(target.into());
        self
    }

    /// Where this span links, if it links anywhere.
    pub fn target(&self) -> Option<&str> {
        self.target.as_deref()
    }
}

/// A paragraph's content: its characters, and the styled runs over them.
///
/// This is what a [`Text`](crate::widget::Text)'s content property *is* — plain
/// text is the case with no runs, which is why `From<String>` exists and why
/// one setter takes both. A `(rich …)` in a `.gdc` and a `String` binding
/// therefore reach the widget through the same method, and a widget declared in
/// a `.gdw` can take one for the same reason.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RichText {
    pub text: String,
    /// Styled runs, by byte range into `text`. Applied in order, so a later
    /// run wins where two overlap.
    pub spans: Vec<(Range<usize>, TextSpan)>,
}

impl RichText {
    pub fn new(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            spans: Vec::new(),
        }
    }

    /// Style a byte range.
    ///
    /// Ranges may overlap and nest; a run sets only what it names, and later
    /// runs win where they collide. An out-of-range or non-char-boundary range
    /// is dropped rather than panicking — a span is a decoration, and nothing
    /// about text should be able to take the window down.
    pub fn span(mut self, range: Range<usize>, span: TextSpan) -> Self {
        if range.start < range.end
            && self.text.is_char_boundary(range.start)
            && self.text.is_char_boundary(range.end)
        {
            self.spans.push((range, span));
        }
        self
    }
}

impl<T: Into<String>> From<T> for RichText {
    fn from(text: T) -> Self {
        Self::new(text)
    }
}

/// The appearance a paragraph dresses its link spans in, as the theme states
/// it: `link-color` and `link-underline-width`.
///
/// A link's look is a *theme* fact, not a widget one — "blue and underlined" is
/// a convention, not a law — so every paragraph-bearing widget reads it from
/// its own computed style through this one type rather than deciding for
/// itself. The underline is a width because that is what an underline is; zero
/// is no underline, on the `border-width` precedent. It takes the link's own
/// color, since parley draws a decoration in the run's brush, so a link's look
/// has one definition rather than two that can disagree.
#[derive(Clone, Debug, PartialEq)]
pub struct LinkStyle {
    pub color: Brush,
    /// Zero is no underline.
    pub underline_width: f64,
}

impl LinkStyle {
    /// Copy `link-color` and `link-underline-width` out of a computed style,
    /// reporting whether either landed differently — the standing
    /// [`StyleReader`] contract, so a token the style does not carry leaves the
    /// slot alone.
    pub fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.color, "link-color");
        reader.number(&mut self.underline_width, "link-underline-width");
        reader.changed()
    }
}

impl Default for LinkStyle {
    /// Invisible: a link nobody has dressed yet. Consumers fill this from the
    /// theme floor for their own widget type before anything is painted.
    fn default() -> Self {
        Self {
            color: css::TRANSPARENT.into(),
            underline_width: 0.0,
        }
    }
}

/// Characters, styled runs, and the parley layout they shape to.
///
/// Setters return whether the value really changed and drop the cached layout
/// when it did; the *dirt class* of each change stays with the consuming
/// widget, because that is a fact about the widget's property, not about text.
pub struct Paragraph {
    content: RichText,
    font_size: f32,
    brush: Brush,
    family: Option<String>,
    link_style: LinkStyle,
    layout: Option<Layout<Brush>>,
    layout_max_width: Option<f32>,
    /// The accessibility node of each link span, by its position among them.
    /// Held so the ids stay put across passes: an assistive technology tracks
    /// a node by id, and a link that got a fresh one every frame would be a
    /// link that vanished and reappeared every frame.
    link_access_ids: Vec<accesskit::NodeId>,
}

impl Default for Paragraph {
    /// Empty, 14px, black, in the font collection's default family, with no
    /// link appearance yet.
    ///
    /// This is the one statement of what an undressed paragraph is; a widget
    /// that has a theme opinion about any of it says so through the setters
    /// during its style pass, which is what keeps the size from being written
    /// down twice.
    fn default() -> Self {
        Self {
            content: RichText::default(),
            font_size: 14.0,
            brush: css::BLACK.into(),
            family: None,
            link_style: LinkStyle::default(),
            layout: None,
            layout_max_width: None,
            link_access_ids: Vec::new(),
        }
    }
}

impl Paragraph {
    /// Replace the characters and the styled runs. Drops the layout and the
    /// link nodes' ids when the content really changed: new content means new
    /// links, and the old ids named spans that no longer exist.
    pub fn set_content(&mut self, content: RichText) -> bool {
        if self.content == content {
            return false;
        }
        self.content = content;
        self.link_access_ids.clear();
        self.invalidate();
        true
    }

    pub fn set_font_size(&mut self, size: f32) -> bool {
        if self.font_size == size {
            return false;
        }
        self.font_size = size;
        self.invalidate();
        true
    }

    pub fn set_family(&mut self, family: Option<String>) -> bool {
        if self.family == family {
            return false;
        }
        self.family = family;
        self.invalidate();
        true
    }

    pub fn set_brush(&mut self, brush: Brush) -> bool {
        if self.brush == brush {
            return false;
        }
        self.brush = brush;
        self.invalidate();
        true
    }

    /// Dress this paragraph's links. Layout-neutral in geometry — a rebuild at
    /// the same width places the same glyphs — but the appearance is baked into
    /// the cached layout at build, so a change still drops it.
    pub fn set_link_style(&mut self, link_style: LinkStyle) -> bool {
        if self.link_style == link_style {
            return false;
        }
        self.link_style = link_style;
        self.invalidate();
        true
    }

    /// Read the link appearance out of a computed style; see [`LinkStyle`].
    pub fn read_link_style(&mut self, style: &ComputedStyle) -> bool {
        let mut link_style = self.link_style.clone();
        if !link_style.read(style) {
            return false;
        }
        self.link_style = link_style;
        self.invalidate();
        true
    }

    pub fn link_style(&self) -> &LinkStyle {
        &self.link_style
    }

    pub fn content(&self) -> &RichText {
        &self.content
    }

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

    pub fn font_size(&self) -> f32 {
        self.font_size
    }

    /// Style a byte range, reporting whether the range was accepted (see
    /// [`RichText::span`] for the ones that are not).
    pub fn add_span(&mut self, range: Range<usize>, span: TextSpan) -> bool {
        let before = self.content.spans.len();
        self.content = std::mem::take(&mut self.content).span(range, span);
        if self.content.spans.len() == before {
            return false;
        }
        self.invalidate();
        true
    }

    /// Drop the cached layout; the next [`layout_at`](Self::layout_at) rebuilds
    /// it.
    pub fn invalidate(&mut self) {
        self.layout = None;
        self.layout_max_width = None;
    }

    /// The cached layout, if the paragraph has been laid out.
    pub fn layout(&self) -> Option<&Layout<Brush>> {
        self.layout.as_ref()
    }

    /// Build (or reuse) the parley layout wrapped at `max_width`.
    pub fn layout_at(
        &mut self,
        text_cx: &mut TextContext,
        max_width: Option<f32>,
    ) -> &Layout<Brush> {
        if self.layout.is_none() || self.layout_max_width != max_width {
            let mut builder = text_cx.layout_cx.ranged_builder(
                &mut text_cx.font_cx,
                &self.content.text,
                1.0,
                true,
            );
            if let Some(family) = &self.family {
                builder.push_default(StyleProperty::FontFamily(font_family(family)));
            }
            builder.push_default(StyleProperty::FontSize(self.font_size));
            builder.push_default(StyleProperty::Brush(self.brush.clone()));
            // Ranged styles over the paragraph's own: each span pushes only
            // the properties it sets, so the rest keep inheriting.
            for (range, span) in &self.content.spans {
                // A link's theme appearance goes on first, under the span's
                // own properties, so an explicit `(link "x" (color "#f00" …))`
                // is red: what the file states beats what the theme suggests.
                if span.target.is_some() {
                    builder.push(
                        StyleProperty::Brush(self.link_style.color.clone()),
                        range.clone(),
                    );
                    if self.link_style.underline_width > 0.0 {
                        builder.push(StyleProperty::Underline(true), range.clone());
                        builder.push(
                            StyleProperty::UnderlineSize(Some(
                                self.link_style.underline_width as f32,
                            )),
                            range.clone(),
                        );
                    }
                }
                if let Some(size) = span.font_size {
                    builder.push(StyleProperty::FontSize(size), range.clone());
                }
                if let Some(family) = &span.family {
                    builder.push(
                        StyleProperty::FontFamily(font_family(family)),
                        range.clone(),
                    );
                }
                if let Some(brush) = &span.brush {
                    builder.push(StyleProperty::Brush(brush.clone()), range.clone());
                }
                if let Some(weight) = span.weight {
                    builder.push(
                        StyleProperty::FontWeight(parley::FontWeight::new(weight)),
                        range.clone(),
                    );
                }
                if span.italic {
                    builder.push(
                        StyleProperty::FontStyle(parley::FontStyle::Italic),
                        range.clone(),
                    );
                }
                if span.underline {
                    builder.push(StyleProperty::Underline(true), range.clone());
                }
                if span.strikethrough {
                    builder.push(StyleProperty::Strikethrough(true), range.clone());
                }
            }
            let mut layout = builder.build(&self.content.text);
            layout.break_all_lines(max_width);
            layout.align(Alignment::Start, AlignmentOptions::default());
            self.layout = Some(layout);
            self.layout_max_width = max_width;
        }
        self.layout.as_ref().expect("just built")
    }

    /// The target of the link span under `local`, if the point is on one.
    ///
    /// This is the whole of "a point becomes a span". parley answers the hard
    /// half — [`Cluster::from_point_exact`] walks the laid-out lines and hands
    /// back the cluster actually under the point, or nothing if the point is
    /// past the end of a line — and a cluster carries the byte range it came
    /// from, which is the coordinate the spans are stated in. Wrapping needs no
    /// help at all here: a span is a byte range, a line break does not move a
    /// byte, so a link split across two lines is the same range on both and
    /// every cluster of it answers alike.
    ///
    /// `from_point_exact`, not `from_point`: the inexact one snaps to the
    /// nearest cluster, which is right for placing a caret and wrong for
    /// deciding whether the pointer is on a link — it would make the whitespace
    /// past a line's last word a click on it.
    pub fn link_at(&self, local: Point) -> Option<&str> {
        let layout = self.layout.as_ref()?;
        let (cluster, _side) = Cluster::from_point_exact(layout, local.x as f32, local.y as f32)?;
        let index = cluster.text_range().start;
        // Later spans win where they overlap, as in `layout_at` — but only
        // among those that state a target: a span that says nothing about
        // links no more clears one than a span with no brush clears the color.
        self.content
            .spans
            .iter()
            .rev()
            .find(|(range, span)| span.target.is_some() && range.contains(&index))
            .and_then(|(_, span)| span.target.as_deref())
    }

    /// How many lines the paragraph wrapped to. Zero before it is laid out.
    pub fn line_count(&self) -> usize {
        self.layout.as_ref().map_or(0, Layout::len)
    }

    /// Where a byte of the text ended up: the rectangle of the cluster it
    /// belongs to, in the paragraph's own coordinates.
    ///
    /// The inverse of [`link_at`](Self::link_at) — that one asks what is at a
    /// point, this one asks where something is — and the honest unit for it is
    /// a cluster, because a cluster is the smallest thing a layout places.
    /// `None` before layout, or for a byte no cluster covers.
    pub fn byte_bounds(&self, byte: usize) -> Option<Rect> {
        let layout = self.layout.as_ref()?;
        let cluster = Cluster::from_byte_index(layout, byte)?;
        let x = cluster.visual_offset()?;
        let line = cluster.line();
        let metrics = line.metrics();
        Some(Rect::new(
            f64::from(x),
            f64::from(metrics.block_min_coord),
            f64::from(x + cluster.advance()),
            f64::from(metrics.block_max_coord),
        ))
    }

    /// The link spans, each with the byte range it covers, in order.
    pub fn links(&self) -> impl Iterator<Item = (&Range<usize>, &str)> {
        self.content
            .spans
            .iter()
            .filter_map(|(range, span)| Some((range, span.target.as_deref()?)))
    }

    /// Paint every glyph run, with the paragraph's top-left corner at `origin`.
    pub fn paint(&self, fragment: &mut Fragment, origin: Point) {
        let Some(layout) = &self.layout else {
            return;
        };
        // Span backgrounds (inline code) paint under the glyphs, one box per
        // line the span crosses — so a background behind a run that wraps is a
        // box on each line, not one rectangle bridging the gap between them.
        let offset = origin.to_vec2();
        for (range, span) in &self.content.spans {
            if let Some(background) = &span.background {
                for rect in Self::span_line_rects(layout, range) {
                    fragment.fill(rect + offset, background.clone());
                }
            }
        }
        super::append_layout(fragment, layout, origin);
    }

    /// The rectangles a byte range occupies, one per line it crosses — the
    /// per-line boxes a span background paints. Unlike [`span_bounds`], which
    /// unions across lines for a single accessibility bound, this keeps each
    /// line's box separate so the fill follows the text rather than bridging
    /// the ragged gap between a line's end and the next line's start.
    ///
    /// [`span_bounds`]: Self::span_bounds
    fn span_line_rects(layout: &Layout<Brush>, range: &Range<usize>) -> Vec<Rect> {
        let mut rects = Vec::new();
        for line in layout.lines() {
            let metrics = line.metrics();
            let mut line_rect: Option<Rect> = None;
            for item in line.items() {
                let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
                    continue;
                };
                let mut x = glyph_run.offset();
                for cluster in glyph_run.run().visual_clusters() {
                    let advance = cluster.advance();
                    let cluster_range = cluster.text_range();
                    if cluster_range.start < range.end && cluster_range.end > range.start {
                        let rect = Rect::new(
                            f64::from(x),
                            f64::from(metrics.block_min_coord),
                            f64::from(x + advance),
                            f64::from(metrics.block_max_coord),
                        );
                        line_rect = Some(match line_rect {
                            Some(existing) => existing.union(rect),
                            None => rect,
                        });
                    }
                    x += advance;
                }
            }
            if let Some(rect) = line_rect {
                rects.push(rect);
            }
        }
        rects
    }

    /// The rectangle a byte range occupies in the laid-out paragraph.
    ///
    /// A wrapped span covers a piece of each line it crosses; this is their
    /// union, which is what an accessibility node's single bounds can say and
    /// what every platform's accessibility API means by a wrapped link's
    /// extents.
    fn span_bounds(layout: &Layout<Brush>, range: &Range<usize>) -> Option<Rect> {
        let mut bounds: Option<Rect> = None;
        for line in layout.lines() {
            let metrics = line.metrics();
            for item in line.items() {
                let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
                    continue;
                };
                let mut x = glyph_run.offset();
                for cluster in glyph_run.run().visual_clusters() {
                    let advance = cluster.advance();
                    let cluster_range = cluster.text_range();
                    // Any overlap at all: a cluster is indivisible, so a
                    // cluster the range touches is a cluster the range covers.
                    if cluster_range.start < range.end && cluster_range.end > range.start {
                        let rect = Rect::new(
                            f64::from(x),
                            f64::from(metrics.block_min_coord),
                            f64::from(x + advance),
                            f64::from(metrics.block_max_coord),
                        );
                        bounds = Some(match bounds {
                            Some(bounds) => bounds.union(rect),
                            None => rect,
                        });
                    }
                    x += advance;
                }
            }
        }
        bounds
    }

    /// Contribute a `Role::Link` node per link span as a child of `node`, with
    /// the paragraph placed at `origin` in the parent node's coordinates.
    ///
    /// A link in prose is something an assistive technology should be able to
    /// find, name, and act on, rather than a stretch of label text that happens
    /// to be blue. A widget stacking several paragraphs passes each one's own
    /// origin; a widget that is one paragraph passes [`Point::ORIGIN`].
    pub fn push_link_nodes(
        &mut self,
        node: &mut accesskit::Node,
        update: &mut accesskit::TreeUpdate,
        next_id: &mut dyn FnMut() -> accesskit::NodeId,
        origin: Point,
    ) {
        if self.layout.is_none() {
            return;
        }
        let links: Vec<(Range<usize>, String)> = self
            .links()
            .map(|(range, target)| (range.clone(), target.to_owned()))
            .collect();
        for (index, (range, target)) in links.into_iter().enumerate() {
            let layout = self.layout.as_ref().expect("checked above");
            let Some(bounds) = Self::span_bounds(layout, &range) else {
                continue;
            };
            // The id is minted once and kept: see `link_access_ids`.
            if self.link_access_ids.len() <= index {
                let id = next_id();
                self.link_access_ids.push(id);
            }
            let id = self.link_access_ids[index];
            let mut link = accesskit::Node::new(accesskit::Role::Link);
            // Child bounds are relative to the parent node, which is the
            // widget — so the layout's own coordinates plus where the widget
            // put this paragraph.
            link.set_bounds(accesskit::Rect::new(
                bounds.x0 + origin.x,
                bounds.y0 + origin.y,
                bounds.x1 + origin.x,
                bounds.y1 + origin.y,
            ));
            link.set_label(self.content.text[range].to_owned());
            // Whatever the target names, this is the slot every platform reads
            // for "where does this link go", and announcing it is strictly
            // better than announcing nothing.
            link.set_url(target);
            link.add_action(accesskit::Action::Click);
            update.nodes.push((id, link));
            node.push_child(id);
        }
    }

    /// The target of the link whose generated accessibility node this is, if it
    /// is one of this paragraph's. The inverse of the ids
    /// [`push_link_nodes`](Self::push_link_nodes) minted, so an assistive
    /// technology's click on a node becomes the same answer the pointer gets.
    pub fn link_target_for_node(&self, node: accesskit::NodeId) -> Option<&str> {
        let index = self.link_access_ids.iter().position(|id| *id == node)?;
        self.links().nth(index).map(|(_, target)| target)
    }

    /// The ids minted for this paragraph's link nodes, in order.
    pub fn link_access_ids(&self) -> &[accesskit::NodeId] {
        &self.link_access_ids
    }
}