Skip to main content

kui_core/
depart.rs

1//! Exit transitions: a subtree the view stopped declaring, kept as a
2//! picture and played out.
3//!
4//! [`crate::enter::Enter`] says where a node's slots *start* on its first
5//! sight. `NodeSpec::exit` is the same declaration read the other way —
6//! where they *end* — and it needs something `enter` does not: the node
7//! itself, one frame after the view stopped mentioning it. The frame model
8//! is immediate, so nothing about a node survives the frame that dropped
9//! it; what survives here is a copy.
10//!
11//! A node that declared both a `transition` and an `exit`, was in the last
12//! frame's tree and is not in this one — while its parent still is: a node
13//! that went with an ancestor goes at once, as the ancestor's subtree does
14//! (backlog DX19) — becomes a **ghost**: its specs,
15//! contents and laid-out rects are copied out of the previous frame's tree
16//! into this store, stamped with the clock reading it left at. Every later
17//! frame replays it:
18//!
19//! - **frozen, not re-laid-out.** Rects are the ones layout gave it the
20//!   last time it existed. A dying node must not fight the live layout for
21//!   space, which is also how CSS's exit transitions work — the element is
22//!   out of flow the moment it is removed.
23//! - **in its place, and unclipped.** It is painted where the node was in
24//!   the paint order — the same pass (in flow, or among the floats) and
25//!   just under the node that painted after it — so a panel that sat
26//!   under a HUD leaves under it, rather than jumping to the top of the
27//!   window for its last few frames. There is no z-index; floats stack in
28//!   tree order, and a picture of a float keeps the place it stacked in.
29//!   Its ancestors may be gone, so there is no clip to inherit and nothing
30//!   to sit inside: it draws outside every clip *they* held. The clips
31//!   inside the picture are its own and stay: a scroll box that departs
32//!   still bounds the rows it held, so the overscan a virtual list built
33//!   past its edge does not appear the frame the list leaves.
34//! - **inert.** No hit region, no place in the Tab ring, no access row. It
35//!   is a picture of a node, not a node.
36//! - **self-easing.** Nothing can retarget a ghost — the view has already
37//!   stopped talking about it — so its slots are one lerp over its own
38//!   clock rather than a retained tween in [`crate::anim::AnimStore`].
39//!   Spring easings sample as ease-out, the same substitution
40//!   `AnimStore::sample` makes for a keyframed slot, since there is no leg
41//!   to carry momentum across.
42//!
43//! A ghost is dropped when its transition ends, when the same key comes
44//! back (the live node wins immediately, so a toast dismissed and re-shown
45//! does not double), when it has not been replayed for
46//! [`crate::retain::KEEP_FOR`] frames, and — the part that makes this safe for a
47//! list — when a later frame's removal needs the room it is taking. The
48//! store holds at most [`MAX_NODES`] nodes, and the budget is applied to a
49//! frame's removal *whole*: the diff counts what the frame wants to add
50//! before it copies anything ([`DepartStore::admit`]), evicts the oldest
51//! ghosts until the removal fits, and if the removal alone is over the
52//! budget refuses all of it, so a list never gets half its rows sliding
53//! out and the rest blinking away. See
54//! `docs/adr/0005-the-paint-vocabulary.md` for why the budget is over
55//! nodes, and `docs/adr/0012-the-exit-budget.md` for why it is judged per
56//! frame and what it costs when it bites.
57
58use crate::anim::Easing;
59use crate::color::Color;
60use crate::enter::Enter;
61use crate::geom::{Rect, Vec2};
62use crate::key::Key;
63use crate::resources::ImageId;
64use crate::spec::{NodeSpec, Sizing};
65use crate::tree::{NIL, NodeContent, Tree};
66
67/// The most nodes every departing subtree together may retain. The budget
68/// is over *nodes* rather than subtrees because a node is what a replayed
69/// frame pays for. A frame whose removal is larger than this gets none of
70/// it animated — every departing node vanishes at once, which is exactly
71/// the behaviour of a node with no `exit` at all — rather than the first
72/// of them sliding out and the rest blinking (ADR 0012, decision 2). The
73/// number is a decision with a curve behind it: with the departing frame
74/// linear (decision 5), and measured again on 2026-09-27 (release, M3 Pro,
75/// a pane of rows of a box and a text), a departing subtree costs about
76/// 0.065 µs a node in the frame it leaves and 0.021 µs a node a frame while
77/// it plays — 4096 nodes are 267 µs, then 87 µs a frame, where the same
78/// pane cost 404 µs a frame alive. So a fade costs less than the frames it
79/// follows, and the bound is on memory and on a removal nobody meant to
80/// animate, not on time. It was 512 until kawoosh's fonts and themes panes
81/// (1,500–1,800 nodes) could not fade out (backlog DX23).
82pub const MAX_NODES: usize = 4096;
83
84/// Whether a node can depart at all: it declared an `exit`, and a
85/// `transition` with a duration to run it over. The diff counts a frame's
86/// removal by this before the store copies any of it, and
87/// [`DepartStore::depart`] returns early on the same two conditions, so a
88/// subtree counted is a subtree copied.
89#[inline]
90pub(crate) fn can_depart(spec: &NodeSpec) -> bool {
91    spec.anim().exit.is_some()
92        && spec
93            .transition
94            .as_ref()
95            .is_some_and(|t| t.duration_ms > 0.0)
96}
97
98/// A departing node's content. Same leaves a live node has, in the forms
99/// that outlive the frame that made them: a `TextId` indexes the frame's
100/// text list, which is rebuilt every frame, so a ghost carries the cache
101/// key of the shaped buffer behind it instead.
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub(crate) enum GhostContent {
104    Container,
105    Text {
106        cache_key: u64,
107        color: Color,
108    },
109    Edit(Key),
110    Image(ImageId, crate::resources::ImageOpts),
111    /// A stroke: `len` points from `first` in the ghost's own point list
112    /// (relative to the node's box, like the live run's), drawn `width`
113    /// wide in the colour the node's `bg` slot eases to.
114    Line {
115        first: u32,
116        len: u32,
117        width: f32,
118    },
119    /// A fragment, by value: the handle and the parameters the node last
120    /// declared. The ghost re-declares them every frame it draws, so the
121    /// picture is frozen at departure while the box eases.
122    Fragment(crate::fragment::Draw),
123    /// A polygon, by value like a fragment; the fill is the colour the
124    /// node's `bg` slot eases to.
125    Polygon(crate::fragment::Draw),
126}
127
128pub(crate) struct GhostNode {
129    /// Index within this ghost, `NIL` for its root.
130    pub parent: u32,
131    pub spec: NodeSpec,
132    pub content: GhostContent,
133    /// Where layout left it, logical, in viewport coordinates.
134    pub rect: Rect,
135}
136
137/// Where a departing subtree sat in the paint order (ADR 0023): the
138/// layer, and the key of the first node painted after it in that layer
139/// that the frame which noticed it gone still declares. Its ghost is
140/// painted just under that node — and at the end of its layer once the
141/// node is gone too, or on top of everything once the layer is.
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub(crate) enum Place {
144    /// In the in-flow layer, just under `before`; at its end for `None`.
145    InFlow { before: Option<Key> },
146    /// Inside the float layer rooted at `layer`, just under `before`; at
147    /// that layer's end for `None`.
148    InLayer { layer: Key, before: Option<Key> },
149    /// A float layer of its own, just under the layer rooted at `before`;
150    /// on top of the stack for `None`.
151    Layer { before: Option<Key> },
152}
153
154impl Place {
155    /// The key a per-node ask names, for the membership mask: the node a
156    /// ghost is painted under, or for one at a layer's end the layer.
157    fn mask_key(self) -> Option<Key> {
158        match self {
159            Place::InFlow { before } | Place::Layer { before } => before,
160            Place::InLayer { layer, before } => Some(before.unwrap_or(layer)),
161        }
162    }
163
164    /// The node (or, for a whole layer, the layer) the ghost is painted
165    /// just under; `None` at the end of wherever it is.
166    fn before(self) -> Option<Key> {
167        match self {
168            Place::InFlow { before } | Place::Layer { before } | Place::InLayer { before, .. } => {
169                before
170            }
171        }
172    }
173
174    /// The same place, under something else.
175    fn with_before(self, before: Option<Key>) -> Self {
176        match self {
177            Place::InFlow { .. } => Place::InFlow { before },
178            Place::InLayer { layer, .. } => Place::InLayer { layer, before },
179            Place::Layer { .. } => Place::Layer { before },
180        }
181    }
182}
183
184/// One ask a pass makes of the replay: the slot the emission has reached.
185/// The three `Under*` asks are per node and gated by [`Replay::may_precede`];
186/// the three `*End`/`Top` asks are per layer and also sweep up every
187/// ghost of that layer whose `before` node is gone.
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub(crate) enum At {
190    /// Just under in-flow node `key`.
191    UnderInFlow(Key),
192    /// The end of the in-flow layer.
193    InFlowEnd,
194    /// Just under the float layer rooted at `key`.
195    UnderLayer(Key),
196    /// Just under node `key` inside a float layer.
197    UnderInLayer(Key),
198    /// The end of the float layer rooted at `key`.
199    LayerEnd(Key),
200    /// Above every layer: whatever is still unpainted.
201    Top,
202}
203
204/// One departing subtree, with what it needs to play itself out.
205pub(crate) struct Ghost {
206    pub key: Key,
207    pub nodes: Vec<GhostNode>,
208    /// The points of every `line` in the subtree, copied out of the frame
209    /// that had them (a `LineId` indexes a list that is rebuilt every
210    /// frame). Empty, and unallocated, for a subtree with no lines.
211    pub points: Vec<Vec2>,
212    /// Where it is painted, relative to the live frame.
213    pub place: Place,
214    /// Clock reading (driver seconds) of the frame that noticed it gone.
215    left_at: f64,
216    /// How long the exit runs, in seconds (the root's `transition`).
217    duration: f64,
218    easing: Easing,
219    /// Where the root's slots are headed.
220    exit: Enter,
221    /// The group opacity the departing root inherited from ancestors that
222    /// may no longer exist.
223    base_opacity: f32,
224    last_used: u64,
225}
226
227/// A ghost's root as it should be painted this frame: the eased slots, and
228/// the offset to move every rect in the subtree by.
229pub(crate) struct Playback {
230    pub offset: Vec2,
231    pub bg: Option<Color>,
232    pub radius: Option<[f32; 4]>,
233    pub size: Option<(Option<f32>, Option<f32>)>,
234    /// Multiplied into the root's own opacity.
235    pub opacity: f32,
236    pub base_opacity: f32,
237}
238
239impl Ghost {
240    /// Where the exit has got to at `now`: `None` once it is over.
241    fn playback(&self, now: f64) -> Option<Playback> {
242        let raw = ((now - self.left_at) / self.duration) as f32;
243        if raw >= 1.0 || raw.is_nan() {
244            return None;
245        }
246        let p = self.easing.apply(raw.max(0.0));
247        let root = &self.nodes[0].spec;
248        let lerp = |from: f32, to: f32| from + (to - from) * p;
249        let e = &self.exit;
250        Some(Playback {
251            offset: Vec2::new(e.dx * p, e.dy * p),
252            bg: e.bg.map(|to| root.style.bg.lerp(to, p)),
253            radius: e
254                .radius
255                .map(|to| root.style.radius.map(|from| lerp(from, to))),
256            size: (e.width.is_some() || e.height.is_some()).then(|| {
257                let rect = self.nodes[0].rect;
258                (
259                    e.width.and_then(Sizing::amount).map(|to| lerp(rect.w, to)),
260                    e.height.and_then(Sizing::amount).map(|to| lerp(rect.h, to)),
261                )
262            }),
263            opacity: match e.opacity {
264                Some(to) => lerp(root.style.opacity, to),
265                None => root.style.opacity,
266            },
267            base_opacity: self.base_opacity,
268        })
269    }
270}
271
272/// The departing subtrees, bounded (see [`MAX_NODES`]).
273#[derive(Default)]
274pub struct DepartStore {
275    ghosts: Vec<Ghost>,
276    /// Nodes across every ghost, kept in step with `ghosts` so the budget
277    /// is a comparison rather than a walk.
278    nodes: usize,
279    /// The core's frame counter as of `begin_frame`.
280    frame_no: u64,
281    /// Whether a ghost replayed this frame is still mid-flight.
282    active: bool,
283    /// A membership mask over the ghosts' `before` keys, so a departure
284    /// whose key no ghost sits under — every row of a mass removal — skips
285    /// the walk that would hand its place on. Never cleared: a stale bit
286    /// costs one walk, and there are at most a few hundred ghosts to walk.
287    before_mask: u64,
288    /// The ghosts' own keys, as an exact set kept in step with `ghosts`,
289    /// so a departure of a key no ghost holds skips [`Self::retire`] —
290    /// which is a pass over the whole store, and a mass removal would
291    /// otherwise pay one per row. Exact rather than a 64-bit mask like
292    /// `before_mask`: a mass removal is hundreds of distinct keys, which
293    /// saturates 64 bits and makes a mask answer "maybe" every time. See
294    /// `docs/adr/0012-the-exit-budget.md`, decision 5.
295    held: rustc_hash::FxHashSet<Key>,
296}
297
298impl DepartStore {
299    /// Starts a frame under the core's counter (backlog AR45).
300    pub(crate) fn begin_frame(&mut self, frame_no: u64) {
301        self.frame_no = frame_no;
302        self.active = false;
303        // A ghost not replayed for a while goes — the same backstop the
304        // anim store keeps, for a driver whose clock stops moving while
305        // frames keep coming (`retain::sweep_cutoff`).
306        if !self.ghosts.is_empty()
307            && let Some(cutoff) = crate::retain::sweep_cutoff(self.frame_no)
308        {
309            self.drop_where(|g| g.last_used < cutoff);
310        }
311    }
312
313    /// True when a ghost replayed this frame is still mid-flight, i.e. the
314    /// driver owes another frame.
315    pub fn animating(&self) -> bool {
316        self.active
317    }
318
319    /// Whether anything is departing at all — the gate every pass that
320    /// would otherwise walk an empty store checks first.
321    pub fn is_empty(&self) -> bool {
322        self.ghosts.is_empty()
323    }
324
325    /// How many nodes are retained across every departing subtree.
326    pub fn node_count(&self) -> usize {
327        self.nodes
328    }
329
330    /// The keys of the departing roots — what a caller tests the live tree
331    /// against to notice one coming back.
332    pub fn keys(&self) -> impl Iterator<Item = Key> + '_ {
333        self.ghosts.iter().map(|g| g.key)
334    }
335
336    /// Each departing root's key and the spec it left with — what a
337    /// trace names a departure by, since its node is no longer in any
338    /// tree (backlog F111).
339    pub(crate) fn roots(&self) -> impl Iterator<Item = (Key, &NodeSpec)> + '_ {
340        self.ghosts
341            .iter()
342            .filter_map(|g| g.nodes.first().map(|n| (g.key, &n.spec)))
343    }
344
345    fn drop_where(&mut self, mut pred: impl FnMut(&Ghost) -> bool) {
346        let nodes = &mut self.nodes;
347        let held = &mut self.held;
348        self.ghosts.retain(|g| {
349            let drop = pred(g);
350            if drop {
351                *nodes -= g.nodes.len();
352                held.remove(&g.key);
353            }
354            !drop
355        });
356    }
357
358    /// A key the view declared again: the live node wins and its ghost is
359    /// discarded, so a dismissed and re-shown toast does not draw twice.
360    pub(crate) fn retire(&mut self, key: Key) {
361        self.drop_where(|g| g.key == key);
362    }
363
364    /// The same, for every ghost whose key is in `live` — the whole of a
365    /// frame's returns in one pass.
366    pub(crate) fn retire_returned(&mut self, live: &rustc_hash::FxHashSet<Key>) {
367        if !live.is_empty() {
368            self.drop_where(|g| live.contains(&g.key));
369        }
370    }
371
372    /// The budget, applied to a frame's removal whole (ADR 0012, decisions
373    /// 2 and 3): `wanted` is every node the frame's departing subtrees
374    /// would add together, counted before any of them is copied. A removal
375    /// that fits an empty store is admitted — and if the store is holding
376    /// earlier exits it has no room beside, the **oldest** ghosts go first
377    /// until it fits, since they are the ones furthest through their own
378    /// fade and the removal the user just caused is the one they are
379    /// looking at. A removal larger than [`MAX_NODES`] on its own is refused
380    /// whole: `false`, no ghost, and the caller raises `exit-budget` for
381    /// the frame. There is no partial credit either way, so a view never
382    /// gets half its rows animating and the rest blinking, and how a
383    /// removal reads cannot depend on what else the app happened to be
384    /// doing 100 ms earlier.
385    pub(crate) fn admit(&mut self, wanted: usize) -> bool {
386        if wanted > MAX_NODES {
387            return false;
388        }
389        let mut evict = 0;
390        while self.nodes + wanted > MAX_NODES {
391            // Store order is departure order, so the front is the oldest.
392            let g = &self.ghosts[evict];
393            self.nodes -= g.nodes.len();
394            self.held.remove(&g.key);
395            evict += 1;
396        }
397        if evict > 0 {
398            self.ghosts.drain(..evict);
399        }
400        true
401    }
402
403    /// Copies `root`'s subtree out of `tree` (which is the *previous*
404    /// frame's, the last one that had it) and starts its exit at `now`.
405    /// `text` is read for that same frame's list, since the subtree's text
406    /// nodes carry its ids.
407    /// The budget is not checked here: the caller has counted the frame's
408    /// whole removal and had it admitted ([`Self::admit`]) before copying
409    /// any of it, so a subtree that reaches this always fits.
410    // The two lists at the end are the frame's per-node side tables, read
411    // for the same kept frame `tree` is; a struct for the pair would name
412    // one thing that only exists here.
413    #[allow(clippy::too_many_arguments)]
414    pub(crate) fn depart(
415        &mut self,
416        tree: &Tree,
417        root: usize,
418        now: f64,
419        base_opacity: f32,
420        place: Place,
421        text: &crate::text::TextSystem,
422        lines: &crate::line::LineStore,
423        fragments: &crate::fragment::FragmentList,
424    ) {
425        let spec = &tree.specs[root];
426        let (Some(t), Some(exit)) = (spec.transition, spec.anim().exit) else {
427            return;
428        };
429        let duration = t.duration_ms.max(0.0) as f64 / 1000.0;
430        if duration <= 0.0 {
431            return;
432        }
433        let end = tree.subtree_end(root);
434        let key = tree.keys[root];
435        // A second departure of the same key (the view showed it, dropped
436        // it, showed it and dropped it again inside one exit) replaces the
437        // first: two pictures of one node are never right. Behind `held`
438        // because `retire` walks the whole store: unguarded, a frame that
439        // drops a thousand rows pays one walk per row, which is the one
440        // quadratic left in a mass removal — 32 ms for 10k subtrees
441        // against 1.05 ms guarded, and 44% of the departing frame at
442        // today's budget. Kept rather than deleted even though the frame
443        // path cannot reach it — `collect_departures` retires a returning
444        // key before it can depart again — because that argument runs
445        // through another module's early returns, and a set lookup is a
446        // cheap thing to be wrong about.
447        if self.held.contains(&key) {
448            self.retire(key);
449        }
450        let mut points = Vec::new();
451        let nodes = (root..end)
452            .map(|i| GhostNode {
453                parent: if i == root {
454                    NIL
455                } else {
456                    tree.parent[i] - root as u32
457                },
458                spec: tree.specs[i].clone(),
459                content: match tree.content[i] {
460                    NodeContent::Container => GhostContent::Container,
461                    NodeContent::Text(id) => {
462                        let (cache_key, color) = text.prev_frame_text(id);
463                        GhostContent::Text { cache_key, color }
464                    }
465                    NodeContent::Edit(k) => GhostContent::Edit(k),
466                    NodeContent::Image(id, opts) => GhostContent::Image(id, opts),
467                    // A departing grid is its box: the cells are the
468                    // frame's and go with it.
469                    NodeContent::Cells(_) => GhostContent::Container,
470                    // A departing fragment keeps painting. Its draw is
471                    // sixteen floats and a handle, fixed-size and `Copy`,
472                    // so the ghost owns a copy outright rather than
473                    // indexing a list that has to outlive the frame —
474                    // which is what the fixed sixteen buys. The copy comes
475                    // from the previous frame's list, because that is the
476                    // tree this ghost is being cut out of.
477                    NodeContent::Fragment(id) => match fragments.prev_get(id) {
478                        Some(draw) => GhostContent::Fragment(draw),
479                        None => GhostContent::Container,
480                    },
481                    NodeContent::Polygon(id) => match fragments.prev_get(id) {
482                        Some(draw) => GhostContent::Polygon(draw),
483                        None => GhostContent::Container,
484                    },
485                    NodeContent::Line(id) => {
486                        let (run, pts) = lines.prev_run(id);
487                        let first = points.len() as u32;
488                        points.extend_from_slice(pts);
489                        GhostContent::Line {
490                            first,
491                            len: pts.len() as u32,
492                            width: run.width,
493                        }
494                    }
495                },
496                rect: Rect::from_pos_size(tree.pos[i], tree.size[i]),
497            })
498            .collect::<Vec<_>>();
499        debug_assert!(
500            self.nodes + nodes.len() <= MAX_NODES,
501            "a departure the frame did not have admitted"
502        );
503        self.nodes += nodes.len();
504        // A ghost that was painted just under this node loses its place
505        // with it, and takes the place this one is taking: the two stay in
506        // the order they had, since the store keeps departures in order.
507        if self.before_mask & (1u64 << (key.0 & 63)) != 0 {
508            for g in &mut self.ghosts {
509                if g.place.before() == Some(key) {
510                    g.place = g.place.with_before(place.before());
511                }
512            }
513        }
514        if let Some(before) = place.before() {
515            self.before_mask |= 1u64 << (before.0 & 63);
516        }
517        self.held.insert(key);
518        self.ghosts.push(Ghost {
519            key,
520            nodes,
521            points,
522            place,
523            left_at: now,
524            duration,
525            easing: t.curve(),
526            exit,
527            base_opacity,
528            last_used: self.frame_no,
529        });
530    }
531
532    /// Starts this frame's replay: drops the ghosts whose exit is over,
533    /// and hands the rest out with where each has got to at `now`, taken
534    /// out of the store so the emitter can borrow the rest of the core
535    /// while it paints them between the live nodes. [`Self::end_replay`]
536    /// puts them back.
537    pub(crate) fn begin_replay(&mut self, now: f64) -> Replay {
538        let frame_no = self.frame_no;
539        let nodes = &mut self.nodes;
540        let held = &mut self.held;
541        let mut plays = Vec::with_capacity(self.ghosts.len());
542        let mut mask = 0u64;
543        self.ghosts.retain_mut(|g| match g.playback(now) {
544            Some(play) => {
545                g.last_used = frame_no;
546                if let Some(k) = g.place.mask_key() {
547                    mask |= 1u64 << (k.0 & 63);
548                }
549                plays.push(play);
550                true
551            }
552            None => {
553                *nodes -= g.nodes.len();
554                held.remove(&g.key);
555                false
556            }
557        });
558        self.active = !self.ghosts.is_empty();
559        Replay {
560            painted: vec![false; self.ghosts.len()],
561            ghosts: std::mem::take(&mut self.ghosts),
562            plays,
563            mask,
564        }
565    }
566
567    /// The other half of [`Self::begin_replay`]. Nothing departs between
568    /// the two — the diff runs before the passes — so nothing can have
569    /// been pushed while the ghosts were out.
570    pub(crate) fn end_replay(&mut self, replay: Replay) {
571        debug_assert!(self.ghosts.is_empty());
572        self.ghosts = replay.ghosts;
573        // `held` is what lets `depart` skip the walk, so it has to be the
574        // ghosts' keys exactly: a key missing from it is a retire that
575        // will not happen, which is two pictures of one node. Checked here
576        // because every frame that replays passes through.
577        debug_assert_eq!(self.held.len(), self.ghosts.len());
578    }
579
580    /// Every still-running ghost handed to `emit` in store order, and the
581    /// finished ones dropped — the replay without the passes, for a test
582    /// that has no frame to paint.
583    #[cfg(test)]
584    pub(crate) fn replay(&mut self, now: f64, mut emit: impl FnMut(&Ghost, &Playback)) {
585        let mut replay = self.begin_replay(now);
586        replay.paint(At::Top, &mut emit);
587        self.end_replay(replay);
588    }
589
590    /// Drops every ghost. The frame driver has no reason to; a test that
591    /// wants a clean slate does.
592    pub fn clear(&mut self) {
593        self.ghosts.clear();
594        self.held.clear();
595        self.nodes = 0;
596        self.active = false;
597    }
598}
599
600/// One frame's ghosts, out of the store for the length of the emission
601/// passes (see [`DepartStore::begin_replay`]). The passes ask for the
602/// ghosts under each node as they reach it, and for the rest of a pass
603/// once they are through it.
604#[derive(Default)]
605pub(crate) struct Replay {
606    ghosts: Vec<Ghost>,
607    plays: Vec<Playback>,
608    /// Painted this frame already: a ghost is painted once, whichever of
609    /// the two asks finds it first.
610    painted: Vec<bool>,
611    /// A membership mask over the `before` keys, so a pass answers
612    /// "nothing under this node" with one AND for almost every node
613    /// rather than a walk over the ghosts.
614    mask: u64,
615}
616
617impl Replay {
618    pub fn is_empty(&self) -> bool {
619        self.ghosts.is_empty()
620    }
621
622    /// Whether any ghost *may* be painted just under `key`: false is
623    /// certain, true is worth the walk [`Self::paint`] makes.
624    #[inline]
625    pub fn may_precede(&self, key: Key) -> bool {
626        self.mask & (1u64 << (key.0 & 63)) != 0
627    }
628
629    /// Hands `emit` the ghosts whose place is `at` — and, for a layer's
630    /// end, every ghost of that layer not painted yet, which is where a
631    /// ghost whose `before` node is gone ends up: at the end of its layer,
632    /// still under everything above it. `At::Top` takes what is left,
633    /// which is a ghost whose whole layer is gone.
634    pub fn paint(&mut self, at: At, mut emit: impl FnMut(&Ghost, &Playback)) {
635        for i in 0..self.ghosts.len() {
636            if self.painted[i] {
637                continue;
638            }
639            let here = match (at, self.ghosts[i].place) {
640                (At::UnderInFlow(k), Place::InFlow { before }) => before == Some(k),
641                (At::InFlowEnd, Place::InFlow { .. }) => true,
642                (At::UnderLayer(k), Place::Layer { before }) => before == Some(k),
643                (At::UnderInLayer(k), Place::InLayer { before, .. }) => before == Some(k),
644                (At::LayerEnd(k), Place::InLayer { layer, .. }) => layer == k,
645                (At::Top, _) => true,
646                _ => false,
647            };
648            if !here {
649                continue;
650            }
651            self.painted[i] = true;
652            emit(&self.ghosts[i], &self.plays[i]);
653        }
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    /// The core's frame counter, stood in for: each test's frames count
662    /// from one.
663    struct Frames(u64);
664    impl Frames {
665        fn next(&mut self) -> u64 {
666            self.0 += 1;
667            self.0
668        }
669    }
670    use crate::tree::OriginId;
671
672    const IN_FLOW: Place = Place::InFlow { before: None };
673
674    fn tree_with(spec: NodeSpec, children: usize) -> Tree {
675        let mut t = Tree::new();
676        let root = t.push(
677            NIL,
678            Key::ROOT,
679            OriginId::HOST,
680            NodeSpec::default(),
681            NodeContent::Container,
682        );
683        let node = t.push(
684            root,
685            Key::ROOT.str("x"),
686            OriginId::HOST,
687            spec,
688            NodeContent::Container,
689        );
690        for i in 0..children {
691            t.push(
692                node,
693                Key::ROOT.str("x").index(i as u64),
694                OriginId::HOST,
695                NodeSpec::default(),
696                NodeContent::Container,
697            );
698        }
699        t
700    }
701
702    fn departing(spec: NodeSpec) -> NodeSpec {
703        spec.transition(100.0)
704            .exit(Enter::from(50.0, 0.0).opacity(0.0))
705    }
706
707    #[test]
708    fn a_ghost_plays_out_and_then_goes() {
709        let mut d = DepartStore::default();
710        let mut frame = Frames(0);
711        let text = crate::text::TextSystem::new();
712        let lines = crate::line::LineStore::default();
713        let fragments = crate::fragment::FragmentList::default();
714        let tree = tree_with(departing(NodeSpec::column()), 2);
715        d.begin_frame(frame.next());
716        d.depart(&tree, 1, 0.0, 1.0, IN_FLOW, &text, &lines, &fragments);
717        assert_eq!(d.node_count(), 3, "the subtree, not just its root");
718
719        let mut seen = Vec::new();
720        d.begin_frame(frame.next());
721        d.replay(0.05, |_, p| seen.push(p.offset.x));
722        assert!(d.animating());
723        assert_eq!(seen.len(), 1);
724        assert!(seen[0] > 0.0 && seen[0] < 50.0, "halfway out: {}", seen[0]);
725
726        d.begin_frame(frame.next());
727        d.replay(0.2, |_, _| panic!("the exit is over"));
728        assert!(!d.animating());
729        assert!(d.is_empty());
730        assert_eq!(d.node_count(), 0);
731    }
732
733    #[test]
734    fn a_key_that_comes_back_takes_its_ghost_with_it() {
735        let mut d = DepartStore::default();
736        let mut frame = Frames(0);
737        let text = crate::text::TextSystem::new();
738        let lines = crate::line::LineStore::default();
739        let fragments = crate::fragment::FragmentList::default();
740        let tree = tree_with(departing(NodeSpec::column()), 0);
741        d.begin_frame(frame.next());
742        d.depart(&tree, 1, 0.0, 1.0, IN_FLOW, &text, &lines, &fragments);
743        assert_eq!(d.keys().collect::<Vec<_>>(), vec![Key::ROOT.str("x")]);
744        d.retire(Key::ROOT.str("x"));
745        assert!(d.is_empty());
746        assert_eq!(d.node_count(), 0);
747    }
748
749    /// One key departing twice with no return in between: the second
750    /// picture replaces the first rather than joining it. The frame path
751    /// cannot produce this — `collect_departures` retires a returning key
752    /// first — so this is the only cover the `retire` inside `depart` has,
753    /// and it is what says the `held` guard (ADR 0012 decision 5) does not
754    /// skip a retire it owed.
755    #[test]
756    fn a_second_departure_of_one_key_replaces_the_first() {
757        let mut d = DepartStore::default();
758        let mut frame = Frames(0);
759        let text = crate::text::TextSystem::new();
760        let lines = crate::line::LineStore::default();
761        let fragments = crate::fragment::FragmentList::default();
762        let tree = tree_with(departing(NodeSpec::column()), 2);
763        d.begin_frame(frame.next());
764        d.depart(&tree, 1, 0.0, 1.0, IN_FLOW, &text, &lines, &fragments);
765        assert_eq!(d.keys().count(), 1);
766        assert_eq!(d.node_count(), 3);
767
768        d.depart(&tree, 1, 0.05, 1.0, IN_FLOW, &text, &lines, &fragments);
769        assert_eq!(d.keys().count(), 1, "one picture of one node, not two");
770        assert_eq!(d.node_count(), 3, "and the budget charged once for it");
771    }
772
773    #[test]
774    fn a_node_without_both_halves_never_departs() {
775        let text = crate::text::TextSystem::new();
776        let lines = crate::line::LineStore::default();
777        let fragments = crate::fragment::FragmentList::default();
778        for spec in [
779            NodeSpec::column(),
780            NodeSpec::column().transition(100.0),
781            // An `exit` with no duration to run over.
782            NodeSpec::column()
783                .exit(Enter::from(10.0, 0.0))
784                .transition(0.0),
785        ] {
786            assert!(!can_depart(&spec), "and the diff never counts it");
787            let mut d = DepartStore::default();
788            let mut frame = Frames(0);
789            let tree = tree_with(spec, 0);
790            d.begin_frame(frame.next());
791            d.depart(&tree, 1, 0.0, 1.0, IN_FLOW, &text, &lines, &fragments);
792            assert!(d.is_empty());
793        }
794    }
795
796    /// `count` subtrees of 16 nodes each, keyed `ROOT[i]` from `from`,
797    /// departed one after another at `now` — the way `collect_departures`
798    /// feeds a frame's admitted removal to the store.
799    fn depart_sixteens(d: &mut DepartStore, from: u64, count: u64, now: f64) {
800        let text = crate::text::TextSystem::new();
801        let lines = crate::line::LineStore::default();
802        let fragments = crate::fragment::FragmentList::default();
803        let spec = departing(NodeSpec::column());
804        for i in from..from + count {
805            let mut t = Tree::new();
806            let root = t.push(
807                NIL,
808                Key::ROOT,
809                OriginId::HOST,
810                NodeSpec::default(),
811                NodeContent::Container,
812            );
813            let node = t.push(
814                root,
815                Key::ROOT.index(i),
816                OriginId::HOST,
817                spec.clone(),
818                NodeContent::Container,
819            );
820            for c in 0..15 {
821                t.push(
822                    node,
823                    Key::ROOT.index(i).index(c),
824                    OriginId::HOST,
825                    NodeSpec::default(),
826                    NodeContent::Container,
827                );
828            }
829            d.depart(&t, 1, now, 1.0, IN_FLOW, &text, &lines, &fragments);
830        }
831    }
832
833    /// ADR 0012 decision 2: a removal is judged whole. One larger than the
834    /// budget is refused before anything is copied, and the store is
835    /// exactly what it was.
836    #[test]
837    fn a_removal_over_the_budget_is_refused_whole() {
838        let mut d = DepartStore::default();
839        let mut frame = Frames(0);
840        d.begin_frame(frame.next());
841        assert!(d.admit(3 * 16));
842        depart_sixteens(&mut d, 0, 3, 0.0);
843        d.begin_frame(frame.next());
844        assert!(!d.admit(MAX_NODES + 1), "one node past the budget");
845        assert_eq!(d.keys().count(), 3, "and the store was not touched");
846        assert_eq!(d.node_count(), 48);
847        assert!(d.admit(MAX_NODES), "the budget itself fits an empty store");
848    }
849
850    /// ADR 0012 decision 3: a removal that fits the budget but not the
851    /// room beside earlier exits evicts those, oldest first, and exactly
852    /// as many as it needs.
853    #[test]
854    fn a_new_removal_evicts_the_oldest_ghosts_until_it_fits() {
855        let mut d = DepartStore::default();
856        let mut frame = Frames(0);
857        // Subtrees of 16 in flight, keyed ROOT[0..n], filling the store to
858        // 192 short of the budget.
859        let n = (MAX_NODES - 192) / 16;
860        d.begin_frame(frame.next());
861        assert!(d.admit(n * 16));
862        depart_sixteens(&mut d, 0, n as u64, 0.0);
863        assert_eq!(d.node_count(), MAX_NODES - 192);
864        // A frame wants 15 more of 16 = 240, 48 past the budget, so three
865        // ghosts have to go, and they are ROOT[0], [1], [2].
866        d.begin_frame(frame.next());
867        assert!(d.admit(15 * 16));
868        assert_eq!(
869            d.node_count(),
870            MAX_NODES - 192 - 48,
871            "three evicted, not four, not two"
872        );
873        assert_eq!(
874            d.keys().next(),
875            Some(Key::ROOT.index(3)),
876            "the oldest went first"
877        );
878        depart_sixteens(&mut d, 100_000, 15, 0.1);
879        assert_eq!(
880            d.node_count(),
881            MAX_NODES,
882            "full, with the new removal whole"
883        );
884        assert_eq!(d.keys().count(), n - 3 + 15);
885        assert!(d.held.contains(&Key::ROOT.index(100_014)));
886        assert!(!d.held.contains(&Key::ROOT.index(2)), "and `held` followed");
887    }
888
889    /// The case decisions 2 and 3 exist to leave alone: a removal that fits
890    /// beside what is in flight evicts nothing.
891    #[test]
892    fn a_removal_that_fits_evicts_nothing() {
893        let mut d = DepartStore::default();
894        let mut frame = Frames(0);
895        d.begin_frame(frame.next());
896        assert!(d.admit(16));
897        depart_sixteens(&mut d, 0, 1, 0.0);
898        d.begin_frame(frame.next());
899        assert!(d.admit(MAX_NODES - 16));
900        assert_eq!(d.keys().count(), 1);
901        assert_eq!(d.node_count(), 16);
902        assert!(d.admit(0), "and nothing wanted is always admitted");
903    }
904
905    #[test]
906    fn a_ghost_nobody_replays_is_swept() {
907        let mut d = DepartStore::default();
908        let mut frame = Frames(0);
909        let text = crate::text::TextSystem::new();
910        let lines = crate::line::LineStore::default();
911        let fragments = crate::fragment::FragmentList::default();
912        let tree = tree_with(departing(NodeSpec::column()), 0);
913        d.begin_frame(frame.next());
914        d.depart(&tree, 1, 0.0, 1.0, IN_FLOW, &text, &lines, &fragments);
915        // Frames without a replay: the sweep runs on the 240th.
916        for _ in 0..480 {
917            d.begin_frame(frame.next());
918        }
919        assert!(d.is_empty(), "an unreplayed ghost does not live forever");
920        assert_eq!(d.node_count(), 0);
921    }
922
923    #[test]
924    fn springs_play_out_as_ease_out() {
925        let mut d = DepartStore::default();
926        let mut frame = Frames(0);
927        let text = crate::text::TextSystem::new();
928        let lines = crate::line::LineStore::default();
929        let fragments = crate::fragment::FragmentList::default();
930        let spec = NodeSpec::column()
931            .transition(100.0)
932            .easing(Easing::Spring)
933            .exit(Enter::from(100.0, 0.0));
934        let tree = tree_with(spec, 0);
935        d.begin_frame(frame.next());
936        d.depart(&tree, 1, 0.0, 1.0, IN_FLOW, &text, &lines, &fragments);
937        let mut x = 0.0;
938        d.replay(0.05, |_, p| x = p.offset.x);
939        let expect = Easing::EaseOut.apply(0.5) * 100.0;
940        assert!((x - expect).abs() < 1e-3, "{x} != {expect}");
941    }
942
943    #[test]
944    fn the_exit_reads_an_enter_backwards() {
945        let mut d = DepartStore::default();
946        let mut frame = Frames(0);
947        let text = crate::text::TextSystem::new();
948        let lines = crate::line::LineStore::default();
949        let fragments = crate::fragment::FragmentList::default();
950        let spec = NodeSpec::column()
951            .bg(Color::hex(0xff0000ff))
952            .radius(10.0)
953            .transition(100.0)
954            .easing(Easing::Linear)
955            .exit(
956                Enter::default()
957                    .bg(Color::hex(0xff000000))
958                    .radius(0.0)
959                    .opacity(0.0),
960            );
961        let mut tree = tree_with(spec, 0);
962        tree.size[1] = crate::geom::Size::new(40.0, 20.0);
963        d.begin_frame(frame.next());
964        d.depart(&tree, 1, 0.0, 1.0, IN_FLOW, &text, &lines, &fragments);
965        d.replay(0.05, |_, p| {
966            assert!((p.opacity - 0.5).abs() < 1e-4, "halfway faded");
967            assert!((p.bg.unwrap().a - 0.5).abs() < 1e-4, "halfway transparent");
968            assert!((p.radius.unwrap()[0] - 5.0).abs() < 1e-4, "halfway square");
969            assert!(p.size.is_none(), "an exit that names no size resizes none");
970        });
971    }
972}