use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use teksilo_canvas::{Point, Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::frame_tick_scheduler::FrameTickSubscription;
use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
pub struct Pulse {
min: f32,
max: f32,
period: Option<Duration>,
pending_child: Option<PendingChild>,
child_id: Option<WidgetId>,
frame_tick_sub: Option<FrameTickSubscription>,
}
impl Pulse {
pub fn opacity(min: f32, max: f32) -> Self {
let lo = min.clamp(0.0, 1.0).min(max.clamp(0.0, 1.0));
let hi = min.clamp(0.0, 1.0).max(max.clamp(0.0, 1.0));
Self {
min: lo,
max: hi,
period: None,
pending_child: None,
child_id: None,
frame_tick_sub: None,
}
}
pub fn period(mut self, period: Duration) -> Self {
self.period = Some(period);
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 Pulse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Pulse")
.field("min", &self.min)
.field("max", &self.max)
.field("period", &self.period)
.finish()
}
}
impl Widget for Pulse {
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 mid = (self.min + self.max) * 0.5;
let opacity = ctx.signal(mid);
let id = ctx.self_id();
ctx.set_opacity(id, opacity.clone());
if ctx.prefers_reduced_motion() {
return vec![child_id];
}
let period = self
.period
.unwrap_or(ctx.theme().motion.duration_indeterminate_sweep);
let period_secs = period.as_secs_f32().max(0.001);
let amp = (self.max - self.min) * 0.5;
let elapsed = Rc::new(Cell::new(0.0_f32));
let opacity_for_tick = opacity;
ctx.effect(&ctx.frame_tick(), move |&delta| {
let t = (elapsed.get() + delta) % period_secs;
elapsed.set(t);
let phase = (t / period_secs) * std::f32::consts::TAU;
let v = mid + amp * phase.sin();
opacity_for_tick.set(v);
});
self.frame_tick_sub = None;
self.frame_tick_sub = Some(ctx.subscribe_frame_tick());
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 super::*;
use crate::primitives::TextWidget;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn pulse_starts_at_midpoint() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Pulse::opacity(0.2, 1.0).child(TextWidget::new(lit!("●"))));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
let ops: Vec<f32> = frame
.draw_order
.iter()
.filter_map(|c| match c {
teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
_ => None,
})
.collect();
assert_eq!(ops.len(), 1);
assert!(
(ops[0] - 0.6).abs() < 0.5,
"opacity should start near midpoint 0.6, got {}",
ops[0]
);
}
#[test]
fn pulse_pins_to_midpoint_under_reduced_motion() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.set_accessibility_preferences(false, true, 1.0);
tree.add(Pulse::opacity(0.0, 1.0).child(TextWidget::new(lit!("●"))));
tree.layout(SizeProposal::exact(100.0, 50.0));
let frame = tree.render();
let ops: Vec<f32> = frame
.draw_order
.iter()
.filter_map(|c| match c {
teksilo_canvas::DrawCommand::SetOpacity(v) => Some(*v),
_ => None,
})
.collect();
assert_eq!(ops.len(), 1);
assert!(
(ops[0] - 0.5).abs() < 1e-3,
"reduced-motion opacity should be pinned at midpoint 0.5, got {}",
ops[0]
);
assert!(
!tree.has_active_animations(),
"reduced-motion path must not register animations"
);
}
#[test]
fn pulse_does_not_change_layout() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(Pulse::opacity(0.0, 1.0).child(TextWidget::new(lit!("hello"))));
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let bounds_initial = tree.bounds(id);
tree.layout(SizeProposal {
width: Some(300.0),
height: None,
});
let bounds_again = tree.bounds(id);
assert_eq!(bounds_initial.size(), bounds_again.size());
}
#[test]
fn pulse_clamps_inverted_min_max() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(Pulse::opacity(0.9, 0.1).child(TextWidget::new(lit!("●"))));
tree.layout(SizeProposal::exact(100.0, 50.0));
let _ = tree.render();
}
}