Skip to main content

gpui_ce_motion/text/
state.rs

1use super::{TextMatch, TextOptions, TextTarget};
2use crate::{AnimationState, MotionValue, Playback, PresenceMode, stagger};
3
4use std::{
5    collections::{HashMap, VecDeque},
6    sync::Arc,
7    time::Duration,
8};
9
10/// A renderer-prepared text unit. Geometry is relative to the complete text element.
11#[derive(Clone, Debug)]
12pub struct TextLayoutUnit {
13    /// Logical content, shaped before this unit was separated for painting.
14    pub text: Arc<str>,
15    /// Stable slot, normally logical index; numbers count slots from the right.
16    pub slot: usize,
17    /// Natural top-left position in logical pixels.
18    pub origin: [f64; 2],
19    /// Stationary clipping reservation in logical pixels.
20    pub size: [f64; 2],
21    /// Resolved font size for text-relative offsets.
22    pub em: f64,
23    /// Optional per-unit entrance override, prepared by `enter_with`.
24    pub enter: Option<TextTarget>,
25}
26
27impl TextLayoutUnit {
28    /// Prepares finite geometry. Panics for negative sizes or nonpositive font size.
29    pub fn new(
30        text: impl Into<Arc<str>>,
31        slot: usize,
32        origin: [f64; 2],
33        size: [f64; 2],
34        em: f64,
35    ) -> Self {
36        assert!(origin.iter().all(|value| value.is_finite()));
37        assert!(size.iter().all(|value| value.is_finite() && *value >= 0.));
38        assert!(em.is_finite() && em > 0.);
39
40        Self {
41            text: text.into(),
42            slot,
43            origin,
44            size,
45            em,
46            enter: None,
47        }
48    }
49}
50
51/// A sampled unit. Geometry and opacity are drawing values; retained spring state
52/// remains unclamped so reversals preserve its momentum.
53#[derive(Clone, Copy, Debug)]
54pub struct TextPresentation {
55    /// Stable identity assigned by the text state.
56    pub id: u64,
57    /// Translated ink origin, in logical pixels.
58    pub origin: [f64; 2],
59    /// Stationary mask origin, including natural-position reflow.
60    pub clip_origin: [f64; 2],
61    /// Unit reservation.
62    pub size: [f64; 2],
63    /// Drawable opacity.
64    pub opacity: f64,
65    /// Drawable horizontal reveal fraction.
66    pub reveal: f64,
67    /// A prepared scramble alphabet index, if substituting this unit.
68    pub substitution: Option<usize>,
69    /// Outgoing units remain paintable until their transition finishes.
70    pub exiting: bool,
71    /// False for units waiting for outgoing content or a staggered clip start.
72    pub visible: bool,
73}
74
75struct Unit {
76    layout: TextLayoutUnit,
77    id: u64,
78    position: [MotionValue; 2],
79    pose: [MotionValue; 4],
80    last_pose: [f64; 4],
81    enter: TextTarget,
82    delay: Duration,
83    sequence_index: usize,
84    born: Duration,
85    exiting: bool,
86    waiting: bool,
87    finished: bool,
88    animation: AnimationState,
89    declared_clip: Option<crate::Animation>,
90    clip_resume: Option<(Duration, Duration)>,
91    frame: TextPresentation,
92    scramble: Option<super::Scramble>,
93}
94
95impl Unit {
96    fn reset_pose(&mut self, now: Duration, options: &TextOptions, count: usize, animate: bool) {
97        self.delay = stagger(
98            options.stagger,
99            self.sequence_index,
100            count,
101            options.stagger_from,
102        )
103        .unwrap_or(Duration::ZERO);
104        self.born = now;
105        self.finished = false;
106        let from = if animate {
107            self.enter
108        } else {
109            TextTarget::default()
110        };
111
112        let values = from.resolved_values(self.layout.em, self.layout.size[1]);
113
114        for (value, initial) in self.pose.iter_mut().zip(values) {
115            value.jump(initial, now);
116        }
117
118        self.animation = AnimationState::default();
119        self.declared_clip = None;
120        self.clip_resume = None;
121        self.last_pose = values;
122        self.target_pose(now, options, self.delay);
123    }
124
125    fn entrance_progress(&self, progress: f64, total: f64, options: &TextOptions) -> f64 {
126        let duration = options.entrance_duration(1).as_secs_f64();
127        let start = self.delay.as_secs_f64();
128
129        if duration == 0. {
130            if progress * total >= start { 1. } else { 0. }
131        } else {
132            ((progress * total - start) / duration).clamp(0., 1.)
133        }
134    }
135
136    fn target_pose(&mut self, now: Duration, options: &TextOptions, delay: Duration) {
137        let target = if self.exiting {
138            options.exit
139        } else {
140            TextTarget::default()
141        };
142
143        let values = target.resolved_values(self.layout.em, self.layout.size[1]);
144
145        for (index, (value, target)) in self.pose.iter_mut().zip(values).enumerate() {
146            value.animate_delayed(target, options.pose_transition(index), delay, now);
147        }
148    }
149
150    fn resume_pose(&mut self, now: Duration, options: &TextOptions) {
151        for (value, held) in self.pose.iter_mut().zip(self.last_pose) {
152            value.jump(held, now);
153        }
154
155        self.target_pose(now, options, Duration::ZERO);
156    }
157
158    fn sample(
159        &mut self,
160        now: Duration,
161        options: &TextOptions,
162        reduced: bool,
163        progress: Option<f64>,
164    ) -> bool {
165        if self.waiting {
166            self.frame.visible = false;
167
168            return false;
169        }
170
171        let mut active = false;
172        let mut origin = [0.; 2];
173
174        for (axis, value) in self.position.iter_mut().enumerate() {
175            let frame = value.sample(now, reduced);
176
177            origin[axis] = frame.value();
178            active |= frame.is_animating();
179        }
180
181        let mut pose = [0.; 4];
182
183        if let Some(progress) = progress.filter(|_| !self.exiting && !reduced) {
184            let elapsed = options.entrance_duration(1).mul_f64(progress);
185
186            for (index, ((output, initial), target)) in pose
187                .iter_mut()
188                .zip(
189                    self.enter
190                        .resolved_values(self.layout.em, self.layout.size[1]),
191                )
192                .zip(TextTarget::default().values())
193                .enumerate()
194            {
195                let mut value = MotionValue::new(initial);
196
197                value.animate(target, options.pose_transition(index), Duration::ZERO);
198                *output = if progress >= 1. {
199                    target
200                } else if progress <= 0. {
201                    initial
202                } else {
203                    value.sample(elapsed, false).value()
204                };
205            }
206        } else {
207            for (output, value) in pose.iter_mut().zip(&mut self.pose) {
208                let frame = value.sample(now, reduced);
209
210                *output = frame.value();
211                active |= frame.is_animating();
212            }
213        }
214
215        let mut clip = [0., 0., 1.];
216
217        if let Some(animation) = &options.animation {
218            if self.declared_clip.as_ref() != Some(animation) {
219                if self.declared_clip.is_some() {
220                    self.clip_resume = Some((now, Duration::ZERO));
221                }
222
223                self.animation.sample(animation, now, reduced);
224                self.declared_clip = Some(animation.clone());
225            }
226
227            if let Some(progress) = progress.filter(|_| !self.exiting)
228                && let Some(duration) = animation.duration()
229            {
230                self.clip_resume = Some((now, duration.mul_f64(progress)));
231            }
232
233            let elapsed = self.clip_resume.map_or_else(
234                || now.saturating_sub(self.born).saturating_sub(self.delay),
235                |(anchor, cursor)| cursor.saturating_add(now.saturating_sub(anchor)),
236            );
237
238            let held = self.clip_resume.is_none() && now < self.born.saturating_add(self.delay);
239
240            self.animation.seek(elapsed, now);
241            let frame = self.animation.sample(animation, now, reduced);
242
243            clip = [
244                frame.target().x_px().into(),
245                frame.target().y_px().into(),
246                frame.target().alpha().into(),
247            ];
248            active |= !self.exiting && progress.is_none() && (held || frame.is_animating());
249        }
250
251        let mut substitution = if self.exiting {
252            self.frame.substitution
253        } else {
254            None
255        };
256
257        if let Some(scramble) = &self.scramble {
258            let elapsed = now.saturating_sub(self.born).saturating_sub(self.delay);
259            let in_scramble = progress.map_or(elapsed < scramble.duration, |value| value < 1.);
260
261            if in_scramble && !self.exiting && !reduced && !scramble.duration.is_zero() {
262                let tick = progress.map_or(elapsed.as_millis() as u64 / 35, |value| {
263                    (value * 30.) as u64
264                });
265
266                let mut bits = scramble.seed
267                    ^ self.id.wrapping_mul(0x9e3779b97f4a7c15)
268                    ^ tick.wrapping_mul(0xbf58476d1ce4e5b9);
269
270                bits = (bits ^ (bits >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
271                bits = (bits ^ (bits >> 27)).wrapping_mul(0x94d049bb133111eb);
272                substitution = Some((bits ^ (bits >> 31)) as usize % scramble.alphabet.len());
273                active |= progress.is_none();
274            }
275        }
276
277        self.last_pose = pose;
278        self.frame = TextPresentation {
279            id: self.id,
280            clip_origin: origin,
281            origin: [
282                origin[0] + pose[0] * self.layout.em + clip[0],
283                origin[1] + pose[1] * self.layout.em + clip[1],
284            ],
285            size: self.layout.size,
286            opacity: (pose[2] * clip[2]).clamp(0., 1.),
287            reveal: pose[3].clamp(0., 1.),
288            substitution,
289            exiting: self.exiting,
290            visible: !self.waiting,
291        };
292        self.finished = self.exiting && !active;
293
294        active
295    }
296}
297
298/// Renderer-independent text lifecycle and playback. Retain it beside prepared
299/// glyph data and reconcile only after content, configuration, or layout changes.
300/// No allocations occur in `sample`; reconciliation allocates membership storage.
301#[derive(Default)]
302pub struct TextPlaybackState {
303    units: Vec<Unit>,
304    content: String,
305    options: TextOptions,
306    next_id: u64,
307    clock: Playback,
308    manual_progress: Option<f64>,
309    last_progress: Option<f64>,
310    initialized: bool,
311    active: bool,
312    count: usize,
313}
314
315impl TextPlaybackState {
316    /// Reconciles a prepared destination and returns identities in layout order.
317    /// Equal content can reclaim an outgoing unit before its exit completes.
318    pub fn reconcile(
319        &mut self,
320        text: &str,
321        layout: &[TextLayoutUnit],
322        options: &TextOptions,
323        now: Duration,
324        reduced: bool,
325    ) -> Vec<u64> {
326        self.sample(now, reduced, self.last_progress);
327        let time = self.clock.time(now);
328
329        let transition_changed = self.options.transition != options.transition
330            || self.options.opacity_transition != options.opacity_transition;
331
332        if self.last_progress.is_some() {
333            for unit in &mut self.units {
334                unit.resume_pose(time, &self.options);
335            }
336        }
337
338        let changed = self.content != text;
339        let first = !self.initialized;
340
341        self.initialized = true;
342        let mut candidates: HashMap<(Arc<str>, Option<usize>), VecDeque<usize>> = HashMap::new();
343
344        for (index, unit) in self.units.iter().enumerate() {
345            if !unit.waiting && (!changed || options.matching != TextMatch::Replace) {
346                let slot = if !changed || options.matching == TextMatch::Position {
347                    Some(unit.layout.slot)
348                } else {
349                    None
350                };
351
352                candidates
353                    .entry((unit.layout.text.clone(), slot))
354                    .or_default()
355                    .push_back(index);
356            }
357        }
358
359        let mut used = vec![false; self.units.len()];
360        let mut assigned = Vec::with_capacity(layout.len());
361
362        for unit_layout in layout {
363            let slot = if !changed || options.matching == TextMatch::Position {
364                Some(unit_layout.slot)
365            } else {
366                None
367            };
368
369            let existing = candidates
370                .get_mut(&(unit_layout.text.clone(), slot))
371                .and_then(VecDeque::pop_front);
372
373            assigned.push(existing);
374
375            if let Some(index) = existing {
376                used[index] = true;
377            }
378        }
379
380        self.options = options.clone();
381
382        if options
383            .animation
384            .as_ref()
385            .is_some_and(|clip| clip.duration().is_none())
386        {
387            self.manual_progress = None;
388            self.last_progress = None;
389        }
390
391        self.count = layout.len();
392
393        for (index, unit) in self.units.iter_mut().enumerate() {
394            if !used[index] {
395                if unit.waiting {
396                    unit.finished = true;
397                    continue;
398                }
399
400                if !unit.exiting {
401                    unit.exiting = true;
402                    unit.target_pose(time, options, Duration::ZERO);
403                }
404            }
405        }
406
407        let waiting = options.presence_mode == PresenceMode::Wait
408            && self.units.iter().any(|unit| unit.exiting && !unit.finished);
409
410        let mut ids = Vec::with_capacity(layout.len());
411
412        for (index, (layout, existing)) in layout.iter().zip(assigned).enumerate() {
413            if let Some(existing) = existing {
414                let unit = &mut self.units[existing];
415                let returning = unit.exiting;
416
417                unit.layout = layout.clone();
418                unit.sequence_index = index;
419                unit.enter = layout.enter.unwrap_or(options.enter);
420                unit.scramble = options.scramble.clone();
421                unit.exiting = false;
422                unit.waiting = false;
423                unit.finished = false;
424
425                for (value, target) in unit.position.iter_mut().zip(layout.origin) {
426                    if let Some(transition) = options.reflow {
427                        value.animate(target, transition, time);
428                    } else {
429                        value.jump(target, time);
430                    }
431                }
432
433                if returning || transition_changed {
434                    unit.target_pose(time, options, Duration::ZERO);
435                }
436
437                ids.push(unit.id);
438            } else {
439                let id = self.next_id;
440
441                self.next_id = self
442                    .next_id
443                    .checked_add(1)
444                    .expect("text identity exhausted");
445                let mut unit = Unit {
446                    layout: layout.clone(),
447                    id,
448                    position: layout.origin.map(MotionValue::new),
449                    pose: TextTarget::default().values().map(MotionValue::new),
450                    last_pose: TextTarget::default().values(),
451                    enter: layout.enter.unwrap_or(options.enter),
452                    delay: Duration::ZERO,
453                    sequence_index: index,
454                    born: time,
455                    exiting: false,
456                    waiting,
457                    finished: false,
458                    animation: AnimationState::default(),
459                    declared_clip: None,
460                    clip_resume: None,
461                    frame: TextPresentation {
462                        id,
463                        origin: layout.origin,
464                        clip_origin: layout.origin,
465                        size: layout.size,
466                        opacity: 0.,
467                        reveal: 1.,
468                        substitution: None,
469                        exiting: false,
470                        visible: false,
471                    },
472                    scramble: options.scramble.clone(),
473                };
474
475                unit.reset_pose(time, options, self.count, !first || options.animate_initial);
476                self.units.push(unit);
477                ids.push(id);
478            }
479        }
480
481        self.content.clear();
482        self.content.push_str(text);
483        self.sample(now, reduced, self.last_progress);
484
485        ids
486    }
487
488    /// Samples the presentation with an optional normalized finite entrance cursor.
489    /// Scrubbing holds entrance playback; layout reflow still follows its own target.
490    /// Panics for non-finite progress or values outside zero through one.
491    pub fn sample(&mut self, now: Duration, reduced: bool, progress: Option<f64>) -> bool {
492        let progress = progress.or(self.manual_progress);
493
494        if let Some(progress) = progress {
495            assert!((0. ..=1.).contains(&progress));
496        }
497
498        if progress.is_some() {
499            assert!(
500                self.options
501                    .animation
502                    .as_ref()
503                    .is_none_or(|animation| animation.duration().is_some()),
504                "cannot scrub an infinite text clip"
505            );
506        }
507
508        let time = self.clock.time(now);
509
510        if progress.is_none() && self.last_progress.is_some() {
511            for unit in &mut self.units {
512                unit.resume_pose(time, &self.options);
513            }
514        }
515
516        self.last_progress = progress;
517        self.active = false;
518        let total = self.options.entrance_duration(self.count).as_secs_f64();
519
520        for unit in &mut self.units {
521            let unit_progress =
522                progress.map(|progress| unit.entrance_progress(progress, total, &self.options));
523
524            self.active |= unit.sample(time, &self.options, reduced, unit_progress);
525        }
526
527        self.units.retain(|unit| !unit.finished);
528
529        if !self.units.iter().any(|unit| unit.exiting) {
530            for unit in &mut self.units {
531                if unit.waiting {
532                    unit.waiting = false;
533                    unit.reset_pose(time, &self.options, self.count, !reduced);
534                    let unit_progress = progress
535                        .map(|progress| unit.entrance_progress(progress, total, &self.options));
536
537                    self.active |= unit.sample(time, &self.options, reduced, unit_progress);
538                }
539            }
540        }
541
542        self.active &= self.clock.is_playing() && !reduced;
543
544        self.active
545    }
546
547    /// Moves the coordinate reference without changing velocity or playback time.
548    /// Useful when an unchanged text element follows its parent layout. Panics for
549    /// non-finite offsets. Content-induced reflow should use `reconcile` instead.
550    pub fn translate(&mut self, offset: [f64; 2]) {
551        assert!(offset.iter().all(|value| value.is_finite()));
552
553        for unit in &mut self.units {
554            for (axis, delta) in offset.into_iter().enumerate() {
555                unit.position[axis].translate(delta);
556                unit.layout.origin[axis] += delta;
557                unit.frame.origin[axis] += delta;
558                unit.frame.clip_origin[axis] += delta;
559            }
560        }
561    }
562
563    /// Current prepared presentation, without allocation or advancing time.
564    pub fn presentation(&self) -> impl Iterator<Item = &TextPresentation> {
565        self.units.iter().map(|unit| &unit.frame)
566    }
567
568    /// Semantic destination; outgoing visual content is never included.
569    pub fn text(&self) -> &str {
570        &self.content
571    }
572
573    /// Whether the previous sample requires another frame.
574    pub fn is_animating(&self) -> bool {
575        self.active
576    }
577
578    /// Freezes the animation clock at its current presentation.
579    pub fn pause(&mut self, now: Duration) {
580        self.sample(now, false, self.last_progress);
581        self.clock.pause(now);
582        self.active = false;
583    }
584
585    /// Resumes after a pause or scrub, retargeting from the held pose after a scrub.
586    pub fn play(&mut self, now: Duration) {
587        let time = self.clock.time(now);
588
589        if self.manual_progress.take().is_some() || self.last_progress.is_some() {
590            for unit in &mut self.units {
591                unit.resume_pose(time, &self.options);
592            }
593        }
594
595        self.last_progress = None;
596
597        self.clock.play(now);
598    }
599
600    /// Holds a normalized entrance cursor. Infinite clips reject normalized seeking.
601    /// Returns false for an infinite clip; otherwise panics for invalid progress.
602    pub fn seek(&mut self, progress: f64, now: Duration) -> bool {
603        assert!((0. ..=1.).contains(&progress));
604
605        if self
606            .options
607            .animation
608            .as_ref()
609            .is_some_and(|animation| animation.duration().is_none())
610        {
611            return false;
612        }
613
614        self.manual_progress = Some(progress);
615        self.clock.pause(now);
616        self.sample(now, false, None);
617
618        true
619    }
620
621    /// Replays the current incoming units, preserving their stable identities.
622    pub fn restart(&mut self, now: Duration) {
623        self.manual_progress = None;
624        self.last_progress = None;
625        self.clock.play(now);
626        let time = self.clock.time(now);
627
628        for unit in &mut self.units {
629            if !unit.exiting {
630                unit.reset_pose(time, &self.options, self.count, true);
631            }
632        }
633    }
634}