teksilo_widgets/animations/
collapse.rs1use std::cell::Cell;
27
28use teksilo_canvas::{Point, Rect, Size, SizeProposal};
29use teksilo_core::accessibility::AccessNodeBuilder;
30use teksilo_core::binding::BindingLevel;
31use teksilo_core::build_context::BuildContext;
32use teksilo_core::signal::Signal;
33use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
34use teksilo_core::widget_id::WidgetId;
35
36const COLLAPSED_PROGRESS_EPSILON: f32 = 0.005;
43
44pub struct Collapse {
47 expanded: Signal<bool>,
48 pending_child: Option<PendingChild>,
49 child_id: Option<WidgetId>,
50 progress: Option<Signal<f32>>,
53 natural_size: Cell<Size>,
58}
59
60impl Collapse {
61 pub fn new(expanded: Signal<bool>) -> Self {
65 Self {
66 expanded,
67 pending_child: None,
68 child_id: None,
69 progress: None,
70 natural_size: Cell::new(Size::ZERO),
71 }
72 }
73
74 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
76 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
77 self
78 }
79
80 pub fn child_id(mut self, id: WidgetId) -> Self {
82 self.pending_child = Some(PendingChild::Id(id));
83 self
84 }
85}
86
87impl std::fmt::Debug for Collapse {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("Collapse").finish()
90 }
91}
92
93impl Widget for Collapse {
94 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
95 if let Some(pending) = self.pending_child.take() {
97 self.child_id = Some(match pending {
98 PendingChild::Id(id) => id,
99 PendingChild::Deferred(w) => ctx.add_boxed(w),
100 });
101 }
102 let Some(child_id) = self.child_id else {
103 return vec![];
104 };
105
106 let initial = if self.expanded.get() { 1.0 } else { 0.0 };
107 let progress = ctx.animated_signal(initial);
108 self.progress = Some(progress.clone());
109
110 let id = ctx.self_id();
114 let registry = ctx.binding_registry();
115 progress.bind_to(id, registry, BindingLevel::Relayout);
116
117 let collapse_anim = ctx.animate().collapse().standard();
120 let progress_for_effect = progress;
121 ctx.effect(&self.expanded, move |&expanded| {
122 let target = if expanded { 1.0 } else { 0.0 };
123 collapse_anim.to_or_snap(&progress_for_effect, target);
124 });
125
126 vec![child_id]
127 }
128
129 fn layout_response(
130 &self,
131 proposal: SizeProposal,
132 ctx: &LayoutContext,
133 ) -> teksilo_core::widget::LayoutResponse {
134 let Some(child_id) = self.child_id else {
135 return (proposal.resolve(0.0, 0.0)).into();
136 };
137 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
142 self.natural_size.set(natural);
143
144 let progress = self
145 .progress
146 .as_ref()
147 .map(|s| s.get().clamp(0.0, 1.0))
148 .unwrap_or(1.0);
149
150 let width = if progress < COLLAPSED_PROGRESS_EPSILON {
155 0.0
156 } else {
157 natural.width
158 };
159 Size::new(width, natural.height * progress).into()
160 }
161
162 fn place_children(
163 &self,
164 bounds: Rect,
165 _proposal: SizeProposal,
166 children: &mut [WidgetPlacement],
167 _ctx: &LayoutContext,
168 ) {
169 let natural = self.natural_size.get();
176 for child in children.iter_mut() {
177 child.origin = Point::new(bounds.x, bounds.y);
178 child.size = natural;
179 }
180 }
181
182 fn clips_children(&self) -> bool {
183 true
189 }
190
191 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
192 }
198
199 fn children(&self) -> Vec<WidgetId> {
200 self.child_id.into_iter().collect()
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use std::time::Duration;
207
208 use super::*;
209 use crate::primitives::TextWidget;
210 use teksilo_core::widget_tree::WidgetTree;
211 use teksilo_i18n::lit;
212
213 #[test]
214 fn starts_collapsed_when_signal_is_false() {
215 let expanded = Signal::new(false);
216 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
217 let id = tree
218 .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("hidden content"))));
219 tree.layout(SizeProposal {
220 width: Some(300.0),
221 height: None,
222 });
223 assert!(
224 tree.bounds(id).height < 1.0,
225 "collapsed bounds should be ~0, got {}",
226 tree.bounds(id).height
227 );
228 }
229
230 #[test]
231 fn starts_expanded_when_signal_is_true() {
232 let expanded = Signal::new(true);
233 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
234 let id = tree
235 .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("visible content"))));
236 tree.layout(SizeProposal {
237 width: Some(300.0),
238 height: None,
239 });
240 assert!(
241 tree.bounds(id).height > 1.0,
242 "expanded bounds should be > 0, got {}",
243 tree.bounds(id).height
244 );
245 }
246
247 #[test]
248 fn flipping_signal_drives_animation() {
249 let expanded = Signal::new(false);
250 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
251 let id = tree.add(
252 Collapse::new(expanded.clone())
253 .child(TextWidget::new(lit!("content with some natural height"))),
254 );
255 tree.layout(SizeProposal {
256 width: Some(300.0),
257 height: None,
258 });
259 let collapsed = tree.bounds(id).height;
260
261 expanded.set(true);
262
263 tree.tick_animations(Duration::from_millis(300));
264 tree.layout(SizeProposal {
265 width: Some(300.0),
266 height: None,
267 });
268 let after = tree.bounds(id).height;
269
270 assert!(
271 after > collapsed,
272 "after expanding, height ({}) should exceed collapsed height ({})",
273 after,
274 collapsed
275 );
276 }
277
278 #[test]
279 fn collapse_height_shrinks_proportionally() {
280 let expanded = Signal::new(true);
287 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
288 let root =
289 tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
290 tree.layout(SizeProposal {
291 width: Some(300.0),
292 height: None,
293 });
294 let initial_h = tree.bounds(root).height;
295 assert!(initial_h > 0.0);
296
297 expanded.set(false);
298
299 tree.tick_animations(Duration::from_millis(100));
303 tree.layout(SizeProposal {
304 width: Some(300.0),
305 height: None,
306 });
307 let mid_h = tree.bounds(root).height;
308 assert!(
309 mid_h < initial_h * 0.95,
310 "halfway through collapse, height ({}) should be visibly less than initial ({})",
311 mid_h,
312 initial_h
313 );
314 assert!(
315 mid_h > initial_h * 0.05,
316 "halfway through collapse, height ({}) should not yet be near zero ({})",
317 mid_h,
318 initial_h
319 );
320 }
321
322 #[test]
323 fn collapse_height_monotonically_decreases() {
324 let expanded = Signal::new(true);
325 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
326 let root =
327 tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
328 tree.layout(SizeProposal {
329 width: Some(300.0),
330 height: None,
331 });
332 let initial_h = tree.bounds(root).height;
333
334 expanded.set(false);
335
336 let mut prev = f32::INFINITY;
337 for step in 0..5 {
338 tree.tick_animations(Duration::from_millis(50));
339 tree.layout(SizeProposal {
340 width: Some(300.0),
341 height: None,
342 });
343 let h = tree.bounds(root).height;
344 assert!(
345 h <= prev + 0.01,
346 "height must never grow during collapse: step {} got {} after {}",
347 step,
348 h,
349 prev,
350 );
351 assert!(
352 h <= initial_h + 0.01,
353 "step {} height {} must not exceed initial expanded height {}",
354 step,
355 h,
356 initial_h,
357 );
358 prev = h;
359 }
360 }
361
362 #[test]
363 fn animation_is_active_mid_tween() {
364 let expanded = Signal::new(false);
365 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
366 tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
367 tree.layout(SizeProposal {
368 width: Some(300.0),
369 height: None,
370 });
371
372 expanded.set(true);
373 tree.tick_animations(Duration::from_millis(50));
374 assert!(
375 tree.has_active_animations(),
376 "tween should be in flight 50 ms in"
377 );
378 }
379}