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:
parent
e62b7ccd15
commit
55a5752d25
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue