diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 837e434..9337d91 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -288,6 +288,24 @@ fn text_build_by_paragraph() { assert!(tl.state(ShapeId(1), None, 0, 0.0).visible); } +#[test] +fn by_paragraph_groups_sub_bullets_with_their_parent() { + let mut s = slide_with(vec![]); + let mut body = TextBody::from_text("one\nsub a\nsub b\ntwo"); + body.paragraphs[1].level = 1; + body.paragraphs[2].level = 1; + s.shapes[0].text = Some(body); + let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick); + a.text_build = TextBuild::ByParagraph; + s.animations = vec![a]; + let tl = Timeline::new(&s, W, H); + assert_eq!(tl.steps(), 2); + assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2, 3]); + assert!(tl.state(ShapeId(1), Some(2), 1, 0.0).visible); + assert!(!tl.state(ShapeId(1), Some(3), 1, 0.0).visible); + assert!(!tl.state(ShapeId(1), Some(1), 0, 0.0).visible); +} + #[test] fn hostile_times_and_durations() { let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick); diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index 1c8e102..9a60d13 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -86,6 +86,9 @@ pub struct Timeline { triggers: Vec<(ShapeId, Vec)>, /// Base fill colour per shape (for colour emphasis). colors: Vec<(ShapeId, Rgba)>, + /// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent: + /// (shape, member paragraph, owner paragraph). + groups: Vec<(ShapeId, usize, usize)>, } enum Status { @@ -175,18 +178,22 @@ impl Timeline { let paras: Vec> = match (a.paragraph, a.text_build) { (Some(p), _) => vec![Some(p as usize)], (None, TextBuild::ByParagraph) => { - let v: Vec> = shape - .and_then(|s| s.text.as_ref()) - .map(|t| { - t.paragraphs - .iter() - .enumerate() - .filter(|(_, p)| p.runs.iter().any(|r| !r.text.trim().is_empty())) - .map(|(i, _)| Some(i)) - .take(MAX_PARAGRAPHS) - .collect() - }) - .unwrap_or_default(); + let mut v: Vec> = vec![]; + if let Some(t) = shape.and_then(|s| s.text.as_ref()) { + let filled = |p: &slidecraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty()); + let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0); + let mut owner: Option = None; + for (i, p) in t.paragraphs.iter().enumerate() { + if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS { + owner = Some(i); + v.push(Some(i)); + } else if let Some(o) = owner + && !tl.groups.contains(&(a.shape, i, o)) + { + tl.groups.push((a.shape, i, o)); + } + } + } if v.is_empty() { vec![None] } else { v } } _ => vec![None], @@ -345,6 +352,7 @@ impl Timeline { /// Paragraph indices of `shape` animated separately (by-paragraph builds). pub fn paragraph_targets(&self, shape: ShapeId) -> Vec { let mut v: Vec = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect(); + v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::>()); v.sort_unstable(); v.dedup(); v @@ -371,6 +379,7 @@ impl Timeline { /// [`Timeline::state`] with the playheads of triggered sequences. pub fn state_with(&self, shape: ShapeId, para: Option, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState { + let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p)); let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para); let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); let mut st = AnimState::default(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 43a2aa2..8accca9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -100,11 +100,28 @@ pub struct ShapeState { pub clip: Option<[f64; 4]>, /// Colour override for emphasis effects (fill). pub tint: Option, + /// Per-paragraph states of by-paragraph text builds (paragraph index, state). + pub paras: Vec<(usize, ParaState)>, } impl Default for ShapeState { fn default() -> Self { - ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None } + ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None, paras: vec![] } + } +} + +/// Animation state of one paragraph, relative to its shape. +#[derive(Clone, Copy, Debug)] +pub struct ParaState { + pub visible: bool, + pub opacity: f64, + /// Translation in shape-local points. + pub offset: Vec2, +} + +impl Default for ParaState { + fn default() -> Self { + ParaState { visible: true, opacity: 1.0, offset: Vec2::ZERO } } } @@ -621,7 +638,8 @@ impl Renderer { { let tr = geo.text_rect; if !body.is_empty() { - draw_text(ctx, rctx, s, body, tr, m, &f.fields, None); + let l = slidecraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); + draw_layout_with(ctx, &l, tr, m, &st.paras); } else if f.opts.edit && s.ph.is_some() { let kind = s.ph_type().unwrap_or(PhType::Body); let prompt_body = prompt_body(rctx, s, kind); @@ -918,23 +936,42 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody /// Draw an already laid-out text block. pub fn draw_layout(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr: Rect, m: Affine) { + draw_layout_with(ctx, l, tr, m, &[]); +} + +/// Draw a laid-out text block with per-paragraph animation states. +pub fn draw_layout_with(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { + let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let m = if l.rotation != 0.0 { let c = tr.center().to_vec2(); m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c) } else { m }; - for d in l.decos.iter().filter(|d| d.behind) { - ctx.set_transform(m); - ctx.set_paint(color(d.color, 1.0)); + let deco = |ctx: &mut RenderContext, d: &slidecraft_text::Deco| { + let ps = para(d.para); + if !ps.visible || ps.opacity <= 0.001 { + return; + } + ctx.set_transform(m * Affine::translate(ps.offset)); + ctx.set_paint(color(d.color, ps.opacity)); ctx.fill_rect(&d.rect); + }; + for d in l.decos.iter().filter(|d| d.behind) { + deco(ctx, d); } for run in &l.runs { if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { continue; } + let ps = para(run.para); + if !ps.visible || ps.opacity <= 0.001 { + continue; + } + let m = m * Affine::translate(ps.offset); + let alpha = run.alpha * ps.opacity; let k = run.size / run.face.upem.max(1.0); - ctx.set_paint(color(run.color, run.alpha)); + ctx.set_paint(color(run.color, alpha)); for (gid, x, y) in &run.glyphs { let path = glyph(&run.face, *gid); if path.elements().is_empty() { @@ -943,7 +980,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr: let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k); ctx.set_transform(gm); - if run.alpha > 0.0 && run.color.a > 0 { + if alpha > 0.0 && run.color.a > 0 { ctx.fill_path(&path); if run.fake_bold { ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round)); @@ -951,17 +988,15 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr: } } if let Some((oc, ow)) = run.outline { - ctx.set_paint(color(oc, 1.0)); + ctx.set_paint(color(oc, ps.opacity)); ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); ctx.stroke_path(&path); - ctx.set_paint(color(run.color, run.alpha)); + ctx.set_paint(color(run.color, alpha)); } } } for d in l.decos.iter().filter(|d| !d.behind) { - ctx.set_transform(m); - ctx.set_paint(color(d.color, 1.0)); - ctx.fill_rect(&d.rect); + deco(ctx, d); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index a9d22b9..aadc053 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -53,6 +53,8 @@ pub struct Deco { pub color: Rgba, /// Highlights draw behind text. pub behind: bool, + /// Paragraph the decoration belongs to (by-paragraph animation). + pub para: usize, } #[derive(Clone, Debug, Default)] @@ -900,20 +902,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let adv = line.caret_x.get(ci - line.start + 1).copied().unwrap_or(x) - line.caret_x.get(ci - line.start).copied().unwrap_or(x); let cx = line.caret_x.get(ci - line.start).copied().unwrap_or(x); if let Some(hl) = st.highlight { - out.decos.push(Deco { rect: Rect::new(cx, line.top, cx + adv, line.bottom), color: hl, behind: true }); + out.decos.push(Deco { rect: Rect::new(cx, line.top, cx + adv, line.bottom), color: hl, behind: true, para: line.para }); } if !cell.ch.is_whitespace() || st.underline.is_some() { if let Some(uc) = st.underline { let t = (st.size * 0.06).max(0.5); let uy = line.baseline + st.size * 0.12; - out.decos.push(Deco { rect: Rect::new(cx, uy, cx + adv, uy + t), color: uc, behind: false }); + out.decos.push(Deco { rect: Rect::new(cx, uy, cx + adv, uy + t), color: uc, behind: false, para: line.para }); } if let Some(sk) = st.strike { let t = (st.size * 0.05).max(0.5); let sy = line.baseline - st.size * 0.3; - out.decos.push(Deco { rect: Rect::new(cx, sy, cx + adv, sy + t), color: st.color, behind: false }); + out.decos.push(Deco { rect: Rect::new(cx, sy, cx + adv, sy + t), color: st.color, behind: false, para: line.para }); if sk == Strike::Double { - out.decos.push(Deco { rect: Rect::new(cx, sy - t * 2.0, cx + adv, sy - t), color: st.color, behind: false }); + out.decos.push(Deco { rect: Rect::new(cx, sy - t * 2.0, cx + adv, sy - t), color: st.color, behind: false, para: line.para }); } } } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index a11923b..8a3cb77 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -5,7 +5,7 @@ use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, Textu use serde_json::json; use slidecraft_anim::{Layer, ShowAction, ShowState, Source, Timeline}; use slidecraft_model::{Presentation, ShapeId}; -use slidecraft_render::{RenderOpts, ShapeState}; +use slidecraft_render::{ParaState, RenderOpts, ShapeState}; use crate::SlideApp; use crate::theme; @@ -162,6 +162,14 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { rotate: a.rotate, clip: a.clip, tint: a.tint, + paras: tl + .paragraph_targets(id) + .into_iter() + .map(|p| { + let a = tl.state(id, Some(p), step, t); + (p, ParaState { visible: a.visible, opacity: a.opacity, offset: slidecraft_geom::Vec2::new(a.offset_x, a.offset_y) }) + }) + .collect(), } }