//! 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, family: Option, brush: Option, /// 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, weight: Option, 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, } 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) -> 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) -> 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) -> 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) -> 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` 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, TextSpan)>, } impl RichText { pub fn new(text: impl Into) -> 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, 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> From 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, link_style: LinkStyle, layout: Option>, layout_max_width: Option, /// 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, } 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) -> 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, 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> { 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, ) -> &Layout { 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 { 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, &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, range: &Range) -> Vec { let mut rects = Vec::new(); for line in layout.lines() { let metrics = line.metrics(); let mut line_rect: Option = 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, range: &Range) -> Option { let mut bounds: Option = 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, 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 } }