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) |