From 65e465a61161957dafb15f253a6ea69b95ff7edf Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 05:24:47 +0300 Subject: [PATCH] UI: Create New Theme Colors dialog edits the theme in one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design › Colors › Customize Colors… opens the dialog when design.colors is called with no name and no colours. Save applies that scheme name and the twelve theme colours together, as one undo. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/engine/src/cmd/design.rs | 207 ++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 120 ++++++++++++- crates/ui-egui/src/ribbon.rs | 6 + crates/ui-egui/src/theme_colors_tests.rs | 136 +++++++++++++++ docs/architecture.md | 5 +- docs/gaps.md | 9 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 9 +- 9 files changed, 463 insertions(+), 40 deletions(-) create mode 100644 crates/ui-egui/src/theme_colors_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..1559e929 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; breadth and ready percentages unchanged; previous: hours to ~95% per audience) · **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 @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Design › Colors › Customize Colors… opens Create New Theme Colors: the open master's scheme name and twelve theme colours, applied in one undo. Command count stays 226; dialogs 27 → 28 | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Create New Theme Colors | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 3cad3f00..9725f521 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -16,7 +16,15 @@ pub fn specs() -> Vec { 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.colors", + "Colors", + ["Design", "Variants"], + None, + "{} opens Create New Theme Colors | {name} | {colors: {dk1|lt1|dk2|lt2|accent1..6|hlink|folHlink: #RRGGBB}}", + has_doc, + colors + ), cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{name} | {major, minor}", has_doc, fonts), cmd!( "design.background", @@ -95,36 +103,106 @@ fn themes(_s: &mut Session, _p: &Value) -> Result { } fn colors(s: &mut Session, p: &Value) -> Result { - let scheme = if let Some(name) = str_param(p, "name") { - Some( - builtin_color_schemes() - .into_iter() - .find(|c| c.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("design.colors", format!("no colour scheme `{name}`")))?, - ) - } else { - None + let name_v = p.get("name"); + let colors_v = p.get("colors"); + if name_v.is_none() && colors_v.is_none() { + let sc = current_colors(s)?; + s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) }); + return ok(); + } + let given = match name_v { + None => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() { + return Err(bad("design.colors", "name is empty")); + } + Some(n.to_string()) + } + Some(_) => return Err(bad("design.colors", "`name` must be a string")), }; - let custom = p.get("colors").and_then(Value::as_object).cloned(); + let slots = parse_color_slots(colors_v)?; + let current = current_colors(s)?; + let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?; + if next == current { + return ok(); + } 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.colors", "no master"))?); - if let Some(sc) = &scheme { - m.theme.colors = sc.clone(); - } - if let Some(c) = &custom { - for (k, v) in c { - if let (Some(slot), Some(rgb)) = (deckcraft_color::SchemeSlot::from_xml(k), v.as_str().and_then(deckcraft_color::Rgba::from_hex)) { - m.theme.colors.set(slot, rgb); - } - } - m.theme.colors.name = "Custom".into(); - } + m.theme.colors = next; Ok(()) })?; ok() } +fn current_colors(s: &Session) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master")) +} + +fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value { + let mut colors = serde_json::Map::new(); + for slot in deckcraft_color::SchemeSlot::THEME { + colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex()))); + } + json!({"name": sc.name, "colors": colors}) +} + +/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such +/// as `bg1` are rejected because `ColorScheme::set` ignores them. +fn parse_color_slots(colors_v: Option<&Value>) -> Result>> { + let Some(v) = colors_v else { return Ok(None) }; + let Some(map) = v.as_object() else { + return Err(bad("design.colors", "`colors` must be an object")); + }; + if map.is_empty() { + return Err(bad("design.colors", "`colors` is empty")); + } + let mut slots = Vec::with_capacity(map.len()); + for (k, v) in map { + let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else { + return Err(bad("design.colors", format!("unknown colour `{k}`"))); + }; + if !deckcraft_color::SchemeSlot::THEME.contains(&slot) { + return Err(bad("design.colors", format!("`{k}` is not a theme colour"))); + } + let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else { + return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB"))); + }; + slots.push((slot, rgb)); + } + Ok(Some(slots)) +} + +/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name +/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error. +fn next_scheme( + current: &deckcraft_color::ColorScheme, + given: Option<&str>, + slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>, +) -> Result { + let mut next = current.clone(); + if let Some(name) = given { + if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) { + next = builtin; + } else if slots.is_none() { + return Err(bad("design.colors", format!("no colour scheme `{name}`"))); + } + next.name = name.to_string(); + } + if let Some(changes) = slots { + for (slot, rgb) in changes { + next.set(*slot, *rgb); + } + if given.is_none() { + next.name = "Custom".into(); + } + } + Ok(next) +} + fn fonts(s: &mut Session, p: &Value) -> Result { let scheme = if let Some(name) = str_param(p, "name") { builtin_font_schemes() @@ -558,9 +636,94 @@ fn insert_placeholder(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + use deckcraft_color::SchemeSlot; + use deckcraft_model::theme::builtin_color_schemes; + use serde_json::{Value, json}; + + use crate::{Session, UiRequest}; + + fn scheme(s: &Session) -> deckcraft_color::ColorScheme { + s.doc().unwrap().doc.masters[0].theme.colors.clone() + } + + fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + #[test] fn civil_dates() { assert_eq!(super::civil(0), (1970, 1, 1)); assert_eq!(super::civil(20_734), (2026, 10, 8)); } + + #[test] + fn colors_dialog_apply_and_reject() { + let mut s = Session::with_new(); + let original = scheme(&s); + let n = undo_len(&s); + s.execute("design.colors", &json!({})).unwrap(); + s.execute("design.colors", &Value::Null).unwrap(); + assert_eq!(scheme(&s), original); + assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); + assert_eq!(s.ui_requests.len(), 2); + match &s.ui_requests[0] { + UiRequest::Dialog { id, params } => { + assert_eq!(id, "themeColors"); + assert_eq!(params["name"], "DeckCraft"); + let colors = params["colors"].as_object().unwrap(); + assert_eq!(colors.len(), 12); + assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex())); + assert!(colors.contains_key("folHlink")); + } + other => panic!("expected a dialog, got {other:?}"), + } + + s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); + let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap(); + assert_eq!(scheme(&s).name, "Harbor"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors"); + s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); + + let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string(); + assert!(err.contains("no colour scheme `Nope`"), "{err}"); + assert!(s.execute("design.colors", &json!({"name": " "})).is_err()); + assert!(s.execute("design.colors", &json!({"name": 1})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err()); + assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err()); + assert_eq!(scheme(&s).name, "Harbor"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(&s), n + 1); + + s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap(); + assert_eq!(scheme(&s).name, "Custom"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); + assert_eq!(undo_len(&s), n + 2); + + let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap(); + s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap(); + assert_eq!(scheme(&s).name, "Meadow"); + assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(&s), n + 3); + + s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap(); + assert_eq!(scheme(&s).name, "Talk"); + assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); + assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(scheme(&s).name, "Meadow"); + assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..fa931fde 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,6 +1,7 @@ //! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). +use deckcraft_color::SchemeSlot; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -56,6 +57,7 @@ fn title(id: &str) -> &'static str { "trim" => "Trim Media", "smartart" => "Choose a SmartArt Graphic", "quit" => "DeckCraft", + "themeColors" => "Create New Theme Colors", _ => "DeckCraft", } } @@ -64,16 +66,24 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let mut open = true; let mut close = false; - egui::Window::new(title(&d.id)) + let mut window = egui::Window::new(title(&d.id)) .id(egui::Id::new(("dialog", d.id.clone()))) .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) - .open(&mut open) - .show(ctx, |ui| { - ui.set_min_width(340.0); - close = body(app, ui, &mut d); - }); + .open(&mut open); + // Two columns of colour rows plus Save must fit the first frame. A later resize clips the + // buttons (the window size sticks) and clicks miss them. + if d.id == "themeColors" { + window = window.default_size(vec2(760.0, 420.0)).min_width(720.0).min_height(420.0); + } + window.show(ctx, |ui| { + ui.set_min_width(if d.id == "themeColors" { 700.0 } else { 340.0 }); + if d.id == "themeColors" { + ui.set_min_height(360.0); + } + close = body(app, ui, &mut d); + }); if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.dialog = Some(d); } @@ -117,6 +127,99 @@ fn num(d: &mut Dialog, key: &str, default: f64) -> f64 { d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).unwrap_or(default) } +fn seed_theme_colors(d: &mut Dialog) { + if d.fields.contains_key("seeded") { + return; + } + let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom"); + d.fields.insert("schemeName".into(), name.to_string()); + let obj = d.params.get("colors").and_then(Value::as_object); + for slot in SchemeSlot::THEME { + let hex = obj.and_then(|o| o.get(slot.xml_name())).and_then(Value::as_str).unwrap_or("000000"); + d.fields.insert(slot.xml_name().into(), hex.to_string()); + } + d.fields.insert("seeded".into(), "1".into()); +} + +fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { + let key = slot.xml_name(); + let mut v = d.get(key, "000000"); + ui.horizontal(|ui| { + ui.add_sized(vec2(188.0, 18.0), egui::Label::new(slot.label())); + let fill = deckcraft_color::Rgba::from_hex(&v).map(theme::to_color32).unwrap_or_else(|| Color32::from_gray(200)); + let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + ui.painter().rect(rect, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); + let resp = ui.add(egui::TextEdit::singleline(&mut v).desired_width(84.0).id_salt(("theme-hex", key))); + let shown = v.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, format!("{} hex", slot.label())); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert(key.into(), v); +} + +fn theme_color_payload(d: &mut Dialog) -> Result { + let name = d.get("schemeName", "").trim().to_string(); + if name.is_empty() { + return Err("Enter a scheme name.".into()); + } + let mut colors = serde_json::Map::new(); + for slot in SchemeSlot::THEME { + let raw = d.get(slot.xml_name(), ""); + let Some(rgb) = deckcraft_color::Rgba::from_hex(raw.trim()) else { + return Err(format!("{} needs a colour like #RRGGBB.", slot.label())); + }; + colors.insert(slot.xml_name().into(), Value::String(format!("#{}", rgb.hex()))); + } + Ok(json!({"name": name, "colors": colors})) +} + +/// Create New Theme Colors. Returns true when the dialog should close. +fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + seed_theme_colors(d); + // The window keeps the height of its contents and will not grow after the first frame, so the + // rows stay short enough that Save is inside that rect. + ui.style_mut().spacing.interact_size.y = 16.0; + ui.style_mut().spacing.item_spacing.y = 2.0; + ui.style_mut().spacing.button_padding.y = 2.0; + let left = [SchemeSlot::Dk1, SchemeSlot::Lt1, SchemeSlot::Dk2, SchemeSlot::Lt2, SchemeSlot::Hlink, SchemeSlot::FolHlink]; + let right = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; + egui::Grid::new("theme-colors").spacing(vec2(18.0, 4.0)).show(ui, |ui| { + for (a, b) in left.into_iter().zip(right) { + theme_color_row(ui, d, a); + theme_color_row(ui, d, b); + ui.end_row(); + } + }); + ui.add_space(6.0); + let mut name = d.get("schemeName", ""); + ui.horizontal(|ui| { + ui.label("Name:"); + let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName")); + let shown = name.clone(); + resp.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name"); + info.current_text_value = Some(shown.clone()); + info + }); + }); + d.fields.insert("schemeName".into(), name); + ui.label(egui::RichText::new("Applies to this presentation's theme colours.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + let (ok, cancel) = buttons(ui, "Save"); + if ok { + match theme_color_payload(d) { + Ok(payload) => return app.run("design.colors", payload).is_ok(), + Err(msg) => { + app.set_status(msg); + return false; + } + } + } + cancel +} + /// Returns true when the dialog should close. fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let run = |app: &mut SlideApp, id: &str, p: Value| { @@ -730,6 +833,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Done"); ok } + "themeColors" => theme_colors_dialog(app, ui, d), _ => { ui.label(format!("“{}” is not available yet.", d.id)); let (ok, cancel) = buttons(ui, "OK"); @@ -829,3 +933,7 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +#[cfg(test)] +#[path = "theme_colors_tests.rs"] +mod theme_colors_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..06397bfe 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1235,6 +1235,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let r = big_button(ui, Icon::Colors, "Colors", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Colors")); egui::Popup::menu(&r).width(240.0).show(|ui| { for cs in deckcraft_model::theme::builtin_color_schemes() { ui.horizontal(|ui| { @@ -1254,6 +1255,11 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } }); } + ui.separator(); + if ui.button("Customize Colors…").clicked() { + run(app, "design.colors", json!({})); + ui.close(); + } }); let r = big_button(ui, Icon::Fonts, "Fonts", true); egui::Popup::menu(&r).width(220.0).show(|ui| { diff --git a/crates/ui-egui/src/theme_colors_tests.rs b/crates/ui-egui/src/theme_colors_tests.rs new file mode 100644 index 00000000..d7dba9bb --- /dev/null +++ b/crates/ui-egui/src/theme_colors_tests.rs @@ -0,0 +1,136 @@ +//! Design › Colors › Customize Colors… opens Create New Theme Colors and saves in one step. + +use deckcraft_color::SchemeSlot; +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.ui.tab = "Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn scheme(h: &Harness<'_, SlideApp>) -> deckcraft_color::ColorScheme { + h.state().session.active().unwrap().doc.masters[0].theme.colors.clone() +} + +fn undo_len(h: &Harness<'_, SlideApp>) -> usize { + h.state().session.active().unwrap().history.undo.len() +} + +fn open_dialog(h: &mut Harness<'_, SlideApp>) { + h.get_by_label("Colors").click(); + h.run_steps(2); + h.get_by_label("Customize Colors…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("themeColors")); +} + +#[test] +fn customize_colors_opens_from_the_design_tab() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + assert_eq!(h.get_by_label("Scheme name").value().as_deref(), Some(before.name.as_str())); + let accent = format!("#{}", before.get(SchemeSlot::Accent1).hex()); + assert_eq!(h.get_by_label("Accent 1 hex").value().as_deref(), Some(accent.as_str())); + let _ = h.get_by_label("Text/Background - Dark 1 hex"); + let _ = h.get_by_label("Text/Background - Light 1 hex"); + let _ = h.get_by_label("Followed Hyperlink hex"); + let _ = h.get_by_label("Hyperlink hex"); + let _ = h.get_by_label("Save"); + let _ = h.get_by_label("Cancel"); + let save = h.get_by_label("Save").rect(); + let win = h.get_by_label("Create New Theme Colors").rect(); + assert!(win.contains(save.center()), "Save {save:?} is outside the dialog window {win:?}"); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n, "saving the current colours is not an undo step"); +} + +#[test] +fn save_applies_the_name_and_one_colour() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("schemeName".into(), "Talk".into()); + d.fields.insert("accent1".into(), "#ABCDEF".into()); + } + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "status: {:?}", h.state().status); + let after = scheme(&h); + assert_eq!(after.name, "Talk"); + assert_eq!(after.get(SchemeSlot::Accent1).hex(), "ABCDEF"); + assert_eq!(after.get(SchemeSlot::Dk1), before.get(SchemeSlot::Dk1)); + assert_eq!(undo_len(&h), n + 1); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Colors"); +} + +#[test] +fn a_bad_hex_keeps_the_dialog_open() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + h.state_mut().dialog.as_mut().unwrap().fields.insert("accent1".into(), "zzzzzz".into()); + h.get_by_label("Save").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("themeColors")); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n); + let status = h.state().status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default(); + assert!(status.contains("Accent 1"), "{status}"); +} + +#[test] +fn cancel_closes_without_editing() { + let mut h = harness(); + let before = scheme(&h); + let n = undo_len(&h); + open_dialog(&mut h); + h.state_mut().dialog.as_mut().unwrap().fields.insert("accent1".into(), "#112233".into()); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(scheme(&h), before); + assert_eq!(undo_len(&h), n); +} + +#[test] +fn typing_a_hex_replaces_accent_1() { + let mut h = harness(); + let n = undo_len(&h); + open_dialog(&mut h); + h.get_by_label("Accent 1 hex").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.get_by_label("Accent 1 hex").type_text("#11AA22"); + h.run_steps(1); + let shown = h.get_by_label("Accent 1 hex").value().unwrap_or_default(); + assert_eq!(shown, "#11AA22", "the hex field should replace its text, got {shown}"); + h.get_by_label("Save").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "status: {:?}", h.state().status); + assert_eq!(scheme(&h).name, "DeckCraft"); + assert_eq!(scheme(&h).get(SchemeSlot::Accent1).hex(), "11AA22"); + assert_eq!(undo_len(&h), n + 1); +} diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..b2c64739 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors dialog; previous: transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -21,7 +21,7 @@ nothing below L6 uses egui, eframe, winit or rfd. | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | | L4 | `pdf` | PDF export through krilla | | L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | -| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | +| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with @@ -78,5 +78,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Create New Theme Colors (`design.colors` with no params); command count stays 226 | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..c231878d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; dialogs 27 → 28; previous: 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -203,8 +203,10 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. +- **Dialogs**: 28 dialogs vs PowerPoint's ~60. Create New Theme Colors (Design › Colors › + Customize Colors…) edits the open master's twelve theme colours, not a saved library of named + schemes. Still missing: Font, Bullets and Numbering, Find/Replace dialog, Replace Fonts, Action + Settings, Print, Insert Object. 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -256,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Create New Theme Colors is in (dialogs 27 → 28). Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print and Insert Object stay open | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..286a7a05 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors noted under what works today; percentages unchanged; previous: hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -211,7 +211,9 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc inheritance (slide → layout → master → theme), sections, notes, comments, custom shows, header/footer fields, embedded media, unlimited undo on shared slide snapshots. - **Themes:** 8 original themes with colour and font schemes, custom colours and fonts, - backgrounds (solid, gradient, picture, pattern), slide size presets. + backgrounds (solid, gradient, picture, pattern), slide size presets. Design › Colors › + Customize Colors… opens Create New Theme Colors and edits the open master's twelve theme + colours (one scheme on that master, not a library of saved schemes). - **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop, corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites, @@ -276,6 +278,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | What works today: Create New Theme Colors. The d556ff9 snapshot (226 commands, 27 dialogs) and the breadth / ready percentages are unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..ce26f6a5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,8 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Create New Theme Colors; previous: text editing keys, #268) · **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 @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | -| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages, Create New Theme Colors | ## Direct manipulation @@ -137,7 +137,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been |---|---| | Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 | | Format Shape pane depth | 6–10 | -| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | +| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object; Create New Theme Colors is in) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Grid Options, edit points UI, crop depth | 4–7 | | Multiple windows | 3–5 | @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Design › Colors › Customize Colors… opens Create New Theme Colors (the open master's twelve theme colours; dialogs 27 → 28) | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |