1use 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
11fn 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 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
78pub struct TitleCard {
80 host: Host,
81}
82
83impl TitleCard {
84 #[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 pub fn enter(&self) -> Tween<f64> {
96 self.host.enter()
97 }
98 pub fn next(&self) -> Tween<f64> {
100 self.host.next()
101 }
102 pub fn exit(&self) -> Tween<f64> {
104 self.host.exit()
105 }
106}
107
108#[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 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
137pub struct EndCard {
139 host: Host,
140}
141
142impl EndCard {
143 #[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 pub fn enter(&self) -> Tween<f64> {
155 self.host.enter()
156 }
157 pub fn next(&self) -> Tween<f64> {
159 self.host.next()
160 }
161 pub fn exit(&self) -> Tween<f64> {
163 self.host.exit()
164 }
165}
166
167#[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 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
196pub struct BulletList {
198 host: Host,
199}
200
201impl BulletList {
202 #[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 pub fn enter(&self) -> Tween<f64> {
213 self.host.enter()
214 }
215 pub fn next(&self) -> Tween<f64> {
217 self.host.next()
218 }
219 pub fn exit(&self) -> Tween<f64> {
221 self.host.exit()
222 }
223 pub fn len(&self) -> usize {
225 self.host.len()
226 }
227 pub fn is_empty(&self) -> bool {
229 self.host.is_empty()
230 }
231}
232
233#[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 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 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 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 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}