fenestra-core 0.40.0

Element IR, theme tokens, style resolution, layout, text, and scene building for the fenestra GUI framework
Documentation
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
//! Motion completion: FLIP / shared-element layout animation (`.animate_layout()`)
//! and exit animations (`.exit()` / `.exit_to()`). Every assertion is headless
//! (no GPU): the retained `FrameState` exposes the motion lifecycle through the
//! `has_anim` / `has_exiting` / `exiting_settled` / `prev_rect` hooks, and
//! `Frame::animating` reports whether the runner must keep scheduling.
//!
//! The load-bearing invariant: under `reduced_motion` (which headless golden
//! rendering forces) FLIP snaps and exits settle on creation, so neither ever
//! perturbs a frame — the goldens stay byte-identical.

use fenestra_core::{Element, Fonts, FrameState, Theme, Transition, build_frame, by, col, div};

/// A fixed canvas so layout rows land at predictable pixels.
const SIZE: (f32, f32) = (200.0, 400.0);

// ----------------------------------------------------------------- FLIP

/// A keyed element pushed down the page by a leading spacer of height `top_h`.
fn flip_view(top_h: f32) -> Element<()> {
    col::<()>().w(200.0).h(400.0).children([
        div::<()>().id("top").w(100.0).h(top_h).shrink0(),
        div::<()>()
            .id("k")
            .w(100.0)
            .h(20.0)
            .shrink0()
            .animate_layout(),
    ])
}

#[test]
fn flip_snaps_under_reduced_motion() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new();
    state.reduced_motion = true;

    let f1 = build_frame(&flip_view(10.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let k = f1.get(&by::id("k")).id;
    let f2 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);

    // The element jumps straight to its new layout row: no spring is held and
    // nothing animates, so a headless golden is unperturbed.
    assert!(
        !state.has_anim(k),
        "reduced motion must not inject a FLIP spring"
    );
    assert!(!f2.animating, "reduced motion snaps; nothing animates");
    assert!(
        (f2.rect_of(k).unwrap().y0 - 60.0).abs() < 1.0,
        "layout still moved to the new row"
    );
}

#[test]
fn flip_injects_a_spring_on_a_real_move() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new(); // reduced_motion = false

    let f1 = build_frame(&flip_view(10.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let k = f1.get(&by::id("k")).id;
    assert!(!f1.animating, "first mount with no movement is settled");
    assert!(
        (state.prev_rect(k).unwrap().y0 - 10.0).abs() < 1.0,
        "the first frame's rect is recorded for next-frame FLIP"
    );

    let f2 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(
        state.has_anim(k),
        "a real layout move retargets a retained spring"
    );
    assert!(f2.animating, "the FLIP slide keeps the runner scheduling");
    // FLIP offsets paint, not layout: the rect is the new row either way.
    assert!((f2.rect_of(k).unwrap().y0 - 60.0).abs() < 1.0);
    assert!(
        (state.prev_rect(k).unwrap().y0 - 60.0).abs() < 1.0,
        "prev_rects advanced to this frame's measurement"
    );
}

#[test]
fn flip_slide_settles_over_time() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new();

    let f1 = build_frame(&flip_view(10.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let k = f1.get(&by::id("k")).id;
    let f2 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(f2.animating, "the slide is in flight right after the move");

    // Hold the new layout steady and let the spring run out.
    state.tick(5.0);
    let f3 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(
        !f3.animating,
        "the FLIP spring settles and releases the runner"
    );
    assert!(
        state.has_anim(k),
        "the settled spring is retained while mounted"
    );
}

/// Two equal-height rows; identity follows the key when `keyed`, else the index.
fn list_view(order: [&str; 2], keyed: bool) -> Element<()> {
    let item = |name: &str| {
        let d = div::<()>().w(100.0).h(20.0).shrink0().animate_layout();
        if keyed { d.id(name) } else { d }
    };
    col::<()>()
        .w(200.0)
        .h(400.0)
        .children([item(order[0]), item(order[1])])
}

#[test]
fn flip_needs_a_stable_key() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();

    // Index-keyed: reordering swaps which row sits at each index, but the
    // per-index id and pixel position are unchanged, so FLIP cannot see a move.
    let mut state = FrameState::new();
    build_frame(
        &list_view(["a", "b"], false),
        &theme,
        &mut fonts,
        &mut state,
        SIZE,
        1.0,
    );
    let f = build_frame(
        &list_view(["b", "a"], false),
        &theme,
        &mut fonts,
        &mut state,
        SIZE,
        1.0,
    );
    assert!(
        !f.animating,
        "index-keyed reorder cannot FLIP: identity follows position"
    );

    // Stable-keyed: identity follows the key, so the displaced row slides.
    let mut state = FrameState::new();
    let f1 = build_frame(
        &list_view(["a", "b"], true),
        &theme,
        &mut fonts,
        &mut state,
        SIZE,
        1.0,
    );
    let a = f1.get(&by::id("a")).id;
    let f2 = build_frame(
        &list_view(["b", "a"], true),
        &theme,
        &mut fonts,
        &mut state,
        SIZE,
        1.0,
    );
    assert!(f2.animating, "keyed reorder slides the moved row");
    assert!(state.has_anim(a), "the moved row holds a retargeted spring");
}

// ----------------------------------------------------------------- exit

/// A list whose second, exit-tagged child is present only when `present`.
fn exit_view(present: bool) -> Element<()> {
    let mut kids: Vec<Element<()>> = vec![div::<()>().id("keep").w(100.0).h(20.0).shrink0()];
    if present {
        kids.push(
            div::<()>()
                .id("gone")
                .w(100.0)
                .h(20.0)
                .shrink0()
                .exit(Transition::all()),
        );
    }
    col::<()>().w(200.0).h(400.0).children(kids)
}

#[test]
fn exit_ghost_created_on_departure() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new(); // motion on

    let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let gone = f1.get(&by::id("gone")).id;
    assert!(!state.has_exiting(gone), "a present element is not exiting");

    let f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(
        state.has_exiting(gone),
        "removing an exit-tagged element starts an exit"
    );
    assert_eq!(
        state.exiting_settled(gone),
        Some(false),
        "the exit is mid-flight, not settled"
    );
    assert!(
        f2.animating,
        "an in-flight exit keeps the runner scheduling"
    );

    // Painting at t0 (no clock advance) must not panic nor settle the ghost.
    let _ = f2.paint(&mut fonts, &mut state);
    assert!(
        state.has_exiting(gone),
        "still exiting immediately after one paint at t0"
    );
}

#[test]
fn exit_settles_instantly_under_reduced_motion_and_is_gced() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new();
    state.reduced_motion = true;

    let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let gone = f1.get(&by::id("gone")).id;

    let f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
    // Detected, but settled at once: it is never painted and the frame is inert.
    assert_eq!(state.exiting_settled(gone), Some(true));
    assert!(
        !f2.animating,
        "a settled exit does not animate (headless stays inert)"
    );
    let _ = f2.paint(&mut fonts, &mut state); // a settled ghost paints nothing

    let _f3 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(
        !state.has_exiting(gone),
        "a settled exit is garbage-collected on the next build"
    );
}

#[test]
fn exit_cancels_when_the_element_reappears() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new();

    let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let gone = f1.get(&by::id("gone")).id;
    let _f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(state.has_exiting(gone), "the exit started");

    let f3 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(
        !state.has_exiting(gone),
        "the element reappearing cancels its exit"
    );
    assert!(
        f3.query(&by::id("gone")).is_some(),
        "and it is live again in the tree"
    );
}

#[test]
fn exit_completes_after_its_duration_then_is_collected() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new();

    let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let gone = f1.get(&by::id("gone")).id;
    let f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert_eq!(state.exiting_settled(gone), Some(false));

    // Advance well past the 200ms exit and paint: the ghost reaches its end.
    state.tick(5.0);
    let _ = f2.paint(&mut fonts, &mut state);
    assert_eq!(
        state.exiting_settled(gone),
        Some(true),
        "the exit settles once its duration has elapsed"
    );

    let _f3 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(
        !state.has_exiting(gone),
        "the completed exit is collected next build"
    );
}

