use std::cell::Cell;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
const COLLAPSED_PROGRESS_EPSILON: f32 = 0.005;
pub struct Collapse {
expanded: Signal<bool>,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
progress: Option<Signal<f32>>,
natural_size: Cell<Size>,
}
impl Collapse {
pub fn new(expanded: Signal<bool>) -> Self {
Self {
expanded,
pending_child: None,
child_id: None,
progress: None,
natural_size: Cell::new(Size::ZERO),
}
}
pub fn child(mut self, widget: impl Widget + 'static) -> Self {
self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
self
}
pub fn child_id(mut self, id: WidgetId) -> Self {
self.pending_child = Some(PendingChild::Id(id));
self
}
}
impl std::fmt::Debug for Collapse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Collapse").finish()
}
}
impl Widget for Collapse {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if let Some(pending) = self.pending_child.take() {
self.child_id = Some(match pending {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
let Some(child_id) = self.child_id else {
return vec![];
};
let initial = if self.expanded.get() { 1.0 } else { 0.0 };
let progress = ctx.animated_signal(initial);
self.progress = Some(progress.clone());
let id = ctx.self_id();
let registry = ctx.binding_registry();
progress.bind_to(id, registry, BindingLevel::Relayout);
let collapse_anim = ctx.animate().collapse().standard();
let progress_for_effect = progress;
ctx.effect(&self.expanded, move |&expanded| {
let target = if expanded { 1.0 } else { 0.0 };
collapse_anim.to_or_snap(&progress_for_effect, target);
});
vec![child_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let Some(child_id) = self.child_id else {
return (proposal.resolve(0.0, 0.0)).into();
};
let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
self.natural_size.set(natural);
let progress = self
.progress
.as_ref()
.map(|s| s.get().clamp(0.0, 1.0))
.unwrap_or(1.0);
let width = if progress < COLLAPSED_PROGRESS_EPSILON {
0.0
} else {
natural.width
};
Size::new(width, natural.height * progress).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
let natural = self.natural_size.get();
for child in children.iter_mut() {
child.origin = Point::new(bounds.x, bounds.y);
child.size = natural;
}
}
fn clips_children(&self) -> bool {
true
}
fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
}
fn children(&self) -> Vec<WidgetId> {
self.child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::primitives::TextWidget;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn starts_collapsed_when_signal_is_false() {
let expanded = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree
.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("hidden content"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
assert!(
tree.bounds(id).height < 1.0,
"collapsed bounds should be ~0, got {}",
tree.bounds(id).height
);
}
#[test]
fn starts_expanded_when_signal_is_true() {
let expanded = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree
.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("visible content"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
assert!(
tree.bounds(id).height > 1.0,
"expanded bounds should be > 0, got {}",
tree.bounds(id).height
);
}
#[test]
fn flipping_signal_drives_animation() {
let expanded = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
Collapse::new(expanded.clone())
.child(TextWidget::new(lit!("content with some natural height"))),
);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let collapsed = tree.bounds(id).height;
expanded.set(true);
tree.tick_animations(Duration::from_millis(300));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let after = tree.bounds(id).height;
assert!(
after > collapsed,
"after expanding, height ({}) should exceed collapsed height ({})",
after,
collapsed
);
}
#[test]
fn collapse_height_shrinks_proportionally() {
let expanded = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let root =
tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let initial_h = tree.bounds(root).height;
assert!(initial_h > 0.0);
expanded.set(false);
tree.tick_animations(Duration::from_millis(100));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let mid_h = tree.bounds(root).height;
assert!(
mid_h < initial_h * 0.95,
"halfway through collapse, height ({}) should be visibly less than initial ({})",
mid_h,
initial_h
);
assert!(
mid_h > initial_h * 0.05,
"halfway through collapse, height ({}) should not yet be near zero ({})",
mid_h,
initial_h
);
}
#[test]
fn collapse_height_monotonically_decreases() {
let expanded = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let root =
tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let initial_h = tree.bounds(root).height;
expanded.set(false);
let mut prev = f32::INFINITY;
for step in 0..5 {
tree.tick_animations(Duration::from_millis(50));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let h = tree.bounds(root).height;
assert!(
h <= prev + 0.01,
"height must never grow during collapse: step {} got {} after {}",
step,
h,
prev,
);
assert!(
h <= initial_h + 0.01,
"step {} height {} must not exceed initial expanded height {}",
step,
h,
initial_h,
);
prev = h;
}
}
#[test]
fn animation_is_active_mid_tween() {
let expanded = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
expanded.set(true);
tree.tick_animations(Duration::from_millis(50));
assert!(
tree.has_active_animations(),
"tween should be in flight 50 ms in"
);
}
}