motion_content/text/
parts.rs1use super::*;
4
5impl Text {
6 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#[derive(Clone, Debug)]
112pub struct TextPart {
113 pub index: usize,
115 pub count: usize,
117 pub source: Range<usize>,
119 pub bounds: Rect,
121 time: Time,
122 line: Option<usize>,
123}
124
125impl TextPart {
126 pub fn time(&self) -> Time {
128 self.time
129 }
130
131 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 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#[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 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 pub fn offset(mut self, horizontal: f64, vertical: f64) -> Self {
234 self.offset = motion_core::Vector2::new(horizontal, vertical);
235 self
236 }
237
238 pub fn scale(mut self, scale: f64) -> Self {
240 self.scale = scale;
241 self
242 }
243
244 pub fn rotate(mut self, rotation: f64) -> Self {
246 self.rotation = rotation;
247 self
248 }
249
250 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}