Merge pull request #1187 from samirhvbr/fix/group-shape-appearance

This commit is contained in:
Brandon Thomas
2026-10-23 17:10:42 -04:00
3 changed files with 136 additions and 23 deletions
+2
View File
@@ -3,6 +3,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **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
@@ -139,6 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 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 |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
+131 -23
View File
@@ -1,6 +1,8 @@
//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles,
//! names and alt text.
use std::collections::HashMap;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
@@ -85,7 +87,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",
"{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",
has_selection,
fill
),
@@ -94,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Outline",
["Shape Format", "Shape Styles"],
None,
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}",
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",
has_selection,
line
),
@@ -533,6 +535,24 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
}
/// Fill and outline apply to a group's visible members as well as its container. Other edits
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
if depth > 64 {
return Ok(());
}
f(sh)?;
if let Some(children) = sh.children_mut() {
for child in children {
apply(child, depth + 1, f)?;
}
}
Ok(())
}
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
}
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let new_fill: Option<Option<Fill>> = if bool_or(p, "reset", false) {
Some(None)
@@ -565,27 +585,26 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
}
// Transparency on an inherited (style) fill needs the effective colour.
let st = s.doc()?;
let ctx_fill: Vec<(deckcraft_model::ShapeId, Option<Fill>)> = {
let ctx_fill = {
let ctx = super::text::ctx_for(&st.doc, &st.selection);
targets(s, p)?
.into_iter()
.filter_map(|id| {
st.shape(id).map(|sh| {
(
id,
ctx.as_ref().and_then(|c| {
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
f.map(|f| match f {
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
other => other,
})
}),
)
})
})
.collect()
let mut fills = HashMap::new();
for id in targets(s, p)? {
if let Some(sh) = st.shape(id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| {
let fill = ctx.as_ref().and_then(|c| {
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
f.map(|f| match f {
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
other => other,
})
});
fills.insert(sh.id, fill);
});
}
}
fills
};
edit_shapes(s, p, "shape.fill", |sh| {
edit_group_appearance(s, p, "shape.fill", |sh| {
if let Some(f) = &new_fill {
sh.fill.clone_from(f);
}
@@ -598,7 +617,7 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(());
}
if let Some(t) = transparency {
let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone()));
let base = sh.fill.clone().or_else(|| ctx_fill.get(&sh.id).cloned().flatten());
if let Some(Fill::Solid { mut color }) = base {
color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if t > 0.0 {
@@ -690,7 +709,7 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
"miter" => deckcraft_model::style::LineJoin::Miter,
_ => deckcraft_model::style::LineJoin::Round,
});
edit_shapes(s, p, "shape.line", |sh| {
edit_group_appearance(s, p, "shape.line", |sh| {
if reset {
sh.line = None;
return Ok(());
@@ -1503,6 +1522,95 @@ mod tests {
use super::*;
use serde_json::json;
fn nested_group() -> (Session, Vec<ShapeId>, ShapeId, ShapeId) {
let mut s = Session::with_new();
let mut ids = vec![];
for i in 0..4 {
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40 + i * 100, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.line", &json!({"width": i + 1, "dash": "dash"})).unwrap();
ids.push(ShapeId(id as u32));
}
let group = |s: &mut Session, ids: &[ShapeId]| {
s.execute("edit.select", &json!({"ids": ids})).unwrap();
ShapeId(s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap() as u32)
};
let inner = group(&mut s, &ids[..2]);
let outer = group(&mut s, &[inner, ids[2]]);
(s, ids[..3].to_vec(), outer, ids[3])
}
#[test]
fn group_fill_reaches_nested_members_in_one_undo_step() {
let (mut s, members, group, outside) = nested_group();
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
let untouched = serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap();
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
for id in &members {
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))));
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![group]);
assert_eq!(serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap(), untouched);
let filled = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), filled);
s.execute("shape.fill", &json!({"none": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill == Some(Fill::None)));
s.execute("shape.fill", &json!({"reset": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill.is_none()));
}
#[test]
fn group_outline_reaches_members_without_resetting_their_other_properties() {
let (mut s, members, group, outside) = nested_group();
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
// Explicit ids work even when another object is selected.
s.execute("edit.select", &json!({"ids": [outside]})).unwrap();
s.execute("shape.line", &json!({"ids": [group], "color": "#00ff00"})).unwrap();
for (i, id) in members.iter().enumerate() {
let line = s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap();
assert_eq!(line.fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 255, 0)))));
assert_eq!(line.width, Some((i + 1) as f64));
assert_eq!(line.dash, Some(Dash::Preset { name: "dash".into() }));
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![outside]);
let outlined = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), outlined);
s.execute("shape.line", &json!({"ids": [group], "none": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap().fill == Some(Fill::None)));
s.execute("shape.line", &json!({"ids": [group], "reset": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.is_none()));
}
#[test]
fn group_fill_transparency_keeps_each_members_inherited_colour() {
let (mut s, members, group, _) = nested_group();
s.execute("shape.fill", &json!({"ids": [members[1]], "color": "#00ff00"})).unwrap();
let colours: Vec<_> = {
let st = s.doc().unwrap();
let ctx = super::super::text::ctx_for(&st.doc, &st.selection).unwrap();
members
.iter()
.map(|id| {
let (fill, ph) = deckcraft_model::resolve::fill(&ctx, st.shape(*id).unwrap());
let Some(Fill::Solid { color }) = fill else { panic!("solid theme fill") };
ctx.color(&color, ph)
})
.collect()
};
s.execute("shape.fill", &json!({"ids": [group], "transparency": 0.5})).unwrap();
for (id, colour) in members.iter().zip(colours) {
let mut expected = ColorRef::rgb(colour);
expected.mods.push(ColorTransform::Alpha(50000));
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(expected)));
}
}
fn rotation(s: &Session) -> f64 {
let st = s.active().unwrap();
let id = st.selection.shapes[0];
+3
View File
@@ -1,6 +1,7 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **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
@@ -95,6 +96,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Drag the frame of a text box being edited to move it | yes | yes |
| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells |
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
| Group fill and outline | formats the members | Shape Fill and Shape Outline reach nested members in one undo step; a transparency-only fill edit retains each member's colour (#1079) |
| WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. Adjust handles: see WordArt adjust handles |
| Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog |
| Drag and drop of files and between thumbnails | yes | yes |
@@ -168,6 +170,7 @@ consistency is open (#73, #56).
|---|---|---|
| 2026-10-23 | trivial | Scroll bars on the zoomed slide pane (#1157) |
| 2026-10-23 | minor | Shape Fill and Shape Outline (Home and Shape Format): the button applies the last colour picked from its menu, Accent 1 and Text 1 until one is picked. Line weight and dashes are not changed by the button; Font Color and Highlight stay as they were (#1080) |
| 2026-10-22 | trivial | Group fill and outline commands reach members, including nested groups; selection and single-step undo are preserved (#1079). The existing Arrange / Group checklist score stays D |
| 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |