Browse / github.com/storytold/deckcraft / patch
Format: Fill offers Slide background fill
Format: Fill offers Slide background fill Format Shape ▸ Fill had no Slide background fill option, and `shape.fill` had no parameter for it. The model already had the fill (`Fill::Background`), PPTX read and wrote it as `useBgFill`, and the renderer showed the slide background through such a shape (#274). A shape opened from a file with this fill showed Automatic chosen. The Fill pane documentation lists "Slide background fill" after Pattern fill: "To use the presentation background as the fill for a shape, line, or character, click this option." It lists no other options for it ([Format Object (Fill pane)](https://web.archive.org/we
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #1397, open)
- Base
13ed8889da1ddd336da50e8f981a36d7236d9295- Size
- +230 −4 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f307d4331855f43ba5f126dc
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/shape.rs crates/ui-egui/src/panes.rs docs/ui-parity.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 |@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> { "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; 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 ),@@ -561,6 +561,8 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> { 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<Value> { 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()?;@@ -607,7 +612,36 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> { } 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 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;+ 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 { .. }) && !group_fill_used(sh, 0) {+ sh.fill = None;+ }+ return Ok(());+ } if let Some(f) = &new_fill { sh.fill.clone_from(f); }@@ -1616,6 +1650,103 @@ 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");+ }++ /// 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");+ }++ /// 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];@@ -449,23 +449,49 @@ 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", Some(Fill::Gradient(_)) => "gradient", Some(Fill::Picture(_)) => "picture", Some(Fill::Pattern(_)) => "pattern",+ 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"), ("Gradient fill", "gradient"), ("Picture or texture fill", "picture"), ("Pattern fill", "pattern"),+ ("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})),@@ -477,6 +503,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})), } }@@ -492,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;@@ -1679,6 +1706,73 @@ 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");+ }++ /// 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@@ -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 |