#[test]
fn exit_to_targets_scale_and_translate() {
    // `.exit_to` configures opacity/scale/translation targets and a default
    // exit timing; removal still leaves a tracked, in-flight ghost.
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new();

    let view = |present: bool| -> Element<()> {
        let mut kids: Vec<Element<()>> = vec![div::<()>().id("keep").w(100.0).h(20.0).shrink0()];
        if present {
            kids.push(
                div::<()>()
                    .id("toast")
                    .w(100.0)
                    .h(20.0)
                    .shrink0()
                    .exit_to(0.0, 0.96, 0.0, 8.0),
            );
        }
        col::<()>().w(200.0).h(400.0).children(kids)
    };

    let f1 = build_frame(&view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let toast = f1.get(&by::id("toast")).id;
    let f2 = build_frame(&view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert_eq!(state.exiting_settled(toast), Some(false));
    assert!(f2.animating);
    // Mid-flight paint exercises the scale/translate ghost path without panic.
    state.tick(0.05);
    let _ = f2.paint(&mut fonts, &mut state);
    assert!(state.has_exiting(toast));
}

#[test]
fn exit_transform_pivots_about_the_elements_own_transform_origin() {
    // A ghost's static frozen transform pivots about its transform_origin
    // (via Style::paint_affine). The exit animation's OWN scale/translate
    // must pivot about that SAME point, not a hardcoded rect center — else
    // the static and animated halves of the paint disagree the instant the
    // exit begins, visibly "jumping" pivot.
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new();

    let view = |present: bool| -> Element<()> {
        let mut kids: Vec<Element<()>> = vec![div::<()>().id("keep").w(100.0).h(20.0).shrink0()];
        if present {
            kids.push(
                div::<()>()
                    .id("corner")
                    .w(100.0)
                    .h(20.0)
                    .shrink0()
                    .transform_origin(0.0, 0.0)
                    .exit_to(0.0, 0.5, 0.0, 0.0),
            );
        }
        col::<()>().w(200.0).h(400.0).children(kids)
    };

    let f1 = build_frame(&view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let corner = f1.get(&by::id("corner")).id;
    let rect = f1.rect_of(corner).expect("corner rect");
    let _f2 = build_frame(&view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);

    let pivot = state
        .exiting_ghost_pivot(corner)
        .expect("an in-flight exit reports its pivot");
    assert!(
        (f64::from(pivot.0) - rect.x0).abs() < 1e-3 && (f64::from(pivot.1) - rect.y0).abs() < 1e-3,
        "the exit pivots at the element's top-left transform_origin {:?}, not its rect center {:?}",
        pivot,
        rect.center()
    );
}

// ---------------------------------------------------------- FLIP + exit

/// An element that both slides (`animate_layout`) and fades (`exit`), removed
/// while its FLIP slide is in flight: the ghost must inherit the slid offset, so
/// it animates out from where the element actually was — not from its settled
/// layout rect. Guards two halves of the fix together: the snapshot is taken
/// after the FLIP pass, and the ghost painter replays that frozen translate.
#[test]
fn exit_ghost_inherits_a_mid_flip_slide() {
    let theme = Theme::light();
    let mut fonts = Fonts::embedded();
    let mut state = FrameState::new(); // motion on

    // "k" both slides and fades; a leading spacer of height `top_h` pushes it.
    let view = |top_h: f32, present: bool| -> Element<()> {
        let mut kids: Vec<Element<()>> = vec![div::<()>().id("top").w(100.0).h(top_h).shrink0()];
        if present {
            kids.push(
                div::<()>()
                    .id("k")
                    .w(100.0)
                    .h(20.0)
                    .shrink0()
                    .animate_layout()
                    .exit(Transition::all()),
            );
        }
        col::<()>().w(200.0).h(400.0).children(kids)
    };

    let f1 = build_frame(&view(10.0, true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    let k = f1.get(&by::id("k")).id;
    // A real move: "k" slides down 50px, so its FLIP translate this frame is
    // -50 in y (painted back at the old position, springing toward 0).
    let _f2 = build_frame(&view(60.0, true), &theme, &mut fonts, &mut state, SIZE, 1.0);
    assert!(state.has_anim(k), "the move injected a FLIP spring");

    // Remove it on the very next frame, mid-slide. The ghost is snapshotted
    // after FLIP, so it carries the slid translate, not (0, 0).
    let f3 = build_frame(
        &view(60.0, false),
        &theme,
        &mut fonts,
        &mut state,
        SIZE,
        1.0,
    );
    assert!(
        state.has_exiting(k),
        "removing the sliding element starts an exit"
    );
    let t = state
        .exiting_ghost_translate(k)
        .expect("a removed exit-tagged element is exiting");
    assert!(
        t.1 < -40.0,
        "the exit ghost inherits the mid-FLIP slide (translate.y ~= -50), got {t:?}"
    );
    // Painting the transformed ghost exercises the frozen-transform path.
    let _ = f3.paint(&mut fonts, &mut state);
}