Skip to main content

ravenrs_widgets/
motion.rs

1//! Implicit animation: a view declares how a node moves, and the runtime plays
2//! it during paint.
3//!
4//! Tracks live here, outside the view tree, keyed by identity. A build only sets
5//! targets. Frames in between run the paint phase alone, so an animation does
6//! not rebuild views or lay them out again.
7
8use std::collections::HashMap;
9use std::ops::Range;
10
11use ravenrs_animation::{Animated, Transition};
12use ravenrs_core::{Border, Brush, Color, Effect, Point, Primitive, Rect, Shadow};
13use ravenrs_layout::{Axis, LayoutStyle};
14
15use crate::context::{BuildCx, NodeId, Paint, QuadPaint, WidgetId};
16use crate::{AnyView, View};
17
18/// How a node animates. Attach it with [`BuildCx::motion`].
19///
20/// ```
21/// use ravenrs_animation::Transition;
22/// use ravenrs_core::Effect;
23/// use ravenrs_widgets::Motion;
24///
25/// // Colors ease on hover; the node pops in and fades out.
26/// let motion = Motion::new()
27///     .paint(Transition::fast())
28///     .enter(Effect::hidden().with_scale(0.96), Transition::snappy())
29///     .exit(Effect::hidden(), Transition::fast());
30/// ```
31#[derive(Clone, Copy, Debug)]
32#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
33pub struct Motion {
34    pub(crate) paint: Option<Transition>,
35    pub(crate) layout: Option<Transition>,
36    pub(crate) shape_only: bool,
37    pub(crate) underlay: bool,
38    pub(crate) shared: Option<(WidgetId, u64)>,
39    pub(crate) surface_exit: bool,
40    pub(crate) effect: (Effect, Transition),
41    pub(crate) enter: Option<(Effect, Transition)>,
42    pub(crate) exit: Option<(Effect, Transition)>,
43    pub(crate) key: Option<u64>,
44    pub(crate) anchored: bool,
45}
46
47impl Default for Motion {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53#[cfg_attr(feature = "live", ravenrs_live::expose)]
54impl Motion {
55    /// Nothing animates yet.
56    pub const fn new() -> Self {
57        Self {
58            paint: None,
59            layout: None,
60            shape_only: false,
61            underlay: false,
62            shared: None,
63            surface_exit: false,
64            effect: (Effect::IDENTITY, Transition::Instant),
65            enter: None,
66            exit: None,
67            key: None,
68            anchored: false,
69        }
70    }
71
72    /// Eases this node's own fill, border, shadow, corner radius and text color
73    /// whenever a build changes them, like CSS `transition`. Colors travel
74    /// through OKLab. Gradients and `Paint::Custom` change at once.
75    pub const fn paint(mut self, transition: Transition) -> Self {
76        self.paint = Some(transition);
77        self
78    }
79
80    /// Glides the node from its old frame to its new one when layout moves it.
81    /// Its children travel with it. Input keeps using the final frame.
82    pub const fn layout(mut self, transition: Transition) -> Self {
83        self.layout = Some(transition);
84        self
85    }
86
87    /// Makes this node the same visual object as the node of a previous build
88    /// with the same `scope` and `tag`, wherever it is now: a selection marker
89    /// that slides from the old tab to the new one. `scope` is usually the
90    /// widget id of the common parent.
91    pub const fn shared_layout(mut self, scope: WidgetId, tag: u64, transition: Transition) -> Self {
92        self.shared = Some((scope, tag));
93        self.layout = Some(transition);
94        self
95    }
96
97    /// The exit fades this node as one surface: its content goes first, over the still
98    /// solid panel, and then the panel. Opacity applies to each shape on its own, so
99    /// fading all at once would show the panel through its fields, in a gray like metal.
100    /// The panel must be the first thing the node paints.
101    pub const fn surface_exit(mut self) -> Self {
102        self.surface_exit = true;
103        self
104    }
105
106    /// With [`Self::layout`], moves only the node's own shape. Its children stay
107    /// where layout put them.
108    pub const fn shape_only(mut self) -> Self {
109        self.shape_only = true;
110        self
111    }
112
113    /// Paints the node's own shape behind all its siblings, so a moving marker
114    /// slides under the labels it crosses. The node must be a direct child of
115    /// the container holding those siblings.
116    pub const fn underlay(mut self) -> Self {
117        self.underlay = true;
118        self
119    }
120
121    /// Eases the node and its subtree to this effect, such as a slight shrink
122    /// while pressed. Layout and hit testing are unaffected.
123    pub const fn effect(mut self, effect: Effect, transition: Transition) -> Self {
124        self.effect = (effect, transition);
125        self
126    }
127
128    /// When the node first appears, it starts from this effect.
129    pub const fn enter(mut self, from: Effect, transition: Transition) -> Self {
130        self.enter = Some((from, transition));
131        self
132    }
133
134    /// When the node stops being built, its last picture stays on screen and
135    /// plays this effect. The picture takes no input and is hidden from
136    /// assistive technology: for the app, the node is already gone. Reopening
137    /// during the exit reverses it from where it is.
138    pub const fn exit(mut self, to: Effect, transition: Transition) -> Self {
139        self.exit = Some((to, transition));
140        self
141    }
142
143    /// The node's identity among the motions of its view. Defaults to call
144    /// order, so use it for nodes built in a loop whose items come and go.
145    pub const fn key(mut self, key: u64) -> Self {
146        self.key = Some(key);
147        self
148    }
149
150    /// For the root of an overlay: scales around the point nearest its anchor,
151    /// and a vertical offset flips when the overlay opened on the other side.
152    pub const fn anchored(mut self) -> Self {
153        self.anchored = true;
154        self
155    }
156}
157
158/// The identity of an animated visual object.
159#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
160pub(crate) enum MotionKey {
161    Node(WidgetId, u64),
162    Shared(WidgetId, u64),
163}
164
165/// One [`BuildCx::motion`] call of the latest build.
166#[derive(Clone, Copy, Debug)]
167pub(crate) struct MotionEntry {
168    pub node: NodeId,
169    pub key: MotionKey,
170    pub motion: Motion,
171}
172
173#[derive(Debug)]
174struct PaintTrack {
175    fill: Animated<Color>,
176    radius: Animated<f32>,
177    border_color: Animated<Color>,
178    border_width: Animated<f32>,
179    shadow_color: Animated<Color>,
180    shadow_shape: Animated<Rect>,
181}
182
183#[cfg_attr(feature = "live", ravenrs_live::expose)]
184impl PaintTrack {
185    fn new(q: &QuadPaint) -> Self {
186        let (border, shadow) = (border_of(q, None), shadow_of(q, None));
187        Self {
188            fill: Animated::new(solid(q.fill)),
189            radius: Animated::new(q.corner_radius),
190            border_color: Animated::new(border.color),
191            border_width: Animated::new(border.width),
192            shadow_color: Animated::new(shadow.color),
193            shadow_shape: Animated::new(shadow_rect(&shadow)),
194        }
195    }
196
197    fn set_target(&mut self, q: &QuadPaint, t: Transition, now: f64) {
198        let was_border = self.border_width.target();
199        let was_shadow = self.shadow_shape.target();
200        let (border, shadow) = (border_of(q, Some(was_border)), shadow_of(q, Some(was_shadow)));
201        self.fill.set_target(solid(q.fill), t, now);
202        self.radius.set_target(q.corner_radius, t, now);
203        self.border_color.set_target(border.color, t, now);
204        self.border_width.set_target(border.width, t, now);
205        self.shadow_color.set_target(shadow.color, t, now);
206        self.shadow_shape.set_target(shadow_rect(&shadow), t, now);
207    }
208
209    fn sample(&self, q: &QuadPaint, now: f64) -> QuadPaint {
210        let fill = match q.fill {
211            Brush::Solid(_) => Brush::Solid(self.fill.value(now)),
212            other => other,
213        };
214        let border_color = self.border_color.value(now);
215        let border = (border_color.a > 0.003)
216            .then(|| Border { width: self.border_width.value(now).max(0.0), color: border_color });
217        let shadow_color = self.shadow_color.value(now);
218        let shape = self.shadow_shape.value(now);
219        let shadow = (shadow_color.a > 0.003).then_some(Shadow {
220            offset: shape.origin,
221            blur: shape.width(),
222            color: shadow_color,
223        });
224        QuadPaint { fill, corner_radius: self.radius.value(now).max(0.0), border, shadow }
225    }
226
227    fn step(&mut self, now: f64) -> bool {
228        // Not short-circuited: every property forgets what it has finished.
229        self.fill.step(now)
230            | self.radius.step(now)
231            | self.border_color.step(now)
232            | self.border_width.step(now)
233            | self.shadow_color.step(now)
234            | self.shadow_shape.step(now)
235    }
236}
237
238fn solid(brush: Brush) -> Color {
239    match brush {
240        Brush::Solid(c) => c,
241        Brush::LinearGradient(g) => g.to,
242        _ => Color::TRANSPARENT,
243    }
244}
245
246/// A missing border fades out where it was instead of shrinking from a default.
247fn border_of(q: &QuadPaint, was_width: Option<f32>) -> Border {
248    q.border.unwrap_or(Border { width: was_width.unwrap_or(1.0), color: solid(q.fill).with_alpha(0.0) })
249}
250
251fn shadow_of(q: &QuadPaint, was: Option<Rect>) -> Shadow {
252    q.shadow.unwrap_or_else(|| {
253        let was = was.unwrap_or(Rect::ZERO);
254        Shadow { offset: was.origin, blur: was.width(), color: Color::TRANSPARENT }
255    })
256}
257
258fn shadow_rect(s: &Shadow) -> Rect {
259    Rect::new(s.offset.x, s.offset.y, s.blur, 0.0)
260}
261
262/// What the node looked like the last time it was painted, kept for its exit.
263#[derive(Debug, Default)]
264struct Snapshot {
265    primitives: Vec<Primitive>,
266    nested: Vec<NestedLayer>,
267    clip: Rect,
268    pivot: Point,
269    flip: bool,
270    layer: GhostLayer,
271    /// When it was painted among the others, so that exits keep their stacking.
272    order: u64,
273    valid: bool,
274}
275
276/// A part of a snapshot with an exit of its own, such as a dialog's card inside
277/// its backdrop.
278#[derive(Clone, Debug)]
279pub(crate) struct NestedLayer {
280    range: Range<usize>,
281    pivot: Point,
282    exit: (Effect, Transition),
283}
284
285#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
286pub(crate) enum GhostLayer {
287    /// Between the main tree and the overlays.
288    #[default]
289    Main,
290    /// Above the overlays.
291    Overlay,
292    /// Above everything: the old picture during a theme change.
293    Top,
294}
295
296#[derive(Debug)]
297struct Track {
298    seen: u64,
299    effect: Animated<Effect>,
300    paint: Option<PaintTrack>,
301    text: Option<Animated<Color>>,
302    rect: Option<Animated<Rect>>,
303    /// Which widget holds a shared object now. It slides only when this changes.
304    owner: Option<WidgetId>,
305    exit: Option<(Effect, Transition)>,
306    surface_exit: bool,
307    snapshot: Snapshot,
308}
309
310#[derive(Debug)]
311struct Ghost {
312    key: Option<MotionKey>,
313    primitives: Vec<Primitive>,
314    clip: Rect,
315    pivot: Point,
316    layer: GhostLayer,
317    effect: Animated<Effect>,
318    nested: Vec<(Range<usize>, Point, Animated<Effect>)>,
319    surface_exit: bool,
320}
321
322/// The values to paint a node with this frame.
323#[derive(Debug)]
324pub(crate) struct Sampled {
325    pub quad: Option<QuadPaint>,
326    pub text: Option<Color>,
327    /// The node's frame on screen, when it differs from layout.
328    pub frame: Option<Rect>,
329    pub effect: Effect,
330}
331
332/// Every running animation of a [`Ui`](crate::Ui).
333#[derive(Debug, Default)]
334pub(crate) struct MotionSystem {
335    tracks: HashMap<MotionKey, Track>,
336    ghosts: Vec<Ghost>,
337    captures: u64,
338    build: u64,
339    /// The latest paint found something still moving.
340    pub active: bool,
341    /// The animation clock, in seconds (see `Ui::set_time`).
342    pub clock: f64,
343    /// A platform drives the clock. Without one nothing could ever finish, so
344    /// everything lands at once; this is also what makes headless tests stable.
345    pub enabled: bool,
346    pub reduced: bool,
347    /// Nested exits met while painting the subtree of an outer exit.
348    capture_depth: u32,
349    nested: Vec<NestedLayer>,
350}
351
352#[cfg_attr(feature = "live", ravenrs_live::expose)]
353impl MotionSystem {
354    /// Applies reduced motion: nothing travels, but things may still fade.
355    fn adapt(&self, effect: Effect, t: Transition) -> (Effect, Transition) {
356        if self.reduced {
357            (Effect { opacity: effect.opacity, ..Effect::IDENTITY }, t.without_bounce())
358        } else {
359            (effect, t)
360        }
361    }
362
363    /// Records the targets of a build. `snap` lands colors at once (the theme
364    /// changed, and the whole picture crossfades instead).
365    pub fn sync(&mut self, entries: &[MotionEntry], paints: impl Fn(NodeId) -> NodePaint, snap: bool) {
366        self.build += 1;
367        let (now, build) = (self.clock, self.build);
368        let mut started = !self.tracks.is_empty() || self.build > 1;
369        started &= self.enabled;
370        for entry in entries {
371            let m = &entry.motion;
372            let (target, target_t) = self.adapt(m.effect.0, m.effect.1);
373            let track = match self.tracks.get_mut(&entry.key) {
374                Some(track) => {
375                    let t = if self.enabled { target_t } else { Transition::Instant };
376                    track.effect.set_target(target, t, now);
377                    track
378                }
379                None => {
380                    let revived = self.ghosts.iter().position(|g| g.key == Some(entry.key));
381                    let (from, t) = match (revived, m.enter) {
382                        (Some(i), _) => (self.ghosts.remove(i).effect.value(now), m.enter.map(|e| e.1)),
383                        (None, Some((from, t))) if started => {
384                            let (from, t) = self.adapt(from, t);
385                            (from, Some(t))
386                        }
387                        _ => (target, None),
388                    };
389                    let mut effect = Animated::new(from);
390                    effect.set_target(target, t.unwrap_or(target_t), now);
391                    self.tracks.entry(entry.key).or_insert(Track {
392                        seen: build,
393                        effect,
394                        paint: None,
395                        text: None,
396                        rect: None,
397                        owner: None,
398                        exit: None,
399                        surface_exit: false,
400                        snapshot: Snapshot::default(),
401                    })
402                }
403            };
404            track.seen = build;
405            track.surface_exit = m.surface_exit;
406            track.exit = m.exit.map(|(e, t)| {
407                if self.reduced {
408                    (Effect { opacity: e.opacity, ..Effect::IDENTITY }, t.without_bounce())
409                } else {
410                    (e, t)
411                }
412            });
413            if let Some(t) = m.paint {
414                let t = if snap || !self.enabled {
415                    Transition::Instant
416                } else if self.reduced {
417                    t.without_bounce()
418                } else {
419                    t
420                };
421                match paints(entry.node) {
422                    NodePaint::Quad(q) => match &mut track.paint {
423                        Some(p) => p.set_target(&q, t, now),
424                        None => track.paint = Some(PaintTrack::new(&q)),
425                    },
426                    NodePaint::Text(c) => match &mut track.text {
427                        Some(a) => a.set_target(c, t, now),
428                        None => track.text = Some(Animated::new(c)),
429                    },
430                    NodePaint::Other => {}
431                }
432            }
433        }
434        // Whatever was not built starts its exit, from the picture it left.
435        let mut gone: Vec<(u64, MotionKey)> = self
436            .tracks
437            .iter()
438            .filter(|(_, t)| t.seen != build)
439            .map(|(k, t)| (t.snapshot.order, *k))
440            .collect();
441        gone.sort_unstable_by_key(|(order, _)| *order);
442        for (_, key) in gone {
443            let Some(track) = self.tracks.remove(&key) else { continue };
444            let Some((to, t)) = track.exit else { continue };
445            if !self.enabled || !track.snapshot.valid {
446                continue;
447            }
448            let from = track.effect.value(now);
449            let snapshot = track.snapshot;
450            let flip = snapshot.flip;
451            let mut effect = Animated::new(from);
452            effect.set_target(flipped(to, flip), t, now);
453            let nested = snapshot.nested.iter().map(|n| {
454                let mut e = Animated::new(Effect::IDENTITY);
455                let (to, t) = self.adapt(n.exit.0, n.exit.1);
456                e.set_target(to, t, now);
457                (n.range.clone(), n.pivot, e)
458            });
459            let nested = nested.collect();
460            self.ghosts.push(Ghost {
461                key: Some(key),
462                primitives: snapshot.primitives,
463                clip: snapshot.clip,
464                pivot: snapshot.pivot,
465                layer: snapshot.layer,
466                effect,
467                nested,
468                surface_exit: track.surface_exit,
469            });
470        }
471    }
472
473    /// Moves layout targets to the node's frame, measured without scrolling so
474    /// that scrolling does not count as moving. `snap` lands them at once.
475    ///
476    /// A shared object slides from one widget to another. While it stays on the same
477    /// one it just follows it, or it would come loose when the layout around it moves.
478    pub fn sync_frame(&mut self, key: MotionKey, owner: WidgetId, frame: Rect, t: Transition, snap: bool) {
479        let t = if snap || !self.enabled || self.reduced { Transition::Instant } else { t };
480        let now = self.clock;
481        if let Some(track) = self.tracks.get_mut(&key) {
482            let shared = matches!(key, MotionKey::Shared(..));
483            let t = if shared && track.owner == Some(owner) { Transition::Instant } else { t };
484            track.owner = Some(owner);
485            match &mut track.rect {
486                Some(rect) => rect.set_target(frame, t, now),
487                None => track.rect = Some(Animated::new(frame)),
488            }
489        }
490    }
491
492    /// The node's values at this frame. `shift` is what scrolling added to its frame.
493    pub fn sample(&mut self, key: MotionKey, paint: &NodePaint, shift: Point) -> Sampled {
494        let now = self.clock;
495        let Some(track) = self.tracks.get_mut(&key) else {
496            return Sampled { quad: None, text: None, frame: None, effect: Effect::IDENTITY };
497        };
498        let mut moving = track.effect.step(now);
499        let quad = match (&mut track.paint, paint) {
500            (Some(p), NodePaint::Quad(q)) => {
501                moving |= p.step(now);
502                Some(p.sample(q, now))
503            }
504            _ => None,
505        };
506        let text = track.text.as_mut().map(|a| {
507            moving |= a.step(now);
508            a.value(now)
509        });
510        let frame = track.rect.as_mut().map(|a| {
511            moving |= a.step(now);
512            a.value(now).translate(shift)
513        });
514        self.active |= moving;
515        Sampled { quad, text, frame, effect: track.effect.value(now) }
516    }
517
518    /// Starts recording a node's picture; returns false when nested in another exit.
519    pub fn begin_capture(&mut self) -> bool {
520        self.capture_depth += 1;
521        self.capture_depth == 1
522    }
523
524    /// Stores the picture of an exit node painted at `range` of `scene`.
525    #[allow(clippy::too_many_arguments)]
526    pub fn end_capture(
527        &mut self,
528        key: MotionKey,
529        primitives: &[Primitive],
530        range: Range<usize>,
531        clip: Rect,
532        pivot: Point,
533        flip: bool,
534        layer: GhostLayer,
535    ) {
536        self.capture_depth = self.capture_depth.saturating_sub(1);
537        let Some(track) = self.tracks.get_mut(&key) else { return };
538        let Some(exit) = track.exit else { return };
539        if self.capture_depth > 0 {
540            self.nested.push(NestedLayer { range, pivot, exit });
541            return;
542        }
543        let s = &mut track.snapshot;
544        s.primitives.clear();
545        s.primitives.extend_from_slice(primitives);
546        s.nested.clear();
547        s.nested.extend(
548            self.nested
549                .drain(..)
550                .map(|n| NestedLayer { range: n.range.start - range.start..n.range.end - range.start, ..n }),
551        );
552        s.clip = clip;
553        s.pivot = pivot;
554        s.flip = flip;
555        s.layer = layer;
556        self.captures += 1;
557        s.order = self.captures;
558        s.valid = true;
559    }
560
561    /// Fades out a whole picture: what the screen showed before a theme change.
562    pub fn crossfade(&mut self, primitives: Vec<Primitive>, viewport: Rect, t: Transition) {
563        let mut effect = Animated::new(Effect::IDENTITY);
564        effect.set_target(Effect::hidden(), t, self.clock);
565        self.ghosts.push(Ghost {
566            key: None,
567            primitives,
568            clip: viewport,
569            pivot: viewport.center(),
570            layer: GhostLayer::Top,
571            effect,
572            nested: Vec::new(),
573            surface_exit: false,
574        });
575    }
576
577    /// Draws the exiting pictures of `layer` and forgets those that finished.
578    pub fn paint_ghosts(&mut self, layer: GhostLayer, scene: &mut ravenrs_core::Scene) {
579        let now = self.clock;
580        let mut active = false;
581        self.ghosts.retain_mut(|g| {
582            if g.layer != layer {
583                return true;
584            }
585            let mut moving = g.effect.step(now);
586            for (_, _, e) in &mut g.nested {
587                moving |= e.step(now);
588            }
589            if !moving {
590                return false;
591            }
592            active = true;
593            scene.push_clip(g.clip);
594            let start = scene.len();
595            scene.extend_from_slice(&g.primitives);
596            for (range, pivot, e) in &g.nested {
597                let r = start + range.start..start + range.end;
598                scene.apply_effect(r, e.value(now), *pivot);
599            }
600            let effect = g.effect.value(now);
601            if g.surface_exit {
602                let panel = Effect { opacity: (effect.opacity * 2.0).min(1.0), ..effect };
603                let content = Effect { opacity: (effect.opacity * 2.0 - 1.0).max(0.0), ..effect };
604                let first = (start + 1).min(scene.len());
605                scene.apply_effect(start..first, panel, g.pivot);
606                scene.apply_effect(first..scene.len(), content, g.pivot);
607            } else {
608                scene.apply_effect(start..scene.len(), effect, g.pivot);
609            }
610            scene.pop_clip();
611            true
612        });
613        self.active |= active;
614    }
615}
616
617/// An overlay on the far side of its anchor exits toward it the other way.
618pub(crate) fn flipped(effect: Effect, flip: bool) -> Effect {
619    if flip { Effect { offset: Point::new(effect.offset.x, -effect.offset.y), ..effect } } else { effect }
620}
621
622/// The animatable part of what a node paints.
623#[derive(Clone, Copy, Debug)]
624pub(crate) enum NodePaint {
625    Quad(QuadPaint),
626    Text(Color),
627    Other,
628}
629
630#[cfg_attr(feature = "live", ravenrs_live::expose)]
631impl NodePaint {
632    pub(crate) fn of(paint: &Paint, text: Option<Color>) -> Self {
633        match (paint, text) {
634            (Paint::Quad(q), _) => Self::Quad(*q),
635            (_, Some(c)) => Self::Text(c),
636            _ => Self::Other,
637        }
638    }
639}
640
641/// Animates its content's height as it opens and closes: the "height: auto"
642/// transition. The content stays built until it has finished closing.
643#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
644pub struct Collapse {
645    open: bool,
646    content: AnyView,
647}
648
649impl std::fmt::Debug for Collapse {
650    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651        f.debug_struct("Collapse").field("open", &self.open).finish_non_exhaustive()
652    }
653}
654
655#[cfg_attr(feature = "live", ravenrs_live::expose)]
656impl Collapse {
657    /// Shows `content` at full height while `open` is true, and nothing otherwise.
658    pub fn new(open: bool, content: impl View + 'static) -> Self {
659        Self { open, content: Box::new(content) }
660    }
661
662    /// The same, for content already boxed.
663    pub fn boxed(open: bool, content: AnyView) -> Self {
664        Self { open, content }
665    }
666}
667
668impl View for Collapse {
669    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
670        collapse(cx, self.open, self.content.as_ref())
671    }
672}
673
674pub(crate) fn collapse(cx: &mut BuildCx<'_>, open: bool, content: &dyn View) -> NodeId {
675    struct Body<'a>(bool, &'a dyn View);
676    impl View for Body<'_> {
677        fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
678            let transition = cx.theme().motion.base;
679            let target = if self.0 { 1.0_f32 } else { 0.0 };
680            let progress = cx.animated(target, transition).clamp(0.0, 1.0);
681            let at_rest = (progress - target).abs() < f32::EPSILON;
682            if at_rest && !self.0 {
683                return cx.empty();
684            }
685            let child = cx.child(self.1);
686            let natural = cx.measured_content().map_or(0.0, |s| s.height);
687            let style = LayoutStyle {
688                height: (!at_rest).then_some(natural * progress),
689                overflow_hidden: !at_rest,
690                align: ravenrs_layout::Align::Stretch,
691                ..LayoutStyle::stack(Axis::Vertical)
692            };
693            let node = cx.container(style, &[child], Paint::None);
694            cx.measure_content(node);
695            if !at_rest {
696                cx.clip(node);
697            }
698            node
699        }
700    }
701    cx.child(&Body(open, content))
702}
703
704pub(crate) const SURFACE_ENTER_SCALE: f32 = 0.96;
705pub(crate) const SURFACE_EXIT_SCALE: f32 = 0.98;
706
707/// How floating panels appear: from the side of their anchor.
708///
709/// Quick and without a spring: a spring's tail keeps the scale changing by fractions of a
710/// pixel, and the text shivers. It comes in solid, since a panel fading in would show
711/// what is behind it through its fields, and leaves as one surface.
712pub(crate) fn popup(cx: &mut BuildCx<'_>, node: NodeId) {
713    let m = cx.theme().motion;
714    cx.motion(
715        node,
716        Motion::new()
717            .anchored()
718            .surface_exit()
719            .enter(Effect::scaled(SURFACE_ENTER_SCALE), m.fast)
720            .exit(Effect { opacity: 0.0, ..Effect::scaled(SURFACE_EXIT_SCALE) }, m.fast),
721    );
722}
723
724/// The dimmed backdrop of a modal layer, which fades. It goes behind the panel as its
725/// sibling: a fade on the panel's parent would reach the panel too.
726pub(crate) fn scrim(cx: &mut BuildCx<'_>, viewport: ravenrs_core::Size) -> NodeId {
727    let theme = *cx.theme();
728    let node = cx.container(
729        LayoutStyle {
730            absolute: Some((0.0, 0.0)),
731            width: Some(viewport.width),
732            height: Some(viewport.height),
733            ..LayoutStyle::leaf()
734        },
735        &[],
736        Paint::Quad(QuadPaint::new(theme.color(ravenrs_style::Semantic::Scrim))),
737    );
738    let m = theme.motion;
739    cx.motion(node, Motion::new().enter(Effect::hidden(), m.base).exit(Effect::hidden(), m.base));
740    node
741}
742
743/// How a control acknowledges hover and press.
744pub(crate) fn control(cx: &mut BuildCx<'_>, node: NodeId) {
745    let t = cx.theme().motion.fast;
746    cx.motion(node, Motion::new().paint(t));
747}