use teksilo_canvas::{Point, Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
pub struct Fade {
visible: Prop<bool>,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
opacity: Option<Signal<f32>>,
}
impl Fade {
pub fn new(visible: impl Into<Prop<bool>>) -> Self {
Self {
visible: visible.into(),
pending_child: None,
child_id: None,
opacity: None,
}
}
pub fn child(mut self, widget: impl Widget + 'static) -> Self {
self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
self
}
pub fn child_id(mut self, id: WidgetId) -> Self {
self.pending_child = Some(PendingChild::Id(id));
self
}
}
impl std::fmt::Debug for Fade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Fade").finish()
}
}
impl Widget for Fade {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if let Some(pending) = self.pending_child.take() {
self.child_id = Some(match pending {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
let Some(child_id) = self.child_id else {
return vec![];
};
let initial = if self.visible.get() { 1.0 } else { 0.0 };
let opacity = ctx.animated_signal(initial);
self.opacity = Some(opacity.clone());
let id = ctx.self_id();
ctx.set_opacity(id, opacity.clone());
if let Prop::Bound(visible_signal) = &self.visible {
let visible_signal = visible_signal.clone();
let fade_anim = ctx.animate().fast().standard();
let opacity_for_effect = opacity;
ctx.effect(&visible_signal, move |&v| {
let target = if v { 1.0 } else { 0.0 };
fade_anim.to_or_snap(&opacity_for_effect, target);
});
}
vec![child_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
self.child_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = Point::new(bounds.x, bounds.y);
child.size = bounds.size();
}
}
fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
}
fn children(&self) -> Vec<WidgetId> {
self.child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::primitives::{RectWidget, TextWidget};
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
use teksilo_tokens::Color;
fn count_set_opacity(frame: &teksilo_canvas::RenderFrame) -> Vec<f32> {
frame
.draw_order
.iter()
.filter_map(|c| match c {
teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
_ => None,
})
.collect()
}
#[test]
fn starts_hidden_when_signal_is_false() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
assert!(count_set_opacity(&frame).is_empty());
assert!(
!frame
.shapes
.iter()
.any(|s| s.color == Color::RED.to_array()),
"hidden subtree must not paint"
);
}
#[test]
fn starts_visible_when_signal_is_true() {
let visible = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Fade::new(visible.clone()).child(RectWidget::new().background(Color::RED)));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
let ops = count_set_opacity(&frame);
assert_eq!(ops.len(), 1);
assert!((ops[0] - 1.0).abs() < 1e-6);
}
#[test]
fn flipping_signal_drives_animation() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
tree.layout(SizeProposal::exact(100.0, 50.0));
visible.set(true);
tree.tick_animations(Duration::from_millis(60));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
let ops = count_set_opacity(&frame);
assert_eq!(ops.len(), 1, "exactly one opacity scope should be active");
assert!(
ops[0] > 0.05 && ops[0] < 0.95,
"mid-tween opacity should be between 0 and 1, got {}",
ops[0]
);
}
#[test]
fn animation_completes_at_target() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("payload"))));
tree.layout(SizeProposal::exact(100.0, 50.0));
visible.set(true);
tree.tick_animations(Duration::from_millis(200));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
let ops = count_set_opacity(&frame);
assert_eq!(ops.len(), 1);
assert!(
(ops[0] - 1.0).abs() < 0.01,
"post-tween opacity should be 1.0, got {}",
ops[0]
);
}
#[test]
fn fade_does_not_change_layout() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(Fade::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let hidden_bounds = tree.bounds(id);
visible.set(true);
tree.tick_animations(Duration::from_millis(200));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let visible_bounds = tree.bounds(id);
assert_eq!(
hidden_bounds.size(),
visible_bounds.size(),
"Fade must not change its own size based on opacity"
);
}
#[test]
fn static_visible_does_not_register_observer() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Fade::new(true).child(RectWidget::new().background(Color::RED)));
tree.layout(SizeProposal::exact(100.0, 50.0));
let _ = tree.render();
assert!(
!tree.has_active_animations(),
"static Prop must not start a fade animation"
);
}
}