Animation Pane: advanced timeline with draggable bars

The pane's bars now come from deckcraft_anim::Timeline, the same schedule the
slide show plays, so With/After Previous, delays, repeats and auto-reverse line
up on a shared seconds ruler. Dragging a bar sets the delay and dragging its
right edge sets the duration (snapped to 0.05 s), each through
animation.timing, so one undoable command per drag. Bar geometry and drag maths
live in animtimeline.rs as pure, unit-tested functions; kittest drives the
drags. Parity and gaps docs updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 21:30:05 +03:00
co-authored by Cursor
parent 457406a7a5
commit 1b9b830f32
6 changed files with 331 additions and 18 deletions
+90
View File
@@ -0,0 +1,90 @@
//! The Animation Pane's advanced timeline driven with the mouse: dragging a bar changes the
//! delay, dragging its right edge changes the duration, each one undoable command.
use deckcraft_engine::Session;
use egui::Pos2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::{Services, SlideApp};
/// A blank slide with two shapes: Fade on click (1 s), then Zoom after previous (2 s).
fn harness() -> Harness<'static, SlideApp> {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
for (i, (effect, start, dur)) in [("fade", "onClick", 1000), ("zoom", "afterPrevious", 2000)].iter().enumerate() {
let id =
s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0 + 200.0 * i as f64, 80, 150, 100]})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
s.execute("animation.add", &json!({"effect": effect, "start": start, "duration": dur})).unwrap();
}
let mut app = SlideApp::new(s, Services::default());
app.ui.pane = Some("animation".into());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
fn timing(h: &Harness<'_, SlideApp>, i: usize) -> (u32, u32) {
let a = &h.state().session.active().unwrap().current_slide().unwrap().animations[i];
(a.delay_ms, a.duration_ms)
}
fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for i in 1..=6 {
h.hover_at(from + (to - from) * (i as f32 / 6.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(3);
}
#[test]
fn after_previous_bar_starts_where_the_first_ends() {
let h = harness();
let a = h.get_by_label("Timing bar 1").rect();
let b = h.get_by_label("Timing bar 2").rect();
assert!((b.left() - a.right()).abs() < 1.5, "{a:?} {b:?}");
assert!((b.width() - 2.0 * a.width()).abs() < 1.5, "2 s bar twice the 1 s bar");
}
#[test]
fn dragging_a_bar_sets_the_delay_and_undoes() {
let mut h = harness();
let a = h.get_by_label("Timing bar 1").rect();
// One second is the width of bar 1.
drag(&mut h, a.center(), a.center() + egui::vec2(a.width() / 2.0, 0.0));
assert_eq!(timing(&h, 0), (500, 1000));
h.state_mut().session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(timing(&h, 0), (0, 1000));
}
#[test]
fn dragging_the_edge_sets_the_duration() {
let mut h = harness();
let b = h.get_by_label("Timing bar 2").rect();
let one_sec = b.width() / 2.0;
let edge = h.get_by_label("Duration handle 2").rect().center();
drag(&mut h, edge, edge - egui::vec2(one_sec, 0.0));
assert_eq!(timing(&h, 1), (0, 1000));
}
#[test]
#[ignore = "writes a screenshot for review"]
fn screenshot() {
let mut h = harness();
h.run_steps(2);
let img = h.render().unwrap();
img.save("/tmp/animpane.png").unwrap();
}
+150
View File
@@ -0,0 +1,150 @@
//! Animation Pane advanced timeline: bar geometry and drag-to-retime maths.
//!
//! Pure functions so the layout and the drag results are unit-tested without a frame. Times
//! come from [`deckcraft_anim::Timeline`], the same schedule the slide show plays.
use deckcraft_anim::{EffectTiming, Sequence};
/// Drags snap to this step (milliseconds), like PowerPoint's 0.05 s granularity.
pub const SNAP_MS: u32 = 50;
/// Shortest duration a drag can produce (milliseconds).
pub const MIN_DURATION_MS: u32 = 50;
/// Upper bound for delay and duration, matching `animation.timing`'s clamp.
pub const MAX_MS: u32 = 600_000;
/// Pixels per second are kept in this range whatever the pane width.
const MIN_PPS: f32 = 8.0;
const MAX_PPS: f32 = 120.0;
/// One effect row of the timeline (first paragraph of a by-paragraph build).
#[derive(Clone, Debug, PartialEq)]
pub struct Bar {
/// Index into `slide.animations`.
pub anim: usize,
/// Click step in the main sequence, or `None` for a triggered sequence.
pub step: Option<usize>,
/// Start within the step, delay included (seconds).
pub start: f64,
/// Start to end, all repeats included (seconds).
pub span: f64,
}
/// One bar per animation, in `slide.animations` order; animations without timing get `None`.
pub fn bars(count: usize, effects: &[EffectTiming]) -> Vec<Option<Bar>> {
let mut out: Vec<Option<Bar>> = vec![None; count];
for e in effects {
let Some(slot) = out.get_mut(e.anim) else { continue };
if slot.is_some() {
continue;
}
let start = if e.start.is_finite() { e.start.max(0.0) } else { 0.0 };
let span = if e.span.is_finite() { e.span.max(0.0) } else { 0.0 };
let step = match e.sequence {
Sequence::Main => Some(e.step),
Sequence::Trigger(_) => None,
};
*slot = Some(Bar { anim: e.anim, step, start, span });
}
out
}
/// Seconds the ruler must show: the longest bar end, at least one second, rounded up.
pub fn ruler_seconds(bars: &[Option<Bar>]) -> u32 {
let end = bars.iter().flatten().map(|b| b.start + b.span).fold(1.0_f64, f64::max);
let s = end.ceil().min(f64::from(MAX_MS / 1000));
if s.is_finite() && s >= 1.0 { s as u32 } else { 1 }
}
/// Pixels per second so `seconds` fit in `width`.
pub fn pixels_per_second(width: f32, seconds: u32) -> f32 {
let secs = seconds.max(1) as f32;
let pps = if width.is_finite() { width / secs } else { MIN_PPS };
pps.clamp(MIN_PPS, MAX_PPS)
}
fn snap(ms: f64) -> u32 {
if !ms.is_finite() || ms <= 0.0 {
return 0;
}
let snapped = (ms / f64::from(SNAP_MS)).round() * f64::from(SNAP_MS);
if snapped >= f64::from(MAX_MS) { MAX_MS } else { snapped as u32 }
}
fn delta_ms(dx: f32, pps: f32) -> f64 {
if !dx.is_finite() || !pps.is_finite() || pps <= 0.0 { 0.0 } else { f64::from(dx) / f64::from(pps) * 1000.0 }
}
/// New delay after dragging a bar's body `dx` pixels.
pub fn dragged_delay(delay_ms: u32, dx: f32, pps: f32) -> u32 {
snap(f64::from(delay_ms) + delta_ms(dx, pps))
}
/// New duration after dragging a bar's right edge `dx` pixels. The bar shows all `repeats`
/// (auto-reverse doubles each), so the edge moves `factor` times as fast as one iteration.
pub fn dragged_duration(duration_ms: u32, dx: f32, pps: f32, factor: f64) -> u32 {
let factor = if factor.is_finite() && factor >= 1.0 { factor } else { 1.0 };
snap(f64::from(duration_ms) + delta_ms(dx, pps) / factor).max(MIN_DURATION_MS)
}
/// Tooltip text for a bar: `Start: 0.5s, End: 2.5s`.
pub fn label(bar: &Bar) -> String {
format!("Start: {:.2}s, End: {:.2}s", bar.start, bar.start + bar.span)
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::{AnimClass, ShapeId};
fn eff(anim: usize, step: usize, start: f64, span: f64) -> EffectTiming {
EffectTiming {
anim,
shape: ShapeId(1),
paragraph: None,
sequence: Sequence::Main,
step,
start,
duration: span,
iterations: 1.0,
until_next_click: false,
auto_reverse: false,
rewind: false,
span,
class: AnimClass::Entrance,
}
}
#[test]
fn first_paragraph_wins_and_missing_are_none() {
let b = bars(3, &[eff(0, 0, 0.0, 0.5), eff(0, 0, 0.5, 0.5), eff(2, 1, f64::NAN, -1.0), eff(9, 0, 0.0, 1.0)]);
assert_eq!(b[0], Some(Bar { anim: 0, step: Some(0), start: 0.0, span: 0.5 }));
assert_eq!(b[1], None);
assert_eq!(b[2], Some(Bar { anim: 2, step: Some(1), start: 0.0, span: 0.0 }));
}
#[test]
fn ruler_and_scale() {
assert_eq!(ruler_seconds(&[]), 1);
assert_eq!(ruler_seconds(&bars(1, &[eff(0, 0, 1.2, 2.0)])), 4);
assert_eq!(pixels_per_second(200.0, 4), 50.0);
assert_eq!(pixels_per_second(10_000.0, 1), MAX_PPS);
assert_eq!(pixels_per_second(f32::NAN, 0), MIN_PPS);
}
#[test]
fn drags_snap_and_clamp() {
assert_eq!(dragged_delay(0, 25.0, 50.0), 500);
assert_eq!(dragged_delay(500, -100.0, 50.0), 0);
assert_eq!(dragged_delay(0, 13.0, 50.0), 250);
assert_eq!(dragged_delay(0, f32::INFINITY, 50.0), 0);
assert_eq!(dragged_delay(MAX_MS, 1.0e9, 1.0), MAX_MS);
assert_eq!(dragged_duration(500, 50.0, 50.0, 1.0), 1500);
assert_eq!(dragged_duration(500, 50.0, 50.0, 2.0), 1000);
assert_eq!(dragged_duration(500, -1000.0, 50.0, 1.0), MIN_DURATION_MS);
assert_eq!(dragged_duration(500, 0.0, 0.0, f64::NAN), 500);
}
#[test]
fn label_text() {
assert_eq!(label(&Bar { anim: 0, step: None, start: 0.5, span: 2.0 }), "Start: 0.50s, End: 2.50s");
}
}
+3
View File
@@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod animtimeline;
pub mod backstage;
pub mod canvas;
pub mod control;
@@ -1127,6 +1128,8 @@ fn js_now() -> f64 {
js_sys::Date::now()
}
#[cfg(test)]
mod animpane_tests;
#[cfg(test)]
mod ime_tests;
+85 -15
View File
@@ -5,6 +5,7 @@ use deckcraft_model::{AnimClass, AnimStart, Fill, ShapeKind};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::animtimeline;
use crate::icons::{self, Icon};
use crate::ribbon::{cref_param, paint_anim_tile};
use crate::theme::{self, Tokens};
@@ -462,6 +463,8 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let Some(slide) = st.current_slide() else { return };
let anims = slide.animations.clone();
let size = (st.doc.slide_size.width, st.doc.slide_size.height);
let effects = deckcraft_anim::Timeline::new(slide, size.0, size.1).effects();
let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect();
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
ui.horizontal(|ui| {
@@ -479,6 +482,10 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim));
return;
}
let bars = animtimeline::bars(anims.len(), &effects);
let seconds = animtimeline::ruler_seconds(&bars);
let track_w = (ui.available_width() * 0.45).max(60.0);
let pps = animtimeline::pixels_per_second(track_w, seconds);
let mut click = 0;
for (i, a) in anims.iter().enumerate() {
if a.start == AnimStart::OnClick {
@@ -506,20 +513,70 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
};
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(18.0, 18.0)), icon, t.text, false);
p.text(pos2(r.min.x + 44.0, r.center().y), Align2::LEFT_CENTER, &name, theme::font(12.5), t.text);
// Timeline bar.
let bar_x = r.max.x - 80.0;
let w = (a.duration_ms as f32 / 1000.0 * 20.0).clamp(4.0, 70.0);
let col = match a.class {
AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F),
AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C),
AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B),
_ => Color32::from_rgb(0x2E, 0x6F, 0xD8),
};
p.rect_filled(
Rect::from_min_size(pos2(bar_x + (a.delay_ms as f32 / 1000.0 * 20.0).min(40.0), r.center().y - 4.0), vec2(w, 8.0)),
CornerRadius::same(2),
col,
);
// Advanced timeline bar: drag the body to change the delay, the right edge for the duration.
if let Some(Some(b)) = bars.get(i) {
let col = match a.class {
AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F),
AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C),
AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B),
_ => Color32::from_rgb(0x2E, 0x6F, 0xD8),
};
let x0 = r.max.x - track_w - 6.0;
let x_of = |sec: f64| x0 + (sec as f32 * pps).min(track_w);
let mut bar = Rect::from_min_max(
pos2(x_of(b.start), r.center().y - 5.0),
pos2(x_of(b.start + b.span).max(x_of(b.start) + 4.0), r.center().y + 5.0),
);
let edge = Rect::from_min_max(pos2(bar.max.x - 3.0, bar.min.y - 3.0), pos2(bar.max.x + 4.0, bar.max.y + 3.0));
let body_resp = ui.interact(bar, ui.id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b));
body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Timing bar {}", i + 1)));
let edge_resp = ui.interact(edge, ui.id().with(("anim_edge", i)), Sense::drag());
edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Duration handle {}", i + 1)));
if edge_resp.hovered() || edge_resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
} else if body_resp.hovered() || body_resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
// The drag offset is remembered: the pointer's press origin is gone on release.
let dx_id = ui.id().with(("anim_drag_dx", i));
let live = ui.input(|inp| match (inp.pointer.press_origin(), inp.pointer.interact_pos()) {
(Some(o), Some(p)) => Some(p.x - o.x),
_ => None,
});
let dx = match live {
Some(d) if body_resp.dragged() || edge_resp.dragged() => {
ui.data_mut(|m| m.insert_temp(dx_id, d));
d
}
_ => ui.data_mut(|m| m.get_temp::<f32>(dx_id)).unwrap_or(0.0),
};
if body_resp.drag_stopped() || edge_resp.drag_stopped() {
ui.data_mut(|m| m.remove::<f32>(dx_id));
}
let iter_secs = f64::from(a.duration_ms) / 1000.0;
let factor = if iter_secs > 0.0 && b.span > 0.0 { b.span / iter_secs } else { 1.0 };
if edge_resp.dragged() || edge_resp.drag_stopped() {
let d = animtimeline::dragged_duration(a.duration_ms, dx, pps, factor);
bar.max.x = x_of(b.start + f64::from(d) / 1000.0 * factor).max(bar.min.x + 4.0);
if edge_resp.drag_stopped() && d != a.duration_ms {
run(app, "animation.timing", json!({"index": i, "duration": d}));
}
} else if body_resp.dragged() || body_resp.drag_stopped() {
let d = animtimeline::dragged_delay(a.delay_ms, dx, pps);
let shift = (f64::from(d) - f64::from(a.delay_ms)) / 1000.0;
let w = bar.width();
bar.min.x = x_of((b.start + shift).max(0.0));
bar.max.x = bar.min.x + w;
if body_resp.drag_stopped() && d != a.delay_ms {
run(app, "animation.timing", json!({"index": i, "delay": d}));
}
}
let p = ui.painter();
p.rect_filled(bar, CornerRadius::same(2), col);
if a.repeat == u32::MAX {
p.text(pos2(bar.max.x + 2.0, bar.center().y), Align2::LEFT_CENTER, "⟳", theme::font(10.0), t.text_dim);
}
}
if resp.clicked() {
run(app, "edit.select", json!({"ids": [a.shape.0]}));
}
@@ -545,8 +602,21 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
}
});
}
// Seconds ruler under the bars.
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
let x0 = r.max.x - track_w - 6.0;
let p = ui.painter();
p.line_segment([pos2(x0, r.min.y + 2.0), pos2(x0 + track_w, r.min.y + 2.0)], Stroke::new(1.0, t.text_faint));
let every = (seconds / 6).max(1);
for s in (0..=seconds).step_by(every as usize) {
let x = x0 + s as f32 * pps;
if x > x0 + track_w + 0.5 {
break;
}
p.line_segment([pos2(x, r.min.y), pos2(x, r.min.y + 5.0)], Stroke::new(1.0, t.text_faint));
p.text(pos2(x, r.min.y + 6.0), Align2::CENTER_TOP, format!("{s}s"), theme::font(10.0), t.text_dim);
}
let _ = paint_anim_tile;
let _ = Stroke::NONE;
}
fn comments(app: &mut SlideApp, ui: &mut Ui) {
+2 -2
View File
@@ -71,7 +71,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Triggers on a media bookmark | yes | no |
| Animation Painter | yes | yes (#54) |
| Animation Pane: list, reorder, remove | yes | yes |
| Animation Pane: advanced timeline (bars you drag) | yes | **no** |
| Animation Pane: advanced timeline (bars you drag) | yes | yes: bars from the show schedule, drag the body for delay, the right edge for duration, seconds ruler (`animpane_tests`) |
| Preview in the editor (current effect, from selected) | yes | partly |
| Motion path editing (edit points, reverse, lock) | yes | partly (no edit points) |
| Multiple animations on one object | yes | yes |
@@ -81,7 +81,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Work | Hours |
|---|---|
| Animation Pane timeline, preview from selected, effect dialog depth | 5–8 |
| Preview from selected, effect dialog depth | 4–6 |
| The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 |
| Rewind and other effect options on round trip (#77) | 1–2 |
| Transition sounds | 1–2 |
+1 -1
View File
@@ -26,7 +26,7 @@ revision history.
| 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
| 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) |
| 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) |
| 10 | Animation Pane: no timeline, thin preview; 64 of ~190 effects | Feature | B (pane) | 10–16 | [animation-parity.md](animation-parity.md) |
| 10 | Animation Pane: thin preview (timeline done); 64 of ~190 effects | Feature | B (pane) | 8–14 | [animation-parity.md](animation-parity.md) |
| 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) |
| 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) |
| 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) |