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 Widget + 'static) -> Self {
76 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
77 self
78 }
79
80 pub fn child_id(mut self, id: WidgetId) -> Self {
82 self.pending_child = Some(PendingChild::Id(id));
83 self
84 }
85}
86
87impl std::fmt::Debug for Fade {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("Fade").finish()
90 }
91}
92
93impl Widget for Fade {
94 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
95 if let Some(pending) = self.pending_child.take() {
97 self.child_id = Some(match pending {
98 PendingChild::Id(id) => id,
99 PendingChild::Deferred(w) => ctx.add_boxed(w),
100 });
101 }
102 let Some(child_id) = self.child_id else {
103 return vec![];
104 };
105
106 let initial = if self.visible.get() { 1.0 } else { 0.0 };
107 let opacity = ctx.animated_signal(initial);
108 self.opacity = Some(opacity.clone());
109
110 let id = ctx.self_id();
113 ctx.set_opacity(id, opacity.clone());
114
115 if let Prop::Bound(visible_signal) = &self.visible {
118 let visible_signal = visible_signal.clone();
119 let fade_anim = ctx.animate().fast().standard();
120 let opacity_for_effect = opacity;
121 ctx.effect(&visible_signal, move |&v| {
122 let target = if v { 1.0 } else { 0.0 };
123 fade_anim.to_or_snap(&opacity_for_effect, target);
124 });
125 }
126
127 vec![child_id]
128 }
129
130 fn layout_response(
131 &self,
132 proposal: SizeProposal,
133 ctx: &LayoutContext,
134 ) -> teksilo_core::widget::LayoutResponse {
135 self.child_id
140 .and_then(|id| ctx.child_size(id, proposal))
141 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
142 .into()
143 }
144
145 fn place_children(
146 &self,
147 bounds: Rect,
148 _proposal: SizeProposal,
149 children: &mut [WidgetPlacement],
150 _ctx: &LayoutContext,
151 ) {
152 for child in children.iter_mut() {
153 child.origin = Point::new(bounds.x, bounds.y);
154 child.size = bounds.size();
155 }
156 }
157
158 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
159 }
166
167 fn children(&self) -> Vec<WidgetId> {
168 self.child_id.into_iter().collect()
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use std::time::Duration;
175
176 use super::*;
177 use crate::primitives::{RectWidget, TextWidget};
178 use teksilo_core::widget_tree::WidgetTree;
179 use teksilo_i18n::lit;
180 use teksilo_tokens::Color;
181
182 fn count_set_opacity(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
183 frame
184 .draw_order
185 .iter()
186 .filter_map(|c| match c {
187 teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
188 _ => None,
189 })
190 .collect()
191 }
192
193 #[test]
194 fn starts_hidden_when_signal_is_false() {
195 let visible = Signal::new(false);
196 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
197 tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
198 tree.layout(SizeProposal::exact(100.0, 50.0));
199 let frame = tree.render();
200 assert!(count_set_opacity(&frame).is_empty());
203 assert!(
204 !frame
205 .shapes
206 .iter()
207 .any(|s| s.color == Color::RED.to_array()),
208 "hidden subtree must not paint"
209 );
210 }
211
212 #[test]
213 fn starts_visible_when_signal_is_true() {
214 let visible = Signal::new(true);
215 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
216 tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
217 tree.layout(SizeProposal::exact(100.0, 50.0));
218 let frame = tree.render();
219 let ops = count_set_opacity(&frame);
223 assert_eq!(ops.len(), 1);
224 assert!((ops[0] - 1.0).abs() < 1e-6);
225 }
226
227 #[test]
228 fn flipping_signal_drives_animation() {
229 let visible = Signal::new(false);
230 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
231 tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
232 tree.layout(SizeProposal::exact(100.0, 50.0));
233
234 visible.set(true);
235 tree.tick_animations(Duration::from_millis(60));
238 tree.layout(SizeProposal::exact(100.0, 50.0));
239 let frame = tree.render();
240 let ops = count_set_opacity(&frame);
241 assert_eq!(ops.len(), 1, "exactly one opacity scope should be active");
242 assert!(
243 ops[0] > 0.05 && ops[0] < 0.95,
244 "mid-tween opacity should be between 0 and 1, got {}",
245 ops[0]
246 );
247 }
248
249 #[test]
250 fn animation_completes_at_target() {
251 let visible = Signal::new(false);
252 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
253 tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
254 tree.layout(SizeProposal::exact(100.0, 50.0));
255
256 visible.set(true);
257 tree.tick_animations(Duration::from_millis(200));
258 tree.layout(SizeProposal::exact(100.0, 50.0));
259 let frame = tree.render();
260 let ops = count_set_opacity(&frame);
261 assert_eq!(ops.len(), 1);
262 assert!(
263 (ops[0] - 1.0).abs() < 0.01,
264 "post-tween opacity should be 1.0, got {}",
265 ops[0]
266 );
267 }
268
269 #[test]
270 fn fade_does_not_change_layout() {
271 let visible = Signal::new(false);
274 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
275 let id = tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
276 tree.layout(SizeProposal {
277 width: Some(300.0),
278 height: None,
279 });
280 let hidden_bounds = tree.bounds(id);
281
282 visible.set(true);
283 tree.tick_animations(Duration::from_millis(200));
284 tree.layout(SizeProposal {
285 width: Some(300.0),
286 height: None,
287 });
288 let visible_bounds = tree.bounds(id);
289
290 assert_eq!(
291 hidden_bounds.size(),
292 visible_bounds.size(),
293 "Fade must not change its own size based on opacity"
294 );
295 }
296
297 #[test]
298 fn static_visible_does_not_register_observer() {
299 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
304 tree.add(Fade::new(true).child(RectWidget::new().background(Color::RED)));
305 tree.layout(SizeProposal::exact(100.0, 50.0));
306 let _ = tree.render();
307 assert!(
308 !tree.has_active_animations(),
309 "static Prop must not start a fade animation"
310 );
311 }
312}