From 5a060c8b48c9f1594bffb2c36611f0e7fbb11c15 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:22:31 +0300 Subject: [PATCH] Design: Variants gallery with four colour variations of the theme PowerPoint's Design tab shows the current theme in four variants. The Variants group had only the Colors and Fonts menus. theme_variants builds four colour schemes from the theme's built-in colours (as designed, two accent rotations, and a dark variant with light and dark swapped), so switching between variants doesn't drift. design.variant applies one to the master; design.variants reports them and which is applied. The Design tab shows them as a gallery before Colors and Fonts. Co-authored-by: Cursor --- crates/engine/src/cmd/design.rs | 28 ++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/model/src/theme.rs | 25 +++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 23 +++++++++++++++++++++++ docs/parity-checklist.md | 8 ++++---- 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 3cad3f00..8f7381b8 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -17,6 +17,8 @@ pub fn specs() -> Vec { cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), cmd!(query "design.themes", "Theme Gallery", [], None, "{} → themes, colour schemes and font schemes", always, themes), cmd!("design.colors", "Colors", ["Design", "Variants"], None, "{name} | {colors: {accent1: #RRGGBB, …}}", has_doc, colors), + cmd!("design.variant", "Variants", ["Design", "Variants"], None, "{index: 0..3} (see design.variants)", has_doc, variant), + cmd!(query "design.variants", "Variant Gallery", [], None, "{} → the current theme's four colour variants and which is applied", has_doc, variants), cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{name} | {major, minor}", has_doc, fonts), cmd!( "design.background", @@ -125,6 +127,32 @@ fn colors(s: &mut Session, p: &Value) -> Result { ok() } +fn variant(s: &mut Session, p: &Value) -> Result { + let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize; + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?); + let v = deckcraft_model::theme::theme_variants(&m.theme); + m.theme.colors = + v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?; + Ok(()) + })?; + Ok(json!({"variant": index})) +} + +fn variants(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default(); + let v = deckcraft_model::theme::theme_variants(&th); + let current = v.iter().position(|c| c.colors == th.colors.colors); + Ok(json!({ + "theme": th.name, + "current": current, + "variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>()})).collect::>(), + })) +} + fn fonts(s: &mut Session, p: &Value) -> Result { let scheme = if let Some(name) = str_param(p, "name") { builtin_font_schemes() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..ceb97c26 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,31 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn theme_variants_apply_and_report_the_current_one() { + let mut s = session(); + let name = deckcraft_model::theme::builtin_themes()[1].name.clone(); + s.execute("design.theme", &json!({"name": name})).unwrap(); + let v = s.execute("design.variants", &json!({})).unwrap(); + assert_eq!(v["variants"].as_array().unwrap().len(), 4); + assert_eq!(v["current"], json!(0)); + let base = s.doc().unwrap().doc.masters[0].theme.colors.clone(); + s.execute("design.variant", &json!({"index": 1})).unwrap(); + let c = s.doc().unwrap().doc.masters[0].theme.colors.clone(); + assert_eq!(c.colors[4], base.colors[5], "accents rotate"); + assert_eq!(c.colors[0], base.colors[0]); + assert_eq!(s.execute("design.variants", &json!({})).unwrap()["current"], json!(1)); + // From a variant, another variant is built from the theme's own colours, not the variant's. + s.execute("design.variant", &json!({"index": 3})).unwrap(); + let d = s.doc().unwrap().doc.masters[0].theme.colors.clone(); + assert_eq!((d.colors[0], d.colors[1], d.colors[4]), (base.colors[1], base.colors[0], base.colors[4])); + s.execute("design.variant", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme.colors.colors, base.colors); + assert!(s.execute("design.variant", &json!({"index": 9})).is_err()); + // It survives PPTX. + s.execute("design.variant", &json!({"index": 2})).unwrap(); + let p = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).unwrap()).unwrap(); + assert_eq!(back.masters[0].theme.colors.colors, p.masters[0].theme.colors.colors); +} diff --git a/crates/model/src/theme.rs b/crates/model/src/theme.rs index 582f0728..d25b99bb 100644 --- a/crates/model/src/theme.rs +++ b/crates/model/src/theme.rs @@ -150,6 +150,31 @@ pub fn default_format() -> FormatScheme { } /// Our original built-in themes (shown in Design ▸ Themes). Colours and names are our own. +/// Design ▸ Variants: four colour variations of `t`, built from its built-in colours when it is +/// a built-in theme (so picking one variant after another doesn't drift), else from its own. +/// 1: as designed; 2 and 3: the accents rotated; 4: dark (light and dark colours swapped). +pub fn theme_variants(t: &Theme) -> Vec { + let base = builtin_themes().into_iter().find(|b| b.name == t.name).map_or_else(|| t.colors.clone(), |b| b.colors); + let rotate = |by: usize| { + let mut c = base.clone(); + let accents: Vec<_> = base.colors.get(4..10).map(<[_]>::to_vec).unwrap_or_default(); + for (i, slot) in c.colors.iter_mut().skip(4).take(6).enumerate() { + if let Some(a) = accents.get((i + by) % accents.len().max(1)) { + *slot = *a; + } + } + c + }; + let mut dark = base.clone(); + dark.colors.swap(0, 1); + dark.colors.swap(2, 3); + let mut out = vec![base.clone(), rotate(1), rotate(3), dark]; + for (i, c) in out.iter_mut().enumerate().skip(1) { + c.name = format!("{} {}", base.name, i + 1); + } + out +} + pub fn builtin_themes() -> Vec { let t = |name: &str, colors: ColorScheme, major: &str, minor: &str| Theme { name: name.into(), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..36cf72d3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1233,6 +1233,29 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.theme", json!({"name": n})); } }); + // Variants: the current theme in its four colour variations. + if let Some(th) = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.clone())) { + let variants = deckcraft_model::theme::theme_variants(&th); + let tiles: Vec = variants.iter().map(|c| deckcraft_model::Theme { colors: c.clone(), ..th.clone() }).collect(); + let names: Vec = variants.iter().map(|c| c.name.clone()).collect(); + let sel = variants.iter().position(|c| c.colors == th.colors.colors); + group(ui, |ui| { + if let Some(i) = widgets::gallery( + ui, + tiles.len(), + vec2(84.0, 56.0), + sel, + |p, r, i| { + if let Some(t) = tiles.get(i) { + paint_theme_tile(p, r, t); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) { + run(app, "design.variant", json!({"index": i})); + } + }); + } group(ui, |ui| { let r = big_button(ui, Icon::Colors, "Colors", true); egui::Popup::menu(&r).width(240.0).show(|ui| { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..ef8068b5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,12 +3,12 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 80%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 38 | 21 | 6 | 75% | +| P1 | 65 | 39 | 20 | 6 | 75% | | P2 | 54 | 13 | 15 | 26 | 38% | | Area | Parity | @@ -17,7 +17,7 @@ The summary between ` | Files | 71% | | Slides | 90% | | Views | 90% | -| Masters & themes | 85% | +| Masters & themes | 88% | | Shapes | 88% | | Format | 83% | | Arrange | 100% | @@ -94,7 +94,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Masters & themes | Insert/rename/delete layouts, insert placeholders | P1 | D | | Masters & themes | Handout and Notes masters | P2 | M | | Masters & themes | Built-in themes (original designs) | P0 | D | -| Masters & themes | Theme variants | P1 | P | +| Masters & themes | Theme variants | P1 | D | | Masters & themes | Colour schemes (built-in and custom) | P0 | D | | Masters & themes | Font schemes (built-in and custom) | P0 | D | | Masters & themes | Effect schemes / background styles | P2 | P |