mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #703 from NikitkaRa: UI: Slide Master Preserve and Title or Footers placeholders
# Conflicts: # crates/engine/src/cmd/design.rs # crates/ui-egui/src/ribbon.rs
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: owned PPTX corpus for more deck kinds, handout-master text; 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 (progress log: Show Media Controls; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Design ▸ Effects; percentages unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **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
|
||||
@@ -203,6 +204,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | The command palette skips commands that need a value, so searching for Export runs the export dialog (#79) |
|
||||
| 2026-10-14 | PNG and JPEG slide export record their resolution. The default 2 pixels per point is 144 dpi, so Preview and print show the slide's real size instead of twice that |
|
||||
| 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` |
|
||||
| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% |
|
||||
| 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 |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -266,6 +268,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | trivial | Progress log: See All Slides and zoom during a show. Breadth stays 79% |
|
||||
| 2026-10-13 | trivial | Progress log: command palette Export (#79) |
|
||||
| 2026-10-14 | trivial | Progress log: Design ▸ Effects |
|
||||
| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
|
||||
@@ -114,6 +114,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
insert_placeholder
|
||||
),
|
||||
cmd!("master.preserve", "Preserve", ["Slide Master", "Edit Master"], None, "{preserve?: bool}", has_doc, preserve),
|
||||
cmd!(
|
||||
"master.placeholders",
|
||||
"Layout Placeholders",
|
||||
["Slide Master", "Edit Master"],
|
||||
None,
|
||||
"{title?: bool, footers?: bool}",
|
||||
has_doc,
|
||||
placeholders
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -736,8 +746,14 @@ pub fn civil(z: i64) -> (i64, u32, u32) {
|
||||
fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
let layout = usize_param(p, "layout")
|
||||
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))));
|
||||
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
|
||||
// that layout. With neither key, follow the current slide's layout.
|
||||
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
|
||||
None
|
||||
} else {
|
||||
usize_param(p, "layout")
|
||||
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
|
||||
};
|
||||
let target = match layout {
|
||||
Some(l) => Target::Layout { master: mi, layout: l },
|
||||
None => Target::Master { master: mi },
|
||||
@@ -1047,6 +1063,178 @@ fn insert_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
fn preserve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (on_master, mi, li, current) = match st.selection.target {
|
||||
Target::Master { master } => {
|
||||
let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?;
|
||||
(true, master, 0, on)
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
let on = st
|
||||
.doc
|
||||
.masters
|
||||
.get(master)
|
||||
.and_then(|m| m.layouts.get(layout))
|
||||
.map(|l| l.preserve)
|
||||
.ok_or_else(|| bad("master.preserve", "no such layout"))?;
|
||||
(false, master, layout, on)
|
||||
}
|
||||
Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")),
|
||||
};
|
||||
let next = bool_param(p, "preserve").unwrap_or(!current);
|
||||
if next == current {
|
||||
return Ok(json!({"preserve": next}));
|
||||
}
|
||||
s.edit(|doc, _| {
|
||||
if on_master {
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?);
|
||||
m.preserve = next;
|
||||
} else {
|
||||
let l =
|
||||
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?;
|
||||
l.preserve = next;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"preserve": next}))
|
||||
}
|
||||
|
||||
fn placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let title = bool_param(p, "title");
|
||||
let footers = bool_param(p, "footers");
|
||||
if title.is_none() && footers.is_none() {
|
||||
return Err(bad("master.placeholders", "give title or footers"));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
if matches!(st.selection.target, Target::Slides) {
|
||||
return Err(bad("master.placeholders", "open Slide Master view"));
|
||||
}
|
||||
let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
|
||||
let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
let title_changes = title.is_some_and(|want| want != has_title);
|
||||
let footer_changes = footers.is_some_and(|want| want != has_footers);
|
||||
if !title_changes && !footer_changes {
|
||||
return Ok(json!({"title": has_title, "footers": has_footers}));
|
||||
}
|
||||
let mut add = Vec::new();
|
||||
if title == Some(true) && !has_title {
|
||||
add.push(PhType::Title);
|
||||
}
|
||||
if footers == Some(true) && !has_footers {
|
||||
add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]);
|
||||
}
|
||||
let size = st.doc.slide_size;
|
||||
s.edit(|doc, sel| {
|
||||
let mut ids = Vec::with_capacity(add.len());
|
||||
for _ in 0..add.len() {
|
||||
ids.push(ShapeId(doc.alloc_id()));
|
||||
}
|
||||
let prototypes = match sel.target {
|
||||
Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(),
|
||||
Target::Slides => Vec::new(),
|
||||
Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")),
|
||||
};
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?;
|
||||
if title == Some(false) {
|
||||
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
|
||||
}
|
||||
if footers == Some(false) {
|
||||
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
}
|
||||
for (kind, id) in add.into_iter().zip(ids) {
|
||||
let idx = free_ph_idx(shapes, preferred_ph_idx(kind));
|
||||
let proto = prototypes.iter().find(|sh| match kind {
|
||||
PhType::Title => sh.ph_type().is_some_and(PhType::is_title),
|
||||
other => sh.ph_type() == Some(other),
|
||||
});
|
||||
let shape = match proto {
|
||||
Some(src) => retarget_placeholder(src.clone(), id, idx),
|
||||
None => synthesized_placeholder(kind, id, idx, size),
|
||||
};
|
||||
shapes.push(shape);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc()?;
|
||||
let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
|
||||
let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
Ok(json!({"title": title, "footers": footers}))
|
||||
}
|
||||
|
||||
fn preferred_ph_idx(kind: PhType) -> u32 {
|
||||
match kind {
|
||||
PhType::Title | PhType::CtrTitle => 0,
|
||||
PhType::Date => 10,
|
||||
PhType::Footer => 11,
|
||||
PhType::SlideNum => 12,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 {
|
||||
let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx));
|
||||
if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) }
|
||||
}
|
||||
|
||||
/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide
|
||||
/// number keeps its `‹#›` field.
|
||||
fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape {
|
||||
sh.id = id;
|
||||
if let Some(ph) = sh.ph.as_mut() {
|
||||
ph.idx = idx;
|
||||
}
|
||||
let slide_num = sh.ph_type() == Some(PhType::SlideNum);
|
||||
if slide_num {
|
||||
let paras = vec![slide_number_paragraph()];
|
||||
if let Some(text) = sh.text.as_mut() {
|
||||
text.paragraphs = paras;
|
||||
} else {
|
||||
sh.text = Some(TextBody { paragraphs: paras, ..Default::default() });
|
||||
}
|
||||
} else if let Some(text) = sh.text.as_mut() {
|
||||
text.paragraphs.clear();
|
||||
}
|
||||
sh
|
||||
}
|
||||
|
||||
fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape {
|
||||
let (name, text) = match kind {
|
||||
PhType::Date => ("Date Placeholder", TextBody::default()),
|
||||
PhType::Footer => ("Footer Placeholder", TextBody::default()),
|
||||
PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }),
|
||||
_ => ("Title", TextBody::default()),
|
||||
};
|
||||
Shape {
|
||||
id,
|
||||
name: name.into(),
|
||||
xfrm: Some(placeholder_rect(kind, size)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard placeholder boxes at 960×540, scaled to the slide size.
|
||||
fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm {
|
||||
let sx = size.width / 960.0;
|
||||
let sy = size.height / 540.0;
|
||||
let (x, y, w, h) = match kind {
|
||||
PhType::Date => (60.0, 500.0, 216.0, 28.0),
|
||||
PhType::Footer => (318.0, 500.0, 324.0, 28.0),
|
||||
PhType::SlideNum => (684.0, 500.0, 216.0, 28.0),
|
||||
_ => (60.0, 29.0, 840.0, 104.0),
|
||||
};
|
||||
deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy)
|
||||
}
|
||||
|
||||
fn slide_number_paragraph() -> deckcraft_model::Paragraph {
|
||||
deckcraft_model::Paragraph {
|
||||
runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_color::SchemeSlot;
|
||||
@@ -1241,9 +1429,11 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod browse_theme_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::{Session, Target};
|
||||
|
||||
#[test]
|
||||
fn applies_the_theme_of_another_deck_and_undoes() {
|
||||
@@ -1308,4 +1498,111 @@ mod browse_theme_tests {
|
||||
assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none()));
|
||||
assert_eq!(plain.lines[2].width, Some(0.75));
|
||||
}
|
||||
|
||||
fn shapes(s: &Session) -> Vec<deckcraft_model::Shape> {
|
||||
s.doc().unwrap().shapes().to_vec()
|
||||
}
|
||||
|
||||
fn has_title(s: &Session) -> bool {
|
||||
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))
|
||||
}
|
||||
|
||||
fn has_footers(s: &Session) -> bool {
|
||||
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_and_placeholders_edit_the_open_master_or_layout() {
|
||||
for id in ["master.preserve", "master.placeholders"] {
|
||||
assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}");
|
||||
}
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap();
|
||||
|
||||
s.execute("view.slideMaster", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 });
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 });
|
||||
assert!(!s.doc().unwrap().doc.masters[0].preserve);
|
||||
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step");
|
||||
s.execute("master.preserve", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.masters[0].preserve);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.masters[0].preserve);
|
||||
|
||||
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
|
||||
assert!(has_title(&s));
|
||||
assert!(!has_footers(&s));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(has_footers(&s));
|
||||
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let err = s.execute("master.preserve", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("open Slide Master view"), "{err}");
|
||||
let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err();
|
||||
assert!(err.to_string().contains("open Slide Master view"), "{err}");
|
||||
let err = s.execute("master.placeholders", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("give title or footers"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank });
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
assert!(!has_title(&s));
|
||||
assert!(has_footers(&s));
|
||||
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state");
|
||||
|
||||
s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
|
||||
assert!(has_title(&s));
|
||||
assert!(!has_footers(&s));
|
||||
let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert_eq!(title.name, "Title 1");
|
||||
assert_eq!(title.ph.unwrap().idx, 0);
|
||||
assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!has_title(&s));
|
||||
assert!(has_footers(&s));
|
||||
|
||||
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
|
||||
s.execute("master.placeholders", &json!({"footers": true})).unwrap();
|
||||
let restored = shapes(&s);
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date)));
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer)));
|
||||
let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap();
|
||||
let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0];
|
||||
assert_eq!(run.text, "‹#›");
|
||||
assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum"));
|
||||
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
|
||||
s.edit(|doc, _| {
|
||||
let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("master.placeholders", &json!({"title": true})).unwrap();
|
||||
let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
|
||||
assert_eq!(title.name, "Title");
|
||||
let box_ = title.xfrm.unwrap();
|
||||
assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01);
|
||||
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Slide Master ribbon: Preserve, Title and Footers on the open layout.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_model::PhType;
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn app_on_blank() -> (SlideApp, usize) {
|
||||
let mut session = Session::with_new();
|
||||
let blank = session.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap();
|
||||
session.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
|
||||
let mut app = SlideApp::new(session, Services::default());
|
||||
app.ui.tab = "Slide Master".into();
|
||||
(app, blank)
|
||||
}
|
||||
|
||||
fn click_check(h: &mut Harness<'_, SlideApp>, label: &str) {
|
||||
h.query_all_by_role_and_label(Role::CheckBox, label).next().unwrap_or_else(|| panic!("no {label} checkbox")).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_master_preserve_title_and_footers() {
|
||||
let (app, blank) = app_on_blank();
|
||||
let mut h = harness(app);
|
||||
assert!(h.query_all_by_role_and_label(Role::CheckBox, "Preserve").next().is_some());
|
||||
assert!(h.query_all_by_role_and_label(Role::CheckBox, "Title").next().is_some());
|
||||
assert!(h.query_all_by_role_and_label(Role::CheckBox, "Footers").next().is_some());
|
||||
{
|
||||
let st = h.state().session.doc().unwrap();
|
||||
assert!(st.doc.masters[0].layouts[blank].preserve);
|
||||
assert!(!st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)));
|
||||
assert!(st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)));
|
||||
}
|
||||
|
||||
click_check(&mut h, "Title");
|
||||
assert!(h.state().session.doc().unwrap().shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)));
|
||||
|
||||
click_check(&mut h, "Footers");
|
||||
assert!(!h.state().session.doc().unwrap().shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)));
|
||||
|
||||
click_check(&mut h, "Preserve");
|
||||
assert!(!h.state().session.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
}
|
||||
@@ -3087,12 +3087,42 @@ fn playback(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Preserve, and whether the open master or layout has a title or any footer placeholder.
|
||||
fn master_checks(app: &SlideApp) -> (bool, bool, bool) {
|
||||
let Some(st) = app.session.active() else { return (false, false, false) };
|
||||
let preserve = match st.selection.target {
|
||||
Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve),
|
||||
Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve),
|
||||
Target::Slides => false,
|
||||
};
|
||||
let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind()));
|
||||
(preserve, title, footers)
|
||||
}
|
||||
|
||||
fn slide_master(app: &mut SlideApp, ui: &mut Ui) {
|
||||
group(ui, |ui| {
|
||||
big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({}));
|
||||
big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({}));
|
||||
big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"}));
|
||||
});
|
||||
group(ui, |ui| {
|
||||
rows(ui, |ui| {
|
||||
let (preserve, title, footers) = master_checks(app);
|
||||
let mut on = preserve;
|
||||
if ui.checkbox(&mut on, "Preserve").changed() {
|
||||
run(app, "master.preserve", json!({"preserve": on}));
|
||||
}
|
||||
let mut on = title;
|
||||
if ui.checkbox(&mut on, "Title").changed() {
|
||||
run(app, "master.placeholders", json!({"title": on}));
|
||||
}
|
||||
let mut on = footers;
|
||||
if ui.checkbox(&mut on, "Footers").changed() {
|
||||
run(app, "master.placeholders", json!({"footers": on}));
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
@@ -3247,3 +3277,7 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "effects_tests.rs"]
|
||||
mod effects_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "master_layout_tests.rs"]
|
||||
mod master_layout_tests;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Fonts dialog; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; 28 dialogs, 226 commands; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (command registry 227; dialogs stay 27; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (command registry counted at 236; dialogs stay 27; the test-count line stays 504; previous: transition frames) · **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
|
||||
@@ -31,6 +32,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
|
||||
| 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 |
|
||||
| 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`) |
|
||||
| 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 (236 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, 27 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 |
|
||||
| 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`) |
|
||||
| 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 |
|
||||
@@ -102,5 +104,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
| 2026-10-14 | minor | Create New Theme Fonts (`design.fonts` with no params); command count stays 226 |
|
||||
| 2026-10-14 | minor | Insert › Date and Time dialog (`insert.dateTime` with no keys). 28 dialogs; command count stays 226 |
|
||||
| 2026-10-14 | minor | Command registry is 227 (`design.effects`). Dialogs stay 27. The test-count line stays 504 |
|
||||
| 2026-10-14 | minor | Command registry counted at 236, including `master.preserve` and `master.placeholders`. Dialogs stay 27. The test-count line stays 504. The menu measurement and the d556ff9 snapshot still count 226 |
|
||||
| 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 |
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (laser pointer in the slide show; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (command palette no longer offers Export without a path, #79; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **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
|
||||
@@ -432,6 +433,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-13 | minor | Laser pointer in the slide show (checklist row Partial). Highlighter, eraser and a laser driven from presenter view remain |
|
||||
| 2026-10-13 | trivial | Command palette skips commands that need a value, so Export opens the dialog (#79) |
|
||||
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 |
|
||||
| 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
|
||||
| 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 |
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (go to slide shows the typed number; breadth stays 79%; previous: trivial, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (essentials discount no longer cites the palette Export failure, #79; percentages unchanged; 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:** minor (Design ▸ Effects; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **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;
|
||||
@@ -265,6 +266,11 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
|
||||
- **Review:** comments with replies and resolve, previous and next across the deck, accessibility checker, a basic spelling list.
|
||||
- **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master; zoom
|
||||
(dialog: percent or Fit); grayscale.
|
||||
- **Slide show:** full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen,
|
||||
presenter window, rehearse timings, custom shows, Set Up Show, Reading View; links and shape
|
||||
actions followed on click.
|
||||
- **Views:** Normal, Outline, Slide Sorter, Notes Page, Reading View, Slide Master (Preserve on
|
||||
the open master or layout, and Title and Footers placeholders); zoom; grayscale.
|
||||
- **Review:** comments with replies and resolve, accessibility checker, a basic spelling list.
|
||||
- **Media:** MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis and Opus audio; H.264, HEVC,
|
||||
VP9 and AV1 video in MP4/MOV/WebM/MKV with pure-Rust decoders; poster frames, trim, fades,
|
||||
@@ -279,6 +285,8 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
|
||||
and MCP.
|
||||
- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel
|
||||
and MCP. The menu-bar measurement and the d556ff9 snapshot still count 226.
|
||||
- **Automation:** 236 commands, every one reachable from `deckcraft-cli`, the JSON control channel
|
||||
and MCP. The menu-bar measurement and the d556ff9 snapshot still count 226 commands.
|
||||
- **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).
|
||||
|
||||
@@ -331,6 +339,7 @@ scored done (16 open issues).
|
||||
| 2026-10-14 | trivial | Slide show evidence includes See All Slides and zoom. Breadth stays 79% |
|
||||
| 2026-10-13 | trivial | Essentials discoverability note: the command palette's Export failure (#79) is fixed; the ×0.95 discount stays for the menu bar and start screen |
|
||||
| 2026-10-14 | minor | Design ▸ Effects noted under what works today. Percentages stay 79% / ~60% / ~55%. The d556ff9 snapshot and the menu measurement stay at 226 commands; the live registry is 227 |
|
||||
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Automation in "what works today" is 236 commands; the menu-bar measurement and the d556ff9 snapshot still count 226. Percentages 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 |
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Zoom dialog: percent or Fit, applies on OK; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (command search skips Export without a path, #79; 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 (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -25,6 +26,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Fonts; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: text editing keys observed in PowerPoint for Windows, #268) · **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
|
||||
@@ -84,6 +86,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options |
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options |
|
||||
| Format Background pane | yes | yes |
|
||||
| Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master |
|
||||
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |
|
||||
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
||||
| Comments pane (threads) | yes | yes: every slide, with Previous and Next on the Review tab |
|
||||
@@ -211,6 +214,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| 2026-10-14 | trivial | Zoom dialog: percent or Fit, applied on OK. View ▸ Zoom opens it; the arrow keeps the presets |
|
||||
| 2026-10-13 | trivial | Command search no longer offers an Export command that fails without a path (#79) |
|
||||
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user