liora-components 0.4.0

Enterprise-style native GPUI component library for Liora applications.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
//! Motion module.
//!
//! This public module implements the Liora motion presets and animation helpers used across interactive components. It keeps the reusable
//! component logic inside `liora-components` rather than host applications so
//! downstream GPUI applications can compose the same behavior with their own
//! app state, assets, and release policy.
//!
//! ## Usage model
//!
//! Components in this module render native GPUI element trees. Stateless builder
//! values can be constructed inline, while controls with focus, selection,
//! popup, drag, or editing state should be stored as `gpui::Entity<T>` fields in
//! the parent view so state survives GPUI render passes.
//!
//! ## Design contract
//!
//! The implementation should use Liora theme tokens from `liora-core` and
//! `liora-theme`, keep accessibility-oriented keyboard/pointer behavior close to
//! the component, and avoid app-specific host-application resources in this SDK
//! crate.

use gpui::{
    Animation, AnimationElement, AnimationExt, ElementId, IntoElement, SpringAnimation,
    SpringConfig, SpringTarget, Styled, radians,
};
use liora_icons::Icon;
use std::{f32::consts::TAU, time::Duration};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Options that control motion duration behavior.
pub enum MotionDuration {
    /// Uses the `Fast` option for `MotionDuration`.
    Fast,
    /// Uses the `Normal` option for `MotionDuration`.
    Normal,
    /// Uses the `Slow` option for `MotionDuration`.
    Slow,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Options that control motion easing behavior.
pub enum MotionEasing {
    /// Uses the `Linear` option for `MotionEasing`.
    Linear,
    /// Uses the `EaseInOut` option for `MotionEasing`.
    EaseInOut,
    /// Uses the `EaseOut` option for `MotionEasing`.
    EaseOut,
    /// Uses the `Elastic` option for `MotionEasing`.
    Elastic,
}

/// Physical spring presets for interruptible native GPUI motion.
///
/// Unlike duration-based easing, GPUI retains position and velocity when the
/// target changes under the same element ID. Existing motion helpers keep their
/// duration-based behavior; springs are opt-in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionSpring {
    /// A critically damped spring for smooth motion without overshoot.
    Gentle,
    /// A faster critically damped spring for compact interactive controls.
    Snappy,
    /// An underdamped spring with a visible overshoot before settling.
    Bouncy,
}

impl MotionSpring {
    /// Returns the finite, positive physical parameters for this preset.
    pub fn config(self) -> SpringConfig {
        match self {
            Self::Gentle => SpringConfig::new(100.0, 20.0, 1.0),
            Self::Snappy => SpringConfig::new(400.0, 40.0, 1.0),
            Self::Bouncy => SpringConfig::new(200.0, 15.0, 1.0),
        }
    }

