From 1b9b830f323afb7e4540048de8bb33aa76bfb4a9 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:30:05 +0300 Subject: [PATCH] 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 --- crates/ui-egui/src/animpane_tests.rs | 90 ++++++++++++++++ crates/ui-egui/src/animtimeline.rs | 150 +++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panes.rs | 100 +++++++++++++++--- docs/animation-parity.md | 4 +- docs/gaps.md | 2 +- 6 files changed, 331 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/animpane_tests.rs create mode 100644 crates/ui-egui/src/animtimeline.rs diff --git a/crates/ui-egui/src/animpane_tests.rs b/crates/ui-egui/src/animpane_tests.rs new file mode 100644 index 00000000..7fe2d875 --- /dev/null +++ b/crates/ui-egui/src/animpane_tests.rs @@ -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(); +} diff --git a/crates/ui-egui/src/animtimeline.rs b/crates/ui-egui/src/animtimeline.rs new file mode 100644 index 00000000..bdd1a1dc --- /dev/null +++ b/crates/ui-egui/src/animtimeline.rs @@ -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, + /// 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> { + let mut out: Vec> = 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]) -> 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"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..22af2a02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..66e53725 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -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 = 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::(dx_id)).unwrap_or(0.0), + }; + if body_resp.drag_stopped() || edge_resp.drag_stopped() { + ui.data_mut(|m| m.remove::(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) { diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 8ec5d0f4..186e259a 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -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 | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..5ff11a19 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) |