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
}
}