oxframe-components 0.20.4

Ready-made blocks for explainer videos: titles, lists, diagrams, code
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
//! Title card, end card, and a list whose items appear one by one.

use oxframe_2d::{
    Accent, BaseProps, Group, Lg, Md, Muted, NodeBuilder, NodeId, Rect, SceneGraph, Surface,
    TextAlign, Txt, TxtBuilder, pct,
};
use oxframe_core::Tween;

use crate::host::{self, Host};

/// The card behind a title and an optional second line. Both wrap inside
/// the card, and a card whose text wraps grows to hold it; text that fits on
/// one line keeps the usual layout.
fn plate(g: &SceneGraph, root: &Group, title: String, subtitle: Option<String>) -> Host {
    const HEIGHT: f64 = 260.0;
    const PAD: f64 = 48.0;
    const GAP: f64 = 20.0;
    let wrap = (g.frame_size().0 * 0.64 - 2.0 * PAD).max(200.0);
    let card = root.add(
        Rect::new()
            .width(pct(64))
            .height(HEIGHT)
            .radius(Lg)
            .fill(Surface)
            .elevation(Md)
            .name("plate"),
    );
    let title = root.add(
        Txt::new()
            .text(title)
            .h1()
            .max_width(wrap)
            .align(TextAlign::Center)
            .y(-28.0)
            .name("title"),
    );
    let mut reveals = Vec::new();
    let mut second = None;
    if let Some(text) = subtitle {
        let line = root.add(
            Txt::new()
                .text(text)
                .caption()
                .fill(Muted)
                .max_width(wrap)
                .align(TextAlign::Center)
                .y(56.0)
                .opacity(0.0)
                .name("subtitle"),
        );
        reveals.push(line.node());
        second = Some(line);
    }
    // One line each: the layout above. Otherwise stack them in the middle.
    let one_line = |txt: &Txt, probe: TxtBuilder| {
        let p = root.add(probe.text("Ag").opacity(0.0));
        let h = p.size().1;
        p.remove();
        txt.size().1 <= h * 1.5
    };
    let fits = one_line(&title, Txt::new().h1())
        && second
            .as_ref()
            .is_none_or(|s| one_line(s, Txt::new().caption()));
    if !fits {
        let th = title.size().1;
        let sh = second.as_ref().map_or(0.0, |s| s.size().1);
        let total = th + second.as_ref().map_or(0.0, |_| GAP + sh);
        card.height().set((total + 2.0 * PAD).max(HEIGHT));
        title.y().set(-total / 2.0 + th / 2.0);
        if let Some(s) = &second {
            s.y().set(total / 2.0 - sh / 2.0);
        }
    }
    Host::new(root.node(), reveals)
}

/// A centered title. `enter` shows the card and the title, `next` shows the subtitle.
pub struct TitleCard {
    host: Host,
}

impl TitleCard {
    /// Starts a card. The subtitle stays hidden until [`next`](Self::next).
    #[allow(clippy::new_ret_no_self)]
    pub fn new(title: impl Into<String>) -> TitleCardBuilder {
        TitleCardBuilder {
            base: BaseProps::default(),
            title: title.into(),
            subtitle: None,
        }
    }

    /// Fades the card in.
    pub fn enter(&self) -> Tween<f64> {
        self.host.enter()
    }
    /// Fades the subtitle in, or holds the card if there is none.
    pub fn next(&self) -> Tween<f64> {
        self.host.next()
    }
    /// Fades the card out.
    pub fn exit(&self) -> Tween<f64> {
        self.host.exit()
    }
}

/// Builder for [`TitleCard`].
#[must_use = "a builder does nothing until it is passed to `add`"]
pub struct TitleCardBuilder {
    base: BaseProps,
    title: String,
    subtitle: Option<String>,
}

impl TitleCardBuilder {
    /// Second line, revealed by `next`.
    pub fn subtitle(mut self, text: impl Into<String>) -> Self {
        self.subtitle = Some(text.into());
        self
    }
}

impl NodeBuilder for TitleCardBuilder {
    type Handle = TitleCard;
    fn base(&mut self) -> &mut BaseProps {
        &mut self.base
    }
    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> TitleCard {
        let root = host::root(parent, self.base);
        TitleCard {
            host: plate(g, &root, self.title, self.subtitle),
        }
    }
}

