use std::time::Duration;
use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use crate::primitives::ZStack;
pub struct Crossfade<K: Eq + Clone + 'static> {
key_signal: Signal<K>,
builder: Box<dyn Fn(&K) -> Box<dyn Widget>>,
duration: Option<Duration>,
last_key: Option<K>,
root_child_id: Option<WidgetId>,
}
impl<K: Eq + Clone + 'static> Crossfade<K> {
pub fn new(key_signal: Signal<K>, builder: impl Fn(&K) -> Box<dyn Widget> + 'static) -> Self {
Self {
key_signal,
builder: Box::new(builder),
duration: None,
last_key: None,
root_child_id: None,
}
}
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
}
impl<K: Eq + Clone + 'static> std::fmt::Debug for Crossfade<K> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Crossfade")
.field("duration", &self.duration)
.finish()
}
}
impl<K: Eq + Clone + 'static> Widget for Crossfade<K> {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let current_key = self.key_signal.get();
let prev_key = self.last_key.take();
let key_changed = prev_key.as_ref().is_some_and(|p| p != ¤t_key);
let duration = self.duration.unwrap_or(ctx.theme().motion.duration_normal);
let easing = ctx.theme().motion.easing_standard;
let reduced = ctx.prefers_reduced_motion();
let mut zstack = ZStack::new();
if key_changed {
let prev_key = prev_key.expect("key_changed implies prev_key is Some");
let outgoing = (self.builder)(&prev_key);
let outgoing_id = ctx.add_boxed(outgoing);
let opacity = ctx.animated_signal(1.0);
ctx.set_opacity(outgoing_id, opacity.clone());
ctx.visible_when(outgoing_id, opacity.map(|&o| o > 0.005));
if reduced {
opacity.set(0.0);
} else {
opacity.animate_to(0.0, duration, easing);
}
zstack = zstack.add_child(outgoing_id);
}
let incoming = (self.builder)(¤t_key);
let incoming_id = ctx.add_boxed(incoming);
let initial = if key_changed { 0.0 } else { 1.0 };
let opacity = ctx.animated_signal(initial);
ctx.set_opacity(incoming_id, opacity.clone());
if key_changed {
if reduced {
opacity.set(1.0);
} else {
opacity.animate_to(1.0, duration, easing);
}
}
zstack = zstack.add_child(incoming_id);
let self_id = ctx.self_id();
let registry = ctx.binding_registry();
self.key_signal
.bind_to(self_id, registry, BindingLevel::Rebuild);
self.last_key = Some(current_key);
let root = ctx.add(zstack);
self.root_child_id = Some(root);
vec![root]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
self.root_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 = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::TextWidget;
use teksilo_canvas::Size;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
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 first_build_shows_initial_key_at_full_opacity() {
let key = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Crossfade::new(key, |k| {
Box::new(TextWidget::new(lit!(format!("page {k}"))))
}));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
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 key_change_starts_overlap_with_two_opacity_scopes() {
let key = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Crossfade::new(key.clone(), |k| {
Box::new(TextWidget::new(lit!(format!("page {k}"))))
}));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
key.set(1);
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
tree.tick_animations(Duration::from_millis(50));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
let frame = tree.render();
let ops = count_set_opacity(&frame);
assert_eq!(
ops.len(),
2,
"during transition, outgoing and incoming should both have opacity scopes"
);
for o in &ops {
assert!(*o >= 0.0 && *o <= 1.0, "opacity must be in [0, 1], got {o}");
}
}
#[test]
fn outgoing_goes_dormant_after_fade_so_layout_can_shrink() {
use crate::primitives::FixedSize;
#[derive(Debug)]
struct Sized(f32);
impl Widget for Sized {
fn layout_response(
&self,
_p: SizeProposal,
_c: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
Size::new(40.0, self.0).into()
}
}
let key = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(FixedSize::new().child(Crossfade::new(
key.clone(),
|&k| -> Box<dyn Widget> {
let h = if k == 0 { 100.0 } else { 30.0 };
Box::new(Sized(h))
},
)));
tree.layout(SizeProposal {
width: None,
height: None,
});
let initial = tree.bounds(id);
assert!((initial.height - 100.0).abs() < 0.5);
key.set(1);
tree.layout(SizeProposal {
width: None,
height: None,
});
tree.layout(SizeProposal {
width: None,
height: None,
});
tree.tick_animations(Duration::from_millis(400));
tree.layout(SizeProposal {
width: None,
height: None,
});
let after = tree.bounds(id);
assert!(
(after.height - 30.0).abs() < 1.0,
"after fade-out, wrapper should shrink to incoming's natural height; got {}",
after.height
);
}
#[test]
fn reduced_motion_snaps_instantly() {
let key = Signal::new(0_u32);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_accessibility_preferences(false, true, 1.0);
tree.add(Crossfade::new(key.clone(), |k| {
Box::new(TextWidget::new(lit!(format!("page {k}"))))
}));
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
key.set(1);
tree.layout(SizeProposal {
width: Some(200.0),
height: None,
});
assert!(
!tree.has_active_animations(),
"reduced-motion path must not register animations"
);
}
}