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