mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e62b7ccd15
commit
55a5752d25
@@ -288,6 +288,24 @@ fn text_build_by_paragraph() {
|
|||||||
assert!(tl.state(ShapeId(1), None, 0, 0.0).visible);
|
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]
|
#[test]
|
||||||
fn hostile_times_and_durations() {
|
fn hostile_times_and_durations() {
|
||||||
let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick);
|
let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick);
|
||||||
|
|||||||
+21
-12
@@ -86,6 +86,9 @@ pub struct Timeline {
|
|||||||
triggers: Vec<(ShapeId, Vec<StepInfo>)>,
|
triggers: Vec<(ShapeId, Vec<StepInfo>)>,
|
||||||
/// Base fill colour per shape (for colour emphasis).
|
/// Base fill colour per shape (for colour emphasis).
|
||||||
colors: Vec<(ShapeId, Rgba)>,
|
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 {
|
enum Status {
|
||||||
@@ -175,18 +178,22 @@ impl Timeline {
|
|||||||
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
|
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
|
||||||
(Some(p), _) => vec![Some(p as usize)],
|
(Some(p), _) => vec![Some(p as usize)],
|
||||||
(None, TextBuild::ByParagraph) => {
|
(None, TextBuild::ByParagraph) => {
|
||||||
let v: Vec<Option<usize>> = shape
|
let mut v: Vec<Option<usize>> = vec![];
|
||||||
.and_then(|s| s.text.as_ref())
|
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||||
.map(|t| {
|
let filled = |p: &slidecraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||||
t.paragraphs
|
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||||
.iter()
|
let mut owner: Option<usize> = None;
|
||||||
.enumerate()
|
for (i, p) in t.paragraphs.iter().enumerate() {
|
||||||
.filter(|(_, p)| p.runs.iter().any(|r| !r.text.trim().is_empty()))
|
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
|
||||||
.map(|(i, _)| Some(i))
|
owner = Some(i);
|
||||||
.take(MAX_PARAGRAPHS)
|
v.push(Some(i));
|
||||||
.collect()
|
} else if let Some(o) = owner
|
||||||
})
|
&& !tl.groups.contains(&(a.shape, i, o))
|
||||||
.unwrap_or_default();
|
{
|
||||||
|
tl.groups.push((a.shape, i, o));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if v.is_empty() { vec![None] } else { v }
|
if v.is_empty() { vec![None] } else { v }
|
||||||
}
|
}
|
||||||
_ => vec![None],
|
_ => vec![None],
|
||||||
@@ -345,6 +352,7 @@ impl Timeline {
|
|||||||
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
||||||
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
|
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();
|
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.sort_unstable();
|
||||||
v.dedup();
|
v.dedup();
|
||||||
v
|
v
|
||||||
@@ -371,6 +379,7 @@ impl Timeline {
|
|||||||
|
|
||||||
/// [`Timeline::state`] with the playheads of triggered sequences.
|
/// [`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 {
|
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 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 base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||||
let mut st = AnimState::default();
|
let mut st = AnimState::default();
|
||||||
|
|||||||
+47
-12
@@ -100,11 +100,28 @@ pub struct ShapeState {
|
|||||||
pub clip: Option<[f64; 4]>,
|
pub clip: Option<[f64; 4]>,
|
||||||
/// Colour override for emphasis effects (fill).
|
/// Colour override for emphasis effects (fill).
|
||||||
pub tint: Option<Rgba>,
|
pub tint: Option<Rgba>,
|
||||||
|
/// Per-paragraph states of by-paragraph text builds (paragraph index, state).
|
||||||
|
pub paras: Vec<(usize, ParaState)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ShapeState {
|
impl Default for ShapeState {
|
||||||
fn default() -> Self {
|
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;
|
let tr = geo.text_rect;
|
||||||
if !body.is_empty() {
|
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() {
|
} else if f.opts.edit && s.ph.is_some() {
|
||||||
let kind = s.ph_type().unwrap_or(PhType::Body);
|
let kind = s.ph_type().unwrap_or(PhType::Body);
|
||||||
let prompt_body = prompt_body(rctx, s, kind);
|
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.
|
/// Draw an already laid-out text block.
|
||||||
pub fn draw_layout(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr: Rect, m: Affine) {
|
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 m = if l.rotation != 0.0 {
|
||||||
let c = tr.center().to_vec2();
|
let c = tr.center().to_vec2();
|
||||||
m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c)
|
m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c)
|
||||||
} else {
|
} else {
|
||||||
m
|
m
|
||||||
};
|
};
|
||||||
for d in l.decos.iter().filter(|d| d.behind) {
|
let deco = |ctx: &mut RenderContext, d: &slidecraft_text::Deco| {
|
||||||
ctx.set_transform(m);
|
let ps = para(d.para);
|
||||||
ctx.set_paint(color(d.color, 1.0));
|
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);
|
ctx.fill_rect(&d.rect);
|
||||||
|
};
|
||||||
|
for d in l.decos.iter().filter(|d| d.behind) {
|
||||||
|
deco(ctx, d);
|
||||||
}
|
}
|
||||||
for run in &l.runs {
|
for run in &l.runs {
|
||||||
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||||
continue;
|
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);
|
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 {
|
for (gid, x, y) in &run.glyphs {
|
||||||
let path = glyph(&run.face, *gid);
|
let path = glyph(&run.face, *gid);
|
||||||
if path.elements().is_empty() {
|
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 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);
|
let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k);
|
||||||
ctx.set_transform(gm);
|
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);
|
ctx.fill_path(&path);
|
||||||
if run.fake_bold {
|
if run.fake_bold {
|
||||||
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
|
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 {
|
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.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
|
||||||
ctx.stroke_path(&path);
|
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) {
|
for d in l.decos.iter().filter(|d| !d.behind) {
|
||||||
ctx.set_transform(m);
|
deco(ctx, d);
|
||||||
ctx.set_paint(color(d.color, 1.0));
|
|
||||||
ctx.fill_rect(&d.rect);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ pub struct Deco {
|
|||||||
pub color: Rgba,
|
pub color: Rgba,
|
||||||
/// Highlights draw behind text.
|
/// Highlights draw behind text.
|
||||||
pub behind: bool,
|
pub behind: bool,
|
||||||
|
/// Paragraph the decoration belongs to (by-paragraph animation).
|
||||||
|
pub para: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[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 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);
|
let cx = line.caret_x.get(ci - line.start).copied().unwrap_or(x);
|
||||||
if let Some(hl) = st.highlight {
|
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 !cell.ch.is_whitespace() || st.underline.is_some() {
|
||||||
if let Some(uc) = st.underline {
|
if let Some(uc) = st.underline {
|
||||||
let t = (st.size * 0.06).max(0.5);
|
let t = (st.size * 0.06).max(0.5);
|
||||||
let uy = line.baseline + st.size * 0.12;
|
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 {
|
if let Some(sk) = st.strike {
|
||||||
let t = (st.size * 0.05).max(0.5);
|
let t = (st.size * 0.05).max(0.5);
|
||||||
let sy = line.baseline - st.size * 0.3;
|
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 {
|
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 serde_json::json;
|
||||||
use slidecraft_anim::{Layer, ShowAction, ShowState, Source, Timeline};
|
use slidecraft_anim::{Layer, ShowAction, ShowState, Source, Timeline};
|
||||||
use slidecraft_model::{Presentation, ShapeId};
|
use slidecraft_model::{Presentation, ShapeId};
|
||||||
use slidecraft_render::{RenderOpts, ShapeState};
|
use slidecraft_render::{ParaState, RenderOpts, ShapeState};
|
||||||
|
|
||||||
use crate::SlideApp;
|
use crate::SlideApp;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
@@ -162,6 +162,14 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState {
|
|||||||
rotate: a.rotate,
|
rotate: a.rotate,
|
||||||
clip: a.clip,
|
clip: a.clip,
|
||||||
tint: a.tint,
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user