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 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#[derive(Clone, Debug)]
107pub struct TextPart {
108 pub index: usize,
110 pub count: usize,
112 pub source: Range<usize>,
114 pub bounds: Rect,
116 time: Time,
117 line: Option<usize>,
118}
119
120impl TextPart {
121 pub fn time(&self) -> Time {
123 self.time
124 }
125
126 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 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#[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 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 pub fn offset(mut self, horizontal: f64, vertical: f64) -> Self {
229 self.offset = motion_core::Vector2::new(horizontal, vertical);
230 self
231 }
232
233 pub fn scale(mut self, scale: f64) -> Self {
235 self.scale = scale;
236 self
237 }
238
239 pub fn rotate(mut self, rotation: f64) -> Self {
241 self.rotation = rotation;
242 self
243 }
244
245 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}