Skip to main content

motion_content/text/
parts.rs

1//! Text part selection and animated per-part styling.
2
3use super::*;
4
5impl Text {
6    /// Evaluates part styles synchronously at explicit scene-local signed time,
7    /// without reshaping for animation. Source bounds and line wrapping stay fixed;
8    /// returned node bounds also include transformed animation ink.
9    /// Measured paragraph baseline is retained for horizontal Row alignment.
10    pub fn try_animate_parts_at(
11        &self,
12        context: &motion_core::SceneContext<'_>,
13        delay: motion_core::Duration,
14        style: impl Fn(&TextPart) -> PartStyle,
15    ) -> Result<Node, ValidationError> {
16        let fonts = self
17            .paragraph
18            .fonts
19            .as_ref()
20            .or_else(|| {
21                context
22                    .assets()
23                    .fonts()
24                    .and_then(|environment| environment.downcast_ref::<FontLibrary>())
25            })
26            .ok_or_else(|| {
27                invalid(
28                    "fonts",
29                    "Text animation requires an explicit font environment",
30                )
31            })?;
32        let drawing = DrawContext::default()
33            .with_fonts(fonts)
34            .with_time(context.time());
35        self.animate_parts_resolved(&drawing, delay, &style)
36    }
37
38    pub(super) fn animate_parts_resolved(
39        &self,
40        context: &DrawContext,
41        delay: motion_core::Duration,
42        style: &dyn Fn(&TextPart) -> PartStyle,
43    ) -> Result<Node, ValidationError> {
44        let layout = self.resolve(context)?;
45        let parts = layout.parts(self.split);
46        let mut nodes = Vec::new();
47        for (index, mut part) in parts.iter().cloned().enumerate() {
48            if self.visible.is_some_and(|visible| index >= visible) {
49                continue;
50            }
51            let ticks =
52                i128::from(context.time().ticks()) - i128::from(delay.ticks()) * index as i128;
53            part.time = Time::from_ticks(
54                i64::try_from(ticks)
55                    .map_err(|_| invalid("time", "Staggered part time exceeds signed ticks"))?,
56            );
57            let styled = style(&part);
58            styled.validate()?;
59            let children = layout
60                .glyphs
61                .iter()
62                .filter(|glyph| part.owns(glyph))
63                .map(|glyph| self.glyph_node(glyph))
64                .collect::<Vec<_>>();
65            nodes.push(
66                Node::group(children)
67                    .pivot(motion_core::Pivot::Point(part.bounds.center()))
68                    .scale(styled.scale)
69                    .rotate(styled.rotation)
70                    .translate(styled.offset.x, styled.offset.y)
71                    .opacity(styled.opacity),
72            );
73        }
74        let node = Node::group(
75            std::iter::once(Node::from(ParagraphAnchor {
76                bounds: layout.bounds,
77                baseline: layout.baseline,
78            }))
79            .chain(nodes),
80        );
81        let bounds = node.measure(motion_core::ValidationLimits::default())?;
82        Ok(node.with_text_metrics(layout.baseline, bounds))
83    }
84}
85
86struct ParagraphAnchor {
87    bounds: Rect,
88    baseline: f64,
89}
90
91impl Drawable for ParagraphAnchor {
92    fn bounds(&self, _: &DrawContext) -> Result<Rect, ValidationError> {
93        Ok(self.bounds)
94    }
95
96    fn baseline(&self, _: &DrawContext) -> Option<f64> {
97        Some(self.baseline)
98    }
99
100    fn draw(&self, _: &DrawContext, _: &mut Canvas<'_>) -> Result<(), ValidationError> {
101        Ok(())
102    }
103}
104
105/// One logical part with original source range, layout bounds and delayed time.
106#[derive(Clone, Debug)]
107pub struct TextPart {
108    /// Reading-order part index.
109    pub index: usize,
110    /// Total part count before visibility filtering.
111    pub count: usize,
112    /// Original UTF-8 source bytes.
113    pub source: Range<usize>,
114    /// Stable paragraph-local bounds.
115    pub bounds: Rect,
116    time: Time,
117    line: Option<usize>,
118}
119
120impl TextPart {
121    /// Scene-local time minus index times the supplied delay.
122    pub fn time(&self) -> Time {
123        self.time
124    }
125
126    /// Samples any typed animation at the delayed signed time.
127    pub fn sample<Value>(&self, animation: &impl motion_core::Signal<Value>) -> Value {
128        animation.sample(self.time)
129    }
130
131    pub(super) fn owns(&self, glyph: &TextGlyph) -> bool {
132        self.line.map_or_else(
133            || self.source.contains(&glyph.source.start),
134            |line| glyph.line == line,
135        )
136    }
137}
138
139impl ParagraphLayout {
140    /// Derives parts from an already shaped paragraph, without font/parser work.
141    pub fn parts(&self, split: TextSplit) -> Vec<TextPart> {
142        let ranges = match split {
143            TextSplit::Characters => self
144                .text
145                .grapheme_indices(true)
146                .map(|(start, value)| start..start + value.len())
147                .collect::<Vec<_>>(),
148            TextSplit::Words => {
149                let mut starts = self
150                    .text
151                    .unicode_word_indices()
152                    .map(|(start, _)| start)
153                    .collect::<Vec<_>>();
154                if starts.is_empty() && !self.text.is_empty() {
155                    starts.push(0);
156                }
157                if let Some(first) = starts.first_mut() {
158                    *first = 0;
159                }
160                starts.push(self.text.len());
161                starts.windows(2).map(|pair| pair[0]..pair[1]).collect()
162            }
163            TextSplit::Lines => self.line_sources.to_vec(),
164        };
165        let count = ranges.len();
166        ranges
167            .into_iter()
168            .enumerate()
169            .map(|(index, source)| {
170                let mut bounds = None;
171                for glyph in self.glyphs.iter().filter(|glyph| {
172                    if split == TextSplit::Lines {
173                        glyph.line == index
174                    } else {
175                        source.contains(&glyph.source.start)
176                    }
177                }) {
178                    bounds = Some(
179                        bounds.map_or(glyph.bounds, |bounds: Rect| bounds.union(glyph.bounds)),
180                    );
181                }
182                TextPart {
183                    index,
184                    count,
185                    source,
186                    bounds: bounds.unwrap_or_else(|| {
187                        if split == TextSplit::Lines {
188                            self.lines[index]
189                        } else {
190                            Rect::default()
191                        }
192                    }),
193                    time: Time::ZERO,
194                    line: (split == TextSplit::Lines).then_some(index),
195                }
196            })
197            .collect()
198    }
199}
200
201/// Per-part transform evaluated without changing paragraph layout.
202#[derive(Clone, Copy, Debug)]
203pub struct PartStyle {
204    offset: motion_core::Vector2,
205    scale: f64,
206    rotation: f64,
207    opacity: f64,
208}
209
210impl Default for PartStyle {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216impl PartStyle {
217    /// Identity style.
218    pub fn new() -> Self {
219        Self {
220            offset: motion_core::Vector2::ZERO,
221            scale: 1.0,
222            rotation: 0.0,
223            opacity: 1.0,
224        }
225    }
226
227    /// Adds a logical displacement.
228    pub fn offset(mut self, horizontal: f64, vertical: f64) -> Self {
229        self.offset = motion_core::Vector2::new(horizontal, vertical);
230        self
231    }
232
233    /// Sets uniform scale around the part's measured center.
234    pub fn scale(mut self, scale: f64) -> Self {
235        self.scale = scale;
236        self
237    }
238
239    /// Sets clockwise radians.
240    pub fn rotate(mut self, rotation: f64) -> Self {
241        self.rotation = rotation;
242        self
243    }
244
245    /// Sets opacity in 0..=1.
246    pub fn opacity(mut self, opacity: f64) -> Self {
247        self.opacity = opacity;
248        self
249    }
250
251    pub(super) fn validate(self) -> Result<(), ValidationError> {
252        if ![
253            self.offset.x,
254            self.offset.y,
255            self.scale,
256            self.rotation,
257            self.opacity,
258        ]
259        .iter()
260        .all(|value| value.is_finite())
261            || !(0.0..=1.0).contains(&self.opacity)
262        {
263            Err(invalid(
264                "part",
265                "Part transforms must be finite and opacity in 0..=1",
266            ))
267        } else {
268            Ok(())
269        }
270    }
271}