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};
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);
}
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)
}
pub struct TitleCard {
host: Host,
}
impl TitleCard {
#[allow(clippy::new_ret_no_self)]
pub fn new(title: impl Into<String>) -> TitleCardBuilder {
TitleCardBuilder {
base: BaseProps::default(),
title: title.into(),
subtitle: None,
}
}
pub fn enter(&self) -> Tween<f64> {
self.host.enter()
}
pub fn next(&self) -> Tween<f64> {
self.host.next()
}
pub fn exit(&self) -> Tween<f64> {
self.host.exit()
}
}
#[must_use = "a builder does nothing until it is passed to `add`"]
pub struct TitleCardBuilder {
base: BaseProps,
title: String,
subtitle: Option<String>,
}
impl TitleCardBuilder {
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),
}
}
}
pub struct EndCard {
host: Host,
}
impl EndCard {
#[allow(clippy::new_ret_no_self)]
pub fn new(title: impl Into<String>) -> EndCardBuilder {
EndCardBuilder {
base: BaseProps::default(),
title: title.into(),
note: None,
}
}
pub fn enter(&self) -> Tween<f64> {
self.host.enter()
}
pub fn next(&self) -> Tween<f64> {
self.host.next()
}
pub fn exit(&self) -> Tween<f64> {
self.host.exit()
}
}
#[must_use = "a builder does nothing until it is passed to `add`"]
pub struct EndCardBuilder {
base: BaseProps,
title: String,
note: Option<String>,
}
impl EndCardBuilder {
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),
}
}
}
pub struct BulletList {
host: Host,
}
impl BulletList {
#[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(),
}
}
pub fn enter(&self) -> Tween<f64> {
self.host.enter()
}
pub fn next(&self) -> Tween<f64> {
self.host.next()
}
pub fn exit(&self) -> Tween<f64> {
self.host.exit()
}
pub fn len(&self) -> usize {
self.host.len()
}
pub fn is_empty(&self) -> bool {
self.host.is_empty()
}
}
#[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);
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);
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::*;
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:?}"
);
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:?}");
}
}
}