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 teksilo_core::IntoTeksiChild) -> Self {
111 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
112 self
113 }
114 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
121 match widget {
122 Some(w) => self.child(w),
123 None => self,
124 }
125 }
126}
127
128impl std::fmt::Debug for Slide {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("Slide").field("edge", &self.edge).finish()
131 }
132}
133
134impl Widget for Slide {
135 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
136 if let Some(pending) = self.pending_child.take() {
137 self.child_id = Some(match pending {
138 PendingChild::Id(id) => id,
139 PendingChild::Deferred(w) => ctx.add_boxed(w),
140 });
141 }
142 let Some(child_id) = self.child_id else {
143 return vec![];
144 };
145
146 let initial = if self.visible.get() { 1.0 } else { 0.0 };
147 let progress = ctx.animated_signal(initial);
148 self.progress = Some(progress.clone());
149
150 let id = ctx.self_id();
153 let registry = ctx.binding_registry();
154 progress.bind_to(id, registry, BindingLevel::Relayout);
155
156 if let Prop::Bound(visible_signal) = &self.visible {
158 let visible_signal = visible_signal.clone();
159 let slide_anim = ctx.animate().normal().standard();
160 let progress_for_effect = progress;
161 ctx.effect(&visible_signal, move |&v| {
162 let target = if v { 1.0 } else { 0.0 };
163 slide_anim.to_or_snap(&progress_for_effect, target);
164 });
165 }
166
167 vec![child_id]
168 }
169
170 fn layout_response(
171 &self,
172 proposal: SizeProposal,
173 ctx: &LayoutContext,
174 ) -> teksilo_core::widget::LayoutResponse {
175 let Some(child_id) = self.child_id else {
176 return (proposal.resolve(0.0, 0.0)).into();
177 };
178 let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
179 self.natural_size.set(natural);
180 natural.into()
183 }
184
185 fn place_children(
186 &self,
187 bounds: Rect,
188 _proposal: SizeProposal,
189 children: &mut [WidgetPlacement],
190 ctx: &LayoutContext,
191 ) {
192 let progress = self
193 .progress
194 .as_ref()
195 .map(|s| s.get().clamp(0.0, 1.0))
196 .unwrap_or(1.0);
197 let natural = self.natural_size.get();
198 let off_amount = 1.0 - progress;
202 let resolved = match (self.edge, ctx.is_rtl()) {
205 (SlideEdge::Leading, false) | (SlideEdge::Trailing, true) => SlideEdge::Leading,
206 (SlideEdge::Trailing, false) | (SlideEdge::Leading, true) => SlideEdge::Trailing,
207 (other, _) => other,
208 };
209 let (dx, dy) = match resolved {
210 SlideEdge::Leading => (-natural.width * off_amount, 0.0),
211 SlideEdge::Trailing => (natural.width * off_amount, 0.0),
212 SlideEdge::Top => (0.0, -natural.height * off_amount),
213 SlideEdge::Bottom => (0.0, natural.height * off_amount),
214 };
215 for child in children.iter_mut() {
216 child.origin = Point::new(bounds.x + dx, bounds.y + dy);
217 child.size = natural;
218 }
219 }
220
221 fn clips_children(&self) -> bool {
222 true
225 }
226
227 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
228 }
230
231 fn children(&self) -> Vec<WidgetId> {
232 self.child_id.into_iter().collect()
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use std::time::Duration;
239
240 use super::*;
241 use crate::primitives::TextWidget;
242 use teksilo_core::widget_tree::WidgetTree;
243 use teksilo_i18n::lit;
244
245 #[test]
246 fn starts_visible_when_signal_is_true() {
247 let visible = Signal::new(true);
248 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
249 let id = tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
250 tree.layout(SizeProposal {
251 width: Some(300.0),
252 height: None,
253 });
254 let bounds = tree.bounds(id);
255 assert!(bounds.width > 0.0 && bounds.height > 0.0);
256 }
257
258 #[test]
259 fn flipping_signal_drives_slide_progress() {
260 let visible = Signal::new(false);
261 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
262 tree.add(
263 Slide::new(visible.clone())
264 .from(SlideEdge::Bottom)
265 .child(TextWidget::new(lit!("snackbar message"))),
266 );
267 tree.layout(SizeProposal {
268 width: Some(300.0),
269 height: None,
270 });
271
272 visible.set(true);
273 tree.tick_animations(Duration::from_millis(50));
275 assert!(
276 tree.has_active_animations(),
277 "slide-in should be animating mid-tween"
278 );
279 }
280
281 #[test]
282 fn slide_does_not_change_layout_size() {
283 let visible = Signal::new(false);
284 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
285 let id = tree.add(
286 Slide::new(visible.clone())
287 .from(SlideEdge::Leading)
288 .child(TextWidget::new(lit!("content"))),
289 );
290 tree.layout(SizeProposal {
291 width: Some(300.0),
292 height: None,
293 });
294 let hidden_bounds = tree.bounds(id);
295
296 visible.set(true);
297 tree.tick_animations(Duration::from_millis(300));
298 tree.layout(SizeProposal {
299 width: Some(300.0),
300 height: None,
301 });
302 let visible_bounds = tree.bounds(id);
303
304 assert_eq!(
305 hidden_bounds.size(),
306 visible_bounds.size(),
307 "Slide must not change its own size based on progress"
308 );
309 }
310
311 #[test]
312 fn rtl_swaps_leading_and_trailing() {
313 use teksilo_core::environment::LayoutDirection;
321 let visible = Signal::new(true);
322 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
323 tree.set_layout_direction(LayoutDirection::RightToLeft);
324 let id = tree.add(
325 Slide::new(visible.clone())
326 .from(SlideEdge::Leading)
327 .child(TextWidget::new(lit!("rtl content"))),
328 );
329 tree.layout(SizeProposal {
330 width: Some(300.0),
331 height: None,
332 });
333 let bounds = tree.bounds(id);
334 assert!(bounds.width > 0.0 && bounds.height > 0.0);
335
336 visible.set(false);
337 tree.tick_animations(Duration::from_millis(300));
338 tree.layout(SizeProposal {
339 width: Some(300.0),
340 height: None,
341 });
342 let after = tree.bounds(id);
345 assert_eq!(bounds.size(), after.size());
346 }
347
348 #[test]
349 fn reduced_motion_snaps_progress() {
350 let visible = Signal::new(false);
351 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
352 tree.set_accessibility_preferences(false, true, 1.0);
353 tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("snap"))));
354 tree.layout(SizeProposal {
355 width: Some(300.0),
356 height: None,
357 });
358
359 visible.set(true);
360 assert!(
363 !tree.has_active_animations(),
364 "reduced-motion path must not register animations"
365 );
366 }
367}