teksilo_widgets/animations/
slide.rs1use std::cell::Cell;
48
49use teksilo_canvas::{Point, Rect, Size, SizeProposal};
50use teksilo_core::accessibility::AccessNodeBuilder;
51use teksilo_core::binding::BindingLevel;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::signal::{Prop, Signal};
54use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
55use teksilo_core::widget_id::WidgetId;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SlideEdge {
63 Leading,
65 Trailing,
67 Top,
69 Bottom,
71}
72
73pub struct Slide {
76 visible: Prop<bool>,
77 edge: SlideEdge,
78 pending_child: Option<PendingChild>,
79 child_id: Option<WidgetId>,
80 progress: Option<Signal<f32>>,
83 natural_size: Cell<Size>,
86}
87
88impl Slide {
89 pub fn new(visible: impl Into<Prop<bool>>) -> Self {
93 Self {
94 visible: visible.into(),
95 edge: SlideEdge::Bottom,
96 pending_child: None,
97 child_id: None,
98 progress: None,
99 natural_size: Cell::new(Size::ZERO),
100 }
101 }
102
103 pub fn from(mut self, edge: SlideEdge) -> Self {
105 self.edge = edge;
106 self
107 }
108
109 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
111 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
112 self
113 }
114
115 pub fn child_id(mut self, id: WidgetId) -> Self {
117 self.pending_child = Some(PendingChild::Id(id));
118 self
119 }
120}
121
122impl std::fmt::Debug for Slide {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("Slide").field("edge", &self.edge).finish()
125 }
126}
127
128impl Widget for Slide {
129 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
130 if let Some(pending) = self.pending_child.take() {
131 self.child_id = Some(match pending {
132 PendingChild::Id(id) => id,
133 PendingChild::Deferred(w) => ctx.add_boxed(w),
134 });
135 }
136 let Some(child_id) = self.child_id else {
137 return vec![];
138 };
139
140 let initial = if self.visible.get() { 1.0 } else { 0.0 };
141 let progress = ctx.animated_signal(initial);
142 self.progress = Some(progress.clone());
143
144 let id = ctx.self_id();
147 let registry = ctx.binding_registry();
148 progress.bind_to(id, registry, BindingLevel::Relayout);
149
150 if let Prop::Bound(visible_signal) = &self.visible {
152 let visible_signal = visible_signal.clone();
153 let slide_anim = ctx.animate().normal().standard();
154 let progress_for_effect = progress;
155 ctx.effect(&visible_signal, move |&v| {
156 let target = if v { 1.0 } else { 0.0 };
157 slide_anim.to_or_snap(&progress_for_effect, target);
158 });
159 }
160
161 vec![child_id]
162 }
163
164 fn layout_response(
165 &self,
166 proposal: SizeProposal,
167 ctx: &LayoutContext,
168 ) -> teksilo_core::widget::LayoutResponse {
169 let Some(child_id) = self.child_id else {
170 return (proposal.resolve(0.0, 0.0)).into();
171 };
172 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
173 self.natural_size.set(natural);
174 natural.into()
177 }
178
179 fn place_children(
180 &self,
181 bounds: Rect,
182 _proposal: SizeProposal,
183 children: &mut [WidgetPlacement],
184 ctx: &LayoutContext,
185 ) {
186 let progress = self
187 .progress
188 .as_ref()
189 .map(|s| s.get().clamp(0.0, 1.0))
190 .unwrap_or(1.0);
191 let natural = self.natural_size.get();
192 let off_amount = 1.0 - progress;
196 let resolved = match (self.edge, ctx.is_rtl()) {
199 (SlideEdge::Leading, false) | (SlideEdge::Trailing, true) => SlideEdge::Leading,
200 (SlideEdge::Trailing, false) | (SlideEdge::Leading, true) => SlideEdge::Trailing,
201 (other, _) => other,
202 };
203 let (dx, dy) = match resolved {
204 SlideEdge::Leading => (-natural.width * off_amount, 0.0),
205 SlideEdge::Trailing => (natural.width * off_amount, 0.0),
206 SlideEdge::Top => (0.0, -natural.height * off_amount),
207 SlideEdge::Bottom => (0.0, natural.height * off_amount),
208 };
209 for child in children.iter_mut() {
210 child.origin = Point::new(bounds.x + dx, bounds.y + dy);
211 child.size = natural;
212 }
213 }
214
215 fn clips_children(&self) -> bool {
216 true
219 }
220
221 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
222 }
224
225 fn children(&self) -> Vec<WidgetId> {
226 self.child_id.into_iter().collect()
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use std::time::Duration;
233
234 use super::*;
235 use crate::primitives::TextWidget;
236 use teksilo_core::widget_tree::WidgetTree;
237 use teksilo_i18n::lit;
238
239 #[test]
240 fn starts_visible_when_signal_is_true() {
241 let visible = Signal::new(true);
242 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
243 let id = tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
244 tree.layout(SizeProposal {
245 width: Some(300.0),
246 height: None,
247 });
248 let bounds = tree.bounds(id);
249 assert!(bounds.width > 0.0 && bounds.height > 0.0);
250 }
251
252 #[test]
253 fn flipping_signal_drives_slide_progress() {
254 let visible = Signal::new(false);
255 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
256 tree.add(
257 Slide::new(visible.clone())
258 .from(SlideEdge::Bottom)
259 .child(TextWidget::new(lit!("snackbar message"))),
260 );
261 tree.layout(SizeProposal {
262 width: Some(300.0),
263 height: None,
264 });
265
266 visible.set(true);
267 tree.tick_animations(Duration::from_millis(50));
269 assert!(
270 tree.has_active_animations(),
271 "slide-in should be animating mid-tween"
272 );
273 }
274
275 #[test]
276 fn slide_does_not_change_layout_size() {
277 let visible = Signal::new(false);
278 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
279 let id = tree.add(
280 Slide::new(visible.clone())
281 .from(SlideEdge::Leading)
282 .child(TextWidget::new(lit!("content"))),
283 );
284 tree.layout(SizeProposal {
285 width: Some(300.0),
286 height: None,
287 });
288 let hidden_bounds = tree.bounds(id);
289
290 visible.set(true);
291 tree.tick_animations(Duration::from_millis(300));
292 tree.layout(SizeProposal {
293 width: Some(300.0),
294 height: None,
295 });
296 let visible_bounds = tree.bounds(id);
297
298 assert_eq!(
299 hidden_bounds.size(),
300 visible_bounds.size(),
301 "Slide must not change its own size based on progress"
302 );
303 }
304
305 #[test]
306 fn rtl_swaps_leading_and_trailing() {
307 use teksilo_core::environment::LayoutDirection;
315 let visible = Signal::new(true);
316 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
317 tree.set_layout_direction(LayoutDirection::RightToLeft);
318 let id = tree.add(
319 Slide::new(visible.clone())
320 .from(SlideEdge::Leading)
321 .child(TextWidget::new(lit!("rtl content"))),
322 );
323 tree.layout(SizeProposal {
324 width: Some(300.0),
325 height: None,
326 });
327 let bounds = tree.bounds(id);
328 assert!(bounds.width > 0.0 && bounds.height > 0.0);
329
330 visible.set(false);
331 tree.tick_animations(Duration::from_millis(300));
332 tree.layout(SizeProposal {
333 width: Some(300.0),
334 height: None,
335 });
336 let after = tree.bounds(id);
339 assert_eq!(bounds.size(), after.size());
340 }
341
342 #[test]
343 fn reduced_motion_snaps_progress() {
344 let visible = Signal::new(false);
345 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
346 tree.set_accessibility_preferences(false, true, 1.0);
347 tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("snap"))));
348 tree.layout(SizeProposal {
349 width: Some(300.0),
350 height: None,
351 });
352
353 visible.set(true);
354 assert!(
357 !tree.has_active_animations(),
358 "reduced-motion path must not register animations"
359 );
360 }
361}