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 teksilo_core::IntoTeksiChild) -> Self {
119 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
120 self
121 }
122 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
129 match widget {
130 Some(w) => self.child(w),
131 None => self,
132 }
133 }
134
135 pub fn reveal_from(mut self, from: UnrollFrom) -> Self {
138 self.from = from;
139 self
140 }
141
142 pub fn progress_signal(&self) -> Option<Signal<f32>> {
147 self.progress.clone()
148 }
149}
150
151impl std::fmt::Debug for Unroll {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("Unroll").field("from", &self.from).finish()
154 }
155}
156
157impl Widget for Unroll {
158 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
159 if let Some(pending) = self.pending_child.take() {
160 self.child_id = Some(match pending {
161 PendingChild::Id(id) => id,
162 PendingChild::Deferred(w) => ctx.add_boxed(w),
163 });
164 }
165 let Some(child_id) = self.child_id else {
166 return vec![];
167 };
168
169 let self_id = ctx.self_id();
170 match &self.driver {
171 Driver::Expanded(expanded) => {
172 let expanded = expanded.clone();
173 let initial = if expanded.get() { 1.0 } else { 0.0 };
174 let progress = ctx.animated_signal(initial);
175 self.progress = Some(progress.clone());
176 progress.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
179
180 let anim = ctx.animate().collapse().standard();
181 let progress_for_effect = progress;
182 ctx.effect(&expanded, move |&expanded| {
183 let target = if expanded { 1.0 } else { 0.0 };
184 anim.to_or_snap(&progress_for_effect, target);
185 });
186 }
187 Driver::Progress(sig) => {
188 self.progress = Some(sig.clone());
189 sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
190 }
191 }
192
193 vec![child_id]
194 }
195
196 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
197 let Some(child_id) = self.child_id else {
198 return proposal.resolve(0.0, 0.0).into();
199 };
200 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
204 self.natural_size.set(natural);
205
206 let progress = self
207 .progress
208 .as_ref()
209 .map(|s| s.get().clamp(0.0, 1.0))
210 .unwrap_or(1.0);
211
212 let width = if progress < ROLLED_UP_PROGRESS_EPSILON {
213 0.0
214 } else {
215 natural.width * progress
216 };
217 Size::new(width, natural.height).into()
218 }
219
220 fn place_children(
221 &self,
222 bounds: Rect,
223 _proposal: SizeProposal,
224 children: &mut [WidgetPlacement],
225 _ctx: &LayoutContext,
226 ) {
227 let natural = self.natural_size.get();
231 let x = match self.from {
232 UnrollFrom::Leading => bounds.x,
233 UnrollFrom::Trailing => bounds.right() - natural.width,
234 };
235 for child in children.iter_mut() {
236 child.origin = Point::new(x, bounds.y);
237 child.size = Size::new(natural.width, natural.height);
238 }
239 }
240
241 fn clips_children(&self) -> bool {
242 true
243 }
244
245 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
246 }
250
251 fn children(&self) -> Vec<WidgetId> {
252 self.child_id.into_iter().collect()
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use std::time::Duration;
259
260 use super::*;
261 use crate::primitives::TextWidget;
262 use teksilo_core::widget_tree::WidgetTree;
263 use teksilo_i18n::lit;
264
265 fn tree() -> WidgetTree {
266 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
267 }
268
269 #[test]
270 fn starts_rolled_up_when_signal_is_false() {
271 let expanded = Signal::new(false);
272 let mut t = tree();
273 let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("hidden"))));
274 t.layout(SizeProposal::unspecified());
275 assert!(
276 t.bounds(id).width < 1.0,
277 "rolled-up width should be ~0, got {}",
278 t.bounds(id).width
279 );
280 }
281
282 #[test]
283 fn starts_unrolled_when_signal_is_true() {
284 let expanded = Signal::new(true);
285 let mut t = tree();
286 let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("visible content"))));
287 t.layout(SizeProposal::unspecified());
288 assert!(
289 t.bounds(id).width > 1.0,
290 "unrolled width should be > 0, got {}",
291 t.bounds(id).width
292 );
293 }
294
295 #[test]
296 fn width_grows_proportionally_during_tween() {
297 let expanded = Signal::new(false);
298 let mut t = tree();
299 let id = t.add(Unroll::new(expanded.clone()).child(TextWidget::new(lit!("some content"))));
300 t.layout(SizeProposal::unspecified());
301 let rolled = t.bounds(id).width;
302
303 expanded.set(true);
304 t.tick_animations(Duration::from_millis(300));
305 t.layout(SizeProposal::unspecified());
306 let after = t.bounds(id).width;
307 assert!(
308 after > rolled,
309 "after expanding, width ({after}) should exceed rolled-up ({rolled})"
310 );
311 }
312
313 #[test]
314 fn external_progress_drives_width() {
315 let progress = Signal::new_animated(1.0);
317 let mut t = tree();
318 let child = t.add(TextWidget::new(lit!("0123456789")));
319 let id = t.add(Unroll::from_progress(progress.clone()).child(child));
320 t.layout(SizeProposal::unspecified());
321 let full = t.bounds(id).width;
322 assert!(full > 0.0);
323
324 progress.set(0.5);
325 t.layout(SizeProposal::unspecified());
326 let half = t.bounds(id).width;
327 assert!(
328 (half - full * 0.5).abs() < full * 0.1,
329 "half progress width ({half}) should be ~half of full ({full})"
330 );
331 }
332
333 #[test]
334 fn trailing_anchor_pins_trailing_edge() {
335 let progress = Signal::new_animated(0.5);
336 let mut t = tree();
337 let child = t.add(TextWidget::new(lit!("0123456789")));
338 let id = t.add(
339 Unroll::from_progress(progress)
340 .reveal_from(UnrollFrom::Trailing)
341 .child(child),
342 );
343 t.layout(SizeProposal::unspecified());
344 let wrapper = t.bounds(id);
348 let inner = t.bounds(child);
349 assert!(
350 inner.x < wrapper.x + 0.5,
351 "trailing-anchored child origin ({}) should be at/left of wrapper origin ({})",
352 inner.x,
353 wrapper.x
354 );
355 }
356}