Merge PR #495 from NikitkaRa: Animation Pane: advanced timeline with draggable bars

# Conflicts:
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:22:52 -04:00
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;
@@ -1205,6 +1206,8 @@ fn js_now() -> f64 {
#[cfg(test)]
mod eyedropper_tests;
#[cfg(test)]
mod animpane_tests;
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod ui_soak_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};
@@ -625,6 +626,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| {
@@ -642,6 +645,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 {
@@ -669,20 +676,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]}));
}
@@ -708,8 +765,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
@@ -72,7 +72,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 |
@@ -82,7 +82,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: the rest of PowerPoint's built-in list (Applause, Arrow, Bomb…) | 1 |
+1 -1
View File
@@ -40,7 +40,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) |