Skip to main content

gpui_ce_motion/text/
options.rs

1use crate::{Animation, Easing, PresenceMode, StaggerFrom, Transition};
2use std::{ops::Range, sync::Arc, time::Duration};
3use unicode_segmentation::UnicodeSegmentation;
4
5/// Grouping for text motion. Native adapters merge boundaries inside shaped clusters.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum TextUnit {
8    /// The complete string moves together.
9    #[default]
10    Whole,
11    /// Unicode word-boundary segments, including punctuation and spacing.
12    Words,
13    /// Extended grapheme clusters, including combining marks and joined emoji.
14    Graphemes,
15    /// Visual wrapped lines, supplied by the native text layout.
16    Lines,
17}
18
19/// Identity policy when the requested text changes.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum TextMatch {
22    /// Replace units when content changes; preserve them on layout-only changes.
23    #[default]
24    Replace,
25    /// Match equal content deterministically in stable occurrence order.
26    Content,
27    /// Match equal content in the same slot. Numbers use slots counted from the right.
28    Position,
29}
30
31/// Stationary clipping around each unit's final layout reservation.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum TextClip {
34    /// Allow offsets and glyph overhangs outside the unit reservation.
35    #[default]
36    None,
37    /// Clip moving ink to the unit's reservation, useful for rolling text.
38    Unit,
39}
40
41/// Convenience effects. A subsequent `enter` or `exit` overrides that target.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub enum TextPreset {
44    /// Fade incoming and outgoing text.
45    #[default]
46    Fade,
47    /// Enter from below and leave above, with an opacity transition.
48    FadeUp,
49    /// Slide through the measured unit height, with masking and a short exit fade.
50    SlideUp,
51    /// Reveal from the left edge using a stationary mask.
52    WipeRight,
53    /// Reveal from the right edge using a stationary mask.
54    WipeLeft,
55    /// Reveal vertically from the top edge.
56    WipeDown,
57    /// Reveal vertically from the bottom edge.
58    WipeUp,
59    /// Reveal complete units at their stagger times, without partial opacity.
60    Typewriter,
61}
62
63/// Text-relative offsets, opacity, and horizontal reveal fraction.
64/// Builders panic on non-finite offsets or fractions outside zero through one.
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct TextTarget {
67    values: [f64; 4],
68    relative_y: bool,
69}
70
71impl Default for TextTarget {
72    fn default() -> Self {
73        Self {
74            values: [0., 0., 1., 1.],
75            relative_y: false,
76        }
77    }
78}
79
80impl TextTarget {
81    /// Horizontal offset in multiples of the resolved font size.
82    pub fn x_em(mut self, value: f32) -> Self {
83        assert!(value.is_finite(), "text offset must be finite");
84        self.values[0] = value.into();
85
86        self
87    }
88
89    /// Vertical offset in multiples of the resolved font size.
90    pub fn y_em(mut self, value: f32) -> Self {
91        assert!(value.is_finite(), "text offset must be finite");
92        self.values[1] = value.into();
93        self.relative_y = false;
94
95        self
96    }
97
98    /// Vertical offset as a fraction of this unit's measured height. One moves the
99    /// complete unit clear of its stationary mask, including line spacing.
100    /// The last `y_em` or `y_relative` declaration wins. Panics for non-finite input.
101    pub fn y_relative(mut self, value: f32) -> Self {
102        assert!(value.is_finite(), "text offset must be finite");
103        self.values[1] = value.into();
104        self.relative_y = true;
105
106        self
107    }
108
109    /// Presentation opacity, clamped at the drawing boundary during spring overshoot.
110    pub fn opacity(mut self, value: f32) -> Self {
111        assert!(
112            (0. ..=1.).contains(&value),
113            "text opacity must be in zero through one"
114        );
115        self.values[2] = value.into();
116
117        self
118    }
119
120    /// Horizontal ink reveal fraction; this always enables a stationary mask.
121    pub fn reveal(mut self, value: f32) -> Self {
122        assert!(
123            (0. ..=1.).contains(&value),
124            "text reveal must be in zero through one"
125        );
126        self.values[3] = value.into();
127
128        self
129    }
130
131    pub(crate) fn values(self) -> [f64; 4] {
132        self.values
133    }
134
135    pub(crate) fn resolved_values(self, em: f64, height: f64) -> [f64; 4] {
136        let mut values = self.values;
137
138        if self.relative_y {
139            values[1] *= height / em;
140        }
141
142        values
143    }
144}
145
146/// Metadata supplied once when preparing each unit's custom entrance.
147pub struct TextUnitInfo<'a> {
148    /// Logical unit index, independent of visual bidi ordering.
149    pub index: usize,
150    /// Number of prepared units.
151    pub count: usize,
152    /// Unit content from the complete shaped string.
153    pub text: &'a str,
154}
155
156/// Deterministic visual substitutions. The logical accessibility text is unchanged.
157#[derive(Clone, Debug, PartialEq)]
158pub struct Scramble {
159    pub(crate) alphabet: Arc<[String]>,
160    pub(crate) duration: Duration,
161    pub(crate) seed: u64,
162}
163
164impl Scramble {
165    /// Prepares a nonempty alphabet of extended graphemes. Panics if empty.
166    pub fn new(alphabet: &str) -> Self {
167        let alphabet: Vec<_> = alphabet.graphemes(true).map(str::to_owned).collect();
168
169        assert!(!alphabet.is_empty(), "scramble alphabet must not be empty");
170
171        Self {
172            alphabet: alphabet.into(),
173            duration: Duration::from_millis(500),
174            seed: 0,
175        }
176    }
177
178    /// Sets the settling duration per unit. Zero disables substitutions.
179    pub fn duration(mut self, duration: Duration) -> Self {
180        self.duration = duration;
181
182        self
183    }
184
185    /// Sets a stable deterministic seed; identical declarations do not restart.
186    pub fn seed(mut self, seed: u64) -> Self {
187        self.seed = seed;
188
189        self
190    }
191
192    /// Prepared replacement graphemes, for native shaping or a custom renderer.
193    pub fn alphabet(&self) -> &[String] {
194        &self.alphabet
195    }
196}
197
198/// Digit transitions. Rolling and sliding retain unchanged digits by place value.
199#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
200pub enum NumberEffect {
201    /// Slide digits through stationary per-place masks.
202    #[default]
203    Roll,
204    /// Fade changed digits in the same places.
205    Crossfade,
206    /// Slide changed digits without a mask.
207    Slide,
208    /// Interpolate an integer count, rounding each displayed sample.
209    Interpolate,
210}
211
212/// Axis of an axis-aligned text reveal mask.
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum TextRevealAxis {
215    /// Reveal across the unit's width.
216    #[default]
217    Horizontal,
218    /// Reveal across its measured line-height reservation.
219    Vertical,
220}
221
222/// Portable text configuration. Native element builders expose the same options.
223#[derive(Clone, Debug, PartialEq)]
224pub struct TextOptions {
225    /// Requested grouping.
226    pub unit: TextUnit,
227    /// Identity policy.
228    pub matching: TextMatch,
229    /// Initial pose for entering units.
230    pub enter: TextTarget,
231    /// Pose for retiring units.
232    pub exit: TextTarget,
233    /// Pose transition.
234    pub transition: Transition,
235    /// Optional independent opacity timing; movement and reveal keep `transition`.
236    pub opacity_transition: Option<Transition>,
237    /// Optional natural-position transition. Sizes take their destination immediately.
238    pub reflow: Option<Transition>,
239    /// Per-unit delay.
240    pub stagger: Duration,
241    /// Delay origin.
242    pub stagger_from: StaggerFrom,
243    /// Incoming/outgoing coordination.
244    pub presence_mode: PresenceMode,
245    /// Per-unit clipping.
246    pub clip: TextClip,
247    /// Reverses the reveal to anchor at the trailing edge of the selected axis.
248    pub wipe_left: bool,
249    /// Axis used by reveals; `wipe_left` chooses the trailing edge on either axis.
250    pub reveal_axis: TextRevealAxis,
251    /// Whether the first mounted text enters. Later changes always follow the effect.
252    pub animate_initial: bool,
253    /// Prepared optional substitutions.
254    pub scramble: Option<Scramble>,
255    /// Optional retained clip; positional channels are logical pixels.
256    pub animation: Option<Animation>,
257}
258
259impl Default for TextOptions {
260    fn default() -> Self {
261        Self {
262            unit: TextUnit::Whole,
263            matching: TextMatch::Replace,
264            enter: TextTarget::default().opacity(0.),
265            exit: TextTarget::default().opacity(0.),
266            transition: Transition::default(),
267            opacity_transition: None,
268            reflow: None,
269            stagger: Duration::ZERO,
270            stagger_from: StaggerFrom::Start,
271            presence_mode: PresenceMode::Sync,
272            clip: TextClip::None,
273            wipe_left: false,
274            reveal_axis: TextRevealAxis::Horizontal,
275            animate_initial: true,
276            scramble: None,
277            animation: None,
278        }
279    }
280}
281
282impl TextOptions {
283    /// Applies a preset, replacing entrance/exit poses and its required masking/timing.
284    pub fn preset(&mut self, preset: TextPreset) {
285        self.enter = TextTarget::default();
286        self.exit = TextTarget::default();
287        self.clip = TextClip::None;
288        self.wipe_left = false;
289        self.reveal_axis = TextRevealAxis::Horizontal;
290        self.opacity_transition = None;
291
292        match preset {
293            TextPreset::Fade => {
294                self.enter = self.enter.opacity(0.);
295                self.exit = self.exit.opacity(0.);
296            }
297            TextPreset::FadeUp => {
298                self.enter = self.enter.y_em(0.6).opacity(0.);
299                self.exit = self.exit.y_em(-0.6).opacity(0.);
300            }
301            TextPreset::SlideUp => {
302                self.enter = self.enter.y_relative(1.);
303                self.exit = self.exit.y_relative(-1.).opacity(0.);
304                self.clip = TextClip::Unit;
305                self.opacity_transition = Some(Transition::Tween(
306                    Duration::from_millis(100),
307                    Easing::EaseOut,
308                ));
309            }
310            TextPreset::WipeRight
311            | TextPreset::WipeLeft
312            | TextPreset::WipeDown
313            | TextPreset::WipeUp => {
314                self.enter = self.enter.reveal(0.);
315                self.exit = self.exit.reveal(0.).opacity(0.);
316                self.opacity_transition = Some(Transition::Tween(
317                    Duration::from_millis(100),
318                    Easing::EaseOut,
319                ));
320                self.clip = TextClip::Unit;
321                self.wipe_left = matches!(preset, TextPreset::WipeLeft | TextPreset::WipeUp);
322
323                if matches!(preset, TextPreset::WipeDown | TextPreset::WipeUp) {
324                    self.reveal_axis = TextRevealAxis::Vertical;
325                }
326            }
327            TextPreset::Typewriter => {
328                self.enter = self.enter.opacity(0.);
329                self.exit = self.exit.opacity(0.);
330                self.transition = Transition::Instant;
331            }
332        }
333    }
334
335    pub(crate) fn pose_transition(&self, index: usize) -> Transition {
336        if index == 2 {
337            self.opacity_transition.unwrap_or(self.transition)
338        } else {
339            self.transition
340        }
341    }
342
343    /// Finite entrance duration used for normalized scrubbing. Springs use a two-second
344    /// sampling horizon; infinite clips cannot be scrubbed by normalized progress.
345    pub fn entrance_duration(&self, count: usize) -> Duration {
346        let base = self
347            .animation
348            .as_ref()
349            .and_then(Animation::duration)
350            .unwrap_or_else(|| transition_horizon(self.transition))
351            .max(
352                self.opacity_transition
353                    .map_or(Duration::ZERO, transition_horizon),
354            )
355            .max(
356                self.scramble
357                    .as_ref()
358                    .map_or(Duration::ZERO, |value| value.duration),
359            );
360
361        base.saturating_add(
362            self.stagger
363                .saturating_mul(count.saturating_sub(1).min(u32::MAX as usize) as u32),
364        )
365    }
366}
367
368fn transition_horizon(transition: Transition) -> Duration {
369    match transition {
370        Transition::Instant => Duration::ZERO,
371        Transition::Tween(duration, _) => duration,
372        Transition::Spring(_) => Duration::from_secs(2),
373    }
374}
375
376/// Unicode segmentation for a custom renderer. `Lines` here means explicit newline
377/// lines; native adapters replace these with measured visual wrapped lines.
378pub fn text_ranges(text: &str, unit: TextUnit) -> Vec<Range<usize>> {
379    match unit {
380        TextUnit::Whole => {
381            if text.is_empty() {
382                Vec::new()
383            } else {
384                std::iter::once(0..text.len()).collect()
385            }
386        }
387        TextUnit::Words => text
388            .split_word_bound_indices()
389            .map(|(index, value)| index..index + value.len())
390            .collect(),
391        TextUnit::Graphemes => text
392            .grapheme_indices(true)
393            .map(|(index, value)| index..index + value.len())
394            .collect(),
395        TextUnit::Lines => {
396            let mut start = 0;
397
398            text.split_inclusive('\n')
399                .map(|value| {
400                    let range = start..start + value.trim_end_matches('\n').len();
401
402                    start += value.len();
403
404                    range
405                })
406                .collect()
407        }
408    }
409}
410
411/// Native text failures, independent of the chosen GPUI backend.
412#[derive(Clone, Debug, PartialEq, Eq)]
413pub enum TextError {
414    /// Font shaping or text measurement failed.
415    Shaping(Arc<str>),
416    /// A prepared glyph could not be painted by the backend.
417    Painting(Arc<str>),
418}
419
420impl std::fmt::Display for TextError {
421    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        match self {
423            Self::Shaping(message) => write!(formatter, "text shaping failed: {message}"),
424            Self::Painting(message) => write!(formatter, "text painting failed: {message}"),
425        }
426    }
427}
428
429impl std::error::Error for TextError {}
430
431impl crate::MotionData<4> for TextTarget {
432    fn channels(self) -> [f64; 4] {
433        self.values
434    }
435
436    fn with_channels(self, values: [f64; 4]) -> Self {
437        Self {
438            values,
439            relative_y: self.relative_y,
440        }
441    }
442
443    fn compatible(self, other: Self) -> bool {
444        self.relative_y == other.relative_y
445    }
446}