use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use frus_core::{Alignment, AlignmentGeometry, BoxFit, Curve, Rect, Scene};
use frus_layout::{Dimension, Style};
use frus_lottie::{Animation, AnimationHandle};
use crate::image::State;
use crate::interaction::Status;
use crate::remote::{Remote, Store};
use crate::theme::Theme;
use crate::widget::Widget;
static BY_ADDRESS: Mutex<Option<HashMap<usize, Result<AnimationHandle, String>>>> =
Mutex::new(None);
static BY_TEXT: Mutex<Option<HashMap<u64, Result<AnimationHandle, String>>>> = Mutex::new(None);
static FETCHED: Store<AnimationHandle> = Store::new();
pub(crate) fn in_flight() -> usize {
FETCHED.in_flight()
}
const TEXT_ENTRIES: usize = 64;
const LOOPS: f32 = 10_000.0;
fn parse(bytes: &[u8]) -> Result<AnimationHandle, String> {
match Animation::parse(bytes) {
Ok(animation) => Ok(Arc::new(animation)),
Err(why) => {
log::warn!("frus: {why}");
Err(why.message().to_string())
}
}
}
pub struct Lottie {
source: Remote<AnimationHandle>,
width: Option<f32>,
height: Option<f32>,
fit: BoxFit,
alignment: AlignmentGeometry,
animate: bool,
repeat: bool,
reverse: bool,
speed: f32,
progress: Option<f32>,
opacity: f32,
semantic_label: Option<String>,
exclude_from_semantics: bool,
}
impl Lottie {
pub fn new(animation: AnimationHandle) -> Self {
Self::from_remote(Remote::Ready(animation))
}
pub fn memory(bytes: &'static [u8]) -> Self {
let key = bytes.as_ptr() as usize;
let mut guard = BY_ADDRESS.lock().unwrap_or_else(|e| e.into_inner());
let store = guard.get_or_insert_with(HashMap::new);
let source = store.entry(key).or_insert_with(|| parse(bytes)).clone();
Self::from_source(source)
}
pub fn string(source: impl AsRef<str>) -> Self {
use std::hash::{Hash, Hasher};
let text = source.as_ref();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
let key = hasher.finish();
let mut guard = BY_TEXT.lock().unwrap_or_else(|e| e.into_inner());
let store = guard.get_or_insert_with(HashMap::new);
if store.len() >= TEXT_ENTRIES && !store.contains_key(&key) {
store.clear();
}
let source = store
.entry(key)
.or_insert_with(|| parse(text.as_bytes()))
.clone();
Self::from_source(source)
}
pub fn network(url: impl AsRef<str>) -> Self {
Self::from_remote(FETCHED.get(url.as_ref(), parse))
}
fn from_source(source: Result<AnimationHandle, String>) -> Self {
Self::from_remote(match source {
Ok(animation) => Remote::Ready(animation),
Err(why) => Remote::Failed(why),
})
}
fn from_remote(source: Remote<AnimationHandle>) -> Self {
Self {
source,
width: None,
height: None,
fit: BoxFit::Contain,
alignment: AlignmentGeometry::Physical(Alignment::CENTER),
animate: true,
repeat: false,
reverse: false,
speed: 1.0,
progress: None,
opacity: 1.0,
semantic_label: None,
exclude_from_semantics: false,
}
}
#[must_use]
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
#[must_use]
pub fn height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
#[must_use]
pub fn size(self, width: f32, height: f32) -> Self {
self.width(width).height(height)
}
#[must_use]
pub fn fit(mut self, fit: BoxFit) -> Self {
self.fit = fit;
self
}
#[must_use]
pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
self.alignment = alignment.into();
self
}
#[must_use]
pub fn animate(mut self, animate: bool) -> Self {
self.animate = animate;
self
}
#[must_use]
pub fn repeat(mut self, repeat: bool) -> Self {
self.repeat = repeat;
self
}
#[must_use]
pub fn reverse(mut self, reverse: bool) -> Self {
self.reverse = reverse;
self
}
#[must_use]
pub fn speed(mut self, speed: f32) -> Self {
self.speed = if speed.is_finite() && speed > 0.0 {
speed
} else {
1.0
};
self
}
#[must_use]
pub fn progress(mut self, progress: f32) -> Self {
self.progress = Some(progress.clamp(0.0, 1.0));
self
}
#[must_use]
pub fn opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
#[must_use]
pub fn exclude_from_semantics(mut self, exclude: bool) -> Self {
self.exclude_from_semantics = exclude;
self
}
pub fn error(&self) -> Option<&str> {
match &self.source {
Remote::Failed(why) => Some(why),
_ => None,
}
}
pub fn animation(&self) -> Option<&AnimationHandle> {
match &self.source {
Remote::Ready(animation) => Some(animation),
_ => None,
}
}
pub fn state(&self) -> State<'_> {
match &self.source {
Remote::Ready(_) => State::Ready,
Remote::Loading => State::Loading,
Remote::Failed(why) => State::Failed(why),
}
}
fn plays(&self) -> bool {
self.progress.is_none()
&& self.animate
&& self
.animation()
.is_some_and(|animation| animation.duration() > 0.0)
}
fn moment(&self, value: f32) -> f32 {
if let Some(progress) = self.progress {
return progress;
}
if !self.plays() {
return if self.reverse { 1.0 } else { 0.0 };
}
let (play, within) = if self.repeat {
(value.floor(), value.fract())
} else {
(0.0, value.clamp(0.0, 1.0))
};
match (self.reverse, self.repeat) {
(false, _) => within,
(true, false) => 1.0 - within,
(true, true) => {
if play % 2.0 == 0.0 {
within
} else {
1.0 - within
}
}
}
}
}
impl<Msg> Widget<Msg> for Lottie {
fn style(&self) -> Style {
let length = |v: Option<f32>| v.map_or(Dimension::Auto, Dimension::Length);
let Some(animation) = self.animation() else {
return Style {
width: Dimension::Length(self.width.unwrap_or(0.0)),
height: Dimension::Length(self.height.unwrap_or(0.0)),
..Default::default()
};
};
let natural = animation.size();
let ratio = if natural.height > 0.0 {
natural.width / natural.height
} else {
1.0
};
match (self.width, self.height) {
(None, None) => Style {
width: Dimension::Length(natural.width),
height: Dimension::Length(natural.height),
..Default::default()
},
(w, h) => Style {
width: length(w),
height: length(h),
aspect_ratio: (w.is_none() || h.is_none()).then_some(ratio),
..Default::default()
},
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn anim_target(&self) -> Option<f32> {
self.plays()
.then_some(if self.repeat { LOOPS } else { 1.0 })
}
fn anim_initial(&self) -> Option<f32> {
self.plays().then_some(0.0)
}
fn anim_duration(&self) -> f32 {
let one = self
.animation()
.map_or(0.0, |animation| animation.duration())
/ self.speed;
if self.repeat {
one * LOOPS
} else {
one
}
}
fn anim_curve(&self) -> Curve {
Curve::Linear
}
fn paint(&self, bounds: Rect, status: Status, _theme: &Theme, scene: &mut Scene) {
if let Some(animation) = self.animation() {
animation.paint(
scene,
bounds,
self.fit,
self.alignment,
self.moment(status.value),
status.opacity * self.opacity,
);
for what in animation.unsupported() {
log_once(what);
}
}
}
fn on_click(&self) -> Option<Msg> {
None
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
if self.exclude_from_semantics {
return None;
}
let mut semantics = frus_core::SemanticsProperties::new(frus_core::Role::Image);
if let Some(label) = self.semantic_label.as_deref() {
semantics = semantics.label(label);
}
Some(semantics)
}
}
fn log_once(what: &'static str) {
static SAID: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
let mut said = SAID.lock().unwrap_or_else(|e| e.into_inner());
if !said.contains(&what) {
said.push(what);
log::warn!("frus: a Lottie animation uses {what}, which is not drawn yet");
}
}
#[cfg(test)]
mod tests {
use super::*;
use frus_core::Primitive;
const MOVING: &str = r#"{"v":"5.7.4","fr":30,"ip":0,"op":60,"w":100,"h":100,"nm":"t","ddd":0,
"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"s","sr":1,"ip":0,"op":60,"st":0,"bm":0,
"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},
"p":{"a":1,"k":[{"t":0,"s":[20,50,0],"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},
{"t":60,"s":[80,50,0]}]},
"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},
"shapes":[{"ty":"gr","nm":"g","it":[
{"ty":"rc","nm":"r","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[20,20]},"r":{"a":0,"k":0}},
{"ty":"fl","nm":"f","c":{"a":0,"k":[1,0,0,1]},"o":{"a":0,"k":100},"r":1},
{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},
"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}]}]}]}"#;
fn left_at(lottie: &Lottie, value: f32) -> f32 {
let mut scene = Scene::new();
scene.set_clip(Rect::new(0.0, 0.0, 100.0, 100.0));
let status = Status {
value,
..Status::default()
};
Widget::<()>::paint(
lottie,
Rect::new(0.0, 0.0, 100.0, 100.0),
status,
&Theme::default(),
&mut scene,
);
let Some(Primitive::Path { path, .. }) = scene.primitives().first() else {
panic!("a path, not in a layer: {:?}", scene.primitives())
};
path.verbs()
.iter()
.filter_map(|v| match *v {
frus_core::PathVerb::MoveTo(p) | frus_core::PathVerb::LineTo(p) => Some(p.x),
_ => None,
})
.fold(f32::MAX, f32::min)
}
#[test]
fn it_takes_the_animations_size_or_the_one_given() {
let own = Widget::<()>::style(&Lottie::string(MOVING));
assert_eq!(own.width, Dimension::Length(100.0));
let wide = Widget::<()>::style(&Lottie::string(MOVING).width(50.0));
assert_eq!(wide.aspect_ratio, Some(1.0));
}
#[test]
fn it_plays_once_from_the_start_when_it_appears() {
let once = Lottie::string(MOVING);
assert_eq!(Widget::<()>::anim_target(&once), Some(1.0));
assert_eq!(Widget::<()>::anim_initial(&once), Some(0.0));
assert!((Widget::<()>::anim_duration(&once) - 2.0).abs() < 1e-6);
assert!((left_at(&once, 0.0) - 10.0).abs() < 0.5);
assert!(
(left_at(&once, 0.5) - 39.5).abs() < 1.0,
"{}",
left_at(&once, 0.5)
);
assert!((left_at(&once, 7.0) - left_at(&once, 1.0)).abs() < 1e-3);
}
#[test]
fn it_loops_and_goes_back_and_forth_when_asked() {
let looping = Lottie::string(MOVING).repeat(true);
assert_eq!(Widget::<()>::anim_target(&looping), Some(LOOPS));
assert!((left_at(&looping, 3.25) - left_at(&looping, 0.25)).abs() < 1e-3);
let swinging = Lottie::string(MOVING).repeat(true).reverse(true);
assert!((left_at(&swinging, 1.25) - left_at(&swinging, 0.75)).abs() < 1e-3);
let faster = Lottie::string(MOVING).speed(2.0);
assert!((Widget::<()>::anim_duration(&faster) - 1.0).abs() < 1e-6);
}
#[test]
fn a_driven_animation_shows_its_moment_and_does_not_play() {
let driven = Lottie::string(MOVING).progress(1.0);
assert_eq!(Widget::<()>::anim_target(&driven), None);
assert!((left_at(&driven, 0.0) - left_at(&Lottie::string(MOVING), 1.0)).abs() < 1e-3);
let still = Lottie::string(MOVING).animate(false);
assert_eq!(Widget::<()>::anim_target(&still), None);
assert!((left_at(&still, 0.0) - 10.0).abs() < 0.5);
}
#[test]
fn a_fetched_animation_plays_from_its_start_when_it_arrives() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex as StdMutex;
type Deliver = Box<dyn FnOnce(Result<Vec<u8>, String>) + Send>;
static PENDING: StdMutex<Option<Deliver>> = StdMutex::new(None);
static ASKED: AtomicBool = AtomicBool::new(false);
fn held(_: &str, deliver: Deliver) {
ASKED.store(true, Ordering::SeqCst);
*PENDING.lock().unwrap() = Some(deliver);
}
let _guard = crate::remote::fetcher_lock();
frus_core::set_image_fetcher(held);
let url = "https://example.com/moving.json";
let mut rt = crate::runtime::Runtime::default();
let waiting = Lottie::network(url).size(100.0, 100.0);
assert!(ASKED.load(Ordering::SeqCst));
assert_eq!(waiting.state(), State::Loading);
assert!(crate::images_in_flight() > 0, "the frames keep coming");
for _ in 0..6 {
rt.advance_values::<()>(&waiting, 0.5);
}
let style = Widget::<()>::style(&waiting);
assert_eq!(
style.width,
Dimension::Length(100.0),
"the room it was given"
);
let deliver = PENDING.lock().unwrap().take().unwrap();
deliver(Ok(MOVING.as_bytes().to_vec()));
let arrived = Lottie::network(url).size(100.0, 100.0);
assert_eq!(arrived.state(), State::Ready);
assert!(rt.advance_values::<()>(&arrived, 0.5), "playing");
let value = rt.value(crate::WidgetId::ROOT);
assert!(
(value - 0.25).abs() < 1e-6,
"a quarter in, not at the end: {value}"
);
assert!((left_at(&arrived, value) - 24.75).abs() < 1.0);
}
#[test]
fn a_broken_source_says_why() {
let broken = Lottie::string("{");
assert!(broken.error().is_some());
assert_eq!(Widget::<()>::anim_target(&broken), None);
}
}