Text (#93): show gray markers on empty list paragraphs

This commit is contained in:
hajdukste
2026-10-14 17:58:53 -07:00
parent 8d3e4a630f
commit 51de1ad2cf
11 changed files with 274 additions and 73 deletions
+29 -3
View File
@@ -222,6 +222,9 @@ pub struct RenderOpts<'a> {
pub skip: &'a [ShapeId],
/// Hide the text of this shape (the UI draws it while editing).
pub skip_text: Option<ShapeId>,
/// Text being edited: replace its prompt with gray markers on empty list paragraphs.
/// Ignored outside editor rendering.
pub editing_text: Option<ShapeId>,
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
pub gif_times: &'a [(ShapeId, f64)],
/// Worker threads (0 = this thread; required when effects use filters).
@@ -246,6 +249,7 @@ impl Default for RenderOpts<'_> {
state: None,
skip: &[],
skip_text: None,
editing_text: None,
gif_times: &[],
threads: 0,
offset: (0.0, 0.0),
@@ -921,7 +925,7 @@ impl Renderer {
}
ShapeKind::Table(t) => {
// Rows grow with their text: what the cells cover, borders included.
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields, f.opts.edit && f.opts.editing_text == Some(s.id));
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
}
ShapeKind::Chart(c) => {
@@ -1300,8 +1304,21 @@ impl Renderer {
&text_geo
};
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
let editing = f.opts.edit && f.opts.editing_text == Some(s.id);
if !body.is_empty() || editing {
// Imported placeholders can have no paragraph until the first keystroke.
let mut empty;
let body = if editing && body.paragraphs.is_empty() {
empty = body.clone();
empty.paragraphs.push(Default::default());
&empty
} else {
body
};
let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
if editing {
preview_empty_bullets(&mut l);
}
let tm = text_transform(m, tr, l.rotation);
for b in l.lines.iter().filter_map(line_bounds) {
// Glyphs reach a little past their carets and line box (italics, swashes).
@@ -2034,6 +2051,15 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody
draw_layout(ctx, &l, tr, m);
}
/// Add transient list markers to an editor layout, using the document's own bullet glyphs.
fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) {
for mut run in l.empty_bullets.drain(..) {
run.color = Rgba::rgb(0xB3, 0xB3, 0xB3);
run.alpha = 1.0;
l.runs.push(run);
}
}
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
///
+7 -2
View File
@@ -327,6 +327,7 @@ pub fn draw(
_w: f64,
_h: f64,
fields: &dyn deckcraft_text::Fields,
editing: bool,
) -> Option<kurbo::Rect> {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
@@ -418,14 +419,18 @@ pub fn draw(
// Text.
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
if cell.text.is_empty() || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
continue;
}
let body = cell_body(rctx, t, *r, *c, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
#[cfg(test)]
TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1));
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
let mut l = deckcraft_text::layout(rctx, &tmp, &body, &deckcraft_text::Opts { rect: *rect, fields, prompt_color: None, no_shrink: false });
if editing {
crate::preview_empty_bullets(&mut l);
}
crate::draw_layout(ctx, &l, *rect, m);
let _ = peniko::Fill::NonZero;
}
covered
+27
View File
@@ -2509,3 +2509,30 @@ fn single_series_column_chart_varies_colours_by_point() {
};
assert_ne!(render(true), render(false));
}
#[test]
fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
use deckcraft_model::text::{Bullet, Paragraph};
let mut para = Paragraph::default();
para.props.bullet = Some(Bullet::Char { char: "•".into() });
para.props.margin_left = Some(24.0);
para.props.indent = Some(-24.0);
para.end_props.size = Some(32.0);
let sh = Shape {
id: ShapeId(901),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 120.0)),
text: Some(TextBody { paragraphs: vec![para], ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let render = |edit, editing_text| render_slide(&p, 0, &RenderOpts { edit, editing_text, ..Default::default() });
let plain = render(false, None);
assert_eq!(plain.pixels, render(true, None).pixels);
assert_eq!(plain.pixels, render(false, Some(ShapeId(901))).pixels, "export ignores editor markers");
assert_eq!(plain.pixels, render(true, Some(ShapeId(902))).pixels, "other shapes stay empty");
let editing = render(true, Some(ShapeId(901)));
let changed: Vec<_> = editing.pixels.chunks_exact(4).zip(plain.pixels.chunks_exact(4)).filter(|(a, b)| a != b).collect();
assert!(!changed.is_empty(), "an empty bullet must be visible");
assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
}
+1
View File
@@ -105,6 +105,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {
state: o.state,
skip: o.skip,
skip_text: o.skip_text,
editing_text: o.editing_text,
gif_times: o.gif_times,
threads: o.threads,
offset: o.offset,