    /// Creates a stateful spring targeting a scalar, pixels, or another GPUI target.
    ///
    /// ```
    /// use gpui::{AnimationExt, px};
    /// use liora_components::{MotionSpring, Space, Text};
    ///
    /// let animated_row = Space::new()
    ///     .child(Text::new("Status"))
    ///     .with_spring(
    ///         "status-gap",
    ///         MotionSpring::Snappy.animation_to(px(24.0)).from(px(4.0)),
    ///         |row, gap| row.gap(gap),
    ///     );
    /// ```
    ///
    /// Apply it with `AnimationExt::with_spring` and reuse a stable element ID
    /// across renders. A newly mounted spring starts at the target; use `.from`
    /// for an entrance animation. Running springs respect `App::set_reduce_motion`.
    /// Avoid spring overshoot for values constrained to a range, such as opacity.
    pub fn animation_to<T: SpringTarget>(self, target: T) -> SpringAnimation<T> {
        SpringAnimation::new(self.config()).to(target)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Options that control motion preset behavior.
pub enum MotionPreset {
    /// Uses the `FadeIn` option for `MotionPreset`.
    FadeIn,
    /// Uses the `FadeOut` option for `MotionPreset`.
    FadeOut,
    /// Uses the `PopIn` option for `MotionPreset`.
    PopIn,
    /// Uses the `Pulse` option for `MotionPreset`.
    Pulse,
    /// Uses the `Spin` option for `MotionPreset`.
    Spin,
    /// Uses the `ElasticSlide` option for `MotionPreset`.
    ElasticSlide,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Options that control motion curve behavior.
pub enum MotionCurve {
    /// Uses the `Linear` option for `MotionCurve`.
    Linear,
    /// Uses the `EaseInOut` option for `MotionCurve`.
    EaseInOut,
    /// Uses the `EaseOut` option for `MotionCurve`.
    EaseOut,
    /// Uses the `ElasticSnap` option for `MotionCurve`.
    ElasticSnap,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Options that control fade direction behavior.
pub enum FadeDirection {
    /// Uses the in direction.
    In,
    /// Uses the out direction.
    Out,
}

#[derive(Debug, Clone, Copy, PartialEq)]
/// Fluent native GPUI component for rendering Liora interpolator.
pub struct Interpolator {
    from: f32,
    to: f32,
}

impl MotionDuration {
    /// Borrows this value as duration.
    pub fn as_duration(self) -> Duration {
        match self {
            Self::Fast => Duration::from_millis(220),
            Self::Normal => Duration::from_millis(320),
            Self::Slow => Duration::from_millis(900),
        }
    }
}

impl Interpolator {
    /// Creates `Interpolator` initialized from the supplied from, and to.
    pub fn new(from: f32, to: f32) -> Self {
        Self { from, to }
    }

    /// Performs the from operation used by this component.
    pub fn from(&self) -> f32 {
        self.from
    }

    /// Performs the to operation used by this component.
    pub fn to(&self) -> f32 {
        self.to
    }

    /// Performs the sample operation used by this component.
    pub fn sample(&self, delta: f32) -> f32 {
        self.sample_with(delta, MotionCurve::Linear)
    }

    /// Performs the sample with operation used by this component.
    pub fn sample_with(&self, delta: f32, curve: MotionCurve) -> f32 {
        self.from + (self.to - self.from) * curve_progress(delta, curve)
    }

    /// Performs the map operation used by this component.
    pub fn map(&self, delta: f32, mapper: impl FnOnce(f32) -> f32) -> f32 {
        self.from + (self.to - self.from) * mapper(delta.clamp(0.0, 1.0))
    }
}

/// Performs the motion animation operation used by this component.
pub fn motion_animation(duration: MotionDuration, easing: MotionEasing) -> Animation {
    Animation::new(duration.as_duration()).with_easing(move |delta| ease(delta, easing))
}

/// Performs the repeating motion animation operation used by this component.
pub fn repeating_motion_animation(duration: MotionDuration, easing: MotionEasing) -> Animation {
    motion_animation(duration, easing).repeat()
}

/// Performs the fade operation used by this component.
pub fn fade<E>(
    id: impl Into<ElementId>,
    direction: FadeDirection,
    element: E,
) -> AnimationElement<E>
where
    E: Styled + IntoElement + 'static,
{
    element.with_animation(
        ElementId::from(id.into()),
        motion_animation(MotionDuration::Fast, MotionEasing::EaseOut),
        move |element, delta| {
            let opacity = match direction {
                FadeDirection::In => delta,
                FadeDirection::Out => 1.0 - delta,
            };
            element.opacity(opacity)
        },
    )
}

/// Performs the fade in operation used by this component.
pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
    E: Styled + IntoElement + 'static,
{
    fade(id, FadeDirection::In, element)
}

/// Performs the fade out operation used by this component.
pub fn fade_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
    E: Styled + IntoElement + 'static,
{
    fade(id, FadeDirection::Out, element)
}

/// Performs the pop in operation used by this component.
pub fn pop_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
    E: Styled + IntoElement + 'static,
{
    element.with_animation(
        ElementId::from(id.into()),
        motion_animation(MotionDuration::Normal, MotionEasing::EaseOut),
        |element, delta| element.opacity(0.86 + delta * 0.14),
    )
}

/// Performs the pulse operation used by this component.
pub fn pulse<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
    E: Styled + IntoElement + 'static,
{
    element.with_animation(
        ElementId::from(id.into()),
        repeating_motion_animation(MotionDuration::Slow, MotionEasing::EaseInOut),
        |element, delta| element.opacity(0.62 + pulse_alpha(delta) * 0.38),
    )
}

/// Performs the spin icon operation used by this component.
pub fn spin_icon(id: impl Into<ElementId>, icon: Icon) -> AnimationElement<Icon> {
    spin_icon_with_duration(id, icon, MotionDuration::Slow.as_duration())
}

/// Performs the spin icon operation with an explicit cycle duration.
pub fn spin_icon_with_duration(
    id: impl Into<ElementId>,
    icon: Icon,
    cycle_duration: Duration,
) -> AnimationElement<Icon> {
    icon.with_animation(
        ElementId::from(id.into()),
        Animation::new(cycle_duration.max(Duration::from_millis(100)))
            .repeat_synced()
            .with_easing(|delta| delta),
        move |icon, delta| {
            let turn = delta.fract();
            icon.rotation(radians(turn * TAU))
        },
    )
}

/// Performs the elastic slide operation used by this component.
pub fn elastic_slide(delta: f32) -> f32 {
    let t = delta.clamp(0.0, 1.0);
    let c1 = 1.35;
    let c3 = c1 + 1.0;
    1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
}

/// Performs the elastic snap operation used by this component.
pub fn elastic_snap(delta: f32) -> f32 {
    let eased = gpui::ease_in_out(delta.clamp(0.0, 1.0));
    let snap_start = 0.62;

    if eased <= snap_start {
        eased
    } else {
        let local = (eased - snap_start) / (1.0 - snap_start);
        snap_start + (1.0 - snap_start) * elastic_slide(local)
    }
}

/// Performs the slide snap operation used by this component.
pub fn slide_snap(from: f32, to: f32, delta: f32) -> f32 {
    Interpolator::new(from, to).sample_with(delta, MotionCurve::ElasticSnap)
}

fn curve_progress(delta: f32, curve: MotionCurve) -> f32 {
    match curve {
        MotionCurve::Linear => gpui::linear(delta.clamp(0.0, 1.0)),
        MotionCurve::EaseInOut => gpui::ease_in_out(delta.clamp(0.0, 1.0)),
        MotionCurve::EaseOut => gpui::ease_out_quint()(delta.clamp(0.0, 1.0)),
        MotionCurve::ElasticSnap => elastic_snap(delta),
    }
}

fn ease(delta: f32, easing: MotionEasing) -> f32 {
    match easing {
        MotionEasing::Linear => gpui::linear(delta),
        MotionEasing::EaseInOut => gpui::ease_in_out(delta),
        MotionEasing::EaseOut => gpui::ease_out_quint()(delta),
        MotionEasing::Elastic => elastic_slide(delta).clamp(0.0, 1.0),
    }
}

fn pulse_alpha(delta: f32) -> f32 {
    gpui::pulsating_between(0.0, 1.0)(delta)
}

#[cfg(test)]
mod tests {
    use super::*;
    use gpui::SpringState;

    #[test]
    fn spring_presets_are_valid_and_settle_in_finite_time() {
        for preset in [
            MotionSpring::Gentle,
            MotionSpring::Snappy,
            MotionSpring::Bouncy,
        ] {
            let config = preset.config();
            let initial = SpringState::default();
            let duration = config.settle_time(initial, 1.0, 0.001);
            assert!(duration > Duration::ZERO && duration < Duration::from_secs(3));
            let settled = config.step(initial, 1.0, duration.as_secs_f32());
            assert!(config.is_settled(settled, 1.0, 0.001));
            let _animation = preset.animation_to(gpui::px(24.0)).from(gpui::px(0.0));
        }
    }

    #[test]
    fn spring_step_is_independent_of_frame_partitioning() {
        for preset in [
            MotionSpring::Gentle,
            MotionSpring::Snappy,
            MotionSpring::Bouncy,
        ] {
            let config = preset.config();
            let initial = SpringState {
                position: 0.0,
                velocity: 2.0,
            };
            let whole = config.step(initial, 1.0, 0.2);
            let split = config.step(config.step(initial, 1.0, 0.1), 1.0, 0.1);
            assert!((whole.position - split.position).abs() < 0.0001);
            assert!((whole.velocity - split.velocity).abs() < 0.0001);
        }
    }

    #[test]
    fn spring_presets_have_intentional_damping_and_retarget_continuity() {
        assert_eq!(MotionSpring::Gentle.config().canonical().1, 1.0);
        assert_eq!(MotionSpring::Snappy.config().canonical().1, 1.0);
        let config = MotionSpring::Bouncy.config();
        assert!(config.canonical().1 < 1.0);
        assert!(config.step(SpringState::default(), 1.0, 0.25).position > 1.0);
        let moving = config.step(SpringState::default(), 1.0, 0.1);
        let retargeted = config.step(moving, -1.0, 0.0);
        assert!((moving.position - retargeted.position).abs() < 0.0001);
        assert!((moving.velocity - retargeted.velocity).abs() < 0.0001);
    }

    #[test]
    fn motion_duration_tokens_track_liora_defaults() {
        assert_eq!(
            MotionDuration::Fast.as_duration(),
            Duration::from_millis(220)
        );
        assert_eq!(
            MotionDuration::Normal.as_duration(),
            Duration::from_millis(320)
        );
        assert_eq!(
            MotionDuration::Slow.as_duration(),
            Duration::from_millis(900)
        );
    }

    #[test]
    fn elastic_slide_overshoots_then_settles() {
        assert!(elastic_slide(0.0).abs() < 0.000_01);
        assert_eq!(elastic_slide(1.0), 1.0);
        assert!(elastic_slide(0.7) > 1.0);
    }

    #[test]
    fn interpolator_samples_common_curves() {
        let interpolator = Interpolator::new(10.0, 20.0);

        assert_eq!(interpolator.from(), 10.0);
        assert_eq!(interpolator.to(), 20.0);
        assert_eq!(interpolator.sample(0.5), 15.0);
        assert!(interpolator.sample_with(0.25, MotionCurve::EaseInOut) < 12.5);
        assert_eq!(interpolator.map(0.5, |delta| delta * delta), 12.5);
    }

    #[test]
    fn elastic_snap_accelerates_decelerates_and_snaps() {
        assert!(elastic_snap(0.25) < 0.25);
        assert!((elastic_snap(1.0) - 1.0).abs() < 0.000_01);
        assert!(elastic_snap(0.75) > 1.0);
    }

    #[test]
    fn slide_snap_overshoots_toward_target() {
        assert!(slide_snap(3.0, 21.0, 0.25) < 3.0 + (21.0 - 3.0) * 0.25);
        assert!(slide_snap(3.0, 21.0, 0.75) > 21.0);
        assert!(slide_snap(21.0, 3.0, 0.75) < 3.0);
        assert_eq!(slide_snap(3.0, 21.0, 1.0), 21.0);
    }

    #[test]
    fn elastic_easing_is_bounded_for_gpui_animation() {
        let animation = motion_animation(MotionDuration::Normal, MotionEasing::Elastic);
        let eased = (animation.easing)(0.7);

        assert!((0.0..=1.0).contains(&eased));
    }

    #[test]
    fn spin_icon_uses_the_slow_motion_preset() {
        assert_eq!(
            MotionDuration::Slow.as_duration(),
            Duration::from_millis(900)
        );
    }

    #[test]
    fn spin_icon_with_duration_uses_gpui_synced_repeating_animation() {
        let source = include_str!("motion.rs");
        let body = source
            .split("pub fn spin_icon_with_duration")
            .nth(1)
            .expect("spin_icon_with_duration should exist")
            .split("/// Performs the elastic slide")
            .next()
            .expect("spin_icon_with_duration should end before elastic slide");

        assert!(body.contains(".repeat_synced()"));
        assert!(!body.contains("SMOOTH_SPIN_ANIMATION_SECS"));
        assert!(!body.contains(".repeat()"));
    }

    #[test]
    fn motion_presets_cover_requested_component_behaviors() {
        let presets = [
            MotionPreset::FadeIn,
            MotionPreset::FadeOut,
            MotionPreset::PopIn,
            MotionPreset::Pulse,
            MotionPreset::Spin,
            MotionPreset::ElasticSlide,
        ];

        assert_eq!(presets.len(), 6);
    }
}