Skip to main content

oxframe_components/
title.rs

1//! Title card, end card, and a list whose items appear one by one.
2
3use oxframe_2d::{
4    Accent, BaseProps, Group, Lg, Md, Muted, NodeBuilder, NodeId, Rect, SceneGraph, Surface,
5    TextAlign, Txt, TxtBuilder, pct,
6};
7use oxframe_core::Tween;
8
9use crate::host::{self, Host};
10
11/// The card behind a title and an optional second line. Both wrap inside
12/// the card, and a card whose text wraps grows to hold it; text that fits on
13/// one line keeps the usual layout.
14fn plate(g: &SceneGraph, root: &Group, title: String, subtitle: Option<String>) -> Host {
15    const HEIGHT: f64 = 260.0;
16    const PAD: f64 = 48.0;
17    const GAP: f64 = 20.0;
18    let wrap = (g.frame_size().0 * 0.64 - 2.0 * PAD).max(200.0);
19    let card = root.add(
20        Rect::new()
21            .width(pct(64))
22            .height(HEIGHT)
23            .radius(Lg)
24            .fill(Surface)
25            .elevation(Md)
26            .name("plate"),
27    );
28    let title = root.add(
29        Txt::new()
30            .text(title)
31            .h1()
32            .max_width(wrap)
33            .align(TextAlign::Center)
34            .y(-28.0)
35            .name("title"),
36    );
37    let mut reveals = Vec::new();
38    let mut second = None;
39    if let Some(text) = subtitle {
40        let line = root.add(
41            Txt::new()
42                .text(text)
43                .caption()
44                .fill(Muted)
45                .max_width(wrap)
46                .align(TextAlign::Center)
47                .y(56.0)
48                .opacity(0.0)
49                .name("subtitle"),
50        );
51        reveals.push(line.node());
52        second = Some(line);
53    }
54    // One line each: the layout above. Otherwise stack them in the middle.
55    let one_line = |txt: &Txt, probe: TxtBuilder| {
56        let p = root.add(probe.text("Ag").opacity(0.0));
57        let h = p.size().1;
58        p.remove();
59        txt.size().1 <= h * 1.5
60    };
61    let fits = one_line(&title, Txt::new().h1())
62        && second
63            .as_ref()
64            .is_none_or(|s| one_line(s, Txt::new().caption()));
65    if !fits {
66        let th = title.size().1;
67        let sh = second.as_ref().map_or(0.0, |s| s.size().1);
68        let total = th + second.as_ref().map_or(0.0, |_| GAP + sh);
69        card.height().set((total + 2.0 * PAD).max(HEIGHT));
70        title.y().set(-total / 2.0 + th / 2.0);
71        if let Some(s) = &second {
72            s.y().set(total / 2.0 - sh / 2.0);
73        }
74    }
75    Host::new(root.node(), reveals)
76}
77
78/// A centered title. `enter` shows the card and the title, `next` shows the subtitle.
79pub struct TitleCard {
80    host: Host,
81}
82
83impl TitleCard {
84    /// Starts a card. The subtitle stays hidden until [`next`](Self::next).
85    #[allow(clippy::new_ret_no_self)]
86    pub fn new(title: impl Into<String>) -> TitleCardBuilder {
87        TitleCardBuilder {
88            base: BaseProps::default(),
89            title: title.into(),
90            subtitle: None,
91        }
92    }
93
94    /// Fades the card in.
95    pub fn enter(&self) -> Tween<f64> {
96        self.host.enter()
97    }
98    /// Fades the subtitle in, or holds the card if there is none.
99    pub fn next(&self) -> Tween<f64> {
100        self.host.next()
101    }
102    /// Fades the card out.
103    pub fn exit(&self) -> Tween<f64> {
104        self.host.exit()
105    }
106}
107
108/// Builder for [`TitleCard`].
109#[must_use = "a builder does nothing until it is passed to `add`"]
110pub struct TitleCardBuilder {
111    base: BaseProps,
112    title: String,
113    subtitle: Option<String>,
114}
115
116impl TitleCardBuilder {
117    /// Second line, revealed by `next`.
118    pub fn subtitle(mut self, text: impl Into<String>) -> Self {
119        self.subtitle = Some(text.into());
120        self
121    }
122}
123
124impl NodeBuilder for TitleCardBuilder {
125    type Handle = TitleCard;
126    fn base(&mut self) -> &mut BaseProps {
127        &mut self.base
128    }
129    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> TitleCard {
130        let root = host::root(parent, self.base);
131        TitleCard {
132            host: plate(g, &root, self.title, self.subtitle),
133        }
134    }
135}
136
137/// Closing card. `next` reveals the note.
138pub struct EndCard {
139    host: Host,
140}
141
142impl EndCard {
143    /// Starts an end card.
144    #[allow(clippy::new_ret_no_self)]
145    pub fn new(title: impl Into<String>) -> EndCardBuilder {
146        EndCardBuilder {
147            base: BaseProps::default(),
148            title: title.into(),
149            note: None,
150        }
151    }
152
153    /// Fades the card in.
154    pub fn enter(&self) -> Tween<f64> {
155        self.host.enter()
156    }
157    /// Fades the note in.
158    pub fn next(&self) -> Tween<f64> {
159        self.host.next()
160    }
161    /// Fades the card out.
162    pub fn exit(&self) -> Tween<f64> {
163        self.host.exit()
164    }
165}
166
167/// Builder for [`EndCard`].
168#[must_use = "a builder does nothing until it is passed to `add`"]
169pub struct EndCardBuilder {
170    base: BaseProps,
171    title: String,
172    note: Option<String>,
173}
174
175impl EndCardBuilder {
176    /// Second line, revealed by `next`.
177    pub fn note(mut self, text: impl Into<String>) -> Self {
178        self.note = Some(text.into());
179        self
180    }
181}
182
183impl NodeBuilder for EndCardBuilder {
184    type Handle = EndCard;
185    fn base(&mut self) -> &mut BaseProps {
186        &mut self.base
187    }
188    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> EndCard {
189        let root = host::root(parent, self.base);
190        EndCard {
191            host: plate(g, &root, self.title, self.note),
192        }
193    }
194}
195
196/// Items appear one per `next`, after `enter` shows the stack.
197pub struct BulletList {
198    host: Host,
199}
200
201impl BulletList {
202    /// One row per string. Rows stay hidden until `next`.
203    #[allow(clippy::new_ret_no_self)]
204    pub fn new(items: impl IntoIterator<Item = impl Into<String>>) -> BulletListBuilder {
205        BulletListBuilder {
206            base: BaseProps::default(),
207            items: items.into_iter().map(Into::into).collect(),
208        }
209    }
210
211    /// Fades the list in. The rows are still hidden.
212    pub fn enter(&self) -> Tween<f64> {
213        self.host.enter()
214    }
215    /// Reveals the next row.
216    pub fn next(&self) -> Tween<f64> {
217        self.host.next()
218    }
219    /// Fades the list out.
220    pub fn exit(&self) -> Tween<f64> {
221        self.host.exit()
222    }
223    /// How many rows `next` can reveal.
224    pub fn len(&self) -> usize {
225        self.host.len()
226    }
227    /// No rows were given.
228    pub fn is_empty(&self) -> bool {
229        self.host.is_empty()
230    }
231}
232
233/// Builder for [`BulletList`].
234#[must_use = "a builder does nothing until it is passed to `add`"]
235pub struct BulletListBuilder {
236    base: BaseProps,
237    items: Vec<String>,
238}
239
240impl NodeBuilder for BulletListBuilder {
241    type Handle = BulletList;
242    fn base(&mut self) -> &mut BaseProps {
243        &mut self.base
244    }
245    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> BulletList {
246        let root = host::root(parent, self.base);
247        // A long row wraps inside 70 % of the frame instead of running off
248        // both edges; the text starts right of the mark.
249        const MARK: f64 = 18.0;
250        const INDENT: f64 = 34.0;
251        const GAP: f64 = 24.0;
252        let wrap = (g.frame_size().0 * 0.7 - INDENT).max(200.0);
253        let probe = root.add(Txt::new().text("Ag").body().opacity(0.0));
254        let line_height = probe.size().1.max(MARK);
255        probe.remove();
256        let mut rows = Vec::new();
257        for (i, text) in self.items.into_iter().enumerate() {
258            let row: Group = root.add(Group::new().opacity(0.0).name(format!("item{}", i + 1)));
259            let mark = row.add(Rect::new().size(MARK).fill(Accent).name("mark"));
260            let line = row.add(
261                Txt::new()
262                    .text(text)
263                    .body()
264                    .max_width(wrap)
265                    .anchor((-1.0, -1.0))
266                    .name("line"),
267            );
268            let (w, h) = line.size();
269            rows.push((row, mark, line, w, h.max(line_height)));
270        }
271        let widest = rows.iter().map(|r| r.3).fold(0.0, f64::max);
272        let left = -(INDENT + widest) / 2.0;
273        let total: f64 =
274            rows.iter().map(|r| r.4).sum::<f64>() + GAP * rows.len().saturating_sub(1) as f64;
275        let mut top = -total / 2.0;
276        let mut reveals = Vec::new();
277        for (row, mark, line, _, h) in rows {
278            row.y().set(top);
279            line.x().set(left + INDENT);
280            mark.x().set(left + MARK / 2.0);
281            // Level with the first line of a wrapped row.
282            mark.y().set(line_height / 2.0);
283            top += h + GAP;
284            reveals.push(row.node());
285        }
286        BulletList {
287            host: Host::new(root.node(), reveals),
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use oxframe_2d::{SceneGraph, View};
295
296    use super::*;
297
298    /// Bounds of the card, the title and the second line, by name.
299    fn card_parts(graph: &SceneGraph) -> Vec<(String, [f64; 4])> {
300        graph
301            .inspect_nodes()
302            .into_iter()
303            .filter(|n| n.kind == "Rect" || n.kind == "Txt")
304            .map(|n| {
305                (
306                    n.name.rsplit('/').next().unwrap_or_default().to_owned(),
307                    n.bounds,
308                )
309            })
310            .collect()
311    }
312
313    #[test]
314    fn a_long_subtitle_wraps_inside_the_card_and_the_card_grows() {
315        let graph = SceneGraph::new(1920, 1080);
316        graph.enter(|| {
317            View::current().add(TitleCard::new("Fixed").subtitle(
318                "check reported too_fast at a cut between two scenes when the first node of each \
319                 had the same id: node ids start again in every scene, and a move is now only a \
320                 node added at the same place.",
321            ));
322        });
323        let parts = card_parts(&graph);
324        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
325        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
326        assert!(
327            line[0] >= card[0] && line[2] <= card[2],
328            "past the card: {line:?} {card:?}"
329        );
330        assert!(
331            line[1] >= card[1] && line[3] <= card[3],
332            "past the card: {line:?} {card:?}"
333        );
334        assert!(
335            title[3] <= line[1],
336            "title and line overlap: {title:?} {line:?}"
337        );
338
339        // Twice as much text no longer fits the usual card, so it grows.
340        let graph = SceneGraph::new(1920, 1080);
341        let long = "A sentence that keeps going so the second line needs room. ".repeat(6);
342        graph.enter(|| {
343            View::current().add(TitleCard::new("Changed").subtitle(long));
344        });
345        let parts = card_parts(&graph);
346        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
347        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
348        assert!(card[3] - card[1] > 260.0, "the card grows: {card:?}");
349        assert!(
350            line[1] >= card[1] && line[3] <= card[3],
351            "past the card: {line:?} {card:?}"
352        );
353        assert!(
354            title[1] >= card[1] && title[3] <= line[1],
355            "{title:?} {line:?} {card:?}"
356        );
357    }
358
359    #[test]
360    fn short_text_keeps_the_usual_layout() {
361        let graph = SceneGraph::new(1920, 1080);
362        graph.enter(|| {
363            View::current().add(EndCard::new("Thanks").note("oxframe.dev"));
364        });
365        let parts = card_parts(&graph);
366        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
367        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
368        assert_eq!(card[3] - card[1], 260.0);
369        assert_eq!((title[1] + title[3]) / 2.0, -28.0);
370        assert_eq!((line[1] + line[3]) / 2.0, 56.0);
371    }
372
373    #[test]
374    fn a_long_row_wraps_inside_the_frame_and_rows_do_not_overlap() {
375        let graph = SceneGraph::new(1920, 1080);
376        graph.enter(|| {
377            View::current().add(BulletList::new([
378                "Short",
379                "A row much longer than the frame is wide, so that it has to wrap onto a second \
380                 line or it would run off both edges of the picture",
381                "Last",
382            ]));
383        });
384        let lines: Vec<_> = graph
385            .inspect_nodes()
386            .into_iter()
387            .filter(|n| n.kind == "Txt")
388            .map(|n| n.bounds)
389            .collect();
390        assert_eq!(lines.len(), 3);
391        for b in &lines {
392            assert!(
393                b[0] >= -960.0 && b[2] <= 960.0,
394                "row leaves the frame: {b:?}"
395            );
396        }
397        assert!(lines[1][2] - lines[1][0] <= 1920.0 * 0.7, "{:?}", lines[1]);
398        assert!(
399            lines[1][3] - lines[1][1] > lines[0][3] - lines[0][1],
400            "the long row wraps"
401        );
402        for pair in lines.windows(2) {
403            assert!(pair[0][3] <= pair[1][1], "rows overlap: {pair:?}");
404        }
405    }
406}