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;