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, Surface, Txt, pct,
5};
6use oxframe_core::Tween;
7
8use crate::host::{self, Host};
9
10fn plate(root: &Group, title: String, subtitle: Option<String>) -> Host {
11    root.add(
12        Rect::new()
13            .width(pct(64))
14            .height(260.0)
15            .radius(Lg)
16            .fill(Surface)
17            .elevation(Md)
18            .name("plate"),
19    );
20    root.add(Txt::new().text(title).h1().y(-28.0).name("title"));
21    let mut reveals = Vec::new();
22    if let Some(text) = subtitle {
23        let line = root.add(
24            Txt::new()
25                .text(text)
26                .caption()
27                .fill(Muted)
28                .y(56.0)
29                .opacity(0.0)
30                .name("subtitle"),
31        );
32        reveals.push(line.node());
33    }
34    Host::new(root.node(), reveals)
35}
36
37/// A centered title. `enter` shows the card and the title, `next` shows the subtitle.
38pub struct TitleCard {
39    host: Host,
40}
41
42impl TitleCard {
43    /// Starts a card. The subtitle stays hidden until [`next`](Self::next).
44    #[allow(clippy::new_ret_no_self)]
45    pub fn new(title: impl Into<String>) -> TitleCardBuilder {
46        TitleCardBuilder {
47            base: BaseProps::default(),
48            title: title.into(),
49            subtitle: None,
50        }
51    }
52
53    /// Fades the card in.
54    pub fn enter(&self) -> Tween<f64> {
55        self.host.enter()
56    }
57    /// Fades the subtitle in, or holds the card if there is none.
58    pub fn next(&self) -> Tween<f64> {
59        self.host.next()
60    }
61    /// Fades the card out.
62    pub fn exit(&self) -> Tween<f64> {
63        self.host.exit()
64    }
65}
66
67/// Builder for [`TitleCard`].
68#[must_use = "a builder does nothing until it is passed to `add`"]
69pub struct TitleCardBuilder {
70    base: BaseProps,
71    title: String,
72    subtitle: Option<String>,
73}
74
75impl TitleCardBuilder {
76    /// Second line, revealed by `next`.
77    pub fn subtitle(mut self, text: impl Into<String>) -> Self {
78        self.subtitle = Some(text.into());
79        self
80    }
81}
82
83impl NodeBuilder for TitleCardBuilder {
84    type Handle = TitleCard;
85    fn base(&mut self) -> &mut BaseProps {
86        &mut self.base
87    }
88    fn build(self, _: &oxframe_2d::SceneGraph, parent: NodeId) -> TitleCard {
89        let root = host::root(parent, self.base);
90        TitleCard {
91            host: plate(&root, self.title, self.subtitle),
92        }
93    }
94}
95
96/// Closing card. `next` reveals the note.
97pub struct EndCard {
98    host: Host,
99}
100
101impl EndCard {
102    /// Starts an end card.
103    #[allow(clippy::new_ret_no_self)]
104    pub fn new(title: impl Into<String>) -> EndCardBuilder {
105        EndCardBuilder {
106            base: BaseProps::default(),
107            title: title.into(),
108            note: None,
109        }
110    }
111
112    /// Fades the card in.
113    pub fn enter(&self) -> Tween<f64> {
114        self.host.enter()
115    }
116    /// Fades the note in.
117    pub fn next(&self) -> Tween<f64> {
118        self.host.next()
119    }
120    /// Fades the card out.
121    pub fn exit(&self) -> Tween<f64> {
122        self.host.exit()
123    }
124}
125
126/// Builder for [`EndCard`].
127#[must_use = "a builder does nothing until it is passed to `add`"]
128pub struct EndCardBuilder {
129    base: BaseProps,
130    title: String,
131    note: Option<String>,
132}
133
134impl EndCardBuilder {
135    /// Second line, revealed by `next`.
136    pub fn note(mut self, text: impl Into<String>) -> Self {
137        self.note = Some(text.into());
138        self
139    }
140}
141
142impl NodeBuilder for EndCardBuilder {
143    type Handle = EndCard;
144    fn base(&mut self) -> &mut BaseProps {
145        &mut self.base
146    }
147    fn build(self, _: &oxframe_2d::SceneGraph, parent: NodeId) -> EndCard {
148        let root = host::root(parent, self.base);
149        EndCard {
150            host: plate(&root, self.title, self.note),
151        }
152    }
153}
154
155/// Items appear one per `next`, after `enter` shows the stack.
156pub struct BulletList {
157    host: Host,
158}
159
160impl BulletList {
161    /// One row per string. Rows stay hidden until `next`.
162    #[allow(clippy::new_ret_no_self)]
163    pub fn new(items: impl IntoIterator<Item = impl Into<String>>) -> BulletListBuilder {
164        BulletListBuilder {
165            base: BaseProps::default(),
166            items: items.into_iter().map(Into::into).collect(),
167        }
168    }
169
170    /// Fades the list in. The rows are still hidden.
171    pub fn enter(&self) -> Tween<f64> {
172        self.host.enter()
173    }
174    /// Reveals the next row.
175    pub fn next(&self) -> Tween<f64> {
176        self.host.next()
177    }
178    /// Fades the list out.
179    pub fn exit(&self) -> Tween<f64> {
180        self.host.exit()
181    }
182    /// How many rows `next` can reveal.
183    pub fn len(&self) -> usize {
184        self.host.len()
185    }
186    /// No rows were given.
187    pub fn is_empty(&self) -> bool {
188        self.host.is_empty()
189    }
190}
191
192/// Builder for [`BulletList`].
193#[must_use = "a builder does nothing until it is passed to `add`"]
194pub struct BulletListBuilder {
195    base: BaseProps,
196    items: Vec<String>,
197}
198
199impl NodeBuilder for BulletListBuilder {
200    type Handle = BulletList;
201    fn base(&mut self) -> &mut BaseProps {
202        &mut self.base
203    }
204    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> BulletList {
205        let root = host::root(parent, self.base);
206        // A long row wraps inside 70 % of the frame instead of running off
207        // both edges; the text starts right of the mark.
208        const MARK: f64 = 18.0;
209        const INDENT: f64 = 34.0;
210        const GAP: f64 = 24.0;
211        let wrap = (g.frame_size().0 * 0.7 - INDENT).max(200.0);
212        let probe = root.add(Txt::new().text("Ag").body().opacity(0.0));
213        let line_height = probe.size().1.max(MARK);
214        probe.remove();
215        let mut rows = Vec::new();
216        for (i, text) in self.items.into_iter().enumerate() {
217            let row: Group = root.add(Group::new().opacity(0.0).name(format!("item{}", i + 1)));
218            let mark = row.add(Rect::new().size(MARK).fill(Accent).name("mark"));
219            let line = row.add(
220                Txt::new()
221                    .text(text)
222                    .body()
223                    .max_width(wrap)
224                    .anchor((-1.0, -1.0))
225                    .name("line"),
226            );
227            let (w, h) = line.size();
228            rows.push((row, mark, line, w, h.max(line_height)));
229        }
230        let widest = rows.iter().map(|r| r.3).fold(0.0, f64::max);
231        let left = -(INDENT + widest) / 2.0;
232        let total: f64 =
233            rows.iter().map(|r| r.4).sum::<f64>() + GAP * rows.len().saturating_sub(1) as f64;
234        let mut top = -total / 2.0;
235        let mut reveals = Vec::new();
236        for (row, mark, line, _, h) in rows {
237            row.y().set(top);
238            line.x().set(left + INDENT);
239            mark.x().set(left + MARK / 2.0);
240            // Level with the first line of a wrapped row.
241            mark.y().set(line_height / 2.0);
242            top += h + GAP;
243            reveals.push(row.node());
244        }
245        BulletList {
246            host: Host::new(root.node(), reveals),
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use oxframe_2d::{SceneGraph, View};
254
255    use super::*;
256
257    #[test]
258    fn a_long_row_wraps_inside_the_frame_and_rows_do_not_overlap() {
259        let graph = SceneGraph::new(1920, 1080);
260        graph.enter(|| {
261            View::current().add(BulletList::new([
262                "Short",
263                "A row much longer than the frame is wide, so that it has to wrap onto a second \
264                 line or it would run off both edges of the picture",
265                "Last",
266            ]));
267        });
268        let lines: Vec<_> = graph
269            .inspect_nodes()
270            .into_iter()
271            .filter(|n| n.kind == "Txt")
272            .map(|n| n.bounds)
273            .collect();
274        assert_eq!(lines.len(), 3);
275        for b in &lines {
276            assert!(
277                b[0] >= -960.0 && b[2] <= 960.0,
278                "row leaves the frame: {b:?}"
279            );
280        }
281        assert!(lines[1][2] - lines[1][0] <= 1920.0 * 0.7, "{:?}", lines[1]);
282        assert!(
283            lines[1][3] - lines[1][1] > lines[0][3] - lines[0][1],
284            "the long row wraps"
285        );
286        for pair in lines.windows(2) {
287            assert!(pair[0][3] <= pair[1][1], "rows overlap: {pair:?}");
288        }
289    }
290}