teksilo_widgets/animations/
unroll.rs1use std::cell::Cell;
44
45use teksilo_canvas::{Point, Rect, Size, SizeProposal};
46use teksilo_core::accessibility::AccessNodeBuilder;
47use teksilo_core::binding::BindingLevel;
48use teksilo_core::build_context::BuildContext;
49use teksilo_core::signal::Signal;
50use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
51use teksilo_core::widget_id::WidgetId;
52
53const ROLLED_UP_PROGRESS_EPSILON: f32 = 0.005;
58
59#[derive(Copy, Clone, Debug, Eq, PartialEq)]
61pub enum UnrollFrom {
62 Leading,
64 Trailing,
66}
67
68enum Driver {
69 Expanded(Signal<bool>),
71 Progress(Signal<f32>),
73}
74
75pub struct Unroll {
79 driver: Driver,
80 pending_child: Option<PendingChild>,
81 child_id: Option<WidgetId>,
82 progress: Option<Signal<f32>>,
86 from: UnrollFrom,
87 natural_size: Cell<Size>,
90}
91
92impl Unroll {
93 pub fn new(expanded: Signal<bool>) -> Self {
96 Self::with_driver(Driver::Expanded(expanded))
97 }
98
99 pub fn from_progress(progress: Signal<f32>) -> Self {
103 Self::with_driver(Driver::Progress(progress))
104 }
105
106 fn with_driver(driver: Driver) -> Self {
107 Self {
108 driver,
109 pending_child: None,
110 child_id: None,
111 progress: None,
112 from: UnrollFrom::Leading,
113 natural_size: Cell::new(Size::ZERO),
114 }
115 }
116
117 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
119 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
120 self
121 }
122
123 pub fn child_id(mut self, id: WidgetId) -> Self {
125 self.pending_child = Some(PendingChild::Id(id));
126 self
127 }
128
129 pub fn reveal_from(mut self, from: UnrollFrom) -> Self {
132 self.from = from;
133 self
134 }
135
136 pub fn progress_signal(&self) -> Option<Signal<f32>> {
141 self.progress.clone()
142 }
143}
144
145impl std::fmt::Debug for Unroll {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 f.debug_struct("Unroll").field("from", &self.from).finish()
148 }
149}
150
151impl Widget for Unroll {
152 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
153 if let Some(pending) = self.pending_child.take() {
154 self.child_id = Some(match pending {
155 PendingChild::Id(id) => id,
156 PendingChild::Deferred(w) => ctx.add_boxed(w),
157 });
158 }
159 let Some(child_id) = self.child_id else {
160 return vec![];
161 };
162
163 let self_id = ctx.self_id();
164 match &self.driver {
165 Driver::Expanded(expanded) => {
166 let expanded = expanded.clone();
167 let initial = if expanded.get() { 1.0 } else { 0.0 };
168 let progress = ctx.animated_signal(initial);
169 self.progress = Some(progress.clone());
170 progress.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
173
174 let anim = ctx.animate().collapse().standard();
175 let progress_for_effect = progress;
176 ctx.effect(&expanded, move |&expanded| {
177 let target = if expanded { 1.0 } else { 0.0 };
178 anim.to_or_snap(&progress_for_effect, target);
179 });
180 }
181 Driver::Progress(sig) => {
182 self.progress = Some(sig.clone());
183 sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
184 }
185 }
186
187 vec![child_id]
188 }
189
190 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
191 let Some(child_id) = self.child_id else {
192 return proposal.resolve(0.0, 0.0).into();
193 };
194 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
198 self.natural_size.set(natural);
199
200 let progress = self
201 .progress
202 .as_ref()
203 .map(|s| s.get().clamp(0.0, 1.0))
204 .unwrap_or(1.0);
205
206 let width = if progress < ROLLED_UP_PROGRESS_EPSILON {
207 0.0
208 } else {
209 natural.width * progress
210 };
211 Size::new(width, natural.height).into()
212 }
213
214 fn place_children(
215 &self,
216 bounds: Rect,
217 _proposal: SizeProposal,
218 children: &mut [WidgetPlacement],
219 _ctx: &LayoutContext,
220 ) {
221 let natural = self.natural_size.get();
225 let x = match self.from {
226 UnrollFrom::Leading => bounds.x,
227 UnrollFrom::Trailing => bounds.right() - natural.width,
228 };
229 for child in children.iter_mut() {
230 child.origin = Point::new(x, bounds.y);
231 child.size = Size::new(natural.width, natural.height);
232 }
233 }
234
235 fn clips_children(&self) -> bool {
236 true
237 }
238
239 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
240 }
244
245 fn children(&self) -> Vec<WidgetId> {
246 self.child_id.into_iter().collect()
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use std::time::Duration;
253
254 use super::*;
255 use crate::primitives::TextWidget;
256 use teksilo_core::widget_tree::WidgetTree;
257 use teksilo_i18n::lit;
258
259 fn tree() -> WidgetTree {
260 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
261 }
262
263 #[test]
264 fn starts_rolled_up_when_signal_is_false() {
265 let expanded = Signal::new(false);
266 let mut t = tree();
267 let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("hidden"))));
268 t.layout(SizeProposal::unspecified());
269 assert!(
270 t.bounds(id).width < 1.0,
271 "rolled-up width should be ~0, got {}",
272 t.bounds(id).width
273 );
274 }
275
276 #[test]
277 fn starts_unrolled_when_signal_is_true() {
278 let expanded = Signal::new(true);
279 let mut t = tree();
280 let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("visible content"))));
281 t.layout(SizeProposal::unspecified());
282 assert!(
283 t.bounds(id).width > 1.0,
284 "unrolled width should be > 0, got {}",
285 t.bounds(id).width
286 );
287 }
288
289 #[test]
290 fn width_grows_proportionally_during_tween() {
291 let expanded = Signal::new(false);
292 let mut t = tree();
293 let id = t.add(Unroll::new(expanded.clone()).child(TextWidget::new(lit!("some content"))));
294 t.layout(SizeProposal::unspecified());
295 let rolled = t.bounds(id).width;
296
297 expanded.set(true);
298 t.tick_animations(Duration::from_millis(300));
299 t.layout(SizeProposal::unspecified());
300 let after = t.bounds(id).width;
301 assert!(
302 after > rolled,
303 "after expanding, width ({after}) should exceed rolled-up ({rolled})"
304 );
305 }
306
307 #[test]
308 fn external_progress_drives_width() {
309 let progress = Signal::new_animated(1.0);
311 let mut t = tree();
312 let child = t.add(TextWidget::new(lit!("0123456789")));
313 let id = t.add(Unroll::from_progress(progress.clone()).child_id(child));
314 t.layout(SizeProposal::unspecified());
315 let full = t.bounds(id).width;
316 assert!(full > 0.0);
317
318 progress.set(0.5);
319 t.layout(SizeProposal::unspecified());
320 let half = t.bounds(id).width;
321 assert!(
322 (half - full * 0.5).abs() < full * 0.1,
323 "half progress width ({half}) should be ~half of full ({full})"
324 );
325 }
326
327 #[test]
328 fn trailing_anchor_pins_trailing_edge() {
329 let progress = Signal::new_animated(0.5);
330 let mut t = tree();
331 let child = t.add(TextWidget::new(lit!("0123456789")));
332 let id = t.add(
333 Unroll::from_progress(progress)
334 .reveal_from(UnrollFrom::Trailing)
335 .child_id(child),
336 );
337 t.layout(SizeProposal::unspecified());
338 let wrapper = t.bounds(id);
342 let inner = t.bounds(child);
343 assert!(
344 inner.x < wrapper.x + 0.5,
345 "trailing-anchored child origin ({}) should be at/left of wrapper origin ({})",
346 inner.x,
347 wrapper.x
348 );
349 }
350}