teksilo_widgets/animations/
fade.rs1use teksilo_canvas::{Point, Rect, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::build_context::BuildContext;
43use teksilo_core::signal::{Prop, Signal};
44use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
45use teksilo_core::widget_id::WidgetId;
46
47pub struct Fade {
50 visible: Prop<bool>,
51 pending_child: Option<PendingChild>,
52 child_id: Option<WidgetId>,
53 opacity: Option<Signal<f32>>,
56}
57
58impl Fade {
59 pub fn new(visible: impl Into<Prop<bool>>) -> Self {
66 Self {
67 visible: visible.into(),
68 pending_child: None,
69 child_id: None,
70 opacity: None,
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 Fade {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("Fade").finish()
96 }
97}
98
99impl Widget for Fade {
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.visible.get() { 1.0 } else { 0.0 };
113 let opacity = ctx.animated_signal(initial);
114 self.opacity = Some(opacity.clone());
115
116 let id = ctx.self_id();
119 ctx.set_opacity(id, opacity.clone());
120
121 if let Prop::Bound(visible_signal) = &self.visible {
124 let visible_signal = visible_signal.clone();
125 let fade_anim = ctx.animate().fast().standard();
126 let opacity_for_effect = opacity;
127 ctx.effect(&visible_signal, move |&v| {
128 let target = if v { 1.0 } else { 0.0 };
129 fade_anim.to_or_snap(&opacity_for_effect, target);
130 });
131 }
132
133 vec![child_id]
134 }
135
136 fn layout_response(
137 &self,
138 proposal: SizeProposal,
139 ctx: &LayoutContext,
140 ) -> teksilo_core::widget::LayoutResponse {
141 self.child_id
146 .and_then(|id| ctx.child_size(id, proposal))
147 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
148 .into()
149 }
150
151 fn place_children(
152 &self,
153 bounds: Rect,
154 _proposal: SizeProposal,
155 children: &mut [WidgetPlacement],
156 _ctx: &LayoutContext,
157 ) {
158 for child in children.iter_mut() {
159 child.origin = Point::new(bounds.x, bounds.y);
160 child.size = bounds.size();
161 }
162 }
163
164 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
165 }
172
173 fn children(&self) -> Vec<WidgetId> {
174 self.child_id.into_iter().collect()
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use std::time::Duration;
181
182 use super::*;
183 use crate::primitives::{RectWidget, TextWidget};
184 use teksilo_core::widget_tree::WidgetTree;
185 use teksilo_i18n::lit;
186 use teksilo_tokens::Color;
187
188 fn count_set_opacity(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
189 frame
190 .draw_order
191 .iter()
192 .filter_map(|c| match c {
193 teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
194 _ => None,
195 })
196 .collect()
197 }
198
199 #[test]
200 fn starts_hidden_when_signal_is_false() {
201 let visible = Signal::new(false);
202 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
203 tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
204 tree.layout(SizeProposal::exact(100.0, 50.0));
205 let frame = tree.render();
206 assert!(count_set_opacity(&frame).is_empty());
209 assert!(
210 !frame
211 .shapes
212 .iter()
213 .any(|s| s.color == Color::RED.to_array()),
214 "hidden subtree must not paint"
215 );
216 }
217
218 #[test]
219 fn starts_visible_when_signal_is_true() {
220 let visible = Signal::new(true);
221 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
222 tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
223 tree.layout(SizeProposal::exact(100.0, 50.0));
224 let frame = tree.render();
225 let ops = count_set_opacity(&frame);
229 assert_eq!(ops.len(), 1);
230 assert!((ops[0] - 1.0).abs() < 1e-6);
231 }
232
233 #[test]
234 fn flipping_signal_drives_animation() {
235 let visible = Signal::new(false);
236 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
237 tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
238 tree.layout(SizeProposal::exact(100.0, 50.0));
239
240 visible.set(true);
241 tree.tick_animations(Duration::from_millis(60));
244 tree.layout(SizeProposal::exact(100.0, 50.0));
245 let frame = tree.render();
246 let ops = count_set_opacity(&frame);
247 assert_eq!(ops.len(), 1, "exactly one opacity scope should be active");
248 assert!(
249 ops[0] > 0.05 && ops[0] < 0.95,
250 "mid-tween opacity should be between 0 and 1, got {}",
251 ops[0]
252 );
253 }
254
255 #[test]
256 fn animation_completes_at_target() {
257 let visible = Signal::new(false);
258 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
259 tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
260 tree.layout(SizeProposal::exact(100.0, 50.0));
261
262 visible.set(true);
263 tree.tick_animations(Duration::from_millis(200));
264 tree.layout(SizeProposal::exact(100.0, 50.0));
265 let frame = tree.render();
266 let ops = count_set_opacity(&frame);
267 assert_eq!(ops.len(), 1);
268 assert!(
269 (ops[0] - 1.0).abs() < 0.01,
270 "post-tween opacity should be 1.0, got {}",
271 ops[0]
272 );
273 }
274
275 #[test]
276 fn fade_does_not_change_layout() {
277 let visible = Signal::new(false);
280 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
281 let id = tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
282 tree.layout(SizeProposal {
283 width: Some(300.0),
284 height: None,
285 });
286 let hidden_bounds = tree.bounds(id);
287
288 visible.set(true);
289 tree.tick_animations(Duration::from_millis(200));
290 tree.layout(SizeProposal {
291 width: Some(300.0),
292 height: None,
293 });
294 let visible_bounds = tree.bounds(id);
295
296 assert_eq!(
297 hidden_bounds.size(),
298 visible_bounds.size(),
299 "Fade must not change its own size based on opacity"
300 );
301 }
302
303 #[test]
304 fn static_visible_does_not_register_observer() {
305 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
310 tree.add(Fade::new(true).child(RectWidget::new().background(Color::RED)));
311 tree.layout(SizeProposal::exact(100.0, 50.0));
312 let _ = tree.render();
313 assert!(
314 !tree.has_active_animations(),
315 "static Prop must not start a fade animation"
316 );
317 }
318}