use std::cell::Cell;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlideEdge {
Leading,
Trailing,
Top,
Bottom,
}
pub struct Slide {
visible: Prop<bool>,
edge: SlideEdge,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
progress: Option<Signal<f32>>,
natural_size: Cell<Size>,
}
impl Slide {
pub fn new(visible: impl Into<Prop<bool>>) -> Self {
Self {
visible: visible.into(),
edge: SlideEdge::Bottom,
pending_child: None,
child_id: None,
progress: None,
natural_size: Cell::new(Size::ZERO),
}
}
pub fn from(mut self, edge: SlideEdge) -> Self {
self.edge = edge;
self
}
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 Slide {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Slide").field("edge", &self.edge).finish()
}
}
impl Widget for Slide {
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 progress = ctx.animated_signal(initial);
self.progress = Some(progress.clone());
let id = ctx.self_id();
let registry = ctx.binding_registry();
progress.bind_to(id, registry, BindingLevel::Relayout);
if let Prop::Bound(visible_signal) = &self.visible {
let visible_signal = visible_signal.clone();
let slide_anim = ctx.animate().normal().standard();
let progress_for_effect = progress;
ctx.effect(&visible_signal, move |&v| {
let target = if v { 1.0 } else { 0.0 };
slide_anim.to_or_snap(&progress_for_effect, target);
});
}
vec![child_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let Some(child_id) = self.child_id else {
return (proposal.resolve(0.0, 0.0)).into();
};
let natural = ctx.child_size(child_id, proposal).unwrap_or(Size::ZERO);
self.natural_size.set(natural);
natural.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
let progress = self
.progress
.as_ref()
.map(|s| s.get().clamp(0.0, 1.0))
.unwrap_or(1.0);
let natural = self.natural_size.get();
let off_amount = 1.0 - progress;
let resolved = match (self.edge, ctx.is_rtl()) {
(SlideEdge::Leading, false) | (SlideEdge::Trailing, true) => SlideEdge::Leading,
(SlideEdge::Trailing, false) | (SlideEdge::Leading, true) => SlideEdge::Trailing,
(other, _) => other,
};
let (dx, dy) = match resolved {
SlideEdge::Leading => (-natural.width * off_amount, 0.0),
SlideEdge::Trailing => (natural.width * off_amount, 0.0),
SlideEdge::Top => (0.0, -natural.height * off_amount),
SlideEdge::Bottom => (0.0, natural.height * off_amount),
};
for child in children.iter_mut() {
child.origin = Point::new(bounds.x + dx, bounds.y + dy);
child.size = natural;
}
}
fn clips_children(&self) -> bool {
true
}
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::TextWidget;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[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());
let id = tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("hello"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let bounds = tree.bounds(id);
assert!(bounds.width > 0.0 && bounds.height > 0.0);
}
#[test]
fn flipping_signal_drives_slide_progress() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
Slide::new(visible.clone())
.from(SlideEdge::Bottom)
.child(TextWidget::new(lit!("snackbar message"))),
);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
visible.set(true);
tree.tick_animations(Duration::from_millis(50));
assert!(
tree.has_active_animations(),
"slide-in should be animating mid-tween"
);
}
#[test]
fn slide_does_not_change_layout_size() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
Slide::new(visible.clone())
.from(SlideEdge::Leading)
.child(TextWidget::new(lit!("content"))),
);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let hidden_bounds = tree.bounds(id);
visible.set(true);
tree.tick_animations(Duration::from_millis(300));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let visible_bounds = tree.bounds(id);
assert_eq!(
hidden_bounds.size(),
visible_bounds.size(),
"Slide must not change its own size based on progress"
);
}
#[test]
fn rtl_swaps_leading_and_trailing() {
use teksilo_core::environment::LayoutDirection;
let visible = Signal::new(true);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_layout_direction(LayoutDirection::RightToLeft);
let id = tree.add(
Slide::new(visible.clone())
.from(SlideEdge::Leading)
.child(TextWidget::new(lit!("rtl content"))),
);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let bounds = tree.bounds(id);
assert!(bounds.width > 0.0 && bounds.height > 0.0);
visible.set(false);
tree.tick_animations(Duration::from_millis(300));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let after = tree.bounds(id);
assert_eq!(bounds.size(), after.size());
}
#[test]
fn reduced_motion_snaps_progress() {
let visible = Signal::new(false);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_accessibility_preferences(false, true, 1.0);
tree.add(Slide::new(visible.clone()).child(TextWidget::new(lit!("snap"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
visible.set(true);
assert!(
!tree.has_active_animations(),
"reduced-motion path must not register animations"
);
}
}