text.rs raw

//! 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<EventData>,
    dirt: Dirt,
    /// The highlighted range, if any. Cleared when focus leaves, so at most
    /// one paragraph shows a selection at a time.
    selection: Option<TextSelection>,
    /// 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<RichText>) -> 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<Brush>) -> 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<String>) -> 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<RichText>) {
        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<String>) {
        if self.paragraph.set_family(Some(family.into())) {
            self.dirt.mark_layout();
        }
    }

    pub fn set_brush(&mut self, brush: impl Into<Brush>) {
        // 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<usize>, 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<usize>, 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<Rect> {
        self.paragraph.byte_bounds(byte)
    }
}

impl Widget for Text {
    fn measure(
        &mut self,
        text_cx: &mut TextContext,
        known: taffy::Size<Option<f32>>,
        available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        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<EventData> {
        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;