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::Signal;
use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
const ROLLED_UP_PROGRESS_EPSILON: f32 = 0.005;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum UnrollFrom {
Leading,
Trailing,
}
enum Driver {
Expanded(Signal<bool>),
Progress(Signal<f32>),
}
pub struct Unroll {
driver: Driver,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
progress: Option<Signal<f32>>,
from: UnrollFrom,
natural_size: Cell<Size>,
}
impl Unroll {
pub fn new(expanded: Signal<bool>) -> Self {
Self::with_driver(Driver::Expanded(expanded))
}
pub fn from_progress(progress: Signal<f32>) -> Self {
Self::with_driver(Driver::Progress(progress))
}
fn with_driver(driver: Driver) -> Self {
Self {
driver,
pending_child: None,
child_id: None,
progress: None,
from: UnrollFrom::Leading,
natural_size: Cell::new(Size::ZERO),
}
}
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
}
pub fn reveal_from(mut self, from: UnrollFrom) -> Self {
self.from = from;
self
}
pub fn progress_signal(&self) -> Option<Signal<f32>> {
self.progress.clone()
}
}
impl std::fmt::Debug for Unroll {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Unroll").field("from", &self.from).finish()
}
}
impl Widget for Unroll {
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 self_id = ctx.self_id();
match &self.driver {
Driver::Expanded(expanded) => {
let expanded = expanded.clone();
let initial = if expanded.get() { 1.0 } else { 0.0 };
let progress = ctx.animated_signal(initial);
self.progress = Some(progress.clone());
progress.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
let anim = ctx.animate().collapse().standard();
let progress_for_effect = progress;
ctx.effect(&expanded, move |&expanded| {
let target = if expanded { 1.0 } else { 0.0 };
anim.to_or_snap(&progress_for_effect, target);
});
}
Driver::Progress(sig) => {
self.progress = Some(sig.clone());
sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
}
}
vec![child_id]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> 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);
let progress = self
.progress
.as_ref()
.map(|s| s.get().clamp(0.0, 1.0))
.unwrap_or(1.0);
let width = if progress < ROLLED_UP_PROGRESS_EPSILON {
0.0
} else {
natural.width * progress
};
Size::new(width, natural.height).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
let natural = self.natural_size.get();
let x = match self.from {
UnrollFrom::Leading => bounds.x,
UnrollFrom::Trailing => bounds.right() - natural.width,
};
for child in children.iter_mut() {
child.origin = Point::new(x, bounds.y);
child.size = Size::new(natural.width, natural.height);
}
}
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;
fn tree() -> WidgetTree {
WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
}
#[test]
fn starts_rolled_up_when_signal_is_false() {
let expanded = Signal::new(false);
let mut t = tree();
let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("hidden"))));
t.layout(SizeProposal::unspecified());
assert!(
t.bounds(id).width < 1.0,
"rolled-up width should be ~0, got {}",
t.bounds(id).width
);
}
#[test]
fn starts_unrolled_when_signal_is_true() {
let expanded = Signal::new(true);
let mut t = tree();
let id = t.add(Unroll::new(expanded).child(TextWidget::new(lit!("visible content"))));
t.layout(SizeProposal::unspecified());
assert!(
t.bounds(id).width > 1.0,
"unrolled width should be > 0, got {}",
t.bounds(id).width
);
}
#[test]
fn width_grows_proportionally_during_tween() {
let expanded = Signal::new(false);
let mut t = tree();
let id = t.add(Unroll::new(expanded.clone()).child(TextWidget::new(lit!("some content"))));
t.layout(SizeProposal::unspecified());
let rolled = t.bounds(id).width;
expanded.set(true);
t.tick_animations(Duration::from_millis(300));
t.layout(SizeProposal::unspecified());
let after = t.bounds(id).width;
assert!(
after > rolled,
"after expanding, width ({after}) should exceed rolled-up ({rolled})"
);
}
#[test]
fn external_progress_drives_width() {
let progress = Signal::new_animated(1.0);
let mut t = tree();
let child = t.add(TextWidget::new(lit!("0123456789")));
let id = t.add(Unroll::from_progress(progress.clone()).child_id(child));
t.layout(SizeProposal::unspecified());
let full = t.bounds(id).width;
assert!(full > 0.0);
progress.set(0.5);
t.layout(SizeProposal::unspecified());
let half = t.bounds(id).width;
assert!(
(half - full * 0.5).abs() < full * 0.1,
"half progress width ({half}) should be ~half of full ({full})"
);
}
#[test]
fn trailing_anchor_pins_trailing_edge() {
let progress = Signal::new_animated(0.5);
let mut t = tree();
let child = t.add(TextWidget::new(lit!("0123456789")));
let id = t.add(
Unroll::from_progress(progress)
.reveal_from(UnrollFrom::Trailing)
.child_id(child),
);
t.layout(SizeProposal::unspecified());
let wrapper = t.bounds(id);
let inner = t.bounds(child);
assert!(
inner.x < wrapper.x + 0.5,
"trailing-anchored child origin ({}) should be at/left of wrapper origin ({})",
inner.x,
wrapper.x
);
}
}