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
292into_node! {
293    TitleCard => |c| c.host.root;
294    EndCard => |c| c.host.root;
295    BulletList => |c| c.host.root;
296}
297
298#[cfg(test)]
299mod tests {
300    use oxframe_2d::{SceneGraph, View};
301
302    use super::*;
303
304    /// Bounds of the card, the title and the second line, by name.
305    fn card_parts(graph: &SceneGraph) -> Vec<(String, [f64; 4])> {
306        graph
307            .inspect_nodes()
308            .into_iter()
309            .filter(|n| n.kind == "Rect" || n.kind == "Txt")
310            .map(|n| {
311                (
312                    n.name.rsplit('/').next().unwrap_or_default().to_owned(),
313                    n.bounds,
314                )
315            })
316            .collect()
317    }
318
319    #[test]
320    fn a_long_subtitle_wraps_inside_the_card_and_the_card_grows() {
321        let graph = SceneGraph::new(1920, 1080);
322        graph.enter(|| {
323            View::current().add(TitleCard::new("Fixed").subtitle(
324                "check reported too_fast at a cut between two scenes when the first node of each \
325                 had the same id: node ids start again in every scene, and a move is now only a \
326                 node added at the same place.",
327            ));
328        });
329        let parts = card_parts(&graph);
330        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
331        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
332        assert!(
333            line[0] >= card[0] && line[2] <= card[2],
334            "past the card: {line:?} {card:?}"
335        );
336        assert!(
337            line[1] >= card[1] && line[3] <= card[3],
338            "past the card: {line:?} {card:?}"
339        );
340        assert!(
341            title[3] <= line[1],
342            "title and line overlap: {title:?} {line:?}"
343        );
344
345        // Twice as much text no longer fits the usual card, so it grows.
346        let graph = SceneGraph::new(1920, 1080);
347        let long = "A sentence that keeps going so the second line needs room. ".repeat(6);
348        graph.enter(|| {
349            View::current().add(TitleCard::new("Changed").subtitle(long));
350        });
351        let parts = card_parts(&graph);
352        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
353        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
354        assert!(card[3] - card[1] > 260.0, "the card grows: {card:?}");
355        assert!(
356            line[1] >= card[1] && line[3] <= card[3],
357            "past the card: {line:?} {card:?}"
358        );
359        assert!(
360            title[1] >= card[1] && title[3] <= line[1],
361            "{title:?} {line:?} {card:?}"
362        );
363    }
364
365    #[test]
366    fn short_text_keeps_the_usual_layout() {
367        let graph = SceneGraph::new(1920, 1080);
368        graph.enter(|| {
369            View::current().add(EndCard::new("Thanks").note("oxframe.dev"));
370        });
371        let parts = card_parts(&graph);
372        let get = |name: &str| parts.iter().find(|(n, _)| n == name).expect(name).1;
373        let (card, title, line) = (get("plate"), get("title"), get("subtitle"));
374        assert_eq!(card[3] - card[1], 260.0);
375        assert_eq!((title[1] + title[3]) / 2.0, -28.0);
376        assert_eq!((line[1] + line[3]) / 2.0, 56.0);
377    }
378
379    #[test]
380    fn a_long_row_wraps_inside_the_frame_and_rows_do_not_overlap() {
381        let graph = SceneGraph::new(1920, 1080);
382        graph.enter(|| {
383            View::current().add(BulletList::new([
384                "Short",
385                "A row much longer than the frame is wide, so that it has to wrap onto a second \
386                 line or it would run off both edges of the picture",
387                "Last",
388            ]));
389        });
390        let lines: Vec<_> = graph
391            .inspect_nodes()
392            .into_iter()
393            .filter(|n| n.kind == "Txt")
394            .map(|n| n.bounds)
395            .collect();
396        assert_eq!(lines.len(), 3);
397        for b in &lines {
398            assert!(
399                b[0] >= -960.0 && b[2] <= 960.0,
400                "row leaves the frame: {b:?}"
401            );
402        }
403        assert!(lines[1][2] - lines[1][0] <= 1920.0 * 0.7, "{:?}", lines[1]);
404        assert!(
405            lines[1][3] - lines[1][1] > lines[0][3] - lines[0][1],
406            "the long row wraps"
407        );
408        for pair in lines.windows(2) {
409            assert!(pair[0][3] <= pair[1][1], "rows overlap: {pair:?}");
410        }
411    }
412}