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
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
@@ -221,6 +222,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
+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,
+57 -49
View File
@@ -292,6 +292,9 @@ pub struct LineInfo {
#[derive(Clone, Debug, Default)]
pub struct TextLayout {
pub runs: Vec<GlyphRun>,
/// Empty list markers for the editor only; ordinary rendering and export omit them.
/// Their space is still reserved so the caret starts where the first character will.
pub empty_bullets: Vec<GlyphRun>,
pub decos: Vec<Deco>,
pub lines: Vec<LineInfo>,
/// Inner text box (insets applied), shape-local.
@@ -739,56 +742,52 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
let lead_size = lead_style.size;
let lead_face = lead_style.face.clone();
// Bullet.
let bullet = if para.is_empty() && opts.prompt_color.is_none() {
None
} else {
match &props.bullet {
Some(Bullet::Char { char }) => {
let mut rp = lead.clone();
if let Some(f) = &props.bullet_font {
rp.font = Some(f.clone());
}
rp.bold = Some(false);
rp.italic = Some(false);
if let Some(c) = &props.bullet_color {
rp.fill = Some(Fill::solid(c.clone()));
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
rp.strike = None;
rp.highlight = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
bullet_face(db, &mut st, &ch);
let bullet = match &props.bullet {
Some(Bullet::Char { char }) => {
let mut rp = lead.clone();
if let Some(f) = &props.bullet_font {
rp.font = Some(f.clone());
}
rp.bold = Some(false);
rp.italic = Some(false);
if let Some(c) = &props.bullet_color {
rp.fill = Some(Fill::solid(c.clone()));
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
rp.strike = None;
rp.highlight = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
bullet_face(db, &mut st, &ch);
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
// at U+F000 + the character code as such fonts map them; otherwise common symbol
// bullets are mapped to Unicode look-alikes.
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
Some(c) => c.to_string(),
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
};
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
Some((ch, st))
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
// at U+F000 + the character code as such fonts map them; otherwise common symbol
// bullets are mapped to Unicode look-alikes.
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
Some(c) => c.to_string(),
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
};
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
Some(Bullet::AutoNum { scheme, start_at }) => {
let mut rp = lead.clone();
if let Some(c) = &props.bullet_color {
rp.fill = Some(Fill::solid(c.clone()));
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
bullet_face(db, &mut st, &label);
Some((label, st))
}
_ => None,
Some((ch, st))
}
Some(Bullet::AutoNum { scheme, start_at }) => {
let mut rp = lead.clone();
if let Some(c) = &props.bullet_color {
rp.fill = Some(Fill::solid(c.clone()));
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
bullet_face(db, &mut st, &label);
Some((label, st))
}
_ => None,
};
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
}
@@ -1654,6 +1653,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
*deeper = 0;
}
numbering.get(lvl).copied()
} else if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) {
// Preview the next number without consuming it: empty paragraphs do not advance
// numbering in the saved document or affect later populated paragraphs.
numbering.get(lvl).map(|n| n.saturating_add(1))
} else {
if !para.is_empty() {
for n in numbering.iter_mut().skip(lvl) {
@@ -1930,7 +1933,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
&& let Some((_, st)) = p.bullet.as_ref()
{
let (_, g) = measure_str(&st.face, st.size, &s);
out.runs.push(GlyphRun {
let run = GlyphRun {
face: st.face.clone(),
size: st.size,
color: st.color,
@@ -1946,7 +1949,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
gradient: None,
effects: None,
upright: false,
});
};
if body.paragraphs.get(line.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none() {
out.empty_bullets.push(run);
} else {
out.runs.push(run);
}
}
// Group visually consecutive cells by (style, face).
let mut cur: Option<GlyphRun> = None;
+57
View File
@@ -1330,3 +1330,60 @@ fn stacked_combining_marks_take_a_face_that_draws_them() {
}
let _ = f;
}
#[test]
fn empty_bullets_reserve_the_typed_indent_without_becoming_content() {
for rtl in [false, true] {
for level in [0, 1, 2] {
let (p, mut sh) = setup("First\n", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.level = level;
para.props.rtl = Some(rtl);
para.props.align = Some(if rtl { Align::Right } else { Align::Left });
}
let empty = lay(&p, &sh, 600.0, 300.0);
assert_eq!(empty.empty_bullets.len(), 1);
assert_eq!(empty.empty_bullets[0].para, 1);
assert!(empty.runs.iter().all(|r| r.para == 0), "empty bullet must not be exported");
let caret = empty.caret(Pos { para: 1, ch: 0 }).unwrap();
let bx = empty.empty_bullets[0].glyphs[0].1;
assert!(if rtl { bx >= caret.0 } else { bx < caret.0 });
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (1, 0), if rtl { "אבג" } else { "Next" }, None);
let typed = lay(&p, &sh, 600.0, 300.0);
assert!(typed.empty_bullets.is_empty());
let after = typed.caret(Pos { para: 1, ch: 0 }).unwrap();
assert!(
(caret.0 - after.0).abs() < 1e-6 && (caret.1 - after.1).abs() < 1e-6 && (caret.2 - after.2).abs() < 1e-6,
"typing must not move the line's start: {caret:?} vs {after:?}"
);
}
}
}
#[test]
fn empty_numbered_paragraph_previews_the_next_number_without_consuming_it() {
let (p, mut sh) = setup("One\n\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 4 });
}
let l = lay(&p, &sh, 600.0, 300.0);
let preview = &l.empty_bullets[0];
let next = l.runs.iter().find(|r| r.para == 2 && r.cells.is_empty()).unwrap();
let ids = |r: &GlyphRun| r.glyphs.iter().map(|g| g.0).collect::<Vec<_>>();
assert_eq!(preview.para, 1);
assert_eq!(ids(preview), ids(next), "both should use 5.");
let expected = measure_str(&preview.face, preview.size, "5.").1;
assert_eq!(ids(preview), expected.iter().map(|g| g.0).collect::<Vec<_>>());
}
#[test]
fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
let (p, mut sh) = setup("", 600.0, 300.0);
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::None);
assert!(lay(&p, &sh, 600.0, 300.0).empty_bullets.is_empty());
sh.text = Some(TextBody::from_text("First"));
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (0, 5), "\u{b}", None);
let l = lay(&p, &sh, 600.0, 300.0);
assert_eq!(l.lines.len(), 2);
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
}
+76 -8
View File
@@ -181,20 +181,18 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
let visible = visible_region(avail, slide_rect, pixel);
let typing_into = st.and_then(|d| d.selection.text.as_ref()).filter(|t| !t.notes).map(|t| t.shape);
let tex = match st.map(|d| (d.selection.target, d)) {
Some((Target::Slides, d)) => {
let idx = d.selection.slide;
let doc = d.doc.clone();
let frame = anim.as_ref().map(|(tl, t)| (tl, *t));
// A placeholder's prompt makes way for the caret once it is being typed into.
let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape);
let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty()));
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times, no_prompt, frame)
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times, typing_into, frame)
}
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible, typing_into),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible, typing_into),
Some((t @ (Target::Handout | Target::NotesMaster), d)) => {
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible))
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible, typing_into))
}
None => None,
};
@@ -418,6 +416,7 @@ fn master_tex(
layout: Option<usize>,
px: (u32, u32),
visible: deckcraft_render::Region,
editing_text: Option<deckcraft_model::ShapeId>,
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
let region = crate::textures::fit_texture(
@@ -429,7 +428,7 @@ fn master_tex(
doc,
master,
layout,
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
&deckcraft_render::RenderOpts { scale, edit: true, editing_text, offset: region.offset(), size: Some(region.size()), ..Default::default() },
);
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
}
@@ -1476,6 +1475,75 @@ mod tests {
}
}
/// The first empty placeholder and the paragraph after Enter keep a list marker and indent.
#[test]
fn empty_bullet_preview_follows_click_type_enter_and_toggle() {
let mut h = harness();
h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let sh = st
.shapes()
.iter()
.find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj))
.unwrap();
let id = sh.id;
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let p = screen(h.state(), x.x + 40.0, x.y + 20.0);
click(&mut h, p);
h.run_steps(3);
let caret = |h: &Harness<'_, SlideApp>| {
let st = h.state().session.active().unwrap();
let ts = st.selection.text.as_ref().expect("click starts typing");
assert_eq!(ts.shape, id);
deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0
};
let first = caret(&h);
// Optional local screenshots use the actual canvas renderer, including its cache.
let preview = |h: &mut Harness<'_, SlideApp>, name: &str| {
if let Ok(dir) = std::env::var("BULLET_PREVIEW_DIR") {
// Capture the visible phase of the caret, with the pointer clear of the text.
let time = h.ctx.input(|i| i.time);
h.input_mut().time = Some((time * 1.6).floor() / 1.6 + 0.65);
h.event(Event::PointerMoved(pos2(25.0, 25.0)));
h.step();
h.render().unwrap().save(std::path::Path::new(&dir).join(name)).unwrap();
}
};
preview(&mut h, "bullet-empty.png");
h.event(Event::Text("First point".into()));
h.run_steps(3);
h.key_press(egui::Key::Enter);
h.run_steps(3);
let second = caret(&h);
assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent");
assert!(second.y > first.y);
let st = h.state().session.active().unwrap();
let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap();
assert_eq!(l.empty_bullets.len(), 1);
assert_eq!(l.empty_bullets[0].para, 1);
preview(&mut h, "bullet-after-enter.png");
let renders = h.state().textures.canvas_renders;
h.state_mut().run("text.exit", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture");
preview(&mut h, "bullet-after-exit.png");
h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap();
h.run_steps(3);
h.event(Event::Text("Second point".into()));
h.run_steps(3);
let st = h.state().session.active().unwrap();
let ts = st.selection.text.as_ref().unwrap();
assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point");
assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty());
h.key_press(egui::Key::Enter);
h.run_steps(3);
h.state_mut().run("format.bullets", json!({})).unwrap();
h.run_steps(3);
let st = h.state().session.active().unwrap();
assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty());
}
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
#[test]
fn double_click_in_a_table_cell_shows_the_caret_there() {
+6 -7
View File
@@ -21,7 +21,7 @@ struct Key {
extra: u64,
/// The pasteboard region the slide's drawing calls for (canvas only).
want: Region,
skip_text: Option<ShapeId>,
editing_text: Option<ShapeId>,
}
struct Entry {
@@ -248,8 +248,7 @@ impl Textures {
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
/// GIF frames. `skip_text` leaves out one shape's text (the prompt of a placeholder being typed
/// into).
/// GIF frames. `editing_text` replaces its placeholder prompt with gray empty list markers.
#[allow(clippy::too_many_arguments)]
pub fn slide(
&mut self,
@@ -262,7 +261,7 @@ impl Textures {
extra: u64,
grayscale: bool,
gif_times: &[(ShapeId, f64)],
skip_text: Option<ShapeId>,
editing_text: Option<ShapeId>,
anim: Option<(&deckcraft_anim::Timeline, f64)>,
) -> Option<(TextureHandle, Region)> {
let slide = p.slides.get(index)?.clone();
@@ -290,7 +289,7 @@ impl Textures {
edit,
extra: extra ^ grayscale as u64 ^ anim_key(anim),
want,
skip_text,
editing_text,
};
if let Some(e) = &self.canvas
&& e.key == key
@@ -309,7 +308,7 @@ impl Textures {
offset: region.offset(),
size: Some(region.size()),
state,
skip_text,
editing_text,
..Default::default()
};
deckcraft_render::render_slide_drawn(p, index, &opts)
@@ -367,7 +366,7 @@ impl Textures {
edit: false,
extra: 0,
want: Region::slide(width_px, h),
skip_text: None,
editing_text: None,
};
self.clock += 1;
let clock = self.clock;
+2 -2
View File
@@ -1,6 +1,6 @@
# PowerPoint feature checklist
> **Generated:** 2026-10-14 (summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The summary between `<!-- SUMMARY -->
**Weighted breadth parity: 84%** over 191 features.
@@ -145,7 +145,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Text | Change case, grow/shrink font, clear formatting | P1 | D |
| Text | Text outline and text effects | P2 | P |
| Text | Paragraph alignment, indents, spacing, line spacing | P0 | D |
| Text | Bullets and numbering | P0 | D |
| Text | Bullets and numbering (including gray empty-line markers while editing) | P0 | D |
| Text | Picture bullets, custom bullet symbols | P2 | P |
| Text | List levels (Tab / Shift+Tab) | P0 | D |
| Text | Tab stops | P2 | D |
+9 -1
View File
@@ -1,12 +1,19 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to parity.
## Empty list paragraphs
While editing slide text, empty bulleted and numbered paragraphs show gray markers at the list's
indent, including the first click into a content placeholder and the paragraph after Enter.
Typing gives the marker its normal text color. These temporary markers are hidden when editing
ends and are omitted from thumbnails, slide shows and exports.
## Shell
| Element | PowerPoint for Mac | DeckCraft | Gap |
@@ -158,6 +165,7 @@ consistency is open (#73, #56).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |
| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |
| 2026-10-12 | minor | Title bar: an Export button next to Save opens the Export dialog, a quick-access shortcut beside File › Export |