/// Closing card. `next` reveals the note.
pub struct EndCard {
    host: Host,
}

impl EndCard {
    /// Starts an end card.
    #[allow(clippy::new_ret_no_self)]
    pub fn new(title: impl Into<String>) -> EndCardBuilder {
        EndCardBuilder {
            base: BaseProps::default(),
            title: title.into(),
            note: None,
        }
    }

    /// Fades the card in.
    pub fn enter(&self) -> Tween<f64> {
        self.host.enter()
    }
    /// Fades the note in.
    pub fn next(&self) -> Tween<f64> {
        self.host.next()
    }
    /// Fades the card out.
    pub fn exit(&self) -> Tween<f64> {
        self.host.exit()
    }
}

/// Builder for [`EndCard`].
#[must_use = "a builder does nothing until it is passed to `add`"]
pub struct EndCardBuilder {
    base: BaseProps,
    title: String,
    note: Option<String>,
}

impl EndCardBuilder {
    /// Second line, revealed by `next`.
    pub fn note(mut self, text: impl Into<String>) -> Self {
        self.note = Some(text.into());
        self
    }
}

impl NodeBuilder for EndCardBuilder {
    type Handle = EndCard;
    fn base(&mut self) -> &mut BaseProps {
        &mut self.base
    }
    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> EndCard {
        let root = host::root(parent, self.base);
        EndCard {
            host: plate(g, &root, self.title, self.note),
        }
    }
}

/// Items appear one per `next`, after `enter` shows the stack.
pub struct BulletList {
    host: Host,
}

impl BulletList {
    /// One row per string. Rows stay hidden until `next`.
    #[allow(clippy::new_ret_no_self)]
    pub fn new(items: impl IntoIterator<Item = impl Into<String>>) -> BulletListBuilder {
        BulletListBuilder {
            base: BaseProps::default(),
            items: items.into_iter().map(Into::into).collect(),
        }
    }

    /// Fades the list in. The rows are still hidden.
    pub fn enter(&self) -> Tween<f64> {
        self.host.enter()
    }
    /// Reveals the next row.
    pub fn next(&self) -> Tween<f64> {
        self.host.next()
    }
    /// Fades the list out.
    pub fn exit(&self) -> Tween<f64> {
        self.host.exit()
    }
    /// How many rows `next` can reveal.
    pub fn len(&self) -> usize {
        self.host.len()
    }
    /// No rows were given.
    pub fn is_empty(&self) -> bool {
        self.host.is_empty()
    }
}

/// Builder for [`BulletList`].
#[must_use = "a builder does nothing until it is passed to `add`"]
pub struct BulletListBuilder {
    base: BaseProps,
    items: Vec<String>,
}

impl NodeBuilder for BulletListBuilder {
    type Handle = BulletList;
    fn base(&mut self) -> &mut BaseProps {
        &mut self.base
    }
    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> BulletList {
        let root = host::root(parent, self.base);
        // A long row wraps inside 70 % of the frame instead of running off
        // both edges; the text starts right of the mark.
        const MARK: f64 = 18.0;
        const INDENT: f64 = 34.0;
        const GAP: f64 = 24.0;
        let wrap = (g.frame_size().0 * 0.7 - INDENT).max(200.0);
        let probe = root.add(Txt::new().text("Ag").body().opacity(0.0));
        let line_height = probe.size().1.max(MARK);
        probe.remove();
        let mut rows = Vec::new();
        for (i, text) in self.items.into_iter().enumerate() {
            let row: Group = root.add(Group::new().opacity(0.0).name(format!("item{}", i + 1)));
            let mark = row.add(Rect::new().size(MARK).fill(Accent).name("mark"));
            let line = row.add(
                Txt::new()
                    .text(text)
                    .body()
                    .max_width(wrap)
                    .anchor((-1.0, -1.0))
                    .name("line"),
            );
            let (w, h) = line.size();
            rows.push((row, mark, line, w, h.max(line_height)));
        }
        let widest = rows.iter().map(|r| r.3).fold(0.0, f64::max);
        let left = -(INDENT + widest) / 2.0;
        let total: f64 =
            rows.iter().map(|r| r.4).sum::<f64>() + GAP * rows.len().saturating_sub(1) as f64;
        let mut top = -total / 2.0;
        let mut reveals = Vec::new();
        for (row, mark, line, _, h) in rows {
            row.y().set(top);
            line.x().set(left + INDENT);
            mark.x().set(left + MARK / 2.0);
            // Level with the first line of a wrapped row.
            mark.y().set(line_height / 2.0);
            top += h + GAP;
            reveals.push(row.node());
        }
        BulletList {
            host: Host::new(root.node(), reveals),
        }
    }
}

#[cfg(test)]
mod tests {
    use oxframe_2d::{SceneGraph, View};

    use super::*;

    /// Bounds of the card, the title and the second line, by name.
    fn card_parts(graph: &SceneGraph) -> Vec<(String, [f64; 4])> {
        graph
            .inspect_nodes()
            .into_iter()
            .filter(|n| n.kind == "Rect" || n.kind == "Txt")
            .map(|n| {
                (
                    n.name.rsplit('/').next().unwrap_or_default().to_owned(),
                    n.bounds,
                )
            })
            .collect()
    }

    #[test]
    fn a_long_subtitle_wraps_inside_the_card_and_the_card_grows() {
        let graph = SceneGraph::new(1920, 1080);
        graph.enter(|| {
            View::current().add(TitleCard::new("Fixed").subtitle(
                "check reported too_fast at a cut between two scenes when the first node of each \
                 had the same id: node ids start again in every scene, and a move is now only a \
                 node added at the same place.",
            ));
        });
        let parts = card_parts(&graph);
        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
        assert!(
            line[0] >= card[0] && line[2] <= card[2],
            "past the card: {line:?} {card:?}"
        );
        assert!(
            line[1] >= card[1] && line[3] <= card[3],
            "past the card: {line:?} {card:?}"
        );
        assert!(
            title[3] <= line[1],
            "title and line overlap: {title:?} {line:?}"
        );

        // Twice as much text no longer fits the usual card, so it grows.
        let graph = SceneGraph::new(1920, 1080);
        let long = "A sentence that keeps going so the second line needs room. ".repeat(6);
        graph.enter(|| {
            View::current().add(TitleCard::new("Changed").subtitle(long));
        });
        let parts = card_parts(&graph);
        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
        assert!(card[3] - card[1] > 260.0, "the card grows: {card:?}");
        assert!(
            line[1] >= card[1] && line[3] <= card[3],
            "past the card: {line:?} {card:?}"
        );
        assert!(
            title[1] >= card[1] && title[3] <= line[1],
            "{title:?} {line:?} {card:?}"
        );
    }

    #[test]
    fn short_text_keeps_the_usual_layout() {
        let graph = SceneGraph::new(1920, 1080);
        graph.enter(|| {
            View::current().add(EndCard::new("Thanks").note("oxframe.dev"));
        });
        let parts = card_parts(&graph);
        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
        assert_eq!(card[3] - card[1], 260.0);
        assert_eq!((title[1] + title[3]) / 2.0, -28.0);
        assert_eq!((line[1] + line[3]) / 2.0, 56.0);
    }

    #[test]
    fn a_long_row_wraps_inside_the_frame_and_rows_do_not_overlap() {
        let graph = SceneGraph::new(1920, 1080);
        graph.enter(|| {
            View::current().add(BulletList::new([
                "Short",
                "A row much longer than the frame is wide, so that it has to wrap onto a second \
                 line or it would run off both edges of the picture",
                "Last",
            ]));
        });
        let lines: Vec<_> = graph
            .inspect_nodes()
            .into_iter()
            .filter(|n| n.kind == "Txt")
            .map(|n| n.bounds)
            .collect();
        assert_eq!(lines.len(), 3);
        for b in &lines {
            assert!(
                b[0] >= -960.0 && b[2] <= 960.0,
                "row leaves the frame: {b:?}"
            );
        }
        assert!(lines[1][2] - lines[1][0] <= 1920.0 * 0.7, "{:?}", lines[1]);
        assert!(
            lines[1][3] - lines[1][1] > lines[0][3] - lines[0][1],
            "the long row wraps"
        );
        for pair in lines.windows(2) {
            assert!(pair[0][3] <= pair[1][1], "rows overlap: {pair:?}");
        }
    }
}