From 7858d589cd6ceec91e88c2cd783934f864be45a9 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:45:54 +0200 Subject: [PATCH 1/6] Design Ideas: switching ideas removes the Full-bleed title band (#1201) --- crates/engine/src/cmd/ideas.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index 45676754..eeda5f21 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -84,6 +84,11 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] { } } +/// The light fill behind the title in the full-bleed idea. +fn band_fill() -> Fill { + Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))) +} + /// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes). pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> { if !ideas_for(doc, index).iter().any(|i| i.0 == id) { @@ -180,8 +185,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st let pic = slide.shapes.remove(p); slide.shapes.insert(0, pic); } - if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { - t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))); + if let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { + if band { + t.fill = Some(band_fill()); + } else if t.fill == Some(band_fill()) { + // The band belongs to full-bleed only: another idea takes it away again. + t.fill = None; + } } Ok(()) } @@ -314,6 +324,15 @@ mod tests { assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err()); } + #[test] + fn switching_from_full_bleed_removes_the_title_band() { + let mut s = deck(true); + s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); + assert!(shape(&s, 500).fill.is_some()); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only"); + } + #[test] fn cover_crop_matches_the_box_aspect() { let c = cover_crop(2.0, 1.0); From f330a02970a645e7daa6eee38731099a7a8331f3 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:19:55 +0200 Subject: [PATCH 2/6] Design Ideas: mark the Full-bleed band so a fill the user set is kept (#1201) --- crates/engine/src/cmd/ideas.rs | 42 +++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index eeda5f21..96d85fff 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -84,6 +84,9 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] { } } +/// Marks a title whose fill was put there by the full-bleed idea. +const BAND_TAG: &str = "deckcraft.ideaBand"; + /// The light fill behind the title in the full-bleed idea. fn band_fill() -> Fill { Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))) @@ -186,11 +189,17 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st slide.shapes.insert(0, pic); } if let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { + let marked = t.tags.iter().any(|(k, _)| k == BAND_TAG); if band { t.fill = Some(band_fill()); - } else if t.fill == Some(band_fill()) { - // The band belongs to full-bleed only: another idea takes it away again. + if !marked { + t.tags.push((BAND_TAG.into(), "1".into())); + } + } else if marked { + // The band belongs to full-bleed only: another idea takes it away again. A fill the + // user set (no mark) stays. t.fill = None; + t.tags.retain(|(k, _)| k != BAND_TAG); } } Ok(()) @@ -328,11 +337,38 @@ mod tests { fn switching_from_full_bleed_removes_the_title_band() { let mut s = deck(true); s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); - assert!(shape(&s, 500).fill.is_some()); + assert_eq!(shape(&s, 500).fill, Some(band_fill())); s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only"); } + #[test] + fn a_title_fill_set_by_the_user_survives_other_ideas() { + // Set through the command, and set to exactly the band's own fill. + let mut s = deck(true); + s.execute("shape.fill", &json!({"id": 500, "color": "#ffffff", "transparency": 0.14})).unwrap(); + let user = shape(&s, 500).fill; + assert!(user.is_some()); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + assert_eq!(shape(&s, 500).fill, user); + let mut s = deck(true); + Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[0].fill = Some(band_fill()); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + assert_eq!(shape(&s, 500).fill, Some(band_fill()), "not added by an idea, so not removed by one"); + } + + #[test] + fn full_bleed_twice_keeps_the_band_and_undo_after_switching_brings_it_back() { + let mut s = deck(true); + s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); + s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); + assert_eq!(shape(&s, 500).fill, Some(band_fill())); + s.execute("design.applyIdea", &json!({"id": "framed"})).unwrap(); + assert!(shape(&s, 500).fill.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(shape(&s, 500).fill, Some(band_fill())); + } + #[test] fn cover_crop_matches_the_box_aspect() { let c = cover_crop(2.0, 1.0); From ced5dc04ed260f5a315019c36620d095070c0cf5 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:41:46 +0200 Subject: [PATCH 3/6] Design Ideas: recognise the band by its exact fill, not a saved tag (#1201) --- crates/engine/src/cmd/ideas.rs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index 96d85fff..42951131 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -84,9 +84,6 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] { } } -/// Marks a title whose fill was put there by the full-bleed idea. -const BAND_TAG: &str = "deckcraft.ideaBand"; - /// The light fill behind the title in the full-bleed idea. fn band_fill() -> Fill { Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))) @@ -189,17 +186,11 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st slide.shapes.insert(0, pic); } if let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { - let marked = t.tags.iter().any(|(k, _)| k == BAND_TAG); if band { t.fill = Some(band_fill()); - if !marked { - t.tags.push((BAND_TAG.into(), "1".into())); - } - } else if marked { - // The band belongs to full-bleed only: another idea takes it away again. A fill the - // user set (no mark) stays. + } else if t.fill == Some(band_fill()) { + // The band belongs to full-bleed only: another idea takes it away again. t.fill = None; - t.tags.retain(|(k, _)| k != BAND_TAG); } } Ok(()) @@ -351,10 +342,6 @@ mod tests { assert!(user.is_some()); s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); assert_eq!(shape(&s, 500).fill, user); - let mut s = deck(true); - Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[0].fill = Some(band_fill()); - s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); - assert_eq!(shape(&s, 500).fill, Some(band_fill()), "not added by an idea, so not removed by one"); } #[test] From 49f296cca18cb5b932f1ac92a13bbe2d25793519 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:25:46 +0200 Subject: [PATCH 4/6] Design Ideas: Full-bleed puts the band on the visible title, not a hidden one (#1201) --- crates/engine/src/cmd/ideas.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index 42951131..f9785b8a 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -185,7 +185,7 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st let pic = slide.shapes.remove(p); slide.shapes.insert(0, pic); } - if let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { + if let Some(t) = slide.shapes.iter_mut().find(|s| !s.hidden && s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { if band { t.fill = Some(band_fill()); } else if t.fill == Some(band_fill()) { @@ -333,6 +333,28 @@ mod tests { assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only"); } + #[test] + fn the_band_goes_on_the_visible_title_not_a_hidden_one_before_it() { + let mut s = deck(true); + let st = s.doc_mut().unwrap(); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]); + let hidden = Shape { + id: ShapeId(503), + hidden: true, + ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }), + xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)), + text: Some(deckcraft_model::text::TextBody::from_text("Old title")), + ..Default::default() + }; + slide.shapes.insert(0, hidden); + s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); + assert_eq!(shape(&s, 500).fill, Some(band_fill()), "visible title gets the band"); + assert!(shape(&s, 503).fill.is_none(), "hidden title is untouched"); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + assert!(shape(&s, 500).fill.is_none(), "switching clears the visible title's band"); + assert!(shape(&s, 503).fill.is_none()); + } + #[test] fn a_title_fill_set_by_the_user_survives_other_ideas() { // Set through the command, and set to exactly the band's own fill. From 8a61586585976a337f3513d39c384140a7c9ed60 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:08:09 +0200 Subject: [PATCH 5/6] Design Ideas: store the Full-bleed band's opacity as PPTX does, so switching ideas still removes it after a save (#1201) --- crates/engine/src/cmd/ideas.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index f9785b8a..2b23ebd8 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -84,9 +84,10 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] { } } -/// The light fill behind the title in the full-bleed idea. +/// The light fill behind the title in the full-bleed idea. Its opacity is an `alpha` transform +/// (about 220/255), as PPTX stores it, so the fill compares equal after a save and reopen. fn band_fill() -> Fill { - Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))) + Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(255, 255, 255)).with(deckcraft_model::ColorTransform::Alpha(86274))) } /// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes). @@ -355,6 +356,20 @@ mod tests { assert!(shape(&s, 503).fill.is_none()); } + #[test] + fn switching_ideas_removes_the_band_after_a_pptx_round_trip() { + let mut s = deck(true); + s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap(); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + s.doc_mut().unwrap().doc = Arc::new(back); + let title = s.doc().unwrap().doc.slides[0].shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.kind.is_title())).unwrap(); + let id = s.doc().unwrap().doc.slides[0].shapes[title].id.0; + assert_eq!(shape(&s, id).fill, Some(band_fill()), "band survives the save"); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + assert!(shape(&s, id).fill.is_none(), "the band is removed after reopening"); + } + #[test] fn a_title_fill_set_by_the_user_survives_other_ideas() { // Set through the command, and set to exactly the band's own fill. From ebf67f8a9a8f24b75cfab4712abac0c180eeb850 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:26:08 +0200 Subject: [PATCH 6/6] Design Ideas: switching ideas also removes a band saved by earlier versions (#1201) --- crates/engine/src/cmd/ideas.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/ideas.rs b/crates/engine/src/cmd/ideas.rs index 2b23ebd8..ac760d0f 100644 --- a/crates/engine/src/cmd/ideas.rs +++ b/crates/engine/src/cmd/ideas.rs @@ -90,6 +90,16 @@ fn band_fill() -> Fill { Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(255, 255, 255)).with(deckcraft_model::ColorTransform::Alpha(86274))) } +/// The band as released versions up to 0.10 stored it (opacity in the colour's own alpha). +fn legacy_band_fill() -> Fill { + Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))) +} + +/// Whether `f` is a Full-bleed band, in the current or the legacy form. +fn is_band(f: &Fill) -> bool { + *f == band_fill() || *f == legacy_band_fill() +} + /// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes). pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> { if !ideas_for(doc, index).iter().any(|i| i.0 == id) { @@ -189,7 +199,7 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st if let Some(t) = slide.shapes.iter_mut().find(|s| !s.hidden && s.ph.as_ref().is_some_and(|p| p.kind.is_title())) { if band { t.fill = Some(band_fill()); - } else if t.fill == Some(band_fill()) { + } else if t.fill.as_ref().is_some_and(is_band) { // The band belongs to full-bleed only: another idea takes it away again. t.fill = None; } @@ -370,6 +380,17 @@ mod tests { assert!(shape(&s, id).fill.is_none(), "the band is removed after reopening"); } + #[test] + fn switching_ideas_removes_a_band_saved_by_earlier_versions() { + let mut s = deck(true); + let legacy = Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))); + let st = s.doc_mut().unwrap(); + let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]); + slide.shapes.iter_mut().find(|x| x.id == ShapeId(500)).unwrap().fill = Some(legacy); + s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap(); + assert!(shape(&s, 500).fill.is_none()); + } + #[test] fn a_title_fill_set_by_the_user_survives_other_ideas() { // Set through the command, and set to exactly the band's own fill.