use std::{cell::RefCell, ops::Deref, rc::Rc};
use ribir_core::prelude::*;
#[must_use = "AnimateMatch does nothing if discarded immediately; store it in a variable whose \
lifetime covers the animation scope"]
pub struct AnimateMatch<V: 'static, S: AnimateState + 'static> {
subscription: Rc<RefCell<Option<BoxedSubscription>>>,
value: Box<dyn StateWatcher<Value = V>>,
animate: Stateful<Animate<S>>,
}
impl<V: 'static, S: AnimateState + 'static> AnimateMatch<V, S> {
pub fn value_watcher(&self) -> Box<dyn StateWatcher<Value = V>> {
self.value.clone_boxed_watcher()
}
pub fn dispose(&self) {
self.animate.stop();
if let Some(sub) = self.subscription.borrow_mut().take() {
sub.unsubscribe();
}
}
}
impl<V: Clone + 'static, S: AnimateState + 'static> AnimateMatch<V, S> {
pub fn current_value(&self) -> V { self.value.read().clone() }
}
impl<V, S> AnimateMatch<V, S>
where
V: Clone + PartialEq + 'static,
S: AnimateState<Value: Clone> + 'static,
{
pub fn observe<TS>(
value: impl StateWatcher<Value = V>, cases: MatchCases<V, S>, transitions: TS,
interruption: Interruption,
) -> Self
where
TS: IntoTransitionSelector<V>,
{
let transitions = transitions.into_transition_selector();
let value: Box<dyn StateWatcher<Value = V>> = value.clone_boxed_watcher();
let init_value = value.read().clone();
let initial_target = cases.resolve(&init_value);
let animate_state = cases.state.clone_animate_state();
animate_state.revert(initial_target.clone());
let animate: Stateful<Animate<S>> = {
let mut builder = Animate::declarer();
builder
.with_state(animate_state)
.with_from(initial_target);
builder.finish()
};
let subscription = watch!($read(value).clone())
.merge(Local::of(init_value))
.distinct_until_changed()
.pairwise()
.subscribe({
let animate = animate.clone_writer();
move |(from, to)| {
let target = cases.resolve(&to);
let Some(transition) = transitions(&from, &to) else {
animate.stop();
animate.read().state.set_value(target);
return;
};
if interruption == Interruption::Snap {
animate.stop();
}
let mut animate_ref = animate.write();
let restart_from = match interruption {
Interruption::Fluid => animate_ref.interpolated_value(),
Interruption::Snap => cases.resolve(&from),
};
animate_ref.transition = transition;
animate_ref.from = restart_from;
animate_ref.state.set_value(target);
animate_ref.forget_modifies();
drop(animate_ref);
animate.run();
}
})
.into_boxed();
Self { subscription: Rc::new(RefCell::new(Some(subscription))), value, animate }
}
}
impl<V: 'static, S: AnimateState + 'static> Declare for AnimateMatch<V, S>
where
V: Clone + PartialEq,
S: AnimateState<Value: Clone> + 'static,
{
type Builder = AnimateMatchDeclarer<V, S>;
fn declarer() -> Self::Builder {
AnimateMatchDeclarer { value: None, cases: None, transitions: None, interruption: None }
}
}
impl<V: 'static, S: AnimateState + 'static> Clone for AnimateMatch<V, S> {
fn clone(&self) -> Self {
Self {
subscription: self.subscription.clone(),
value: self.value.clone_boxed_watcher(),
animate: self.animate.clone_writer(),
}
}
}
impl<V: 'static, S: AnimateState + 'static> Deref for AnimateMatch<V, S> {
type Target = Stateful<Animate<S>>;
fn deref(&self) -> &Self::Target { &self.animate }
}
pub struct AnimateMatchDeclarer<V: 'static, S: AnimateState<Value: Clone> + 'static> {
value: Option<Box<dyn StateWatcher<Value = V>>>,
cases: Option<MatchCases<V, S>>,
transitions: Option<TransitionBuilder<V>>,
interruption: Option<Interruption>,
}
impl<V: 'static, S: AnimateState + 'static> AnimateMatchDeclarer<V, S> {
pub fn with_value(&mut self, value: impl StateWatcher<Value = V> + 'static) -> &mut Self {
self.value = Some(Box::new(value));
self
}
pub fn with_cases(&mut self, cases: MatchCases<V, S>) -> &mut Self {
self.cases = Some(cases);
self
}
pub fn with_transitions<TS>(&mut self, transitions: TS) -> &mut Self
where
TS: IntoTransitionSelector<V>,
{
self.transitions = Some(transitions.into_transition_selector());
self
}
pub fn with_interruption(&mut self, interruption: Interruption) -> &mut Self {
self.interruption = Some(interruption);
self
}
}
impl<V, S> ObjDeclarer for AnimateMatchDeclarer<V, S>
where
V: Clone + PartialEq + 'static,
S: AnimateState<Value: Clone> + 'static,
{
type Target = AnimateMatch<V, S>;
#[track_caller]
fn finish(self) -> Self::Target {
let value = self
.value
.expect("AnimateMatch requires a `value`");
let cases = self.cases.expect("AnimateMatch requires `cases`");
let transitions = self
.transitions
.expect("AnimateMatch requires `transitions`");
let interruption = self.interruption.unwrap_or_default();
AnimateMatch::observe(value, cases, OptionalTransitionSelector::from(transitions), interruption)
}
}
pub struct MatchCases<V, S: AnimateState> {
state: S,
map: CaseResolver<V, S>,
}
pub type TransitionBuilder<V> = Box<dyn Fn(&V, &V) -> Option<Box<dyn Transition>>>;
pub trait IntoTransitionSelector<V>: 'static {
fn into_transition_selector(self) -> TransitionBuilder<V>;
}
pub struct OptionalTransitionSelector<V>(TransitionBuilder<V>);
impl<V> OptionalTransitionSelector<V> {
pub fn new(f: impl Fn(&V, &V) -> Option<Box<dyn Transition>> + 'static) -> Self {
Self(Box::new(f))
}
}
impl<V> From<TransitionBuilder<V>> for OptionalTransitionSelector<V> {
fn from(value: TransitionBuilder<V>) -> Self { Self(value) }
}
impl<V, F, T> IntoTransitionSelector<V> for F
where
F: Fn(&V, &V) -> T + 'static,
T: Transition + 'static,
{
fn into_transition_selector(self) -> TransitionBuilder<V> {
Box::new(move |from, to| Some(self(from, to).into_box()))
}
}
impl<V: 'static> IntoTransitionSelector<V> for OptionalTransitionSelector<V> {
fn into_transition_selector(self) -> TransitionBuilder<V> { self.0 }
}
pub type CaseResolver<V, S> = Box<dyn Fn(&V) -> <S as AnimateState>::Value>;
impl<V, S: AnimateState> MatchCases<V, S> {
pub fn new(state: S, map: impl Fn(&V) -> S::Value + 'static) -> Self {
Self { state, map: Box::new(map) }
}
#[inline]
pub(crate) fn resolve(&self, value: &V) -> S::Value { (self.map)(value) }
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Interruption {
#[default]
Fluid,
Snap,
}
#[macro_export]
macro_rules! cases {
(state: ($($state:expr),+ $(,)?), $($pattern:pat_param => ($($value:expr),+ $(,)?)),+ $(,)?) => {{
$crate::animation::animate_match::MatchCases::new(
$crate::core::animate_state_pack!($($state),+),
move |__value| match __value {
$( &$pattern => $crate::core::animate_state_pack!($($value),+) ),+
},
)
}};
(state: $state:expr, $($pattern:pat_param => $value:expr),+ $(,)?) => {{
$crate::animation::animate_match::MatchCases::new(
$state,
move |__value| match __value {
$( &$pattern => $value ),+
},
)
}};
}
pub use cases;
#[macro_export]
macro_rules! transitions {
() => {{
Box::new(move |_: &_, _: &_| -> Box<dyn $crate::core::prelude::Transition> {
Box::new($crate::core::prelude::EasingTransition {
easing: $crate::core::prelude::easing::LINEAR,
duration: $crate::core::prelude::Duration::from_millis(300),
})
}) as Box<dyn Fn(&_, &_) -> Box<dyn $crate::core::prelude::Transition>>
}};
($($pattern:pat => $transition:expr),+ $(,)?) => {{
Box::new(move |__from: &_, __to: &_| -> Box<dyn $crate::core::prelude::Transition> {
match (__from, __to) {
$(
$pattern => Box::new($transition),
)+
}
}) as Box<dyn Fn(&_, &_) -> Box<dyn $crate::core::prelude::Transition>>
}};
($closure:expr) => {{
Box::new(move |__from: &_, __to: &_| -> Box<dyn $crate::core::prelude::Transition> {
Box::new($closure(__from, __to))
}) as Box<dyn Fn(&_, &_) -> Box<dyn $crate::core::prelude::Transition>>
}};
}
pub use transitions;
#[cfg(test)]
mod tests {
use ribir_core::{reset_test_env, test_helper::*, window::WindowFlags};
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Status {
Idle,
Hover,
Active,
}
#[derive(Declare)]
struct ValueRecorder {
opacity: Stateful<f32>,
scale: Stateful<f32>,
frames: Stateful<Vec<(f32, f32)>>,
}
impl Render for ValueRecorder {
fn measure(&self, clamp: BoxClamp, _ctx: &mut MeasureCtx) -> Size { clamp.min }
fn paint(&self, _ctx: &mut PaintingCtx) {
self
.frames
.write()
.push((*self.opacity.read(), *self.scale.read()));
}
}
fn linear(duration_ms: u64) -> EasingTransition<impl Easing + Clone> {
EasingTransition { easing: easing::LINEAR, duration: Duration::from_millis(duration_ms) }
}
#[test]
fn initial_case_applies_absolute_targets() {
reset_test_env!();
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let opacity_reader = opacity.clone_reader();
let scale_reader = scale.clone_reader();
let frames_reader = frames.clone_reader();
let w = fn_widget! {
let _am = AnimateMatch::observe(
Stateful::new(Status::Hover).clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (1.0, 1.0),
Status::Hover => (0.8, 1.1),
Status::Active => (0.5, 0.9),
},
transitions! {},
Interruption::default(),
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
assert_eq!(*opacity_reader.read(), 0.8);
assert_eq!(*scale_reader.read(), 1.1);
assert_eq!(frames_reader.read().last().copied(), Some((0.8, 1.1)));
}
#[test]
fn route_specific_transition_overrides_fallback() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let w = fn_widget! {
let _am = AnimateMatch::observe(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.6, 1.05),
Status::Active => (1.0, 0.95),
},
transitions! {
(_, Status::Active) => linear(0),
_ => linear(200),
},
Interruption::default(),
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
assert_eq!(frames_reader.read().last().copied(), Some((1.0, 0.95)));
}
#[test]
fn none_transition_switches_immediately_with_notification() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let frames = Stateful::new(Vec::new());
let opacity_reader = opacity.clone_reader();
let scale_reader = scale.clone_reader();
let modify_hits = Stateful::new(0);
let modify_hits_reader = modify_hits.clone_reader();
let _subscription = opacity
.modifies()
.subscribe({
let modify_hits = modify_hits.clone_writer();
move |_| *modify_hits.write() += 1
})
.into_boxed();
let c_status = status.clone_writer();
let w = fn_widget! {
let _am = AnimateMatch::observe(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.6, 1.05),
Status::Active => (1.0, 0.95),
},
OptionalTransitionSelector::new(move |from, to| match (*from, *to) {
(Status::Idle, Status::Active) => None,
_ => Some(linear(200).into_box()),
}),
Interruption::default(),
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
for _ in 0..3 {
wnd.draw_frame();
if *opacity_reader.read() == 1.0 && *scale_reader.read() == 0.95 {
break;
}
}
assert_eq!(*opacity_reader.read(), 1.0);
assert_eq!(*scale_reader.read(), 0.95);
assert!(
*modify_hits_reader.read() > 0,
"none-transition direct switch should notify downstream state watchers"
);
}
#[test]
fn fluid_interruption_continues_from_interpolated_value() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let frames = Stateful::new(Vec::new());
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let host = fn_widget! {
let _am = AnimateMatch::observe(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.9),
},
transitions! { _ => linear(200), },
Interruption::Fluid,
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(host, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
std::thread::sleep(Duration::from_millis(80));
wnd.draw_frame();
let mid = frames_reader.read().last().copied().unwrap().0;
let animation_in_progress = mid > 0.1 && mid < 0.9;
*status.write() = Status::Hover;
wnd.draw_frame();
let resumed = frames_reader.read().last().copied().unwrap().0;
if animation_in_progress {
assert!(
(resumed - mid).abs() < 0.25,
"fluid interruption should continue near current value: mid={mid}, resumed={resumed}"
);
}
}
#[test]
fn snap_interruption_restarts_from_previous_case_target() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let frames = Stateful::new(Vec::new());
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let w = fn_widget! {
let _am = AnimateMatch::observe(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.9),
},
transitions! { _ => linear(200), },
Interruption::Snap,
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
std::thread::sleep(Duration::from_millis(80));
wnd.draw_frame();
*status.write() = Status::Hover;
wnd.draw_frame();
let restarted = frames_reader.read().last().copied().unwrap().0;
assert!(
restarted > 0.8,
"snap interruption should restart from previous case target, got {restarted}"
);
}
#[test]
fn stop_manually() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let frames = Stateful::new(Vec::new());
let c_status = status.clone_writer();
let opacity_reader = opacity.clone_reader();
let w = fn_widget! {
let am = AnimateMatch::observe(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.95),
},
transitions! { _ => linear(100), },
Interruption::default(),
);
am.dispose();
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
assert_eq!(*opacity_reader.read(), 0.0);
}
#[test]
fn cloned_handles_share_controller_and_stop_together() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let wnd =
TestWindow::new(fn_widget! { @Void {} }, Size::new(10., 10.), WindowFlags::ANIMATIONS);
let am = AnimateMatch::observe(
status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.95),
},
transitions! { _ => linear(100), },
Interruption::default(),
);
let clone = am.clone();
am.init_window(wnd.id());
*status.write() = Status::Active;
wnd.draw_frame();
assert!(clone.is_running() || am.is_running());
assert_eq!(am.current_value(), Status::Active);
assert_eq!(clone.current_value(), Status::Active);
let stopped_value = *opacity.read();
am.dispose();
assert!(!clone.is_running());
*status.write() = Status::Hover;
wnd.draw_frame();
assert_eq!(clone.current_value(), Status::Hover);
assert_eq!(*opacity.read(), stopped_value);
}
#[test]
fn current_value_and_watcher_track_latest_business_state() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let seen = Stateful::new(Vec::new());
let seen_reader = seen.clone_reader();
let wnd =
TestWindow::new(fn_widget! { @Void {} }, Size::new(10., 10.), WindowFlags::ANIMATIONS);
let am = AnimateMatch::observe(
status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.95),
},
OptionalTransitionSelector::new(move |_, _| None),
Interruption::default(),
);
am.init_window(wnd.id());
let am_for_watch = am.clone();
let _sub = am
.value_watcher()
.raw_modifies()
.subscribe({
let seen = seen.clone_writer();
move |_| seen.write().push(am_for_watch.current_value())
})
.into_boxed();
assert_eq!(am.current_value(), Status::Idle);
*status.write() = Status::Hover;
wnd.draw_frame();
assert_eq!(am.current_value(), Status::Hover);
assert_eq!(seen_reader.read().as_slice(), &[Status::Hover]);
*status.write() = Status::Active;
wnd.draw_frame();
assert_eq!(am.current_value(), Status::Active);
assert_eq!(seen_reader.read().as_slice(), &[Status::Hover, Status::Active]);
}
#[test]
fn declarative_syntax() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let opacity_reader = opacity.clone_reader();
let scale_reader = scale.clone_reader();
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let w = fn_widget! {
let _am = @AnimateMatch {
value: c_status.clone_watcher(),
cases: cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.6, 1.05),
Status::Active => (1.0, 0.95),
},
transitions: transitions! {
(_, Status::Active) => linear(0),
_ => linear(200),
},
interruption: Interruption::Fluid,
};
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
assert_eq!(*opacity_reader.read(), 0.0);
assert_eq!(*scale_reader.read(), 1.0);
*status.write() = Status::Active;
wnd.draw_frame();
assert_eq!(frames_reader.read().last().copied(), Some((1.0, 0.95)));
}
#[test]
#[should_panic(expected = "AnimateMatch requires `transitions`")]
fn declarative_requires_transitions() {
reset_test_env!();
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let w = fn_widget! {
let _am = @AnimateMatch {
value: Stateful::new(Status::Hover).clone_watcher(),
cases: cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (1.0, 1.0),
Status::Hover => (0.8, 1.1),
Status::Active => (0.5, 0.9),
},
};
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let _wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
}
#[test]
fn declarative_with_closure_transition() {
reset_test_env!();
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let w = fn_widget! {
let _am = @AnimateMatch {
value: Stateful::new(Status::Hover).clone_watcher(),
cases: cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (1.0, 1.0),
Status::Hover => (0.8, 1.1),
Status::Active => (0.5, 0.9),
},
transitions: transitions!(|_, _| linear(200)),
};
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
}
}