use std::cell::Cell;
use std::rc::Rc;
use day_core::*;
use day_spec::props::*;
use day_spec::{Font, Rect, Size, kinds};
use crate::*;
use day_geometry::Proposal;
#[derive(Clone)]
struct FormLabelColumn(Rc<Cell<f64>>);
const SECTION_RADIUS: f64 = 10.0;
const LABELED_GAP: f64 = 12.0;
const STACKED_GAP: f64 = 6.0;
pub fn form<C: PieceSeq + 'static>(sections: C) -> Form<C> {
Form { sections }
}
pub struct Form<C: PieceSeq> {
sections: C,
}
impl<C: PieceSeq + 'static> Piece for Form<C> {
fn build(self, cx: &mut BuildCx) -> RNode {
with_environment(FormLabelColumn(Rc::new(Cell::new(0.0))), move || {
column(self.sections).spacing(16.0).align(HAlign::Leading)
})
.build(cx)
}
}
pub struct FormSection<C: PieceSeq> {
title: Option<TextSource>,
footer: Option<TextSource>,
children: C,
}
pub fn section<C: PieceSeq + 'static>(children: C) -> FormSection<C> {
FormSection {
title: None,
footer: None,
children,
}
}
#[day_macros::builder(FormSectionBuilder)]
impl<C: PieceSeq + 'static> FormSection<C> {
pub fn title<M>(mut self, t: impl IntoText<M>) -> Self {
self.title = Some(t.into_text());
self
}
pub fn footer<M>(mut self, t: impl IntoText<M>) -> Self {
self.footer = Some(t.into_text());
self
}
}
impl<C: PieceSeq + 'static> Piece for FormSection<C> {
fn build(self, cx: &mut BuildCx) -> RNode {
let children = self.children;
let card = piece_fn(move |cx: &mut BuildCx| {
let node = cx.native(
kinds::CONTAINER,
&ContainerProps {
background: None,
corner_radius: SECTION_RADIUS,
clips: true,
role: Some(day_spec::SurfaceRole::SectionCard),
},
Rc::new(SectionCardLayout),
Flex {
grow_w: true,
..Default::default()
},
Boundary::No,
);
let inner = column(children)
.spacing(10.0)
.align(HAlign::Leading)
.padding(14.0);
cx.under(node, |cx| {
let _ = inner.build(cx);
});
node
});
let note = |t: TextSource| Label {
text: t,
font: Font::Footnote,
font_scale: 1.0,
weight: None,
italic: false,
tabular: false,
monospace: false,
wraps: true,
max_lines: 0,
runs: Vec::new(),
markdown: false,
align: day_spec::props::TextAlign::Leading,
on_link: None,
color: None,
role: Default::default(),
};
let footer_insets = day_spec::Insets {
top: 0.0,
leading: 12.0,
bottom: 0.0,
trailing: 12.0,
};
match (self.title, self.footer) {
(None, None) => card.build(cx),
(Some(t), None) => column((note(t), card))
.spacing(6.0)
.align(HAlign::Leading)
.build(cx),
(None, Some(f)) => column((card, note(f).secondary().padding(footer_insets)))
.spacing(6.0)
.align(HAlign::Leading)
.build(cx),
(Some(t), Some(f)) => {
column((note(t), card, note(f).secondary().padding(footer_insets)))
.spacing(6.0)
.align(HAlign::Leading)
.build(cx)
}
}
}
}
struct SectionCardLayout;
impl day_core::Layout for SectionCardLayout {
fn measure(&self, cx: &mut dyn day_core::LayoutOps, children: &[RNode], p: Proposal) -> Size {
let cs = children
.first()
.map(|&c| cx.measure_child(c, Proposal::new(p.width, None)))
.unwrap_or(Size::ZERO);
Size::new(p.width.unwrap_or(cs.width).max(cs.width), cs.height)
}
fn place(&self, cx: &mut dyn day_core::LayoutOps, children: &[RNode], bounds: Rect) {
if let Some(&c) = children.first() {
let s = cx.measure_child(c, Proposal::new(Some(bounds.size.width), None));
cx.place_child(c, Rect::new(0.0, 0.0, bounds.size.width, s.height));
}
}
}
pub fn labeled<M, P: Piece>(text: impl IntoText<M>, control: P) -> Labeled<P> {
Labeled {
text: text.into_text(),
control,
}
}
pub struct Labeled<P: Piece> {
text: TextSource,
control: P,
}
impl<P: Piece> Piece for Labeled<P> {
fn build(self, cx: &mut BuildCx) -> RNode {
let Labeled { text, control } = self;
let a11y_text = text.clone();
let col = environment::<FormLabelColumn>();
let node = cx.layout_only(
Rc::new(LabeledLayout { col }),
Flex {
grow_w: true,
..Default::default()
},
Boundary::No,
);
cx.under(node, |cx| {
let row_label = Label {
text,
font: Font::Body,
font_scale: 1.0,
weight: None,
italic: false,
tabular: false,
monospace: false,
wraps: true,
max_lines: 0,
runs: Vec::new(),
markdown: false,
align: day_spec::props::TextAlign::Leading,
on_link: None,
color: None,
role: Default::default(),
};
let _ = row_label.build(cx);
let ctl = control.build(cx);
name_control(ctl, &a11y_text);
});
node
}
}
fn name_control(ctl: RNode, text: &TextSource) {
let named = with_tree(|t| t.node_a11y(ctl)).is_some_and(|a| a.label.is_some());
if named {
return;
}
let label = |s: String| day_spec::A11yProps {
label: Some(s),
..Default::default()
};
match text {
TextSource::Static(s) => with_tree(|t| t.set_a11y(ctl, label(s.clone()))),
TextSource::Dyn(read) => {
let read = read.clone();
let seed = day_reactive::untrack(|| read());
with_tree(|t| t.set_a11y(ctl, label(seed.clone())));
day_reactive::bind_seeded(
seed,
move || read(),
move |s: &String| with_tree(|t| t.set_a11y(ctl, label(s.clone()))),
);
}
}
}
struct LabeledLayout {
col: Option<FormLabelColumn>,
}
impl LabeledLayout {
fn column_width(&self, label_w: f64) -> f64 {
match &self.col {
Some(c) => {
if label_w > c.0.get() {
c.0.set(label_w);
}
c.0.get()
}
None => label_w,
}
}
}
impl LabeledLayout {
fn baseline_shift(
&self,
cx: &mut dyn day_core::LayoutOps,
lbl: RNode,
ls: Size,
ctl: RNode,
cs: Size,
) -> Option<(f64, f64, f64)> {
let lb = cx.baseline_of(lbl, ls)?;
let cb = cx.baseline_of(ctl, cs)?;
let deepest = lb.max(cb);
let height = deepest + (ls.height - lb).max(cs.height - cb);
Some((deepest - lb, deepest - cb, height))
}
}
impl LabeledLayout {
fn stacks(
&self,
cx: &mut dyn day_core::LayoutOps,
ctl: RNode,
label_w: f64,
width: f64,
) -> bool {
let avail = (width - label_w - LABELED_GAP).max(0.0);
let measured = cx
.measure_child(ctl, Proposal::new(Some(avail), None))
.width;
measured > avail + 0.5
}
}
impl day_core::Layout for LabeledLayout {
fn measure(&self, cx: &mut dyn day_core::LayoutOps, children: &[RNode], p: Proposal) -> Size {
let (Some(&lbl), Some(&ctl)) = (children.first(), children.get(1)) else {
return Size::ZERO;
};
let ls = cx.measure_child(lbl, Proposal::UNCONSTRAINED);
if let Some(w) = p.width
&& self.stacks(cx, ctl, ls.width, w)
{
let cs = cx.measure_child(ctl, Proposal::new(Some(w), None));
return Size::new(w, ls.height + STACKED_GAP + cs.height);
}
let colw = self.column_width(ls.width);
let avail = p.width.map(|w| (w - colw - LABELED_GAP).max(0.0));
let cs = cx.measure_child(ctl, Proposal::new(avail, None));
let natural = colw + LABELED_GAP + cs.width;
let boxes = ls.height.max(cs.height);
let height = match self.baseline_shift(cx, lbl, ls, ctl, cs) {
Some((_, _, h)) => h.max(boxes),
None => boxes,
};
Size::new(p.width.unwrap_or(natural).max(natural), height)
}
fn baseline(
&self,
cx: &mut dyn day_core::LayoutOps,
children: &[RNode],
size: Size,
) -> Option<f64> {
let (Some(&lbl), Some(&ctl)) = (children.first(), children.get(1)) else {
return None;
};
let ls = cx.measure_child(lbl, Proposal::UNCONSTRAINED);
if self.stacks(cx, ctl, ls.width, size.width) {
return cx.baseline_of(lbl, ls);
}
let colw = self.column_width(ls.width);
let avail = (size.width - colw - LABELED_GAP).max(0.0);
let cs = cx.measure_child(ctl, Proposal::new(Some(avail), None));
let lb = cx.baseline_of(lbl, ls)?;
let (shift, _, _) = self.baseline_shift(cx, lbl, ls, ctl, cs)?;
Some(shift + lb)
}
fn place(&self, cx: &mut dyn day_core::LayoutOps, children: &[RNode], bounds: Rect) {
let (Some(&lbl), Some(&ctl)) = (children.first(), children.get(1)) else {
return;
};
let ls = cx.measure_child(lbl, Proposal::UNCONSTRAINED);
if self.stacks(cx, ctl, ls.width, bounds.size.width) {
let w = bounds.size.width;
let cs = cx.measure_child(ctl, Proposal::new(Some(w), None));
cx.place_child(lbl, Rect::new(0.0, 0.0, ls.width.min(w), ls.height));
let cw = if cx.flex_of(ctl).grow_w {
w
} else {
cs.width.min(w)
};
cx.place_child(ctl, Rect::new(0.0, ls.height + STACKED_GAP, cw, cs.height));
return;
}
let colw = self.column_width(ls.width);
let avail = (bounds.size.width - colw - LABELED_GAP).max(0.0);
let cs = cx.measure_child(ctl, Proposal::new(Some(avail), None));
let h = bounds.size.height;
let (lbl_y, ctl_y) = match self.baseline_shift(cx, lbl, ls, ctl, cs) {
Some((dl, dc, used)) => {
let slack = ((h - used) / 2.0).max(0.0);
(slack + dl, slack + dc)
}
None => (
((h - ls.height) / 2.0).max(0.0),
((h - cs.height) / 2.0).max(0.0),
),
};
cx.place_child(
lbl,
Rect::new((colw - ls.width).max(0.0), lbl_y, ls.width, ls.height),
);
let cw = if cx.flex_of(ctl).grow_w {
avail
} else {
cs.width.min(avail)
};
cx.place_child(ctl, Rect::new(colw + LABELED_GAP, ctl_y, cw, cs.height));
}
}
#[cfg(feature = "conformance")]
pub(crate) mod conformance {
use day_core::conformance::{Case, Drive, Fail, FrameExpect, TestResult};
use day_reactive::Signal;
use day_spec::{Insets, kinds};
use crate::*;
#[day_macros::test(day_core)]
fn form_sections() -> Case {
let name = Signal::new(String::new());
let on = Signal::new(false);
Case::new()
.proves_modifier("grow")
.page(move || {
form((
section((labeled("Name", text_field(name).id(day_core::id!(name))),))
.title("Profile"),
section((labeled("Updates", toggle(on).id(day_core::id!(updates))),))
.title("Mail"),
))
.id(day_core::id!(form))
})
.shot("default")
.drive(|d: Drive| async move {
d.input("name", "Ada").await?;
d.assert_text("name", "Ada").await?;
d.toggle("updates", true).await?;
d.assert_on("updates", true).await
})
}
#[day_macros::test(day_core)]
fn labeled_aligns_controls() -> Case {
let (a, b) = (Signal::new(String::new()), Signal::new(String::new()));
Case::new()
.page(move || {
form((section((
labeled("Name", text_field(a).id(day_core::id!(short))),
labeled("A much longer label", text_field(b).id(day_core::id!(long))),
)),))
})
.drive(|d: Drive| async move {
d.assert_frame(
"short",
FrameExpect {
x: Some(0.0),
relative_to: Some("long".into()),
..Default::default()
},
)
.await
})
}
fn control_alignment(row_id: &str, control_id: &str) -> TestResult {
day_core::try_with_tree(|t| {
let row = t.find_by_id(row_id).ok_or_else(|| Fail(format!("no row {row_id}")))?;
let label = t.first_child(row).ok_or_else(|| Fail(format!("no label in {row_id}")))?;
let control = t.find_by_id(control_id).ok_or_else(|| Fail(format!("no control {control_id}")))?;
let frames = |node| -> Result<_, Fail> {
let day = t.node_frame(node).ok_or_else(|| Fail("no Day frame".into()))?;
let native = t.read_native(node).and_then(|n| n.frame)
.ok_or_else(|| Fail(format!("{row_id}: native frame unavailable")))?;
Ok((day, native))
};
let (ld, ln) = frames(label)?;
let (cd, cn) = frames(control)?;
for (source, l, c) in [("Day", ld, cd), ("native", ln, cn)] {
let delta = c.origin.y + c.size.height / 2.0 - l.origin.y - l.size.height / 2.0;
if l.size.height <= 0.0 || c.size.height <= 0.0 || delta.abs() > 1.5 {
return Err(Fail(format!("{row_id}: {source} control/label centers differ by {delta:.2}; label {l:?}, control {c:?}")));
}
}
let dx = (cn.origin.x - ln.origin.x) - (cd.origin.x - ld.origin.x);
if dx.abs() > 1.5 {
return Err(Fail(format!("{row_id}: native control/label spacing shifted by {dx:.2}")));
}
Ok(())
}).unwrap_or_else(|| Err(Fail("the tree is busy".into())))
}
async fn controls_align(d: &Drive, pairs: &[(&str, &str)]) -> TestResult {
for attempt in 0..100 {
let result = pairs
.iter()
.try_for_each(|(row, control)| control_alignment(row, control));
if result.is_ok() || attempt == 99 {
return result;
}
d.pause(0.02).await?;
}
unreachable!()
}
#[day_macros::test(day_core)]
fn labeled_nontext_controls_center() -> Case {
let a = Signal::new(false);
let b = Signal::new(true);
let value = Signal::new(0.5);
Case::new()
.proves(kinds::TOGGLE)
.proves(kinds::SLIDER)
.proves_duty("set_frame")
.page(move || {
column((
row((
labeled("Multi", toggle(a).id(day_core::id!(pair_a)))
.id(day_core::id!(pair_a_row)),
labeled("Lock", toggle(b).id(day_core::id!(pair_b)))
.id(day_core::id!(pair_b_row)),
))
.spacing(16.0)
.fit(RowFit::Wrap { run_spacing: 8.0 }),
form((section((
labeled("Updates", toggle(a).id(day_core::id!(form_toggle)))
.id(day_core::id!(form_toggle_row)),
labeled("Level", slider(value).id(day_core::id!(form_slider)))
.id(day_core::id!(form_slider_row)),
)),)),
))
.spacing(12.0)
.width(300.0)
})
.drive(|d: Drive| async move {
let pairs = [
("pair_a_row", "pair_a"),
("pair_b_row", "pair_b"),
("form_toggle_row", "form_toggle"),
("form_slider_row", "form_slider"),
];
controls_align(&d, &pairs).await?;
d.shot("centered-controls").await?;
d.toggle("pair_a", true).await?;
d.toggle("pair_b", false).await?;
d.wait_idle().await?;
controls_align(&d, &pairs).await
})
}
#[day_macros::test(day_core)]
fn row_nontext_controls_center() -> Case {
let on = Signal::new(true);
let value = Signal::new(0.5);
Case::new()
.proves(kinds::TOGGLE)
.proves(kinds::SLIDER)
.proves_duty("set_frame")
.page(move || {
column((
row((label("Switch"), toggle(on).id(day_core::id!(row_toggle))))
.spacing(12.0)
.id(day_core::id!(toggle_row)),
row((label("Level"), slider(value).id(day_core::id!(row_slider))))
.spacing(12.0)
.id(day_core::id!(slider_row)),
))
.spacing(16.0)
.padding(Insets::all(18.0))
})
.drive(|d: Drive| async move {
let pairs = [("toggle_row", "row_toggle"), ("slider_row", "row_slider")];
controls_align(&d, &pairs).await?;
d.toggle("row_toggle", false).await?;
d.wait_idle().await?;
controls_align(&d, &pairs).await
})
}
#[day_macros::test(day_core)]
fn wrapped_controls_stay_inside_row() -> Case {
let on = Signal::new(false);
Case::new()
.proves(kinds::CONTAINER)
.proves(kinds::TOGGLE)
.proves(kinds::BUTTON)
.proves_duty("set_frame")
.page(move || {
column((
row((
labeled("Pause delivery", toggle(on).id(day_core::id!(pause)))
.id(day_core::id!(pause_row)),
labeled("Go offline", toggle(on).id(day_core::id!(offline)))
.id(day_core::id!(offline_row)),
))
.spacing(16.0)
.fit(RowFit::Wrap { run_spacing: 8.0 })
.id(day_core::id!(wrapped_pairs))
.width(220.0),
row((
button("Add task").id(day_core::id!(add)),
button("Delete selected").id(day_core::id!(delete)),
button("Undo").id(day_core::id!(undo)),
button("Redo").id(day_core::id!(redo)),
))
.spacing(8.0)
.fit(RowFit::Wrap { run_spacing: 8.0 })
.id(day_core::id!(wrapped_actions))
.width(220.0),
))
.spacing(20.0)
})
.drive(|d: Drive| async move {
controls_align(&d, &[("pause_row", "pause"), ("offline_row", "offline")]).await?;
d.shot("wrapped-controls").await?;
day_core::with_tree(|t| {
let frame = |id| {
t.find_by_id(id)
.and_then(|n| t.read_native(n))
.and_then(|s| s.frame)
.ok_or_else(|| Fail(format!("{id}: native frame unavailable")))
};
for (parent, children) in [
("wrapped_pairs", &["pause", "offline"][..]),
("wrapped_actions", &["add", "delete", "undo", "redo"][..]),
] {
let bounds = frame(parent)?;
for &id in children {
let f = frame(id)?;
d.check(
f.origin.x >= bounds.origin.x - 1.5
&& f.origin.y >= bounds.origin.y - 1.5
&& f.origin.x + f.size.width
<= bounds.origin.x + bounds.size.width + 1.5
&& f.origin.y + f.size.height
<= bounds.origin.y + bounds.size.height + 1.5,
&format!("{id} {f:?} extends outside {parent} {bounds:?}"),
)?;
}
let first = frame(children[0])?;
let last = frame(children[children.len() - 1])?;
d.check(
last.origin.y >= first.origin.y + first.size.height,
&format!("{parent}: controls did not wrap onto another line"),
)?;
}
Ok(())
})
})
}
}