//! The Text primitive: a styled paragraph shaped and wrapped by parley. //! //! The paragraph itself — its spans, its layout, its link hit testing and //! accessibility nodes — is [`crate::text::Paragraph`], because none of that is //! a widget's business and a document view needs all of it too. What is here is //! the widget's half: the dirt each property change costs, the events a click //! produces, and the theme tokens a paragraph is dressed from. use std::ops::Range; use guiduck_scene::Fragment; use guiduck_scene::geom::{Point, Rect, Size}; use guiduck_scene::paint::{Brush, Color}; use parley::Affinity; use parley::editing::{Cursor, Selection as TextSelection}; use taffy::AvailableSpace; use super::{CursorShape, Widget}; use crate::clipboard::{Clipboard, Selection}; use crate::dirty::Dirt; use crate::event::{EventData, EventKind, Key, KeyInput, PointerButton, PointerEvent}; use crate::style::ComputedStyle; use crate::text::{LinkStyle, Paragraph, TextContext}; pub use crate::text::{RichText, TextSpan}; /// The link appearance the fallback theme gives a `text`, so a paragraph is /// dressed from construction with no ordering to get right. /// /// The values are never painted in practice — the style pass runs before a /// widget's first paint and the default theme covers every token — and the /// two-way coverage test in `style::tests` is what makes them unreachable in a /// working build. fn fallback_link_style() -> LinkStyle { let mut link_style = LinkStyle::default(); link_style.read(&super::fallback_style("text")); link_style } /// The selection-highlight brush the fallback theme gives a `text`, so a /// paragraph is dressed from construction. Overwritten by the style pass; the /// two-way coverage test in `style::tests` is what makes this value /// unreachable in a working build. fn fallback_selection_brush() -> Brush { super::fallback_style("text") .brush("selection-color") .cloned() .unwrap_or_else(|| Color::TRANSPARENT.into()) } /// A paragraph of uniformly-styled text, with styled and linked runs over it. /// Wrapping width comes from layout: taffy asks for the text's size via /// `measure`, and the final assigned width re-wraps the paragraph in /// `finalize_layout`. /// /// The paragraph is *selectable*: a drag highlights a range, Ctrl+C copies it, /// and the selection populates the primary selection. The widget is therefore /// focusable — a rendered note can be copied from — but not a Tab stop: a label /// takes focus when you click into it, without interrupting a Tab walk between /// the controls around it, the same split a scroll area draws. pub struct Text { paragraph: Paragraph, /// Semantic events pending collection by the tree (a clicked link). emitted: Vec, dirt: Dirt, /// The highlighted range, if any. Cleared when focus leaves, so at most /// one paragraph shows a selection at a time. selection: Option, /// A left press began a drag and moves are extending the selection. drag_selecting: bool, /// The selection-highlight brush, from the theme. selection_brush: Brush, } impl Default for Text { fn default() -> Self { let mut paragraph = Paragraph::default(); paragraph.set_link_style(fallback_link_style()); Self { paragraph, emitted: Vec::new(), dirt: Dirt::CLEAN, selection: None, drag_selecting: false, selection_brush: fallback_selection_brush(), } } } impl Text { pub fn new(text: impl Into) -> Self { let mut text_widget = Self::default(); text_widget.set_text(text); text_widget } pub fn font_size(mut self, size: f32) -> Self { self.set_font_size(size); self } pub fn brush(mut self, brush: impl Into) -> Self { self.set_brush(brush); self } /// Use a specific font family instead of the collection's default. pub fn family(mut self, family: impl Into) -> Self { self.set_family(family); self } /// Replace the paragraph's content: its characters and its styled runs. /// Layout-affecting, equality-gated. pub fn set_text(&mut self, text: impl Into) { if self.paragraph.set_content(text.into()) { // New characters mean the old selection's byte ranges name nothing. self.selection = None; self.dirt.mark_layout(); } } /// The highlighted text, if the selection is non-empty. fn selected_text(&self) -> Option<&str> { let range = self.selection.as_ref()?.text_range(); (!range.is_empty()).then(|| &self.paragraph.text()[range]) } /// Publish the current selection to the primary selection (the shared /// helper leaves a collapsed one alone). fn sync_primary(&self, clipboard: &mut dyn Clipboard) { super::sync_primary_selection(clipboard, self.selected_text()); } pub fn set_font_size(&mut self, size: f32) { if self.paragraph.set_font_size(size) { self.dirt.mark_layout(); } } pub fn set_family(&mut self, family: impl Into) { if self.paragraph.set_family(Some(family.into())) { self.dirt.mark_layout(); } } pub fn set_brush(&mut self, brush: impl Into) { // The brush is baked into the cached parley layout, but a rebuild at // the same width has identical geometry — paint-only. if self.paragraph.set_brush(brush.into()) { self.dirt.mark_paint(); } } pub fn text(&self) -> &str { self.paragraph.text() } /// The paragraph's content, characters and runs together. pub fn content(&self) -> &RichText { self.paragraph.content() } /// Style a byte range of the text; layout-affecting. /// /// See [`RichText::span`] for how ranges compose. pub fn span(mut self, range: Range, span: TextSpan) -> Self { self.add_span(range, span); self } /// Style a byte range; layout-affecting (metrics may change). pub fn add_span(&mut self, range: Range, span: TextSpan) { // A range `RichText::span` declined is not a change; say nothing. if self.paragraph.add_span(range, span) { self.dirt.mark_layout(); } } /// The target of the link span under `local`, if the point is on one. /// See [`Paragraph::link_at`], which is the whole of it. pub fn link_at(&self, local: Point) -> Option<&str> { self.paragraph.link_at(local) } /// How many lines the paragraph wrapped to. Zero before it is laid out. pub fn line_count(&self) -> usize { self.paragraph.line_count() } /// Where a byte of the text ended up; see [`Paragraph::byte_bounds`]. pub fn byte_bounds(&self, byte: usize) -> Option { self.paragraph.byte_bounds(byte) } } impl Widget for Text { fn measure( &mut self, text_cx: &mut TextContext, known: taffy::Size>, available: taffy::Size, ) -> taffy::Size { let max_width = known.width.or(match available.width { AvailableSpace::Definite(width) => Some(width), AvailableSpace::MinContent => Some(0.0), AvailableSpace::MaxContent => None, }); let layout = self.paragraph.layout_at(text_cx, max_width); taffy::Size { width: layout.width().ceil(), height: layout.height().ceil(), } } fn finalize_layout(&mut self, text_cx: &mut TextContext, size: Size, _content_size: Size) { self.paragraph.layout_at(text_cx, Some(size.width as f32)); // The selection's cursors cache positions the re-wrap invalidated; a // refresh re-derives them from the same byte offsets against the new // layout, so a resize keeps the highlight over the same words. if let (Some(selection), Some(layout)) = (self.selection, self.paragraph.layout()) { self.selection = Some(selection.refresh(layout)); } } fn paint(&mut self, fragment: &mut Fragment, _size: Size) { // The highlight paints under the glyphs, so the text reads over it. if let (Some(selection), Some(layout)) = (self.selection, self.paragraph.layout()) { let brush = self.selection_brush.clone(); selection.geometry_with(layout, |rect, _line| { fragment.fill(Rect::new(rect.x0, rect.y0, rect.x1, rect.y1), brush.clone()); }); } self.paragraph.paint(fragment, Point::ORIGIN); } fn focusable(&self) -> bool { true } /// Click-focusable to select, but not a Tab stop: a label has no business /// interrupting a Tab walk between the controls around it, and the focus /// invariant does not land here. fn tab_stop(&self) -> bool { false } /// Losing focus drops the selection, so exactly one paragraph is /// highlighted at a time — clicking into another label deselects this one. fn on_focus_changed(&mut self, focused: bool) { if !focused && self.selection.is_some() { self.selection = None; self.dirt.mark_paint(); } } /// Ctrl+C copies the selection; Ctrl+A selects the whole paragraph. Other /// keys are declined, so they bubble on to shortcuts and Tab traversal. fn on_key( &mut self, key: &KeyInput, _text: &mut TextContext, clipboard: &mut dyn Clipboard, ) -> bool { if !key.modifiers.ctrl { return false; } let Key::Character(c) = &key.key else { return false; }; match c.as_str() { "c" | "C" => { if let Some(selected) = self.selected_text() { clipboard.set_text(Selection::Clipboard, selected); } true } "a" | "A" => { let Some(layout) = self.paragraph.layout() else { return false; }; let end = self.paragraph.text().len(); let anchor = Cursor::from_byte_index(layout, 0, Affinity::Downstream); let focus = Cursor::from_byte_index(layout, end, Affinity::Upstream); self.selection = Some(TextSelection::new(anchor, focus)); self.sync_primary(clipboard); self.dirt.mark_paint(); true } _ => false, } } /// Pointer selection, and link activation. /// /// A left press anchors a selection and captures the pointer, so drags /// extend it (single click), or select the word (double) or line (triple). /// The release publishes it to the primary selection. /// /// A link fires only on a *click* — a press and release with no drag /// between — because a press alone has to stay free to become a selection. /// A drag that ends on a link therefore selects text rather than following /// it: the selection is non-empty, so the click is spent on the drag. fn on_pointer( &mut self, kind: EventKind, event: &PointerEvent, _text: &mut TextContext, clipboard: &mut dyn Clipboard, ) -> bool { let Some(layout) = self.paragraph.layout() else { return false; }; let x = event.local.x as f32; let y = event.local.y as f32; // A move carries no button; the press and release arms want the left // one, so they check it themselves rather than a guard rejecting moves. match kind { EventKind::PointerDown if event.button == Some(PointerButton::Left) => { self.selection = Some(match event.click_count { 0 | 1 => TextSelection::from_point(layout, x, y), 2 => TextSelection::word_from_point(layout, x, y), _ => TextSelection::line_from_point(layout, x, y), }); // A single click may grow into a drag; a multi-click selection // is complete at the press, so it publishes now and does not // drag (extending it would be by character, not by word). self.drag_selecting = event.click_count <= 1; if event.click_count >= 2 { self.sync_primary(clipboard); } self.dirt.mark_paint(); true } EventKind::PointerMove if self.drag_selecting => { if let Some(selection) = self.selection { self.selection = Some(selection.extend_to_point(layout, x, y)); self.dirt.mark_paint(); } true } EventKind::PointerUp if event.button == Some(PointerButton::Left) => { self.drag_selecting = false; self.sync_primary(clipboard); true } EventKind::CountedClick if event.button == Some(PointerButton::Left) && event.click_count == 1 => { // A *confirmed* single click (the burst settled at one) with no // drag (the selection stayed collapsed) activates a link under // it, if any. Waiting for the settled count is what keeps a // link from being followed on the first press of a double — // there a word is selected instead. The tree ignores this // return for clicks; it reports whether the link fired, which is // what the paragraph means by consuming the click. if self.selection.is_none_or(|s| s.is_collapsed()) && let Some(target) = self.paragraph.link_at(event.local) { self.emitted.push(EventData::Link(target.to_owned())); return true; } false } _ => false, } } /// The hand over a link, the default over prose — which is the only signal /// that a word in a paragraph is a link at all before you click it. fn cursor(&self, local: Point) -> CursorShape { match self.paragraph.link_at(local) { Some(_) => CursorShape::Pointer, None => CursorShape::Default, } } /// A paragraph with links distinguishes single from multi-clicks (a link /// follows only on a confirmed single), so its clicks accumulate into a /// burst. A plain label has nothing to follow and its clicks are immediate. fn handles_counted_click(&self) -> bool { self.paragraph.links().next().is_some() } fn role(&self) -> accesskit::Role { accesskit::Role::Label } fn accessibility(&self, node: &mut accesskit::Node) { node.set_value(self.paragraph.text().to_owned()); } /// Contribute a node per link span, so a link in prose is something an /// assistive technology can find, name, and act on rather than a stretch of /// label text that happens to be blue. 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.paragraph.text().to_owned()); // The widget *is* the paragraph, so the paragraph's coordinates are // already the ones a child node's bounds are stated in. self.paragraph .push_link_nodes(node, update, next_id, Point::ORIGIN); } /// A screen reader clicked one of the link nodes above. It arrives here /// rather than at `on_pointer` because a generated node has no place on /// screen the tree could synthesize a press at — and it needs none: the /// node *is* the link, so acting on it is reporting its target, through the /// same wire the pointer uses. fn accessibility_action(&mut self, node: accesskit::NodeId, action: accesskit::Action) { if action != accesskit::Action::Click { return; } let target = self.paragraph.link_target_for_node(node).map(str::to_owned); if let Some(target) = target { self.emitted.push(EventData::Link(target)); } } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn take_emitted(&mut self) -> Vec { std::mem::take(&mut self.emitted) } fn type_name(&self) -> &'static str { "text" } fn apply_style(&mut self, style: &ComputedStyle) { if let Some(brush) = style.brush("color") { self.set_brush(brush.clone()); } if let Some(size) = style.number("font-size") { self.set_font_size(size as f32); } if let Some(family) = style.string("font-family") { self.set_family(family); } if let Some(brush) = style.brush("selection-color") { // The highlight is drawn each paint from this brush, not baked into // the layout, so a change is paint-only and needs no invalidation. if self.selection_brush != *brush { self.selection_brush = brush.clone(); self.dirt.mark_paint(); } } // The link look is baked into the cached layout (parley resolves // ranged styles at build), so a change has to drop it — but a rebuild // at the same width has identical geometry, so this is paint-only, as // `set_brush` is for the same reason. if self.paragraph.read_link_style(style) { self.dirt.mark_paint(); } } } #[cfg(test)] mod tests;