From 71352733d40c2e4ef285e1069bf2e47cd1936952 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:42:46 -0700 Subject: [PATCH 1/5] Format: Fill offers Slide background fill Format Shape's Fill section had no Slide background fill option, and shape.fill had no parameter for it. A shape opened from a file with useBgFill showed Automatic chosen. shape.fill {background: true} now fills a shape with the slide background, and Format Shape lists Slide background fill after Pattern fill. It has no Color button or Transparency slider. Saving writes useBgFill, and the slide background shows through the shape. --- ROADMAP.md | 1 + crates/engine/src/cmd/shape.rs | 33 +++++++++++++++++++++++++++++++-- crates/ui-egui/src/panes.rs | 24 ++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b52abe28..3ebc8766 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,6 +160,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions | | 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour | | 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names | +| 2026-10-25 | Format Shape ▸ Fill: Slide background fill shows the slide background through the shape (`shape.fill {background: true}`, saved as `useBgFill`). A shape opened from a file with this fill showed it as Automatic, and the pane offered no way to set it | | 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) | | 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 9ae6ae90..dfffc4ec 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", + "{color? | none?: true | background?: true (the slide background shows through) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", has_selection, fill ), @@ -561,6 +561,8 @@ fn fill(s: &mut Session, p: &Value) -> Result { Some(None) } else if bool_or(p, "none", false) { Some(Some(Fill::None)) + } else if bool_or(p, "background", false) { + Some(Some(Fill::Background)) } else if let Some(c) = color_param(p, "color") { Some(Some(Fill::solid(c))) } else if let Some(g) = p.get("gradient") { @@ -584,7 +586,10 @@ fn fill(s: &mut Session, p: &Value) -> Result { let transparency = f64_param(p, "transparency"); let pic_props = p.get("pictureProps").filter(|v| v.is_object()); if new_fill.is_none() && transparency.is_none() && pic_props.is_none() { - return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`")); + return Err(bad( + "shape.fill", + "give `color`, `none`, `background`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`", + )); } // Transparency on an inherited (style) fill needs the effective colour. let st = s.doc()?; @@ -1616,6 +1621,30 @@ mod tests { } } + #[test] + fn background_fill_shows_the_slide_background_and_survives_a_save() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("design.background", &json!({"color": "#0000c8"})).unwrap(); + let id = ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap() as u32); + let inside = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 200, "y": 150})).unwrap()["color"].clone(); + assert_ne!(inside(&mut s), "0000C8", "a new shape takes the theme's fill"); + s.execute("shape.fill", &json!({"background": true})).unwrap(); + assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background)); + assert_eq!(inside(&mut s), "0000C8", "the slide background shows through the shape"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, None, "one undo step returns to the theme's fill"); + s.execute("edit.redo", &json!({})).unwrap(); + // Transparency changes a solid or picture fill only. + s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap(); + assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background)); + + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let filled = back.slides.iter().flat_map(|sl| sl.shapes.iter()).filter(|sh| sh.fill == Some(Fill::Background)).count(); + assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen"); + } + fn rotation(s: &Session) -> f64 { let st = s.active().unwrap(); let id = st.selection.shapes[0]; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 74482f93..9f7dbfd7 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -456,6 +456,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Gradient(_)) => "gradient", Some(Fill::Picture(_)) => "picture", Some(Fill::Pattern(_)) => "pattern", + Some(Fill::Background) => "background", _ => "auto", }; for (l, k) in [ @@ -464,6 +465,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ("Gradient fill", "gradient"), ("Picture or texture fill", "picture"), ("Pattern fill", "pattern"), + ("Slide background fill", "background"), ("Automatic", "auto"), ] { if ui.radio(kind == k, l).clicked() { @@ -477,6 +479,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ), "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), "picture" => pick_fill_picture(app), + "background" => run(app, "shape.fill", json!({"background": true})), _ => run(app, "shape.fill", json!({"reset": true})), } } @@ -1679,6 +1682,27 @@ mod tests { h.run_steps(2); assert!(alpha(&h).is_some_and(|a| (a - 0.6).abs() < 1e-9), "{:?}", alpha(&h)); } + + /// Slide background fill is one of the Fill options. It has no Color button or Transparency + /// slider of its own. + #[test] + fn slide_background_fill_is_a_fill_option() { + let mut h = fill_pane(None); + let fill = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().selected_shapes()[0].fill.clone(); + let undo = h.state().session.active().unwrap().history.undo.len(); + h.get_by_label("Slide background fill").click(); + h.run_steps(3); + assert_eq!(fill(&h), Some(Fill::Background)); + assert_eq!(h.state().session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(h.get_by_label("Slide background fill").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(color_buttons(&h), 1, "only the line's Color button"); + assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(), 0); + // A shape that already has the background fill shows this option chosen, not Automatic. + let h = fill_pane(Some(json!({"background": true}))); + assert_eq!(h.get_by_label("Slide background fill").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + let automatic = h.query_all_by_label("Automatic").filter(|n| n.accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)).count(); + assert_eq!(automatic, 1, "only the line's Automatic is chosen"); + } } /// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e869c2af..22956e57 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -65,7 +65,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), pattern fill (gallery of the 48 presets PowerPoint offers, foreground and background colors), line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), pattern fill (gallery of the 48 presets PowerPoint offers, foreground and background colors), slide background fill, line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) | | 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 | | Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | 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 | From aef364867d5a316780e1c5b5370e81f6294266ce Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 01:56:23 -0700 Subject: [PATCH 2/5] Format: Slide background fill applies to shapes and text boxes only shape.fill {background: true} leaves the fill of pictures, connectors, tables and charts unchanged. Only a shape or text box (p:sp) saves useBgFill in a .pptx. With a group selected, its shapes and text boxes take the fill and the group's own fill is cleared. When no selected object can take the fill, the command returns an error. The Format pane leaves Slide background fill out of a picture's Fill options, and shows it chosen for a group whose shapes and text boxes all have it. --- crates/engine/src/cmd/shape.rs | 55 +++++++++++++++++++++++++- crates/ui-egui/src/panes.rs | 72 +++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index dfffc4ec..a6cc6036 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | background?: true (the slide background shows through) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", + "{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", has_selection, fill ), @@ -612,7 +612,27 @@ fn fill(s: &mut Session, p: &Value) -> Result { } fills }; + // Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures, + // connectors, tables and charts keep their fill, and a group's own fill is cleared. + let background = matches!(new_fill, Some(Some(Fill::Background))); + if background { + let mut any = false; + for id in targets(s, p)? { + if let Some(sh) = st.shape(id) { + deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| any |= matches!(sh.kind, deckcraft_model::ShapeKind::Shape)); + } + } + if !any { + return Err(bad("shape.fill", "slide background fill applies to shapes and text boxes")); + } + } edit_group_appearance(s, p, "shape.fill", |sh| { + if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) { + if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) { + sh.fill = None; + } + return Ok(()); + } if let Some(f) = &new_fill { sh.fill.clone_from(f); } @@ -1645,6 +1665,39 @@ mod tests { assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen"); } + /// A picture cannot save a slide background fill, so `background` leaves its fill alone. + #[test] + fn background_fill_skips_pictures() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap(); + let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png(); + let pic = s + .execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "red.png", "rect": [400, 100, 100, 100]})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone(); + let err = s.execute("shape.fill", &json!({"background": true, "ids": [pic]})).unwrap_err(); + assert!(err.to_string().contains("shapes and text boxes"), "{err}"); + assert_eq!(fill(&s, pic), None); + + s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap(); + let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.fill", &json!({"background": true})).unwrap(); + assert_eq!(fill(&s, shape), Some(Fill::Background)); + assert!(matches!(fill(&s, pic), Some(Fill::Solid { .. })), "the picture keeps its fill"); + assert_eq!(fill(&s, group), None, "the group's own fill is cleared"); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let mut filled = 0; + for slide in &back.slides { + deckcraft_model::walk(&slide.shapes, &mut |sh, _| filled += usize::from(sh.fill == Some(Fill::Background))); + } + assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen"); + } + fn rotation(s: &Session) -> f64 { let st = s.active().unwrap(); let id = st.selection.shapes[0]; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 9f7dbfd7..42cad56a 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -449,7 +449,20 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { _ => { // Fill & Line. ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + // A group keeps no slide background fill of its own. It shows Slide background fill + // when every shape or text box in it has one. + let members_background = sh.fill.is_none() && matches!(sh.kind, ShapeKind::Group { .. }) && { + let (mut any, mut all) = (false, true); + deckcraft_model::walk(sh.children(), &mut |m, _| { + if matches!(m.kind, ShapeKind::Shape) { + any = true; + all &= m.fill == Some(Fill::Background); + } + }); + any && all + }; let kind = match &sh.fill { + None if members_background => "background", None => "auto", Some(Fill::None) => "none", Some(Fill::Solid { .. }) => "solid", @@ -459,6 +472,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Background) => "background", _ => "auto", }; + // Only a shape or text box saves a slide background fill. A group offers it when it + // holds one. + let mut background_ok = false; + if let Some(st) = app.session.active() { + for s in st.selected_shapes() { + deckcraft_model::walk(std::slice::from_ref(s), &mut |m, _| background_ok |= matches!(m.kind, ShapeKind::Shape)); + } + } for (l, k) in [ ("No fill", "none"), ("Solid fill", "solid"), @@ -468,6 +489,9 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ("Slide background fill", "background"), ("Automatic", "auto"), ] { + if k == "background" && !background_ok { + continue; + } if ui.radio(kind == k, l).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), @@ -495,7 +519,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } // `shape.fill {transparency}` changes a solid fill (an automatic one through its // effective color) and a picture fill; a gradient has a transparency per stop. - let transparency = matches!(sh.fill, None | Some(Fill::Solid { .. } | Fill::Picture(_))); + let transparency = !members_background && matches!(sh.fill, None | Some(Fill::Solid { .. } | Fill::Picture(_))); ui.horizontal(|ui| { if !transparency { return; @@ -1703,6 +1727,52 @@ mod tests { let automatic = h.query_all_by_label("Automatic").filter(|n| n.accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)).count(); assert_eq!(automatic, 1, "only the line's Automatic is chosen"); } + + /// A picture cannot save a slide background fill, so its Fill options leave it out. A group + /// that holds a shape keeps the option. + #[test] + fn a_picture_has_no_slide_background_fill_option() { + let pane = |group: bool| { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png(); + let pic = + s.execute("insert.picture", &json!({"data": deckcraft_engine::cmd::base64_encode(&png), "name": "red.png"})).unwrap()["id"].clone(); + if group { + let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [500.0, 40.0, 120.0, 80.0]})).unwrap()["id"].clone(); + s.execute("edit.select", &json!({"ids": [pic, shape]})).unwrap(); + s.execute("arrange.group", &json!({})).unwrap(); + s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + } + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("format".into()); + app.ui.format_tab = "fill".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h + }; + let h = pane(false); + assert!(h.query_by_label("Pattern fill").is_some(), "the Fill options are shown"); + assert!(h.query_by_label("Slide background fill").is_none()); + let mut h = pane(true); + h.get_by_label("Slide background fill").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let group = st.selected_shapes()[0]; + let fills: Vec<_> = group.children().iter().map(|m| m.fill.clone()).collect(); + assert_eq!(fills[1], Some(Fill::Background), "the group's shape takes the fill"); + assert!(matches!(fills[0], Some(Fill::Solid { .. })), "the picture keeps its fill: {:?}", fills[0]); + assert_eq!(group.fill, None, "the group's own fill is cleared"); + assert_eq!(h.get_by_label("Slide background fill").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(), 0); + assert_eq!(color_buttons(&h), 1, "only the line's Color button"); + } } /// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word From 6396c480b21d49d731edac5294486c8f6e07b986 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 02:38:34 -0700 Subject: [PATCH 3/5] Format: Slide background fill keeps a group fill that a picture uses When a group's shapes take the slide background fill, the group's own fill stays when a picture, connector, table or chart in it is filled with the group fill (grpFill), so that member draws as before. --- crates/engine/src/cmd/shape.rs | 53 ++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index a6cc6036..e02cb9c6 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -613,7 +613,16 @@ fn fill(s: &mut Session, p: &Value) -> Result { fills }; // Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures, - // connectors, tables and charts keep their fill, and a group's own fill is cleared. + // connectors, tables and charts keep their fill, and a group's own fill is cleared unless one + // of them is filled with it (`grpFill`). + fn group_fill_used(sh: &deckcraft_model::Shape, depth: usize) -> bool { + depth <= 64 + && sh.children().iter().any(|c| match c.kind { + deckcraft_model::ShapeKind::Shape => false, + deckcraft_model::ShapeKind::Group { .. } => matches!(c.fill, None | Some(Fill::Group)) && group_fill_used(c, depth + 1), + _ => c.fill == Some(Fill::Group), + }) + } let background = matches!(new_fill, Some(Some(Fill::Background))); if background { let mut any = false; @@ -628,7 +637,7 @@ fn fill(s: &mut Session, p: &Value) -> Result { } edit_group_appearance(s, p, "shape.fill", |sh| { if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) { - if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) { + if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) && !group_fill_used(sh, 0) { sh.fill = None; } return Ok(()); @@ -1698,6 +1707,46 @@ mod tests { assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen"); } + /// A picture filled with its group's fill keeps that fill behind its transparent pixels when + /// the group's shapes take the slide background fill. + #[test] + fn background_fill_keeps_the_group_fill_a_picture_uses() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap(); + let png = deckcraft_render::Image { width: 2, height: 2, pixels: [0, 0, 0, 0].repeat(4) }.to_png(); + let pic = s + .execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "clear.png", "rect": [400, 100, 100, 100]})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap(); + let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))); + s.edit(|doc, _| { + for slide in &mut doc.slides { + let slide = std::sync::Arc::make_mut(slide); + if let Some(g) = slide.shape_mut(ShapeId(group as u32)) { + g.fill.clone_from(&red); + } + if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) { + p.fill = Some(Fill::Group); + } + } + Ok(()) + }) + .unwrap(); + let behind = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 450, "y": 150})).unwrap()["color"].clone(); + assert_eq!(behind(&mut s), "FF0000", "the group's fill shows through the picture"); + s.execute("edit.select", &json!({"ids": [group]})).unwrap(); + s.execute("shape.fill", &json!({"background": true})).unwrap(); + let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone(); + assert_eq!(fill(&s, shape), Some(Fill::Background)); + assert_eq!(fill(&s, pic), Some(Fill::Group)); + assert_eq!(fill(&s, group), red, "the group keeps the fill its picture uses"); + assert_eq!(behind(&mut s), "FF0000", "the group's fill still shows through the picture"); + } + fn rotation(s: &Session) -> f64 { let st = s.active().unwrap(); let id = st.selection.shapes[0]; From fca0492725b6371c4306376783cdc4845b3be770 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 04:10:38 -0700 Subject: [PATCH 4/5] Format: Fill shows Slide background fill for a group that keeps its fill A group whose picture uses the group's fill keeps that fill when its shapes take the slide background fill. The pane now shows Slide background fill chosen for that group. --- crates/ui-egui/src/panels.rs | 46 +++++++++++++++++++++++ crates/ui-egui/src/panels/format_shape.rs | 6 +-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 048b8718..a7dc4f17 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -367,6 +367,52 @@ mod tests { assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(), 0); assert_eq!(color_buttons(&h), 1, "only the line's Color button"); } + + /// A group whose picture uses the group's fill keeps that fill, and still shows Slide + /// background fill chosen once its shapes have it. + #[test] + fn a_group_that_keeps_its_fill_shows_slide_background_fill() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let png = deckcraft_render::Image { width: 2, height: 2, pixels: [0, 0, 0, 0].repeat(4) }.to_png(); + let pic = s.execute("insert.picture", &json!({"data": deckcraft_engine::cmd::base64_encode(&png), "name": "clear.png"})).unwrap()["id"] + .as_u64() + .unwrap(); + let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [500.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [pic, shape]})).unwrap(); + let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + s.edit(|doc, _| { + for slide in &mut doc.slides { + let slide = std::sync::Arc::make_mut(slide); + if let Some(g) = slide.shape_mut(ShapeId(group as u32)) { + g.fill.clone_from(&red); + } + if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) { + p.fill = Some(Fill::Group); + } + } + Ok(()) + }) + .unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("format".into()); + app.ui.format_tab = "fill".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Slide background fill").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().selected_shapes()[0].fill, red, "the group keeps the fill its picture uses"); + assert_eq!(h.get_by_label("Slide background fill").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(), 0); + assert_eq!(color_buttons(&h), 1, "only the line's Color button"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs index 74766a70..9b944ee5 100644 --- a/crates/ui-egui/src/panels/format_shape.rs +++ b/crates/ui-egui/src/panels/format_shape.rs @@ -134,9 +134,9 @@ fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { } /// A group keeps no slide background fill of its own. It shows Slide background fill when every -/// shape or text box in it has one. +/// shape or text box in it has one, including when the group keeps a fill that a picture in it uses. fn members_have_background_fill(sh: &deckcraft_model::Shape) -> bool { - if sh.fill.is_some() || !matches!(sh.kind, ShapeKind::Group { .. }) { + if !matches!(sh.kind, ShapeKind::Group { .. }) { return false; } let (mut any, mut all) = (false, true); @@ -298,7 +298,7 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); let members_background = members_have_background_fill(&sh); let kind = match &sh.fill { - None if members_background => "background", + _ if members_background => "background", None => "auto", Some(Fill::None) => "none", Some(Fill::Solid { .. }) => "solid", From edbf542570174eba945311f9c323e7f7bf5abb51 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 04:15:58 -0700 Subject: [PATCH 5/5] Format: Fill hides the kept group fill's editors with Slide background fill A group that keeps a gradient, pattern or picture fill for its pictures no longer shows that fill's editor while its shapes have the slide background fill, so editing it cannot replace their fill. --- crates/ui-egui/src/panels.rs | 12 ++++++------ crates/ui-egui/src/panels/format_shape.rs | 9 ++++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a7dc4f17..391ea093 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -380,14 +380,11 @@ mod tests { .unwrap(); let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [500.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.select", &json!({"ids": [pic, shape]})).unwrap(); - let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); - let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + s.execute("arrange.group", &json!({})).unwrap(); + s.execute("shape.fill", &json!({"gradient": {"stops": [[0, "#ff0000"], [1, "#0000ff"]]}})).unwrap(); s.edit(|doc, _| { for slide in &mut doc.slides { let slide = std::sync::Arc::make_mut(slide); - if let Some(g) = slide.shape_mut(ShapeId(group as u32)) { - g.fill.clone_from(&red); - } if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) { p.fill = Some(Fill::Group); } @@ -408,8 +405,11 @@ mod tests { h.run_steps(3); h.get_by_label("Slide background fill").click(); h.run_steps(3); - assert_eq!(h.state().session.active().unwrap().selected_shapes()[0].fill, red, "the group keeps the fill its picture uses"); + let st = h.state().session.active().unwrap(); + assert!(matches!(st.selected_shapes()[0].fill, Some(Fill::Gradient(_))), "the group keeps the fill its picture uses"); + assert_eq!(st.shape(ShapeId(shape as u32)).unwrap().fill, Some(Fill::Background)); assert_eq!(h.get_by_label("Slide background fill").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert!(h.query_by_label("Rotate with shape").is_none(), "no editor for the group's kept gradient"); assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(), 0); assert_eq!(color_buttons(&h), 1, "only the line's Color button"); } diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs index 9b944ee5..03a2d2c9 100644 --- a/crates/ui-egui/src/panels/format_shape.rs +++ b/crates/ui-egui/src/panels/format_shape.rs @@ -372,10 +372,13 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, v)); } }); - if let Some(Fill::Picture(pf)) = &sh.fill { + // A group that keeps a fill for its pictures shows no editor for that fill while its + // shapes have the slide background fill. + let shown_fill = sh.fill.as_ref().filter(|_| !members_background); + if let Some(Fill::Picture(pf)) = shown_fill { picture_fill_options(app, ui, sh.id.0, pf); } - if let Some(Fill::Gradient(g)) = &sh.fill { + if let Some(Fill::Gradient(g)) = shown_fill { let sc = scheme(app); if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); @@ -386,7 +389,7 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); } } - if let Some(Fill::Pattern(pt)) = &sh.fill + if let Some(Fill::Pattern(pt)) = shown_fill && let Some(np) = crate::fillui::pattern_editor(ui, pt, &scheme(app)) { run(app, "shape.fill", json!({"pattern": crate::fillui::pattern_json(&np)}));