Files
ElyOffice/apps/ely-show/src/preview/timeline.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

215 lines
6.3 KiB
Rust

//! When each effect runs, and how its object looks meanwhile.
use ely_lok::show::Effect;
/// How an object looks at one moment.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Look {
pub visible: bool,
pub opacity: f32,
/// Offset as a share of the slide's width and height.
pub shift: (f32, f32),
/// Scale about the object's centre.
pub scale: f32,
/// Share of the object shown, from its bottom up.
pub reveal: f32,
}
impl Look {
pub const SHOWN: Look = Look {
visible: true,
opacity: 1.0,
shift: (0.0, 0.0),
scale: 1.0,
reveal: 1.0,
};
pub const HIDDEN: Look = Look {
visible: false,
..Look::SHOWN
};
}
/// One effect placed on the clock, in seconds.
#[derive(Clone, Debug, PartialEq)]
pub struct Run {
pub hash: String,
pub preset: String,
pub begin: f64,
pub end: f64,
}
/// Places the effects back to back, as a preview plays.
pub fn schedule(effects: &[Effect]) -> Vec<Run> {
let (mut runs, mut last_begin, mut last_end) = (Vec::new(), 0.0f64, 0.0f64);
let mut end_of_all = 0.0f64;
for effect in effects {
// A click waits for all before; previews click at once.
let anchor = match effect.start.as_str() {
"with" => last_begin,
"after" => last_end,
_ => end_of_all,
};
let begin = anchor + effect.delay;
let end = begin + effect.duration.max(0.0);
runs.push(Run {
hash: effect.hash.clone(),
preset: effect.preset.clone(),
begin,
end,
});
(last_begin, last_end) = (begin, end);
end_of_all = end_of_all.max(end);
}
runs
}
/// The ease the kernel's presets mostly use.
fn ease(p: f32) -> f32 {
p * p * (3.0 - 2.0 * p)
}
/// An entrance at progress `p`: 0 hidden, 1 in place.
fn entrance(kind: &str, p: f32, top: f32) -> Look {
let p = ease(p.clamp(0.0, 1.0));
match kind {
"fade-in" => Look {
opacity: p,
..Look::SHOWN
},
// From below the slide's bottom edge.
"fly-in" => Look {
shift: (0.0, (1.0 - p) * (1.0 - top)),
..Look::SHOWN
},
"wipe" => Look {
reveal: p,
..Look::SHOWN
},
"zoom" => Look {
scale: p,
..Look::SHOWN
},
"float" => Look {
opacity: p,
shift: (0.0, (1.0 - p) * 0.1),
..Look::SHOWN
},
// Appear, and effects the preview does not draw.
_ => {
if p > 0.0 {
Look::SHOWN
} else {
Look::HIDDEN
}
}
}
}
/// How `run`'s object looks at second `t`, once started.
pub fn look(run: &Run, t: f64, top: f32) -> Option<Look> {
if t < run.begin {
return None;
}
let span = run.end - run.begin;
let p = if span <= 0.0 {
1.0
} else {
((t - run.begin) / span).min(1.0) as f32
};
let (class, kind) = run
.preset
.strip_prefix("ooo-")
.and_then(|rest| rest.split_once('-'))
.unwrap_or(("other", ""));
Some(match (class, kind) {
("entrance", kind) => entrance(kind, if span <= 0.0 { 1.0 } else { p }, top),
("exit", kind) => {
let mirrored = kind.replace("-out", "-in").replace("disappear", "appear");
let gone = entrance(&mirrored, 1.0 - p, top);
if p >= 1.0 { Look::HIDDEN } else { gone }
}
("emphasis", "grow-and-shrink") => Look {
scale: 1.0 + 0.5 * (1.0 - (2.0 * p - 1.0).abs()),
..Look::SHOWN
},
("emphasis", "blink") => {
if p < 0.5 && p > 0.0 {
Look::HIDDEN
} else {
Look::SHOWN
}
}
_ => Look::SHOWN,
})
}
/// The object's look at `t`, from its last started run.
pub fn object_look(runs: &[Run], hash: &str, visible: bool, t: f64, top: f32) -> Look {
let ran = runs
.iter()
.rev()
.filter(|run| run.hash == hash)
.find_map(|run| look(run, t, top));
ran.unwrap_or(if visible { Look::SHOWN } else { Look::HIDDEN })
}
/// When the last effect ends.
pub fn length(runs: &[Run]) -> f64 {
runs.iter().map(|run| run.end).fold(0.0, f64::max)
}
#[cfg(test)]
mod tests {
use super::*;
fn effect(preset: &str, start: &str, duration: f64, delay: f64) -> Effect {
Effect {
preset: preset.into(),
label: String::new(),
class: String::new(),
target: Some(0),
hash: preset.into(),
start: start.into(),
duration,
delay,
}
}
#[test]
fn clicks_wait_with_joins_and_after_follows() {
let runs = schedule(&[
effect("ooo-entrance-fade-in", "click", 2.0, 0.0),
effect("ooo-entrance-fly-in", "with", 1.0, 0.5),
effect("ooo-entrance-wipe", "after", 1.0, 0.0),
effect("ooo-entrance-zoom", "click", 1.0, 0.0),
]);
let times: Vec<(f64, f64)> = runs.iter().map(|r| (r.begin, r.end)).collect();
assert_eq!(times, [(0.0, 2.0), (0.5, 1.5), (1.5, 2.5), (2.5, 3.5)]);
assert_eq!(length(&runs), 3.5);
}
#[test]
fn entrances_hide_before_and_rest_in_place_after() {
let run = &schedule(&[effect("ooo-entrance-fade-in", "click", 2.0, 1.0)])[0];
let at = |t| object_look(std::slice::from_ref(run), &run.hash, false, t, 0.3);
assert_eq!(at(0.5), Look::HIDDEN);
assert!((at(2.0).opacity - 0.5).abs() < 0.01);
assert_eq!(at(4.0), Look::SHOWN);
}
#[test]
fn exits_end_hidden_and_emphasis_returns() {
let runs = schedule(&[
effect("ooo-exit-fly-out", "click", 1.0, 0.0),
effect("ooo-emphasis-grow-and-shrink", "click", 2.0, 0.0),
]);
let out = |t| object_look(&runs[..1], &runs[0].hash, true, t, 0.5);
assert_eq!(out(0.0), Look::SHOWN);
assert!(out(0.5).shift.1 > 0.0, "on its way down");
assert_eq!(out(1.0), Look::HIDDEN);
let grow = |t| object_look(&runs[1..], &runs[1].hash, true, t, 0.5).scale;
assert!((grow(2.0) - 1.5).abs() < 0.01, "biggest halfway");
assert!((grow(3.0) - 1.0).abs() < 0.01, "back after");
}
}