Slide show: by-paragraph text builds render per paragraph, sub-bullets follow their parent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 00:14:59 -04:00
parent e62b7ccd15
commit 55a5752d25
5 changed files with 101 additions and 29 deletions

View File

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

View File

@ -86,6 +86,9 @@ pub struct Timeline {
triggers: Vec<(ShapeId, Vec<StepInfo>)>,
/// 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<Option<usize>> = match (a.paragraph, a.text_build) {
(Some(p), _) => vec![Some(p as usize)],
(None, TextBuild::ByParagraph) => {
let v: Vec<Option<usize>> = 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<Option<usize>> = 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<usize> = 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<usize> {
let mut v: Vec<usize> = 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::<Vec<_>>());
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<usize>, 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();

View File

@ -100,11 +100,28 @@ pub struct ShapeState {
pub clip: Option<[f64; 4]>,
/// Colour override for emphasis effects (fill).
pub tint: Option<Rgba>,
/// 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);
}
}

View File

@ -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 });
}
}
}

View File

@ -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(),
}
}