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 teksilo_core::IntoTeksiChild) -> Self {
76 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
77 self
78 }
79 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
86 match widget {
87 Some(w) => self.child(w),
88 None => self,
89 }
90 }
91}
92
93impl std::fmt::Debug for Collapse {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("Collapse").finish()
96 }
97}
98
99impl Widget for Collapse {
100 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
101 if let Some(pending) = self.pending_child.take() {
103 self.child_id = Some(match pending {
104 PendingChild::Id(id) => id,
105 PendingChild::Deferred(w) => ctx.add_boxed(w),
106 });
107 }
108 let Some(child_id) = self.child_id else {
109 return vec![];
110 };
111
112 let initial = if self.expanded.get() { 1.0 } else { 0.0 };
113 let progress = ctx.animated_signal(initial);
114 self.progress = Some(progress.clone());
115
116 let id = ctx.self_id();
120 let registry = ctx.binding_registry();
121 progress.bind_to(id, registry, BindingLevel::Relayout);
122
123 let collapse_anim = ctx.animate().collapse().standard();
126 let progress_for_effect = progress;
127 ctx.effect(&self.expanded, move |&expanded| {
128 let target = if expanded { 1.0 } else { 0.0 };
129 collapse_anim.to_or_snap(&progress_for_effect, target);
130 });
131
132 vec![child_id]
133 }
134
135 fn layout_response(
136 &self,
137 proposal: SizeProposal,
138 ctx: &LayoutContext,
139 ) -> teksilo_core::widget::LayoutResponse {
140 let Some(child_id) = self.child_id else {
141 return (proposal.resolve(0.0, 0.0)).into();
142 };
143 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
148 self.natural_size.set(natural);
149
150 let progress = self
151 .progress
152 .as_ref()
153 .map(|s| s.get().clamp(0.0, 1.0))
154 .unwrap_or(1.0);
155
156 let width = if progress < COLLAPSED_PROGRESS_EPSILON {
161 0.0
162 } else {
163 natural.width
164 };
165 Size::new(width, natural.height * progress).into()
166 }
167
168 fn place_children(
169 &self,
170 bounds: Rect,
171 _proposal: SizeProposal,
172 children: &mut [WidgetPlacement],
173 _ctx: &LayoutContext,
174 ) {
175 let natural = self.natural_size.get();
182 for child in children.iter_mut() {
183 child.origin = Point::new(bounds.x, bounds.y);
184 child.size = natural;
185 }
186 }
187
188 fn clips_children(&self) -> bool {
189 true
195 }
196
197 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
198 }
204
205 fn children(&self) -> Vec<WidgetId> {
206 self.child_id.into_iter().collect()
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use std::time::Duration;
213
214 use super::*;
215 use crate::primitives::TextWidget;
216 use teksilo_core::widget_tree::WidgetTree;
217 use teksilo_i18n::lit;
218
219 #[test]
220 fn starts_collapsed_when_signal_is_false() {
221 let expanded = Signal::new(false);
222 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
223 let id = tree
224 .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("hidden content"))));
225 tree.layout(SizeProposal {
226 width: Some(300.0),
227 height: None,
228 });
229 assert!(
230 tree.bounds(id).height < 1.0,
231 "collapsed bounds should be ~0, got {}",
232 tree.bounds(id).height
233 );
234 }
235
236 #[test]
237 fn starts_expanded_when_signal_is_true() {
238 let expanded = Signal::new(true);
239 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
240 let id = tree
241 .add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("visible content"))));
242 tree.layout(SizeProposal {
243 width: Some(300.0),
244 height: None,
245 });
246 assert!(
247 tree.bounds(id).height > 1.0,
248 "expanded bounds should be > 0, got {}",
249 tree.bounds(id).height
250 );
251 }
252
253 #[test]
254 fn flipping_signal_drives_animation() {
255 let expanded = Signal::new(false);
256 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
257 let id = tree.add(
258 Collapse::new(expanded.clone())
259 .child(TextWidget::new(lit!("content with some natural height"))),
260 );
261 tree.layout(SizeProposal {
262 width: Some(300.0),
263 height: None,
264 });
265 let collapsed = tree.bounds(id).height;
266
267 expanded.set(true);
268
269 tree.tick_animations(Duration::from_millis(300));
270 tree.layout(SizeProposal {
271 width: Some(300.0),
272 height: None,
273 });
274 let after = tree.bounds(id).height;
275
276 assert!(
277 after > collapsed,
278 "after expanding, height ({}) should exceed collapsed height ({})",
279 after,
280 collapsed
281 );
282 }
283
284 #[test]
285 fn collapse_height_shrinks_proportionally() {
286 let expanded = Signal::new(true);
293 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
294 let root =
295 tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
296 tree.layout(SizeProposal {
297 width: Some(300.0),
298 height: None,
299 });
300 let initial_h = tree.bounds(root).height;
301 assert!(initial_h > 0.0);
302
303 expanded.set(false);
304
305 tree.tick_animations(Duration::from_millis(100));
309 tree.layout(SizeProposal {
310 width: Some(300.0),
311 height: None,
312 });
313 let mid_h = tree.bounds(root).height;
314 assert!(
315 mid_h < initial_h * 0.95,
316 "halfway through collapse, height ({}) should be visibly less than initial ({})",
317 mid_h,
318 initial_h
319 );
320 assert!(
321 mid_h > initial_h * 0.05,
322 "halfway through collapse, height ({}) should not yet be near zero ({})",
323 mid_h,
324 initial_h
325 );
326 }
327
328 #[test]
329 fn collapse_height_monotonically_decreases() {
330 let expanded = Signal::new(true);
331 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
332 let root =
333 tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
334 tree.layout(SizeProposal {
335 width: Some(300.0),
336 height: None,
337 });
338 let initial_h = tree.bounds(root).height;
339
340 expanded.set(false);
341
342 let mut prev = f32::INFINITY;
343 for step in 0..5 {
344 tree.tick_animations(Duration::from_millis(50));
345 tree.layout(SizeProposal {
346 width: Some(300.0),
347 height: None,
348 });
349 let h = tree.bounds(root).height;
350 assert!(
351 h <= prev + 0.01,
352 "height must never grow during collapse: step {} got {} after {}",
353 step,
354 h,
355 prev,
356 );
357 assert!(
358 h <= initial_h + 0.01,
359 "step {} height {} must not exceed initial expanded height {}",
360 step,
361 h,
362 initial_h,
363 );
364 prev = h;
365 }
366 }
367
368 #[test]
369 fn animation_is_active_mid_tween() {
370 let expanded = Signal::new(false);
371 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
372 tree.add(Collapse::new(expanded.clone()).child(TextWidget::new(lit!("content"))));
373 tree.layout(SizeProposal {
374 width: Some(300.0),
375 height: None,
376 });
377
378 expanded.set(true);
379 tree.tick_animations(Duration::from_millis(50));
380 assert!(
381 tree.has_active_animations(),
382 "tween should be in flight 50 ms in"
383 );
384 }
385}