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