UI: Edit Photo Album reflows the album in one step

Reopen the Photo Album dialog on the current album and replace those slides
in one undo, with black and white, rotation, brightness, contrast and a theme.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 04:16:00 +03:00
co-authored by Cursor
parent 4575d19ac7
commit dbffbab59d
10 changed files with 844 additions and 126 deletions
+3 -2
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:** trivial (Photo Album dialog; breadth stays 79%, ready ~55%; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Edit Photo Album; breadth stays 79%, ready ~55%; previous: trivial, Photo Album dialog) · **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
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-14 | Edit Photo Album (`insert.photoAlbumEdit`): reopen the album's slides and re-flow them in one undo step, with black and white, rotation, brightness, contrast and an album theme. Breadth stays 79%, ready ~55% |
| 2026-10-14 | Photo Album dialog (`insert.photoAlbum`): pictures and text boxes become slides in the open presentation, one undo step. Breadth stays 79%, ready ~55% |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
@@ -151,7 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Progress log: Photo Album dialog. Breadth stays 79%, ready ~55% |
| 2026-10-14 | trivial | Progress log: Edit Photo Album. Breadth stays 79%, ready ~55% |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+541 -16
View File
@@ -38,10 +38,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Photo Album…",
["Insert", "Images"],
None,
"{pictures?: [{path?|data: base64, name?} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool} — omit pictures to open the dialog",
"{pictures?: [{path?|data: base64, name?, rot?: deg, brightness?: -1..1, contrast?: -1..1} | {text}], layout?: fit|1|2|4|1title|2title|4title, frame?: none|rect|rounded|simple, captions?: bool, grayscale?: bool, theme?: name} — omit pictures to open the dialog",
has_doc,
photo_album
),
cmd!(
"insert.photoAlbumEdit",
"Edit Photo Album…",
["Insert", "Images"],
None,
"{album?: id, pictures?: same as insert.photoAlbum, layout?, frame?, captions?, grayscale?, theme?} — omit pictures to reopen the dialog on the current album",
has_doc,
photo_album_edit
),
cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table),
cmd!(
"insert.chart",
@@ -408,6 +417,19 @@ struct AlbumPic {
bytes: Vec<u8>,
px_w: f64,
px_h: f64,
rot: f64,
brightness: f64,
contrast: f64,
}
struct AlbumOpts {
layout_key: String,
frame_key: String,
layout: AlbumLayout,
frame: AlbumFrame,
captions: bool,
grayscale: bool,
theme: Option<String>,
}
enum AlbumItem {
@@ -425,22 +447,56 @@ enum AlbumSlide {
/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one
/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit.
fn photo_album(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("pictures") {
None | Some(Value::Null) => {
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) });
return Ok(json!({"dialog": "photoAlbum"}));
}
Some(_) => {}
if p.get("pictures").is_none_or(Value::is_null) {
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) });
return Ok(json!({"dialog": "photoAlbum"}));
}
let layout = album_layout(p)?;
let frame = album_frame(p)?;
let captions = album_captions(p)?;
let opts = album_opts(p)?;
let items = album_items(p)?;
commit_album(s, items, opts, None)
}
fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("pictures").is_none_or(Value::is_null) {
let seed = album_seed(s)?;
let album = seed.get("album").cloned().unwrap_or(Value::Null);
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed });
return Ok(json!({"dialog": "photoAlbum", "album": album}));
}
let opts = album_opts(p)?;
let items = album_items(p)?;
let (id, ids) = album_target(s, p)?;
commit_album(s, items, opts, Some((id, ids)))
}
fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace: Option<(u32, Vec<u64>)>) -> Result<Value> {
let (snapshot, sel) = {
let st = s.doc()?;
(st.doc.clone(), st.selection.clone())
};
let result = insert_album(s, items, layout, frame, captions);
let result = (|| {
let album_id = match replace.as_ref().map(|(id, _)| *id) {
Some(id) if id != 0 => id,
_ => s.edit(|doc, _| Ok(doc.alloc_id()))?,
};
let at = if let Some((_, ids)) = &replace {
let st = s.doc()?;
let indices: Vec<usize> = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect();
let Some(at) = indices.iter().copied().min() else {
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
};
s.execute("slide.delete", &json!({"ids": ids}))?;
Some(at)
} else {
None
};
let mut opts = opts;
if opts.theme.as_deref().unwrap_or("").is_empty() {
opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty());
}
apply_theme_if_needed(s, opts.theme.as_deref())?;
insert_album(s, items, &opts, at, album_id)
})();
if result.is_err()
&& let Some(st) = s.active_mut()
{
@@ -495,6 +551,83 @@ fn album_captions(p: &Value) -> Result<bool> {
}
}
fn album_bool(p: &Value, key: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(v)) => Ok(*v),
Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))),
}
}
fn layout_key_of(layout: AlbumLayout) -> String {
if layout.fit {
return "fit".into();
}
let n = match layout.per {
2 => "2",
4 => "4",
_ => "1",
};
if layout.title { format!("{n}title") } else { n.into() }
}
fn frame_key_of(frame: AlbumFrame) -> &'static str {
match frame {
AlbumFrame::None => "none",
AlbumFrame::Rect => "rect",
AlbumFrame::Rounded => "rounded",
AlbumFrame::Simple => "simple",
}
}
fn album_opts(p: &Value) -> Result<AlbumOpts> {
let layout = album_layout(p)?;
let frame = album_frame(p)?;
let theme = match p.get("theme") {
None | Some(Value::Null) => None,
Some(Value::String(s)) if s.trim().is_empty() => None,
Some(Value::String(s)) => {
let name = s.trim();
let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name));
let Some(t) = found else {
return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`")));
};
Some(t.name)
}
Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")),
};
Ok(AlbumOpts {
layout_key: layout_key_of(layout),
frame_key: frame_key_of(frame).into(),
layout,
frame,
captions: album_captions(p)?,
grayscale: album_bool(p, "grayscale")?,
theme,
})
}
/// A finite number, or 0 when the key is absent. A present non-number is an error.
fn album_finite(item: &Value, key: &str) -> Result<f64> {
match item.get(key) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))),
}
}
fn album_rot(item: &Value) -> Result<f64> {
let n = album_finite(item, "rot")?;
Ok(((n % 360.0) + 360.0) % 360.0)
}
fn album_unit(item: &Value, key: &str) -> Result<f64> {
let n = album_finite(item, key)?;
if !(-1.0..=1.0).contains(&n) {
return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1")));
}
Ok(n)
}
fn album_items(p: &Value) -> Result<Vec<AlbumItem>> {
let Some(arr) = p.get("pictures").and_then(Value::as_array) else {
return Err(bad("insert.photoAlbum", "`pictures` must be a list"));
@@ -543,7 +676,15 @@ fn album_items(p: &Value) -> Result<Vec<AlbumItem>> {
return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read")));
}
let (px_w, px_h) = image_px(&bytes);
items.push(AlbumItem::Picture(AlbumPic { name, bytes, px_w, px_h }));
items.push(AlbumItem::Picture(AlbumPic {
name,
bytes,
px_w,
px_h,
rot: album_rot(item)?,
brightness: album_unit(item, "brightness")?,
contrast: album_unit(item, "contrast")?,
}));
}
Ok(items)
}
@@ -665,17 +806,262 @@ fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> {
Ok(())
}
fn insert_album(s: &mut Session, items: Vec<AlbumItem>, layout: AlbumLayout, frame: AlbumFrame, captions: bool) -> Result<Value> {
struct AlbumMark {
id: u32,
layout: String,
frame: String,
captions: bool,
grayscale: bool,
theme: String,
}
fn parse_album_mark(raw: &str) -> Option<AlbumMark> {
let rest = raw.strip_prefix("deckcraft-album:")?;
let (id_str, rest) = rest.split_once(';').unwrap_or((rest, ""));
let id = id_str.parse::<u32>().ok().filter(|id| *id != 0)?;
let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() };
if !rest.is_empty() {
for part in rest.split(';') {
let Some((k, v)) = part.split_once('=') else { continue };
match k {
"layout" => mark.layout = v.to_string(),
"frame" => mark.frame = v.to_string(),
"captions" => mark.captions = v == "1",
"gray" => mark.grayscale = v == "1",
"theme" => mark.theme = v.to_string(),
_ => {}
}
}
}
Some(mark)
}
fn album_marker(id: u32, opts: &AlbumOpts) -> String {
let theme = opts.theme.as_deref().unwrap_or("");
format!(
"deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}",
opts.layout_key,
opts.frame_key,
if opts.captions { 1 } else { 0 },
if opts.grayscale { 1 } else { 0 },
)
}
fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> {
let marker = album_marker(album_id, opts);
s.edit(|doc, sel| {
let Some(slide) = doc.slides.get_mut(sel.slide) else {
return Err(bad("insert.photoAlbum", "no slide to mark"));
};
let slide = std::sync::Arc::make_mut(slide);
if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) {
slide.raw_ext = Some(marker);
}
slide.name = "Photo Album".into();
Ok(())
})
}
fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> {
let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) };
let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default();
if current.eq_ignore_ascii_case(name) {
return Ok(());
}
s.execute("design.theme", &json!({"name": name}))?;
Ok(())
}
fn album_id_param(p: &Value) -> Option<u32> {
match p.get("album") {
Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0),
Some(Value::String(s)) => s.trim().parse::<u32>().ok().filter(|v| *v != 0),
_ => None,
}
}
fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec<u64> {
doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect()
}
fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec<u64> {
if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") {
return Vec::new();
}
let mut start = index;
while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") {
start -= 1;
}
let mut end = index;
while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") {
end += 1;
}
doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect()
}
/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker).
fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
let st = s.doc()?;
if let Some(id) = album_id_param(p) {
let ids = slides_with_album(&st.doc, id);
if ids.is_empty() {
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
}
return Ok((id, ids));
}
let Some(slide) = st.doc.slides.get(st.selection.slide) else {
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
};
if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) {
let ids = slides_with_album(&st.doc, mark.id);
if ids.is_empty() {
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
}
return Ok((mark.id, ids));
}
if slide.name == "Photo Album" {
let ids = album_name_run(&st.doc, st.selection.slide);
if !ids.is_empty() {
return Ok((0, ids));
}
}
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
}
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
}
fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) {
let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1);
let per = if max_pics >= 3 {
"4"
} else if max_pics == 2 {
"2"
} else {
"1"
};
let title =
slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0)));
let layout = if title { format!("{per}title") } else { per.into() };
let width = slides.iter().find_map(|s| {
s.shapes.iter().find_map(|sh| match &sh.kind {
ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width),
_ => None,
})
});
let frame = match width {
None => "none",
Some(w) if (w - 6.0).abs() < 0.2 => "simple",
Some(w) if (w - 2.25).abs() < 0.2 => "rounded",
Some(w) if w < 0.2 => "none",
Some(_) => "rect",
};
let captions = slides.iter().any(|s| {
let bottoms: Vec<f64> =
s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect();
!bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b)))
});
let mut any = false;
let mut gray = true;
for s in slides {
for sh in &s.shapes {
if let ShapeKind::Picture { fill } = &sh.kind {
any = true;
gray &= fill.adjust.grayscale;
}
}
}
(layout, frame.into(), captions, any && gray, theme_now.to_string())
}
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
let mut items = Vec::new();
for slide in slides {
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
if pics.is_empty() {
let text = slide
.shapes
.iter()
.filter(|sh| sh.text_box)
.filter_map(|sh| sh.text.as_ref().map(|t| t.text()))
.find(|t| !t.trim().is_empty())
.unwrap_or_default();
if !text.trim().is_empty() {
items.push(json!({"text": text.trim()}));
}
continue;
}
for sh in pics {
let ShapeKind::Picture { fill } = &sh.kind else { continue };
let media = doc.media(fill.media);
let name = if !sh.descr.is_empty() {
sh.descr.clone()
} else {
media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into())
};
items.push(json!({
"name": name,
"data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(),
"rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0),
"brightness": fill.adjust.brightness,
"contrast": fill.adjust.contrast,
}));
}
}
items
}
fn album_seed(s: &Session) -> Result<Value> {
let (id, ids) = album_target(s, &Value::Null)?;
let st = s.doc()?;
let slides: Vec<&deckcraft_model::Slide> =
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
let inferred = infer_album(&slides, &theme_now);
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark {
(
if mark.layout.is_empty() { inferred.0 } else { mark.layout },
if mark.frame.is_empty() { inferred.1 } else { mark.frame },
mark.captions,
mark.grayscale,
if mark.theme.is_empty() { theme_now } else { mark.theme },
)
} else {
inferred
};
Ok(json!({
"album": if id == 0 { Value::Null } else { json!(id) },
"layout": layout,
"frame": frame,
"captions": captions,
"grayscale": grayscale,
"theme": theme,
"items": items_from_slides(&st.doc, &slides),
}))
}
fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Option<usize>, album_id: u32) -> Result<Value> {
let (width, height, first) = {
let st = s.doc()?;
let first = if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) };
let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) });
(st.doc.slide_size.width, st.doc.slide_size.height, first)
};
let layout = opts.layout;
let frame = opts.frame;
let captions = opts.captions;
let plan = album_plan(items, layout.per);
let mut slides = 0usize;
let mut pictures = 0usize;
let mut pin = at;
for slide in plan {
s.execute("slide.new", &json!({"layout": "blank"}))?;
if let Some(index) = pin.take() {
s.execute("slide.new", &json!({"layout": "blank", "at": index}))?;
} else {
s.execute("slide.new", &json!({"layout": "blank"}))?;
}
stamp_album_slide(s, album_id, opts)?;
slides += 1;
match slide {
AlbumSlide::Text(text) => {
@@ -696,6 +1082,15 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, layout: AlbumLayout, fra
return Err(bad("insert.photoAlbum", "the picture was not inserted"));
};
apply_frame(s, frame, id)?;
if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 {
s.execute(
"picture.adjust",
&json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}),
)?;
}
if pic.rot > 0.01 {
s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?;
}
pictures += 1;
if let Some(cap) = cell.caption {
s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?;
@@ -708,7 +1103,7 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, layout: AlbumLayout, fra
}
}
s.execute("slide.go", &json!({"index": first}))?;
Ok(json!({"slides": slides, "first": first, "pictures": pictures}))
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
}
fn table(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1294,4 +1689,134 @@ mod tests {
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 4);
}
fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust {
match &pictures(s, index)[0].kind {
ShapeKind::Picture { fill } => fill.adjust.clone(),
_ => panic!("picture"),
}
}
fn theme_name(s: &Session) -> String {
s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default()
}
#[test]
fn edit_without_pictures_reopens_the_dialog() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
s.ui_requests.clear();
let before = n_slides(&s);
let undos = s.active().unwrap().history.undo.len();
let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen");
assert_eq!(r["dialog"], "photoAlbum");
assert_eq!(n_slides(&s), before);
assert_eq!(s.active().unwrap().history.undo.len(), undos);
let params = match &s.ui_requests[0] {
crate::UiRequest::Dialog { id, params } => {
assert_eq!(id, "photoAlbum");
params
}
other => panic!("dialog request, got {other:?}"),
};
assert_eq!(params["items"][0]["name"], "hill.png");
assert_eq!(params["items"][1]["name"], "lake.png");
assert_eq!(params["layout"], "1");
assert!(params["album"].as_u64().unwrap_or(0) > 0);
}
#[test]
fn edit_reflows_the_album_in_one_undo() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
assert_eq!(n_slides(&s), 3);
let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow");
assert_eq!(r["slides"], 1);
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
let undo = &s.active().unwrap().history.undo;
assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(n_slides(&s), 3);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
}
#[test]
fn picture_options_and_theme_are_one_step() {
let mut s = Session::with_new();
s.execute(
"insert.photoAlbum",
&json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}),
)
.unwrap();
let adj = adjust(&s, 1);
assert!(adj.grayscale);
assert!((adj.brightness - 0.2).abs() < 1e-6);
assert!((adj.contrast + 0.1).abs() < 1e-6);
assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01);
assert_eq!(theme_name(&s), "Harbor");
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
assert!(mark.contains("gray=1"), "{mark}");
assert!(mark.contains("theme=Harbor"), "{mark}");
assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(theme_name(&s), "DeckCraft");
assert_eq!(n_slides(&s), 1);
}
#[test]
fn unknown_theme_and_a_plain_slide_change_nothing() {
let mut s = Session::with_new();
let before = n_slides(&s);
let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err();
assert!(err.to_string().contains("unknown theme"), "{err}");
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err());
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err());
let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err();
assert!(err.to_string().contains("not part of a photo album"), "{err}");
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err());
assert_eq!(n_slides(&s), before + 1);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(theme_name(&s), "DeckCraft");
}
#[test]
fn two_albums_stay_independent() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
let last = n_slides(&s) - 1;
s.execute("slide.go", &json!({"index": last})).unwrap();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap();
assert_eq!(n_slides(&s), 5);
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap();
assert_eq!(n_slides(&s), 4);
assert_eq!(pictures(&s, 1).len(), 1);
assert_eq!(pictures(&s, 2).len(), 1);
assert_eq!(pictures(&s, 3).len(), 2);
let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default();
assert!(first.starts_with("deckcraft-album:"));
assert!(second.starts_with("deckcraft-album:"));
assert_ne!(first.split(';').next(), second.split(';').next());
}
#[test]
fn a_named_run_without_a_marker_still_reflows() {
let mut s = Session::with_new();
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
{
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
for sl in doc.slides.iter_mut().skip(1) {
std::sync::Arc::make_mut(sl).raw_ext = None;
}
}
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap();
assert_eq!(n_slides(&s), 2);
assert_eq!(pictures(&s, 1).len(), 2);
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
assert!(mark.starts_with("deckcraft-album:"), "{mark}");
}
}
+232 -87
View File
@@ -185,106 +185,213 @@ fn pick_album_picture(app: &mut SlideApp) -> Option<Value> {
fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str, choices: &[(&str, &str)]) {
let cur = d.get(key, default);
let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default);
egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| {
let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| {
for (k, l) in choices {
if ui.selectable_label(cur == *k, *l).clicked() {
d.fields.insert(key.into(), (*k).to_string());
}
}
});
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label));
}
const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"];
fn current_theme(app: &SlideApp) -> String {
app.session
.active()
.and_then(|d| d.doc.masters.first())
.map(|m| m.theme.name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "DeckCraft".into())
}
fn album_json_id(v: &Value) -> Option<u64> {
v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
}
/// Copy a command's dialog params into fields once. A fresh album has no `items`.
fn seed_photo_album(app: &SlideApp, d: &mut Dialog) {
if d.fields.contains_key("seeded") {
return;
}
d.fields.insert("seeded".into(), "1".into());
let p = d.params.clone();
if let Some(items) = p.get("items").and_then(Value::as_array) {
store_album_items(d, items);
d.fields.insert("editing".into(), "1".into());
}
for key in ["layout", "frame", "theme"] {
if let Some(v) = p.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()) {
d.fields.insert(key.into(), v.to_string());
}
}
for key in ["captions", "grayscale"] {
if let Some(v) = p.get(key).and_then(Value::as_bool) {
d.fields.insert(key.into(), if v { "1".into() } else { "0".into() });
}
}
if let Some(id) = p.get("album").and_then(album_json_id).filter(|n| *n != 0) {
d.fields.insert("album".into(), id.to_string());
}
let theme_was = d.fields.get("theme").cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| current_theme(app));
if !d.fields.contains_key("theme") {
d.fields.insert("theme".into(), theme_was.clone());
}
d.fields.entry("themeWas".into()).or_insert(theme_was);
}
fn selected_album_picture(d: &Dialog, items: &[Value]) -> bool {
let i = d.fields.get("sel").and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
items.get(i).is_some_and(|item| item.get("data").is_some() || item.get("path").is_some())
}
fn with_selected_picture(d: &mut Dialog, f: impl FnOnce(&mut serde_json::Map<String, Value>)) {
let Some(mut list) = album_items(d) else { return };
let i = d.fields.get("sel").and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
let Some(item) = list.get_mut(i) else { return };
if item.get("data").is_none() && item.get("path").is_none() {
return;
}
let Some(obj) = item.as_object_mut() else { return };
f(obj);
store_album_items(d, &list);
}
/// Photo Album. Returns true when the dialog should close.
fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.set_min_width(400.0);
ui.label("Pictures in album");
let items = album_items(d).unwrap_or_default();
let mut sel = d.get("sel", "0").parse::<usize>().unwrap_or(0);
egui::ScrollArea::vertical().max_height(140.0).id_salt("album_list").show(ui, |ui| {
if items.is_empty() {
ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim));
}
for (i, item) in items.iter().enumerate() {
if ui.selectable_label(sel == i, album_item_label(item)).clicked() {
sel = i;
seed_photo_album(app, d);
ui.set_min_width(440.0);
egui::ScrollArea::vertical().max_height(520.0).id_salt("album_body").show(ui, |ui| {
ui.set_min_width(420.0);
ui.label("Pictures in album");
let items = album_items(d).unwrap_or_default();
let mut sel = d.get("sel", "0").parse::<usize>().unwrap_or(0);
// Fixed height so adding pictures does not push Create past the window.
egui::ScrollArea::vertical().max_height(120.0).min_scrolled_height(120.0).auto_shrink([false, false]).id_salt("album_list").show(ui, |ui| {
if items.is_empty() {
ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim));
}
}
});
d.fields.insert("sel".into(), sel.to_string());
ui.horizontal(|ui| {
if ui.button("Add Pictures…").clicked()
&& let Some(item) = pick_album_picture(app)
{
match album_items(d) {
Some(mut list) if list.len() < 48 => {
list.push(item);
let next = list.len().saturating_sub(1);
store_album_items(d, &list);
d.fields.insert("sel".into(), next.to_string());
}
Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"),
None => app.set_status("The picture list is invalid"),
}
}
if ui.button("New Text Box").clicked() {
let text = d.get("boxText", "Text Box");
let text = text.trim();
if text.is_empty() {
app.set_status("Type the text for the new text box");
} else if text.chars().count() > 400 {
app.set_status("Text is longer than 400 characters");
} else if let Some(mut list) = album_items(d) {
if list.len() >= 48 {
app.set_status("A photo album holds at most 48 pictures or text boxes");
} else {
list.push(json!({"text": text}));
let next = list.len().saturating_sub(1);
store_album_items(d, &list);
d.fields.insert("sel".into(), next.to_string());
for (i, item) in items.iter().enumerate() {
if ui.selectable_label(sel == i, album_item_label(item)).clicked() {
sel = i;
}
}
}
if ui.button("Remove").clicked()
&& let Some(mut list) = album_items(d)
&& let Some(i) = d.get("sel", "0").parse::<usize>().ok()
&& i < list.len()
{
list.remove(i);
store_album_items(d, &list);
let next = i.min(list.len().saturating_sub(1));
d.fields.insert("sel".into(), next.to_string());
}
});
d.fields.insert("sel".into(), sel.to_string());
ui.horizontal(|ui| {
if ui.button("Add Pictures…").clicked()
&& let Some(item) = pick_album_picture(app)
{
match album_items(d) {
Some(mut list) if list.len() < 48 => {
list.push(item);
let next = list.len().saturating_sub(1);
store_album_items(d, &list);
d.fields.insert("sel".into(), next.to_string());
}
Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"),
None => app.set_status("The picture list is invalid"),
}
}
if ui.button("New Text Box").clicked() {
let text = d.get("boxText", "Text Box");
let text = text.trim();
if text.is_empty() {
app.set_status("Type the text for the new text box");
} else if text.chars().count() > 400 {
app.set_status("Text is longer than 400 characters");
} else if let Some(mut list) = album_items(d) {
if list.len() >= 48 {
app.set_status("A photo album holds at most 48 pictures or text boxes");
} else {
list.push(json!({"text": text}));
let next = list.len().saturating_sub(1);
store_album_items(d, &list);
d.fields.insert("sel".into(), next.to_string());
}
}
}
if ui.button("Remove").clicked()
&& let Some(mut list) = album_items(d)
&& let Some(i) = d.get("sel", "0").parse::<usize>().ok()
&& i < list.len()
{
list.remove(i);
store_album_items(d, &list);
let next = i.min(list.len().saturating_sub(1));
d.fields.insert("sel".into(), next.to_string());
}
});
field(ui, d, "Text box:", "boxText", "Text Box");
let picture = selected_album_picture(d, &items);
ui.label(album_picture_status(&items, sel));
ui.horizontal_wrapped(|ui| {
ui.add_enabled_ui(picture, |ui| {
if ui.button("Rotate Left").clicked() {
with_selected_picture(d, |obj| {
let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0);
let next = ((cur - 90.0) % 360.0 + 360.0) % 360.0;
obj.insert("rot".into(), json!(next));
});
}
if ui.button("Rotate Right").clicked() {
with_selected_picture(d, |obj| {
let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0);
let next = ((cur + 90.0) % 360.0 + 360.0) % 360.0;
obj.insert("rot".into(), json!(next));
});
}
if ui.button("More Brightness").clicked() {
nudge_picture(d, "brightness", 0.1);
}
if ui.button("Less Brightness").clicked() {
nudge_picture(d, "brightness", -0.1);
}
if ui.button("More Contrast").clicked() {
nudge_picture(d, "contrast", 0.1);
}
if ui.button("Less Contrast").clicked() {
nudge_picture(d, "contrast", -0.1);
}
});
});
ui.add_space(4.0);
ui.label("Picture layout");
album_choice(
ui,
d,
"album_layout",
"layout",
"1",
&[
("fit", "Fit to slide"),
("1", "1 picture"),
("2", "2 pictures"),
("4", "4 pictures"),
("1title", "1 picture with title"),
("2title", "2 pictures with title"),
("4title", "4 pictures with title"),
],
);
ui.label("Frame shape");
album_choice(
ui,
d,
"album_frame",
"frame",
"rect",
&[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")],
);
check(ui, d, "Captions under pictures", "captions", false);
check(ui, d, "ALL pictures black and white", "grayscale", false);
ui.label("Album theme");
album_choice(ui, d, "album_theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::<Vec<_>>());
});
field(ui, d, "Text box:", "boxText", "Text Box");
ui.add_space(4.0);
ui.label("Picture layout");
album_choice(
ui,
d,
"album_layout",
"layout",
"1",
&[
("fit", "Fit to slide"),
("1", "1 picture"),
("2", "2 pictures"),
("4", "4 pictures"),
("1title", "1 picture with title"),
("2title", "2 pictures with title"),
("4title", "4 pictures with title"),
],
);
ui.label("Frame shape");
album_choice(
ui,
d,
"album_frame",
"frame",
"rect",
&[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")],
);
let captions = check(ui, d, "Captions under pictures", "captions", false);
let (ok, cancel) = buttons(ui, "Create");
let captions = d.fields.get("captions").is_some_and(|v| v == "1");
let grayscale = d.fields.get("grayscale").is_some_and(|v| v == "1");
let editing = d.fields.get("editing").is_some_and(|v| v == "1");
let (ok, cancel) = buttons(ui, if editing { "Update" } else { "Create" });
if ok {
let Some(list) = album_items(d) else {
app.set_status("The picture list is invalid");
@@ -296,11 +403,49 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
let layout = d.get("layout", "1");
let frame = d.get("frame", "rect");
return app.run("insert.photoAlbum", json!({"pictures": list, "layout": layout, "frame": frame, "captions": captions})).is_ok();
let theme = d.get("theme", "DeckCraft");
let theme_was = d.fields.get("themeWas").cloned().unwrap_or_default();
let mut payload = json!({"pictures": list, "layout": layout, "frame": frame, "captions": captions, "grayscale": grayscale});
if theme != theme_was
&& let Some(obj) = payload.as_object_mut()
{
obj.insert("theme".into(), json!(theme));
}
if editing {
if let Some(album) = d.fields.get("album").filter(|s| !s.is_empty() && s.as_str() != "0")
&& let Ok(id) = album.parse::<u64>()
&& let Some(obj) = payload.as_object_mut()
{
obj.insert("album".into(), json!(id));
}
return app.run("insert.photoAlbumEdit", payload).is_ok();
}
return app.run("insert.photoAlbum", payload).is_ok();
}
cancel
}
fn album_picture_status(items: &[Value], sel: usize) -> String {
let Some(item) = items.get(sel) else {
return "Select a picture to rotate it or change brightness and contrast".into();
};
if item.get("data").is_none() && item.get("path").is_none() {
return "Text boxes have no picture options".into();
}
let rot = item.get("rot").and_then(Value::as_f64).unwrap_or(0.0);
let brightness = item.get("brightness").and_then(Value::as_f64).unwrap_or(0.0);
let contrast = item.get("contrast").and_then(Value::as_f64).unwrap_or(0.0);
format!("Rotation {rot:.0}°, brightness {brightness:.1}, contrast {contrast:.1}")
}
fn nudge_picture(d: &mut Dialog, key: &str, delta: f64) {
with_selected_picture(d, |obj| {
let cur = obj.get(key).and_then(Value::as_f64).unwrap_or(0.0);
let next = (((cur + delta) * 10.0).round() / 10.0).clamp(-1.0, 1.0);
obj.insert(key.into(), json!(next));
});
}
/// Returns true when the dialog should close.
fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let run = |app: &mut SlideApp, id: &str, p: Value| {
+1
View File
@@ -216,6 +216,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("Chart", "insert.chart"),
("Picture from File…", "app.insertPictureDialog"),
("Photo Album…", "insert.photoAlbum"),
("Edit Photo Album…", "insert.photoAlbumEdit"),
("Audio from File…", "app.insertAudioDialog"),
("Video from File…", "app.insertVideoDialog"),
],
+36 -1
View File
@@ -18,7 +18,7 @@ fn png() -> Vec<u8> {
}
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1200.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
@@ -76,6 +76,41 @@ fn create_inserts_the_album_in_one_step() {
assert_eq!(text, ["Text Box"]);
}
#[test]
fn update_reflows_the_open_album() {
let data = deckcraft_engine::cmd::base64_encode(&png());
let mut app = SlideApp::new(Session::with_new(), Services::default());
app.run("insert.photoAlbum", json!({"pictures": [{"name": "hill.png", "data": data}, {"name": "lake.png", "data": data}], "layout": "1"}))
.expect("create");
assert_eq!(slides_of(&app), 3);
app.run("insert.photoAlbumEdit", json!({})).expect("edit");
let mut h = harness(app);
assert!(h.query_by_label("hill.png").is_some(), "the album's pictures are listed");
assert!(h.query_by_label("Update").is_some());
h.get_by_label("ALL pictures black and white").click();
h.run_steps(2);
h.get_by_label("1 picture").click();
h.run_steps(2);
h.get_by_label("2 pictures").click();
h.run_steps(2);
h.get_by_label("Update").click();
h.run_steps(4);
assert!(h.state().dialog.is_none(), "Update closes the dialog");
assert_eq!(slides(&h), 2);
let st = h.state().session.active().unwrap();
assert_eq!(st.history.undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…"));
let pics: Vec<_> = st.doc.slides[1].shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
assert_eq!(pics.len(), 2);
match &pics[0].kind {
ShapeKind::Picture { fill } => assert!(fill.adjust.grayscale),
_ => panic!("picture"),
}
}
fn slides_of(app: &SlideApp) -> usize {
app.session.active().map(|d| d.doc.slides.len()).unwrap_or(0)
}
#[test]
fn create_with_nothing_keeps_the_dialog() {
let mut app = SlideApp::new(Session::with_new(), Services::default());
+19 -8
View File
@@ -234,13 +234,24 @@ fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Va
}
}
/// Like [`big`], with an accesskit name (the painted label can be two lines).
fn big_labeled(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, access: &str, id: &str, p: Value) {
let en = enabled(app, id);
let resp = big_button(ui, icon, label, en).on_hover_text(tip(id, access));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, access));
if resp.clicked() {
run(app, id, p);
/// Photo Album is a split button: the main click starts a new album, the arrow edits one.
fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) {
let en = enabled(app, "insert.photoAlbum");
let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false);
let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…"));
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album…"));
if let Some(a) = &arrow {
let _ = a.clone().on_hover_text("Photo Album options");
a.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album options"));
}
if main.clicked() {
run(app, "insert.photoAlbum", json!({}));
}
if let Some(a) = arrow {
egui::Popup::menu(&a).show(|ui| {
menu_item(app, ui, Icon::Picture, "New Photo Album…", "insert.photoAlbum", json!({}));
menu_item(app, ui, Icon::Picture, "Edit Photo Album…", "insert.photoAlbumEdit", json!({}));
});
}
}
@@ -1041,7 +1052,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) {
});
group(ui, |ui| {
big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({}));
big_labeled(app, ui, Icon::Picture, "Photo\nAlbum", "Photo Album…", "insert.photoAlbum", json!({}));
photo_album_button(app, ui);
let r = big_button(ui, Icon::Screenshot, "Screenshot", false);
let _ = r.on_hover_text("Screenshot (not available yet)");
});
+3 -3
View File
@@ -1,6 +1,6 @@
# DeckCraft architecture
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Photo Album dialog, `insert.photoAlbum`; 227 commands, 28 dialogs; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Edit Photo Album, `insert.photoAlbumEdit`; 228 commands, 28 dialogs; previous: trivial, Photo Album dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never
break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what
@@ -20,7 +20,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
| L4 | `pdf` | PDF export through krilla |
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (227 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (228 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
@@ -78,6 +78,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Photo Album dialog and `insert.photoAlbum` (227 commands, 28 dialogs) |
| 2026-10-14 | trivial | Edit Photo Album (`insert.photoAlbumEdit`) reopens the Photo Album dialog and re-flows those slides (228 commands, 28 dialogs) |
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
+3 -3
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Photo Album is one of 28 dialogs; Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain; previous: minor, 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Edit Photo Album reuses the Photo Album dialog, still 28; Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain; previous: trivial, Photo Album counted) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -203,7 +203,7 @@ Detail in [ui-parity.md](ui-parity.md).
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
2–4 h.
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Photo Album is one of them). Still missing Font,
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Photo Album is one of them; Edit Photo Album reopens it). Still missing Font,
Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 8–14 h.
- **Command palette defaults to an Export entry that fails** (#79). 0.5 h.
@@ -256,7 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Photo Album dialog counted (28 vs ~60). Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain |
| 2026-10-14 | trivial | Edit Photo Album reuses the Photo Album dialog (still 28 vs ~60). Font, Bullets, Find, Replace Fonts, Action Settings, Print and Insert Object remain |
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
+3 -3
View File
@@ -1,6 +1,6 @@
# DeckCraft vs Microsoft PowerPoint: parity assessment
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (automation 227 commands; breadth stays 79%, ready ~55%; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (automation 228 commands; breadth stays 79%, ready ~55%; previous: trivial, automation 227 commands) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
@@ -239,7 +239,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
- **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF
export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline
text, AutoRecover.
- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel
- **Automation:** 228 commands, every one reachable from `deckcraft-cli`, the JSON control channel
and MCP.
- **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux
(AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09).
@@ -276,7 +276,7 @@ scored done (16 open issues).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Working today: 227 commands. Breadth stays 79%, ready ~55%. The d556ff9 snapshot (226 commands, 27 dialogs) is unchanged |
| 2026-10-14 | trivial | Working today: 228 commands. Breadth stays 79%, ready ~55%. The d556ff9 snapshot (226 commands, 27 dialogs) is unchanged |
| 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |
| 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged |
| 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |
+3 -3
View File
@@ -1,6 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Photo Album dialog; 28 dialogs; breadth stays 79%, UI ~60%; 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:** trivial (Edit Photo Album reuses the Photo Album dialog; 28 dialogs; breadth stays 79%, UI ~60%; previous: trivial, Photo Album dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
| Comments pane (threads) | yes | yes |
| Design Ideas (Designer) | yes (cloud) | no |
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Photo Album, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Photo Album (Edit Photo Album reopens this dialog), Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
## Direct manipulation
@@ -148,7 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Photo Album dialog (28 dialogs). Breadth stays 79%, UI ~60% |
| 2026-10-14 | trivial | Edit Photo Album reuses the Photo Album dialog (28 dialogs). Breadth stays 79%, UI ~60% |
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |