From ad24f545e2c1d5c491c55e0a164e076726a23845 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:09:33 +0300 Subject: [PATCH 01/15] Format Shape: picture or texture fill options shape.fill gains pictureProps for a picture fill: tile on/off, tile offset X/Y (pt), scale X/Y, alignment and mirror type, stretch offsets (left/top/right/bottom), and rotate with shape. `transparency` now also sets a picture fill's opacity. Values are clamped and unknown keywords or non-finite numbers ignored. The Fill page of the Format Shape pane gets a "Picture or texture fill" option with a picture source button and these controls. The model, renderer and PPTX reader/writer already carry the fields; a new engine test checks they survive a PPTX save and reopen. Co-authored-by: Cursor --- crates/engine/src/cmd/shape.rs | 65 +++++++++++++++++++-- crates/engine/src/tests.rs | 40 +++++++++++++ crates/ui-egui/src/panes.rs | 104 ++++++++++++++++++++++++++++++++- docs/ui-parity.md | 2 +- 4 files changed, 203 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..ea5a0940 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -2,7 +2,7 @@ //! names and alt text. use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; -use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow}; +use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow}; use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle}; use serde_json::{Value, json}; @@ -42,7 +42,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?}", + "{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", has_selection, fill ), @@ -290,8 +290,9 @@ fn fill(s: &mut Session, p: &Value) -> Result { None }; let transparency = f64_param(p, "transparency"); - if new_fill.is_none() && transparency.is_none() { - return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`")); + 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`")); } // Transparency on an inherited (style) fill needs the effective colour. let st = s.doc()?; @@ -319,6 +320,14 @@ fn fill(s: &mut Session, p: &Value) -> Result { if let Some(f) = &new_fill { sh.fill.clone_from(f); } + if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) { + picture_props(pf, pp); + } + if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); + 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())); if let Some(Fill::Solid { mut color }) = base { @@ -333,6 +342,54 @@ fn fill(s: &mut Session, p: &Value) -> Result { }) } +/// Applies Format Shape ▸ Picture or texture fill options to a picture fill. +fn picture_props(pf: &mut PictureFill, pp: &Value) { + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); + } + } + } + } + } +} + fn line_end(v: &str) -> Option { if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..1b8e3453 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,43 @@ 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 shape_picture_fill_options_tile_offset_and_round_trip() { + use deckcraft_model::style::PictureMode; + let mut img = image::RgbaImage::new(4, 4); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let pf = |s: &Session| match s.doc().unwrap().selected_shapes()[0].fill.clone() { + Some(deckcraft_model::Fill::Picture(pf)) => pf, + other => panic!("{other:?}"), + }; + // Options without a picture fill are a no-op, not an error. + s.execute("shape.fill", &json!({"pictureProps": {"tile": true}})).unwrap(); + s.execute("shape.fill", &json!({"picture": cmd::base64_encode(&png)})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"stretch": [0.1, 0.2, 5, "x"]}})).unwrap(); + assert_eq!(pf(&s).mode, PictureMode::Stretch { fill_rect: [0.1, 0.2, 0.99, 0.0] }); + s.execute("shape.fill", &json!({"pictureProps": {"tile": true, "offsetX": 12, "offsetY": -3, "scaleX": 0.5, "scaleY": 50, "align": "ctr", "mirror": "xy", "rotateWithShape": false}})).unwrap(); + s.execute("shape.fill", &json!({"pictureProps": {"align": "bogus", "offsetX": f64::NAN}, "transparency": 0.4})).unwrap(); + let want = PictureMode::Tile { tx: 12.0, ty: -3.0, sx: 0.5, sy: 10.0, flip: "xy".into(), align: "ctr".into() }; + let got = pf(&s); + assert_eq!(got.mode, want); + assert!(got.upright); + assert!((got.alpha.unwrap() - 0.6).abs() < 1e-9); + // PPTX round trip keeps the options. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "p.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let doc = &s.doc().unwrap().doc; + let back = doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| match &sh.fill { + Some(deckcraft_model::Fill::Picture(pf)) => Some(pf.clone()), + _ => None, + }); + let back = back.unwrap(); + assert_eq!(back.mode, want); + assert!(back.upright); + assert!((back.alpha.unwrap() - 0.6).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..c56528a6 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -262,9 +262,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Pattern(_)) => "pattern", _ => "auto", }; - for (l, k) in - [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")] - { + for (l, k) in [ + ("No fill", "none"), + ("Solid fill", "solid"), + ("Gradient fill", "gradient"), + ("Picture or texture fill", "picture"), + ("Pattern fill", "pattern"), + ("Automatic", "auto"), + ] { if ui.radio(kind == k, l).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), @@ -275,6 +280,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), ), "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), + "picture" => pick_fill_picture(app), _ => run(app, "shape.fill", json!({"reset": true})), } } @@ -293,6 +299,9 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0})); } }); + if let Some(Fill::Picture(pf)) = &sh.fill { + picture_fill_options(app, ui, sh.id.0, pf); + } if let Some(Fill::Gradient(g)) = &sh.fill { let sc = scheme(app); if let Some(ng) = crate::fillui::gradient_editor(ui, "shape-grad", g, &sc) { @@ -339,6 +348,95 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } +/// Asks for a picture file and makes it the selected shapes' fill. +fn pick_fill_picture(app: &mut SlideApp) { + if let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + run(app, "shape.fill", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)})); + } +} + +/// A number field for one picture-fill option; runs `shape.fill` with `{pictureProps: {key: v × scale}}` +/// when it changes. +fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); + } + }); +} + +/// Format Shape ▸ Fill ▸ Picture or texture fill: source, tiling, offsets, transparency. +fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { + use deckcraft_model::style::PictureMode; + ui.horizontal(|ui| { + ui.label("Picture source"); + if ui.button("Insert…").clicked() { + pick_fill_picture(app); + } + }); + let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); + if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); + } + match &pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + pic_drag(app, ui, "Offset X", *tx, " pt", "offsetX", 1.0); + pic_drag(app, ui, "Offset Y", *ty, " pt", "offsetY", 1.0); + pic_drag(app, ui, "Scale X", sx * 100.0, "%", "scaleX", 0.01); + pic_drag(app, ui, "Scale Y", sy * 100.0, "%", "scaleY", 0.01); + let aligns = [ + ("tl", "Top left"), + ("t", "Top"), + ("tr", "Top right"), + ("l", "Left"), + ("ctr", "Center"), + ("r", "Right"), + ("bl", "Bottom left"), + ("b", "Bottom"), + ("br", "Bottom right"), + ]; + let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; + for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); + egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + for (k, l) in opts { + if ui.selectable_label(*k == cur.as_str(), *l).clicked() { + run(app, "shape.fill", json!({"pictureProps": {key: k}})); + } + } + }); + }); + } + } + PictureMode::Stretch { fill_rect } => { + for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = fill_rect[i] * 100.0; + let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + let mut rect = *fill_rect; + rect[i] = v / 100.0; + run(app, "shape.fill", json!({"pictureProps": {"stretch": rect}})); + } + }); + } + } + } + let mut rot = !pf.upright; + if ui.checkbox(&mut rot, "Rotate with shape").changed() { + run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); + } +} + fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..24504b0e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -58,7 +58,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, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D | | Format Background pane | yes | yes | | 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 | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | From a7bc932cc84b5ffe0de72da6799e3f486f009023 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:38:16 +0300 Subject: [PATCH 02/15] UI: WordArt adjust handles drag a text warp A selected text warp shows one yellow handle per adjust. Dragging it sets that guide value in one undo step. Co-authored-by: Cursor --- ROADMAP.md | 3 +- crates/engine/src/tests.rs | 40 ++++++++ crates/engine/src/tools.rs | 88 +++++++++++++++- crates/geom/src/warp.rs | 188 ++++++++++++++++++++++++++++++++++ crates/geom/src/warp/tests.rs | 40 ++++++++ crates/ui-egui/src/canvas.rs | 6 +- docs/gaps.md | 16 +-- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 4 +- 10 files changed, 380 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..588977b3 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **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-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) | | 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 | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..31290a11 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,43 @@ 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 wordart_adjust_handle_drags_and_undoes() { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + let id = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 60.0, 420.0, 200.0], "text": "ARCHED"})).unwrap()["id"].as_u64().unwrap(); + let sid = model::ShapeId(id as u32); + { + let st = s.doc_mut().unwrap(); + let slide = st.selection.slide; + let mut doc = (*st.doc).clone(); + let sh = model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[slide]).shapes, sid).unwrap(); + sh.text.as_mut().unwrap().body.warp = Some("textArchUp".into()); + st.doc = std::sync::Arc::new(doc); + } + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let (at, top) = { + let st = s.doc().unwrap(); + let sh = st.shape(sid).unwrap(); + let x = cmd::xfrm_of(&st.doc, &st.selection, sh); + let hs = tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h); + assert_eq!(hs.len(), 1); + let at = x.affine() * hs[0].pos; + (at, deckcraft_geom::Point::new(x.center().x, x.y)) + }; + assert!(matches!(s.hit_test(at, 4.0), Some(tools::Hit::Warp { index: 0, .. }))); + let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 4.0 }; + s.pointer(ev(PointerKind::Down, at)).unwrap(); + s.pointer(ev(PointerKind::Drag, top)).unwrap(); + s.pointer(ev(PointerKind::Up, top)).unwrap(); + let adj = s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body.warp_adj.clone(); + let deg = adj.first().copied().unwrap_or(0.0) / 60_000.0; + assert!((deg - 270.0).abs() < 2.0, "{deg} from {adj:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let body = &s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body; + assert_eq!(body.warp.as_deref(), Some("textArchUp")); + assert!(body.warp_adj.is_empty(), "undo restores the default adjusts"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..61939c40 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -76,6 +76,11 @@ pub enum Hit { id: ShapeId, index: usize, }, + /// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`. + Warp { + id: ShapeId, + index: usize, + }, /// Line end 0/1 of a line-like shape. LineEnd { id: ShapeId, @@ -119,6 +124,14 @@ enum Drag { index: usize, orig: Xfrm, }, + /// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults). + Warp { + id: ShapeId, + index: usize, + name: String, + base: Vec, + orig: Xfrm, + }, Marquee { start: Point, add: bool, @@ -204,6 +217,45 @@ pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) { (pts, rot) } +/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the +/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on. +pub fn wordart_handles( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + sh: &Shape, + w: f64, + h: f64, +) -> Vec { + if sh.text.is_none() { + return Vec::new(); + } + let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() }; + let bp = deckcraft_model::resolve::body(&ctx, sh); + let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() }; + let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect; + deckcraft_geom::warp::handles(name, &bp.warp_adj, tr) +} + +/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults). +fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) { + let Some(text) = sh.text.as_mut() else { return }; + if !value.is_finite() || index > 7 { + return; + } + let defaults = deckcraft_geom::warp::adjust_defaults(name); + let n = defaults.len().max(index + 1).min(8); + if n == 0 { + return; + } + let mut adj = Vec::with_capacity(n); + for i in 0..n { + let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) }; + adj.push(v); + } + text.body.warp = Some(name.to_string()); + text.body.warp_adj = adj; +} + impl Session { pub fn set_tool(&mut self, kind: ToolKind) { self.tool.kind = kind; @@ -239,6 +291,11 @@ impl Session { return Some(Hit::Adjust { id: *id, index: hd.index }); } } + for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Warp { id: *id, index: hd.index }); + } + } let (pts, rot) = box_handles(&x); if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { return Some(Hit::Rotate { id: *id }); @@ -370,7 +427,7 @@ impl Session { { let x = xfrm_of(&st.doc, &st.selection, sh); if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) + && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. })) { let pos = text_pos(st, sh, p); let shift = mods.shift; @@ -412,6 +469,18 @@ impl Session { self.begin("Adjust Shape"); self.tool.drag = Some(Drag::Adjust { id, index, orig }); } + Some(Hit::Warp { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + let found = st.shape(id).and_then(|sh| { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; + let bp = deckcraft_model::resolve::body(&ctx, sh); + bp.warp.map(|n| (n, bp.warp_adj)) + }); + if let Some((name, base)) = found { + self.begin("Adjust WordArt"); + self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); + } + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -601,6 +670,23 @@ impl Session { Ok(()) })?; } + Drag::Warp { id, index, name, base, orig } => { + let v = { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; + let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; + let hs = deckcraft_geom::warp::handles(&name, &base, tr); + let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + h.value_at(orig.affine().inverse() * p) + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + set_warp_adj(sh, &name, &base, index, v); + } + Ok(()) + })?; + } Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index acdee999..25eb104f 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -234,6 +234,194 @@ pub fn is_supported(name: &str) -> bool { !adjust_defaults(name).is_empty() } +/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect` +/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the +/// text rectangle's centre for an angle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpHandle { + pub index: usize, + pub pos: Point, + kind: HandleKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum HandleKind { + /// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps. + Line { origin: Point, dir: Vec2, min: f64, max: f64 }, + /// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`. + Angle { center: Point, invert: bool }, +} + +impl WarpHandle { + /// The raw guide value (`adj` / `adjN`) for a pointer at `p`. + pub fn value_at(&self, p: Point) -> f64 { + match self.kind { + HandleKind::Line { origin, dir, min, max } => { + let l2 = dir.hypot2(); + if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) { + return min; + } + let v = (p - origin).dot(dir) / l2; + if !v.is_finite() { + return min; + } + let (lo, hi) = (min.min(max), max.max(min)); + v.clamp(lo, hi).round() + } + HandleKind::Angle { center, invert } => { + let d = p - center; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return 0.0; + } + let mut deg = d.y.atan2(d.x).to_degrees(); + if invert { + deg = 180.0 - deg; + } + (deg.rem_euclid(360.0) * 60_000.0).round() + } + } + } +} + +fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 { + adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) +} + +fn unit(rect: Rect, u: f64, v: f64) -> Point { + Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height()) +} + +/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`. +fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, x, sign * frac), + kind: HandleKind::Line { + origin: unit(rect, x, 0.0), + dir: Vec2::new(0.0, sign * rect.height() / 100_000.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// A fraction handle that slides horizontally: unit `(frac, y)`. +fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle { + let frac = frac.clamp(lo, hi); + WarpHandle { + index, + pos: unit(rect, frac, y), + kind: HandleKind::Line { + origin: unit(rect, 0.0, y), + dir: Vec2::new(rect.width() / 100_000.0, 0.0), + min: lo * 100_000.0, + max: hi * 100_000.0, + }, + } +} + +/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle. +fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle { + let geom = if invert { 180.0 - deg } else { deg }; + let rad = geom.to_radians(); + let c = rect.center(); + WarpHandle { + index, + pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()), + kind: HandleKind::Angle { center: c, invert }, + } +} + +/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse. +fn radius_handle(rect: Rect, r: f64) -> WarpHandle { + let r = r.clamp(0.0, 0.99); + WarpHandle { + index: 1, + pos: unit(rect, 0.5, 0.5 + 0.5 * r), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 }, + } +} + +/// Wave phase: slides sideways around the middle of the lower half. +fn shift_handle(rect: Rect, s: f64) -> WarpHandle { + let s = s.clamp(-0.1, 0.1); + WarpHandle { + index: 1, + pos: unit(rect, 0.5 + s, 0.78), + kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 }, + } +} + +/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn +/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults. +pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return Vec::new(); + } + let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + if rect.width() < 1e-3 || rect.height() < 1e-3 { + return Vec::new(); + } + let defaults = adjust_defaults(name); + if defaults.is_empty() { + return Vec::new(); + } + let raw = |i| stored_raw(adj, defaults, i); + let frac = |i| raw(i) / 100_000.0; + let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0); + let y = |out: &mut Vec, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign)); + let xh = |out: &mut Vec, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi)); + let mut out = Vec::with_capacity(defaults.len()); + match name { + "textPlain" => xh(&mut out, 0.28, 0.3, 0.7), + "textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0), + "textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0), + "textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0), + "textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0), + "textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0), + "textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999), + "textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999), + "textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0), + "textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0), + "textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0), + "textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0), + "textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0), + "textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0), + "textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0), + "textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0), + "textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0), + "textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0), + "textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0), + "textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0), + "textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0), + "textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0), + "textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0), + "textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)), + "textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)), + "textArchUpPour" | "textCirclePour" | "textButtonPour" => { + out.push(angle_handle(rect, 0, deg(0), false)); + out.push(radius_handle(rect, frac(1))); + } + "textArchDownPour" => { + out.push(angle_handle(rect, 0, deg(0), true)); + out.push(radius_handle(rect, frac(1))); + } + "textWave1" | "textWave2" => { + y(&mut out, 0.32, 0.0, 0.2, 1.0); + out.push(shift_handle(rect, frac(1))); + } + "textDoubleWave1" | "textWave4" => { + y(&mut out, 0.32, 0.0, 0.125, 1.0); + out.push(shift_handle(rect, frac(1))); + } + _ => return Vec::new(), + } + out +} + /// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). #[derive(Clone, Copy, Debug)] enum Seg { diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index 2d18b13a..2d56d210 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -307,3 +307,43 @@ fn hostile_adjusts_and_boxes_stay_finite() { } assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); } + +#[test] +fn every_preset_has_one_handle_per_adjust() { + let r = rect(); + for name in ALL { + let hs = handles(name, &[], r); + assert_eq!(hs.len(), adjust_count(name), "{name}"); + let defaults = adjust_defaults(name); + for h in &hs { + assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}"); + let back = h.value_at(h.pos); + let want = defaults[h.index]; + assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index); + } + } + assert!(handles("textNoShape", &[], r).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty()); + assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty()); + // A hostile adjust still places a finite handle on the clamped default. + let hs = handles("textInflate", &[f64::NAN], r); + assert_eq!(hs.len(), 1); + assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite()); +} + +#[test] +fn arch_and_inflate_handles_follow_the_pointer() { + let r = rect(); + let arch = &handles("textArchUp", &[], r)[0]; + let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0; + assert!((deg - 270.0).abs() < 0.5, "{deg}"); + let down = &handles("textArchDown", &[], r)[0]; + // The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90 + // (guide angle 180 − adj). + let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0; + assert!((deg - 90.0).abs() < 0.5, "{deg}"); + let inf = &handles("textInflate", &[], r)[0]; + let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0)); + assert!(more > adjust_defaults("textInflate")[0], "{more}"); + assert!((more - 20_000.0).abs() < 1.0, "{more}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..ed86f6d1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -404,7 +404,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { _ => egui::CursorIcon::ResizeHorizontal, }, (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, - (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, (_, Some(Hit::Shape { text: true, .. })) => { if app.session.active().is_some_and(|d| d.selection.text.is_some()) { egui::CursorIcon::Text @@ -484,6 +484,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let q = xf.to_screen(a * hd.pos); painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); } + for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } } } // Caret / text selection. diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..7459dbe1 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-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **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 @@ -166,12 +166,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 - `prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or - command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by - 1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting - render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, - only the three-point rig, and no command or pane to apply 3-D). -- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, + `prstTxWarp` presets render, their adjusts round-trip, and a selected warp shows a yellow handle + per adjust that dragging sets; still no Transform gallery or command to apply one, and arc/circle + placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation" + (partial: bevels, camera presets and rotations and face lighting render as approximations and + `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, only the three-point rig, and + no command or pane to apply 3-D). +- **Estimate:** 4–6 h (edit points), 1–2 h (Transform gallery and command), 6–10 h (3-D extrusion, rigs and materials, Format Shape page). ### Proofing: spelling, thesaurus, AutoCorrect @@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still 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/roadmap.md b/docs/roadmap.md index 97953034..5a766343 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps: adjust handles drag; gallery and command remain) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -79,7 +79,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as | M6 | Transitions and animations | mostly | Animation Pane timeline, remaining effects, transition sounds | | M7 | Slide show | mostly | second-display placement, laser, record | | M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table | -| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | +| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp gallery and command | | M10 | Views | done | grid options, New Window | | M11 | Output | partly | vector PDF, SVG, GIF, MP4, print | | M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare | @@ -109,5 +109,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..65e12820 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (text-warp shortfall is the gallery and command; adjust handles drag) · **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; @@ -184,7 +184,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | | Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) | -| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) | +| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp gallery and command, WordArt depth | [ui-parity.md](ui-parity.md) | | Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | | Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) | | Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain | | 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..83f4a455 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: 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-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) @@ -79,6 +79,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | | Edit points on freeforms | yes | **no** | +| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes | @@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | WordArt adjust handles on a selected text warp; dragging one sets that `prstTxWarp` adjust | | 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 | From 5acf9eeebf13277c75b4751ae3e2b3d2d0cf21f2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:49:55 +0300 Subject: [PATCH 03/15] Review: compare with a revised copy and accept its slides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review.compare {path | name, data} reads a revised presentation (native or PPTX) and lists slide-level revisions against the open one: inserted, deleted and changed slides, with what changed (text, text formatting, moved, resized, rotated, formatting, picture, objects inserted/deleted, layout, notes, transition, animations, background, hidden). Slides match by id, or by position when the decks share none (fixed for the session so accepting doesn't switch the matching half way). Shapes match by id, then name. review.accept {index | all} applies revisions as one undo step: a revised slide's media is copied (remapped on an id clash), its shape ids kept unless another slide uses them (animations follow), its layout matched by id, then name. review.revisions and review.endCompare complete the set. Review tab: Compare (file picker), Accept All, End Review; the Revisions pane lists changes with Go-to and Accept. Parity row: Compare presentations M → P. Co-authored-by: Cursor --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/review.rs | 81 +++++ crates/engine/src/compare.rs | 449 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 4 + crates/ui-egui/src/compare_tests.rs | 61 ++++ crates/ui-egui/src/lib.rs | 14 + crates/ui-egui/src/panes.rs | 57 ++++ crates/ui-egui/src/ribbon.rs | 13 + docs/gaps.md | 3 +- docs/parity-checklist.md | 6 +- 10 files changed, 685 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/compare.rs create mode 100644 crates/ui-egui/src/compare_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..229296c0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -48,7 +48,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool { || s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty()) } -fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { +pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { deckcraft_model::walk(shapes, &mut |s, _| { match &s.kind { deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media), diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..b9f25d40 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -16,6 +16,10 @@ pub fn specs() -> Vec { cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), + cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare), + cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions), + cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept), + cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare), cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { @@ -264,3 +268,80 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { })?; ok() } + +fn comparing(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) } +} + +fn revised_bytes(p: &Value) -> Result<(String, Vec)> { + if let Some(data) = str_param(p, "data") { + let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes)); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = str_param(p, "path") { + let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + return Ok((path.to_string(), bytes)); + } + Err(bad("review.compare", "give `path` or `data`")) +} + +fn revisions_json(st: &crate::DocState) -> Result { + let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?; + let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone()); + Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position})) +} + +fn compare(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = revised_bytes(p)?; + let revised = super::file::open_presentation(&name, &bytes)?; + let st = s.doc_mut()?; + let by_position = crate::compare::unrelated(&st.doc, &revised); + st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position }); + s.ui_requests.push(UiRequest::Pane { id: "revisions".into() }); + revisions_json(s.doc()?) +} + +fn revisions(s: &mut Session, _: &Value) -> Result { + revisions_json(s.doc()?) +} + +fn accept(s: &mut Session, p: &Value) -> Result { + let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?; + let all = bool_param(p, "all").unwrap_or(false); + let index = usize_param(p, "index"); + if !all && index.is_none() { + return Err(bad("review.accept", "give `index` or `all`")); + } + s.edit(|doc, sel| { + let mut accepted = 0usize; + // Each accept shifts indices, so the list is recomputed; bounded by the slide counts. + let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1); + for _ in 0..limit { + let changes = crate::compare::diff(doc, &rev, by_pos); + let c = if all { + changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1)) + } else { + changes.into_iter().nth(index.unwrap_or(0)) + }; + let Some(c) = c else { break }; + let Some(at) = crate::compare::accept(doc, &rev, &c) else { break }; + accepted += 1; + sel.slide = at; + sel.shapes.clear(); + sel.text = None; + if !all { + break; + } + } + if accepted == 0 { + return Err(bad("review.accept", "no such revision")); + } + Ok(json!({"accepted": accepted})) + }) +} + +fn end_compare(s: &mut Session, _: &Value) -> Result { + s.doc_mut()?.compare = None; + ok() +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs new file mode 100644 index 00000000..5d65c0cc --- /dev/null +++ b/crates/engine/src/compare.rs @@ -0,0 +1,449 @@ +//! Review › Compare: the differences between the open presentation and a revised copy of it, slide +//! by slide, and accepting a revised slide into the open one. +//! +//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no +//! slide id at all, by position. Shapes match by id, then by name. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId}; +use serde::Serialize; + +/// The revised presentation a document is being compared with. +#[derive(Clone, Debug)] +pub struct Compare { + pub name: String, + pub revised: Arc, + /// Slides match by position (the decks shared no slide id when the compare started); fixed + /// for the session so accepting slides doesn't switch the matching half way. + pub by_position: bool, +} + +/// Whether `cur` and `rev` share no slide id (then slides match by position). +pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool { + let ids: HashSet = rev.slides.iter().map(|s| s.id).collect(); + !cur.slides.iter().any(|s| ids.contains(&s.id)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + /// Only in the revised deck. + Inserted, + /// Only in the open deck. + Deleted, + /// In both, different. + Changed, +} + +/// One slide-level revision. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Change { + pub kind: ChangeKind, + /// Index in the open deck (for inserted: where it would go). + pub slide: usize, + /// Index in the revised deck (none for deleted). + pub revised: Option, + /// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`… + pub details: Vec, +} + +const MAX_DETAILS: usize = 50; + +/// Slide pairs (open index, revised index). +fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> { + if by_position { + return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect(); + } + let rev_ids: HashMap = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect(); + cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect() +} + +pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec { + let pairs = pairs(cur, rev, by_position); + let cur_matched: HashSet = pairs.iter().map(|p| p.0).collect(); + let rev_matched: HashSet = pairs.iter().map(|p| p.1).collect(); + let mut out = vec![]; + for (i, j) in &pairs { + let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue }; + let details = slide_details(cur, a, rev, b); + if !details.is_empty() { + out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details }); + } + } + for (i, s) in cur.slides.iter().enumerate() { + if !cur_matched.contains(&i) { + out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] }); + } + } + for (j, s) in rev.slides.iter().enumerate() { + if !rev_matched.contains(&j) { + // After the open slide that matches the revised slide before it. + let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0); + out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] }); + } + } + out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0))); + out +} + +fn slide_title(s: &Slide) -> String { + let t = s + .shapes + .iter() + .find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle))) + .and_then(|x| x.text.as_ref()) + .map(|b| b.text()) + .unwrap_or_default(); + let t = t.trim(); + if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() } +} + +fn shape_label(s: &Shape) -> String { + if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() } +} + +fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> { + p.media(id).map(|m| m.data.as_slice()) +} + +/// A shape without its ids (shape and media), for comparing across decks. +fn plain_shape(s: &Shape) -> Shape { + let mut c = s.clone(); + fn rec(shapes: &mut [Shape]) { + for s in shapes { + s.id = ShapeId(0); + if let Some(ch) = s.children_mut() { + rec(ch); + } + } + } + rec(std::slice::from_mut(&mut c)); + c +} + +fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec) { + let label = shape_label(b); + let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default(); + let mut what = vec![]; + if text(a) != text(b) { + what.push("text"); + } else if a.text != b.text { + what.push("text formatting"); + } + match (&a.xfrm, &b.xfrm) { + (Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"), + (Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"), + (x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"), + _ => {} + } + if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects { + what.push("formatting"); + } + let pic = |p: &Presentation, s: &Shape| match &s.kind { + ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()), + _ => None, + }; + if pic(cp, a) != pic(rp, b) { + what.push("picture"); + } + if what.is_empty() { + let (mut x, mut y) = (plain_shape(a), plain_shape(b)); + x.text = None; + y.text = None; + if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) { + x.kind = ShapeKind::Shape; + y.kind = ShapeKind::Shape; + } + if x != y { + what.push("properties"); + } + } + if !what.is_empty() { + out.push(format!("{label}: {}", what.join(", "))); + } +} + +fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec { + let mut out = vec![]; + let mut used: HashSet = HashSet::new(); + for sa in &a.shapes { + let k = b + .shapes + .iter() + .position(|x| x.id == sa.id) + .or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty())); + match k { + Some(k) if !used.contains(&k) => { + used.insert(k); + if let Some(sb) = b.shapes.get(k) { + shape_details(cp, sa, rp, sb, &mut out); + } + } + _ => out.push(format!("{} deleted", shape_label(sa))), + } + } + for (k, sb) in b.shapes.iter().enumerate() { + if !used.contains(&k) { + out.push(format!("{} inserted", shape_label(sb))); + } + } + let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::>(); + if out.is_empty() && order(a) != order(b) { + out.push("order of objects".into()); + } + let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone()); + if layout_name(cp, a) != layout_name(rp, b) { + out.push("layout".into()); + } + if a.notes.text() != b.notes.text() { + out.push("notes".into()); + } + if a.transition != b.transition { + out.push("transition".into()); + } + if a.animations.len() != b.animations.len() + || a.animations + .iter() + .zip(&b.animations) + .any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms)) + { + out.push("animations".into()); + } + if a.background != b.background { + out.push("background".into()); + } + if a.hidden != b.hidden { + out.push(if b.hidden { "hidden" } else { "shown" }.into()); + } + out.truncate(MAX_DETAILS); + out +} + +fn max_shape_id(shapes: &[Shape], m: &mut u32) { + deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0)); +} + +/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape +/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its +/// layout matched by id, then by name, then the first layout. +pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option) -> Slide { + let mut sl = slide.clone(); + // Media. + let mut mids = vec![]; + crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids); + let mut media_map: HashMap = HashMap::new(); + for id in mids { + let Some(m) = rev.media(id) else { continue }; + match doc.media(id) { + Some(x) if x.data == m.data => {} + None => doc.media.push(m.clone()), + Some(_) => { + let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); + media_map.insert(id, new); + } + } + } + // Shape ids used elsewhere in the open deck. + let mut taken: HashSet = HashSet::new(); + for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) { + deckcraft_model::walk(&s.shapes, &mut |x, _| { + taken.insert(x.id.0); + }); + } + let mut id_map: HashMap = HashMap::new(); + fn rec( + doc: &mut Presentation, + shapes: &mut [Shape], + taken: &HashSet, + ids: &mut HashMap, + media: &HashMap, + depth: usize, + ) { + if depth > 64 { + return; + } + for s in shapes { + if taken.contains(&s.id.0) { + let new = ShapeId(doc.alloc_id()); + ids.insert(s.id, new); + s.id = new; + } + let remap = |m: &mut MediaId| { + if let Some(n) = media.get(m) { + *m = *n; + } + }; + match &mut s.kind { + ShapeKind::Picture { fill } => remap(&mut fill.media), + ShapeKind::Media(m) => { + remap(&mut m.media); + if let Some(p) = m.poster.as_mut() { + remap(p); + } + } + _ => {} + } + if let Some(Fill::Picture(pf)) = &mut s.fill { + remap(&mut pf.media); + } + if let Some(ch) = s.children_mut() { + rec(doc, ch, taken, ids, media, depth + 1); + } + } + } + rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0); + for a in &mut sl.animations { + if let Some(n) = id_map.get(&a.shape) { + a.shape = *n; + } + if let Some(t) = a.trigger.as_mut() + && let Some(n) = id_map.get(t) + { + *t = *n; + } + } + let mut max = 0; + max_shape_id(&sl.shapes, &mut max); + doc.next_id = doc.next_id.max(max); + // Layout. + if doc.layout(sl.layout).is_none() { + let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone()); + let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id)); + sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default(); + } + match replacing { + Some(id) => sl.id = id, + None => { + if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 { + sl.id = SlideId(doc.alloc_id()); + } + doc.next_id = doc.next_id.max(sl.id.0); + } + } + sl +} + +/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show. +pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> Option { + match change.kind { + ChangeKind::Changed => { + let b = rev.slides.get(change.revised?)?.clone(); + let id = doc.slides.get(change.slide)?.id; + let sl = adopt(doc, rev, &b, Some(id)); + *doc.slides.get_mut(change.slide)? = Arc::new(sl); + Some(change.slide) + } + ChangeKind::Inserted => { + let b = rev.slides.get(change.revised?)?.clone(); + let sl = adopt(doc, rev, &b, None); + let at = change.slide.min(doc.slides.len()); + doc.slides.insert(at, Arc::new(sl)); + Some(at) + } + ChangeKind::Deleted => { + if change.slide >= doc.slides.len() || doc.slides.len() <= 1 { + return None; + } + doc.slides.remove(change.slide); + Some(change.slide.min(doc.slides.len().saturating_sub(1))) + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + /// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title + /// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3. + fn decks() -> (Session, String, usize) { + let mut s = Session::new(); + crate::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + // Make the revision in a second document. + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap(); + let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0; + s.execute("edit.select", &json!({"ids": [first]})).unwrap(); + s.execute("text.edit", &json!({"id": first})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Revised title"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": 1})).ok(); + let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0; + s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok(); + s.execute("slide.go", &json!({"index": n - 2})).ok(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + (s, rev, n) + } + + fn kinds(v: &Value) -> Vec { + v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect() + } + + #[test] + fn lists_inserted_deleted_and_changed_slides() { + let (mut s, rev, n) = decks(); + let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let k = kinds(&v); + assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}"); + let ch = &v["changes"].as_array().unwrap()[0]; + assert_eq!(ch["slide"], 0); + assert!(ch["details"].to_string().contains("text"), "{ch}"); + let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap(); + assert_eq!(ins["details"][0], "Brand new"); + let at = ins["slide"].as_u64().unwrap() as usize; + assert_eq!(at, n, "after the open slide matching the revised one before it"); + assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing"); + } + + #[test] + fn accept_one_then_all_makes_the_decks_equal_and_undoes() { + let (mut s, rev, n) = decks(); + s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap(); + let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + s.execute("review.accept", &json!({"index": 0})).unwrap(); + let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(); + assert_eq!(after, before - 1); + assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title")))); + s.execute("review.accept", &json!({"all": true})).unwrap(); + let left = s.execute("review.revisions", &json!({})).unwrap(); + assert!(left["changes"].as_array().unwrap().is_empty(), "{left}"); + assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate()); + assert_eq!(s.doc().unwrap().doc.slides.len(), n); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before); + s.execute("review.endCompare", &json!({})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"all": true})).is_err()); + } + + #[test] + fn unrelated_decks_match_by_position_and_bad_input_fails() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap(); + let mut other = Session::with_new(); + other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap(); + other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap(); + assert!(kinds(&v).contains(&"inserted".into()), "{v}"); + s.execute("review.accept", &json!({"all": true})).unwrap(); + assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty()); + assert!(s.doc().unwrap().doc.validate().is_empty()); + assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err()); + assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err()); + assert!(s.execute("review.compare", &json!({})).is_err()); + assert!(s.execute("review.accept", &json!({"index": 999})).is_err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..2c4df3cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod cmd; +pub mod compare; pub mod connect; pub mod fonts; pub mod guard; @@ -173,6 +174,8 @@ pub struct DocState { pub missing_fonts: Vec, /// An input method's composition in progress ([`Composition`]). pub composition: Option, + /// Review › Compare in progress: the revised copy. + pub compare: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -192,6 +195,7 @@ impl DocState { title, missing_fonts: Vec::new(), composition: None, + compare: None, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/ui-egui/src/compare_tests.rs b/crates/ui-egui/src/compare_tests.rs new file mode 100644 index 00000000..9108b4de --- /dev/null +++ b/crates/ui-egui/src/compare_tests.rs @@ -0,0 +1,61 @@ +//! Review › Compare: the Revisions pane lists a revised copy's changes and accepts them. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// The sample deck compared with a copy whose slide 2 is gone and that has a new last slide. +fn harness() -> (Harness<'static, SlideApp>, usize) { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let n = s.doc().unwrap().doc.slides.len(); + let data = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": data})).unwrap(); + s.execute("slide.delete", &json!({"index": 1})).unwrap(); + s.execute("slide.go", &json!({"index": n - 2})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "From the reviewer", "body": "x"})).unwrap(); + let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + s.close_document(1); + s.set_active(0); + s.execute("review.compare", &json!({"name": "Q3 review.deckcraft", "data": rev})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, n) +} + +#[test] +fn pane_opens_lists_changes_and_accept_all_applies_them() { + let (mut h, n) = harness(); + assert_eq!(h.state().ui.pane.as_deref(), Some("revisions"), "compare opens the Revisions pane"); + assert!(h.query_by_label("Compared with Q3 review.deckcraft").is_some()); + assert!(h.query_by_label("Slide 2 deleted").is_some()); + assert!(h.query_by_label_contains("Slide inserted after").is_some()); + h.get_by_label_contains("Accept All").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.slides.len(), n, "one slide gone, one slide added"); + assert!(h.query_by_label("No differences left.").is_some()); + h.get_by_label("End Review").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().compare.is_none()); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let (mut h, _) = harness(); + // Review tab. + h.state_mut().ui.tab = "Review".into(); + h.run_steps(4); + h.render().unwrap().save("/tmp/compare.png").unwrap(); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..9f28b35d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -167,6 +167,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), + ("app.compareDialog", "Compare…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), ("app.links", "Community Links", None, "{}"), ]; @@ -541,6 +542,17 @@ impl SlideApp { "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); @@ -1127,6 +1139,8 @@ fn js_now() -> f64 { js_sys::Date::now() } +#[cfg(test)] +mod compare_tests; #[cfg(test)] mod ime_tests; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..fac96b69 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "revisions" => "Revisions", _ => "Pane", }; ui.horizontal(|ui| { @@ -50,6 +51,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), + "revisions" => revisions(app, ui), _ => {} }); }); @@ -549,6 +551,61 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let _ = Stroke::NONE; } +/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it. +fn revisions(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(v) = app.session.execute("review.revisions", &json!({})) else { + ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + return; + }; + let changes = v["changes"].as_array().cloned().unwrap_or_default(); + ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); + if v["byPosition"].as_bool() == Some(true) { + ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + } + ui.add_space(4.0); + if changes.is_empty() { + ui.label("No differences left."); + if ui.button("End Review").clicked() { + run(app, "review.endCompare", json!({})); + app.ui.pane = None; + } + return; + } + if ui.button(format!("Accept All ({})", changes.len())).clicked() { + run(app, "review.accept", json!({"all": true})); + } + ui.separator(); + for (i, c) in changes.iter().enumerate() { + let slide = c["slide"].as_u64().unwrap_or(0); + let (head, col) = match c["kind"].as_str().unwrap_or("") { + "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), + "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), + _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), + }; + egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(2), col); + if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() { + let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0); + run(app, "slide.go", json!({"index": (slide as usize).min(max)})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Accept").clicked() { + run(app, "review.accept", json!({"index": i})); + } + }); + }); + for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) { + ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim)); + } + }); + ui.add_space(4.0); + } +} + fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if ui.button("+ New Comment").clicked() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..e4670704 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1791,6 +1791,19 @@ fn review(app: &mut SlideApp, ui: &mut Ui) { let _ = r.on_hover_text("Translation needs a language model (not bundled)"); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); + group(ui, |ui| { + big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); + let comparing = enabled(app, "review.endCompare"); + if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { + run(app, "review.endCompare", json!({})); + if app.ui.pane.as_deref() == Some("revisions") { + app.ui.pane = None; + } + } + }); group(ui, |ui| { big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..5188f10c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,7 +187,8 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide -show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile +show (8–12 h), compare presentations beyond slide-level accept (reject, per-object accept, +markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376 agile encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set). ## UI/UX diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..f00fa766 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -30,7 +30,7 @@ The summary between ` | Transitions | 92% | | Animations | 79% | | Slide show | 66% | -| Review | 60% | +| Review | 70% | | Draw | 33% | | Undo & clipboard | 88% | | Automation (✱) | 100% | @@ -221,7 +221,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Slide show | Multi-monitor presenting | P1 | M | | Review | Comments (add, reply, resolve, delete) | P1 | D | | Review | Accessibility checker | P2 | D | -| Review | Compare presentations | P2 | M | +| Review | Compare presentations | P2 | P | | Review | Track changes / version history | P2 | M | | Draw | Ink pens, highlighter, eraser | P1 | P | | Draw | Ink to shape / text | P2 | M | From 37559f793cc3075f366b760e8d1f9eb0dae6e25d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:14:52 +0300 Subject: [PATCH 04/15] UI: Grid Options sets spacing, snap and the on-screen grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Grid Options sets the grid spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn. The canvas grid follows that spacing. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/ui-egui/src/canvas.rs | 69 ++++++++++++++---- crates/ui-egui/src/dialogs.rs | 39 ++++++++++ crates/ui-egui/src/lib.rs | 129 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/ribbon.rs | 3 + docs/gaps.md | 5 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 11 +-- 10 files changed, 247 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..7d8fc2db 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **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-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | | 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-13 | trivial | Progress log: Grid Options dialog | | 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/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..4b37c2f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -135,18 +135,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } app.perf.render_ms = app.textures.last_render_ms; media_frames(app, &painter, xf); - if app.ui.gridlines { - let step = 72.0 / 2.0 * scale; - let mut x = slide_rect.min.x + step; - while x < slide_rect.max.x { - painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - x += step; - } - let mut y = slide_rect.min.y + step; - while y < slide_rect.max.y { - painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40))); - y += step; - } + if app.ui.gridlines + && let Some(step) = grid_line_step(app.session.prefs.grid_spacing, scale) + { + draw_grid(&painter, slide_rect, step); } if app.ui.guides { let c = slide_rect.center(); @@ -697,3 +689,56 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +/// Screen-point distance between grid lines. `spacing` is the grid spacing in slide points; +/// `scale` maps slide points onto the screen. `None` when the grid would not be a finite set of +/// lines (bad inputs, or so fine that a line would land on every pixel). +fn grid_line_step(spacing: f64, scale: f32) -> Option { + if !spacing.is_finite() || !(1.0..=288.0).contains(&spacing) || !scale.is_finite() || scale <= 0.0 { + return None; + } + let step = (spacing as f32) * scale; + if !step.is_finite() || step < 1.0 { + return None; + } + Some(step) +} + +fn draw_grid(painter: &egui::Painter, slide: Rect, step: f32) { + let stroke = Stroke::new(0.5, Color32::from_black_alpha(40)); + // A denser grid than this paints a grey slab and costs a frame. Skip it. + if !grid_fits(slide.width(), step) || !grid_fits(slide.height(), step) { + return; + } + let mut x = slide.min.x + step; + while x < slide.max.x { + painter.line_segment([pos2(x, slide.min.y), pos2(x, slide.max.y)], stroke); + x += step; + } + let mut y = slide.min.y + step; + while y < slide.max.y { + painter.line_segment([pos2(slide.min.x, y), pos2(slide.max.x, y)], stroke); + y += step; + } +} + +fn grid_fits(span: f32, step: f32) -> bool { + span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 +} + +#[cfg(test)] +mod tests { + use super::grid_line_step; + + #[test] + fn grid_step_follows_spacing_and_rejects_unusable_values() { + let step = grid_line_step(72.0, 0.5).unwrap_or(0.0); + assert!((step - 36.0).abs() < 1e-3, "{step}"); + assert!(grid_line_step(6.0, 1.0).is_some()); + assert!(grid_line_step(0.0, 1.0).is_none()); + assert!(grid_line_step(f64::NAN, 1.0).is_none()); + assert!(grid_line_step(1_000.0, 1.0).is_none()); + assert!(grid_line_step(6.0, 0.0).is_none()); + assert!(grid_line_step(6.0, 0.1).is_none(), "a 6 pt grid at 10% zoom is finer than one screen point"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..845ca485 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -45,6 +45,7 @@ fn title(id: &str) -> &'static str { "paragraph" => "Paragraph", "about" => "About DeckCraft", "preferences" => "Preferences", + "gridOptions" => "Grid Options", "export" => "Export", "symbol" => "Symbol", "equation" => "Equation", @@ -708,6 +709,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Close"); ok } + "gridOptions" => grid_options(app, ui, d), "preferences" => { ui.label(egui::RichText::new("General").font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark; @@ -738,6 +740,43 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } +fn format_spacing(v: f64) -> String { + if !v.is_finite() { + return "6".into(); + } + if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") } +} + +/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one +/// `view.gridOptions` call; Cancel leaves the presentation as it was. +fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); + let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); + let spacing_default = format_spacing(app.session.prefs.grid_spacing); + field(ui, d, "Spacing (pt):", "spacing", &spacing_default); + ui.add_space(6.0); + ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); + let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); + if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { + ui.add_space(4.0); + ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30))); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let spacing = num(d, "spacing", app.session.prefs.grid_spacing); + match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) { + Ok(_) => return true, + Err(e) => { + d.fields.insert("error".into(), e); + return false; + } + } + } + cancel +} + fn pretty_equation(s: &str) -> String { let sup = |c: char| match c { '0' => '⁰', diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..4de17d60 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.ruler", "Ruler", None, "{on?: bool}"), ("view.gridlines", "Gridlines", Some("Shift+F9"), "{on?: bool}"), ("view.guides", "Guides", Some("Alt+F9"), "{on?: bool}"), + ("view.gridOptions", "Grid Options…", None, "{spacing?: pt, snap?: bool, smartGuides?: bool, gridlines?: bool, guides?: bool}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Zoom…", None, "{percent}"), @@ -229,6 +230,18 @@ pub struct SlideApp { pub selpane: selpane::State, } +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + impl SlideApp { pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); @@ -458,6 +471,49 @@ impl SlideApp { "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } "view.zoomIn" | "view.zoomOut" => { let cur = self.ui.zoom.unwrap_or(self.canvas_scale); let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; @@ -1305,6 +1361,79 @@ mod tests { app.run("app.home", json!({})).unwrap(); assert!(!app.backstage_open(), "with nothing open the start screen shows anyway"); } + + #[test] + fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); + let v = + app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + assert_eq!(v["spacing"], 72.0); + assert_eq!(v["snap"], false); + assert_eq!(v["smartGuides"], false); + assert_eq!(v["gridlines"], true); + assert_eq!(v["guides"], true); + assert!(app.dialog.is_none()); + assert!(app.ui.gridlines && app.ui.guides); + assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); + + app.run("view.gridOptions", json!({"snap": true})).unwrap(); + assert!(app.session.prefs.snap_to_grid); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.dialog.is_none()); + + for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { + assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); + } + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.session.prefs.snap_to_grid); + + app.run("view.gridOptions", json!({})).unwrap(); + assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + } + + #[test] + fn grid_options_dialog_lists_spacing_and_display() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Display grid on screen").is_some()); + assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); + assert!(harness.query_by_label_contains("Spacing").is_some()); + let show_grid = harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") + .next() + .unwrap_or_else(|| panic!("no grid checkbox")); + show_grid.click_accesskit(); + harness.run_steps(2); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); + assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); + assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + } + fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..ba9dfb38 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -194,6 +194,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Ruler", "view.ruler"), ("Gridlines", "view.gridlines"), ("Guides", "view.guides"), + ("Grid Options…", "view.gridOptions"), ("-", ""), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..722a3d05 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1842,6 +1842,9 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { app.ui.guides = gd; } }); + if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { + let _ = app.run("view.gridOptions", json!({})); + } let n = app.ui.notes; if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { app.ui.notes = !n; diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..4c68b1af 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **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,7 +203,7 @@ 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, +- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (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. @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing | | 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/roadmap.md b/docs/roadmap.md index 97953034..11bfc0aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options; previous: core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -80,7 +80,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as | M7 | Slide show | mostly | second-display placement, laser, record | | M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table | | M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps | -| M10 | Views | done | grid options, New Window | +| M10 | Views | done | New Window | | M11 | Output | partly | vector PDF, SVG, GIF, MP4, print | | M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare | | M13 | Automation and CLI parity | done | | @@ -109,5 +109,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M10: Grid Options dialog; New Window stays open | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..cf565002 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (Views: Grid Options landed; New Window remains; previous: hours to ~95% per audience added; ready 56% → 55%) · **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; @@ -182,7 +182,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Application shell | 5 | 71% | ~55% | 8–14 | No native macOS menu bar, thin start screen (templates, recent), one window per presentation missing | [ui-parity.md](ui-parity.md) | | Files (commands) | 4 | 69% | ~50% | see file formats | Print, reuse slides, passwords, legacy and macro formats | [file-format-parity.md](file-format-parity.md) | | Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | -| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) | +| Views | 4 | 90% | ~80% | 3–5 | New Window | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) | | Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) | | Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Views: Grid Options dialog landed (spacing, snap, display). Remaining in that row: New Window. Percentages 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..af76d104 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: 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-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) @@ -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, Grid Options, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -74,7 +74,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Smart guides (edges, centres, equal spacing) | yes | yes | -| Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle | +| Snap to grid, grid options, drawing guides | yes | Grid Options (`view.gridOptions`): spacing, snap to grid, snap to other objects, display grid and guides; the on-screen grid uses that spacing | | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | @@ -139,15 +139,16 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Format Shape pane depth | 6–10 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | -| Grid Options, edit points UI, crop depth | 4–7 | +| Edit points UI, crop depth | 3–6 | | Multiple windows | 3–5 | | A screenshot comparison pass on the most-used tabs | 2–4 | -| **Total** | **~30–52** | +| **Total** | **~29–51** | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Grid Options dialog: spacing, snap, and whether gridlines and guides are drawn; the on-screen grid uses that spacing | | 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 | From 1b326e2e8729d31925e6dbdb9d8484242d51afae Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:34:30 +0300 Subject: [PATCH 05/15] Review: Thesaurus pane with a built-in English word list Review > Thesaurus (Shift+F7) opens a Thesaurus pane on the selected word, or on the word at the caret. Clicking a synonym while editing text puts it in place of that word in one undo step, and the pane then looks up the new word. You can also type a word into the pane to look it up. The word list is written for DeckCraft and has no third-party source: about 260 senses of common presentation vocabulary in engine/data/thesaurus.txt. It is embedded, so the Thesaurus also works offline and in the browser build. Lookups try simple English endings and keep the word's capitalisation. New commands: review.thesaurus (a query) and review.replaceWord. Ribbon big buttons now carry an accessibility label. Co-authored-by: Cursor --- crates/engine/data/thesaurus.txt | 264 ++++++++++++++++++++++++++ crates/engine/src/cmd/review.rs | 43 +++++ crates/engine/src/lib.rs | 1 + crates/engine/src/thesaurus.rs | 119 ++++++++++++ crates/ui-egui/src/lib.rs | 17 ++ crates/ui-egui/src/panes.rs | 41 ++++ crates/ui-egui/src/ribbon.rs | 3 +- crates/ui-egui/src/thesaurus_tests.rs | 54 ++++++ crates/ui-egui/src/widgets.rs | 3 + docs/parity-checklist.md | 6 +- 10 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 crates/engine/data/thesaurus.txt create mode 100644 crates/engine/src/thesaurus.rs create mode 100644 crates/ui-egui/src/thesaurus_tests.rs diff --git a/crates/engine/data/thesaurus.txt b/crates/engine/data/thesaurus.txt new file mode 100644 index 00000000..1916eb46 --- /dev/null +++ b/crates/engine/data/thesaurus.txt @@ -0,0 +1,264 @@ +# DeckCraft's built-in thesaurus: headword|synonym, synonym, ... +# Written for DeckCraft (no third-party word list). Lowercase; one meaning group per line; +# a headword may appear on several lines (one per sense). +ability|capability, capacity, skill, competence, talent +able|capable, competent, qualified, skilled +accept|agree to, approve, adopt, embrace +accurate|precise, exact, correct, right +achieve|accomplish, attain, reach, realize, deliver +achievement|accomplishment, success, attainment, milestone +add|include, append, attach, insert +advantage|benefit, edge, strength, asset, upside +affect|influence, impact, change, alter +aim|goal, objective, target, purpose, intent +allow|permit, enable, let, authorize +analyze|examine, study, assess, evaluate, review +answer|reply, response, solution +approach|method, strategy, way, technique, plan +area|region, field, domain, zone, sector +arrange|organize, order, sort, plan +ask|request, inquire, query, question +assess|evaluate, gauge, judge, review, appraise +assist|help, aid, support, back +attempt|try, effort, endeavor, bid +bad|poor, weak, inferior, unfavorable, negative +basic|fundamental, essential, core, elementary, simple +begin|start, commence, launch, initiate, open +benefit|advantage, gain, value, payoff, upside +best|finest, top, leading, optimal, premier +big|large, major, substantial, significant, sizable +boost|increase, raise, lift, improve, strengthen +brief|short, concise, succinct, quick +bright|vivid, brilliant, luminous, clever +build|construct, create, develop, assemble, establish +business|company, firm, enterprise, organization, venture +buy|purchase, acquire, obtain +careful|cautious, thorough, meticulous, attentive +cause|reason, source, origin, driver +change|alter, modify, adjust, revise, transform +change|shift, transition, adjustment, revision +cheap|inexpensive, affordable, low-cost, economical +check|verify, confirm, examine, inspect, review +choice|option, alternative, selection, pick +choose|select, pick, opt for, decide on +clear|plain, obvious, evident, transparent, lucid +client|customer, buyer, account, patron +close|near, nearby, adjacent +close|end, finish, conclude, shut +common|frequent, usual, typical, widespread, shared +company|business, firm, organization, enterprise, corporation +complete|finish, conclude, finalize, accomplish +complete|whole, full, entire, total +complex|complicated, intricate, involved, elaborate +concern|worry, issue, problem, risk +consider|weigh, contemplate, evaluate, think about +continue|proceed, carry on, keep going, persist, maintain +cost|price, expense, charge, outlay +create|make, build, produce, design, generate +critical|crucial, vital, essential, key, decisive +current|present, existing, ongoing, latest, up-to-date +customer|client, buyer, consumer, user +data|information, figures, facts, statistics +decide|determine, choose, resolve, settle +decrease|reduce, lower, cut, decline, drop +define|specify, describe, explain, outline +deliver|provide, supply, bring, hand over, ship +demonstrate|show, prove, illustrate, display +describe|explain, outline, portray, depict +design|plan, layout, blueprint, pattern +design|create, plan, devise, draft +develop|build, create, grow, expand, evolve +difference|distinction, contrast, gap, variation +different|distinct, various, diverse, unlike +difficult|hard, challenging, tough, demanding +discuss|talk about, debate, examine, review +easy|simple, straightforward, effortless, painless +effect|result, impact, outcome, consequence +effective|successful, efficient, productive, powerful +effort|work, attempt, endeavor, exertion +end|finish, conclusion, close, completion +enough|sufficient, adequate, ample +ensure|guarantee, make sure, secure, confirm +error|mistake, fault, flaw, bug, slip +essential|vital, crucial, key, necessary, fundamental +establish|set up, found, create, institute +estimate|forecast, projection, approximation, guess +evaluate|assess, appraise, judge, rate, review +event|occasion, function, happening, incident +example|instance, case, illustration, sample +excellent|outstanding, superb, exceptional, first-rate +expand|grow, extend, enlarge, broaden, scale +expect|anticipate, foresee, predict, await +explain|clarify, describe, illustrate, account for +fail|fall short, miss, flop, break down +fast|quick, rapid, swift, speedy +feature|characteristic, attribute, capability, function +final|last, closing, concluding, ultimate +find|discover, locate, identify, uncover +finish|complete, end, conclude, wrap up +focus|concentrate, center, target, emphasize +focus|emphasis, priority, attention, core +future|upcoming, coming, forthcoming, prospective +gain|increase, rise, improvement, profit +get|obtain, acquire, receive, gain +give|provide, offer, supply, grant +goal|aim, objective, target, purpose, ambition +good|fine, great, strong, solid, positive +great|excellent, outstanding, terrific, remarkable +grow|increase, expand, rise, develop, scale +growth|expansion, increase, development, rise +help|assist, aid, support, enable +help|assistance, aid, support, guidance +high|elevated, tall, top, strong +idea|concept, notion, thought, proposal +identify|recognize, pinpoint, detect, determine +impact|effect, influence, consequence, result +important|significant, key, major, crucial, vital +improve|enhance, upgrade, refine, strengthen, boost +include|contain, cover, incorporate, comprise +increase|raise, boost, grow, expand, lift +increase|rise, growth, gain, jump +information|data, details, facts, knowledge +innovative|inventive, creative, original, novel, pioneering +issue|problem, concern, matter, question +job|task, assignment, role, position +keep|retain, maintain, preserve, hold +key|main, central, critical, essential, principal +large|big, sizable, substantial, major, extensive +last|final, latest, previous, closing +launch|start, introduce, roll out, release, unveil +lead|guide, direct, head, steer, run +learn|discover, find out, master, study +less|fewer, smaller, reduced, lower +little|small, minor, slight, modest +look|appear, seem +look|view, glance, examine, inspect +low|minimal, modest, small, reduced +main|primary, chief, principal, central, key +maintain|keep, preserve, sustain, uphold +major|significant, important, key, principal, big +make|create, build, produce, generate +manage|run, handle, oversee, direct, lead +market|industry, sector, segment, marketplace +measure|gauge, assess, quantify, track +meet|satisfy, fulfil, reach, achieve +meeting|session, conference, discussion, review +method|approach, technique, process, procedure, way +model|framework, template, pattern, design +more|additional, extra, further, added +move|shift, transfer, relocate, transition +need|requirement, demand, necessity +need|require, call for, demand, want +new|fresh, recent, novel, modern, latest +next|following, upcoming, subsequent +number|figure, count, total, quantity, amount +objective|goal, aim, target, purpose +offer|provide, propose, extend, present +old|previous, former, legacy, outdated, existing +open|start, launch, begin, unveil +opportunity|opening, chance, prospect, possibility +option|choice, alternative, possibility, route +organize|arrange, structure, coordinate, order +outcome|result, consequence, effect, end result +overview|summary, outline, synopsis, recap +part|portion, piece, segment, section, component +partner|ally, collaborator, associate, affiliate +people|staff, team, individuals, personnel +plan|strategy, scheme, roadmap, proposal, program +plan|organize, arrange, schedule, prepare +point|aspect, detail, issue, argument +popular|well-liked, widespread, in demand, favored +possible|feasible, achievable, viable, potential +present|show, display, introduce, demonstrate +present|current, existing, ongoing +price|cost, rate, charge, fee +priority|focus, concern, precedence, goal +problem|issue, challenge, difficulty, obstacle +process|procedure, method, workflow, system +produce|make, create, generate, manufacture, yield +product|offering, item, solution, good +profit|earnings, return, gain, margin +progress|advance, headway, development, improvement +project|initiative, program, undertaking, venture +propose|suggest, recommend, put forward, offer +provide|give, supply, deliver, offer +purpose|aim, goal, objective, intent, reason +quality|standard, caliber, excellence, grade +question|query, issue, inquiry +quick|fast, rapid, speedy, swift, prompt +rapid|fast, quick, swift, speedy +reach|achieve, attain, hit, arrive at +reason|cause, rationale, motive, basis +recent|latest, new, current, fresh +recommend|suggest, advise, propose, advocate +reduce|cut, lower, decrease, lessen, trim +region|area, territory, zone, market +relevant|pertinent, applicable, related, appropriate +remove|delete, eliminate, take out, drop +replace|substitute, swap, supersede, change +report|account, summary, review, statement +require|need, demand, call for, necessitate +research|study, investigation, analysis, inquiry +resource|asset, supply, means, capacity +result|outcome, effect, consequence, finding +review|assessment, evaluation, examination, analysis +review|examine, assess, evaluate, check, revisit +revenue|income, sales, earnings, turnover +risk|danger, threat, exposure, hazard +role|part, function, position, responsibility +run|operate, manage, conduct, execute +safe|secure, protected, reliable +same|identical, equal, matching, equivalent +save|keep, store, preserve, conserve +see|view, observe, notice, spot +sell|market, offer, trade, retail +service|support, assistance, offering +share|portion, part, stake, percentage +share|distribute, spread, communicate, pass on +show|demonstrate, display, present, reveal, illustrate +significant|important, notable, considerable, major, meaningful +similar|alike, comparable, related, equivalent +simple|easy, straightforward, plain, basic +size|scale, extent, magnitude, dimensions +small|little, minor, compact, modest +solution|answer, fix, remedy, resolution +solve|resolve, fix, settle, work out +specific|particular, precise, exact, distinct +start|begin, launch, commence, kick off, initiate +step|stage, phase, measure, action +strategy|plan, approach, policy, roadmap, tactic +strong|robust, solid, powerful, sturdy +structure|framework, organization, layout, arrangement +success|achievement, accomplishment, win, triumph +successful|effective, thriving, prosperous, winning +suggest|propose, recommend, advise, imply +summary|overview, recap, synopsis, digest +support|help, back, assist, sustain +support|help, assistance, backing, aid +sure|certain, confident, positive, definite +target|goal, objective, aim +task|job, assignment, duty, activity +team|group, crew, staff, squad, unit +test|trial, check, experiment, evaluation +test|try, check, evaluate, verify +think|believe, consider, reckon, suppose +time|period, duration, while, moment +tool|instrument, utility, device, aid +total|sum, whole, aggregate, overall +trend|pattern, tendency, movement, direction +understand|grasp, comprehend, follow, realize +unique|distinctive, singular, one-of-a-kind, unmatched +update|revise, refresh, upgrade, modernize +update|revision, change, upgrade, news +use|employ, apply, utilize, leverage +useful|helpful, practical, handy, valuable +value|worth, benefit, merit, importance +various|diverse, assorted, different, several +view|opinion, perspective, outlook, position +vision|outlook, aspiration, goal, dream +way|method, approach, manner, means +weak|feeble, fragile, poor, limited +well|effectively, successfully, nicely, properly +work|labor, effort, activity, job +work|function, operate, run, succeed +year|annum, twelve months diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 8e3a532c..bf3b3920 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -16,6 +16,16 @@ pub fn specs() -> Vec { cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), + cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus), + cmd!( + "review.replaceWord", + "Insert Synonym", + [], + None, + "{with}: replace the selected word (or the word at the caret) while editing text", + has_text, + replace_word + ), cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling), cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { @@ -264,3 +274,36 @@ fn custom_show(s: &mut Session, p: &Value) -> Result { })?; ok() } + +/// The selected text when it lies in one paragraph, else the word at the caret. +fn word_under_caret(s: &Session) -> Option { + let st = s.doc().ok()?; + let t = st.selection.text.clone()?; + let body = super::text::body_of(st, &t)?; + let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) }; + if a.0 != b.0 { + return None; + } + let text = body.paragraphs.get(a.0)?.text(); + let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect(); + let w = w.trim().to_string(); + (!w.is_empty()).then_some(w) +} + +fn thesaurus(s: &mut Session, p: &Value) -> Result { + let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default(); + let senses = crate::thesaurus::lookup(&word); + Ok(json!({"word": word, "senses": senses})) +} + +fn replace_word(s: &mut Session, p: &Value) -> Result { + let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string(); + if with.chars().count() > 200 { + return Err(bad("review.replaceWord", "too long")); + } + let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret); + if collapsed { + s.execute("text.selectWord", &json!({}))?; + } + s.execute("text.insert", &json!({"text": with})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..398801d4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod keys; pub mod links; pub mod recovery; pub mod sample; +pub mod thesaurus; pub mod tools; use std::sync::Arc; diff --git a/crates/engine/src/thesaurus.rs b/crates/engine/src/thesaurus.rs new file mode 100644 index 00000000..10cc207d --- /dev/null +++ b/crates/engine/src/thesaurus.rs @@ -0,0 +1,119 @@ +//! Review › Thesaurus: synonyms from a small built-in English word list (`data/thesaurus.txt`), +//! looked up offline so it works in the browser build too. + +const DATA: &str = include_str!("../data/thesaurus.txt"); + +/// One sense of a word: its headword and synonyms. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Sense { + pub word: String, + pub synonyms: Vec, +} + +fn senses_of(head: &str) -> Vec { + DATA.lines() + .filter(|l| !l.starts_with('#')) + .filter_map(|l| l.split_once('|')) + .filter(|(h, _)| h.trim() == head) + .map(|(h, s)| Sense { word: h.trim().to_string(), synonyms: s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() }) + .collect() +} + +/// The senses for `word`, trying simple English endings when the word itself isn't listed +/// ("goals" → "goal", "improved" → "improve", "growing" → "grow"). Synonyms take the word's +/// capitalisation ("Goal" → "Aim", "GOAL" → "AIM"). +pub fn lookup(word: &str) -> Vec { + let w: String = word.trim().trim_matches(|c: char| !c.is_alphanumeric() && c != '-').chars().take(64).collect(); + let lower = w.to_lowercase(); + if lower.is_empty() { + return vec![]; + } + let mut tries = vec![lower.clone()]; + for (suffix, add) in [("ies", "y"), ("es", ""), ("s", ""), ("ied", "y"), ("ed", "e"), ("ed", ""), ("ing", "e"), ("ing", "")] { + if let Some(stem) = lower.strip_suffix(suffix) + && stem.chars().count() >= 2 + { + tries.push(format!("{stem}{add}")); + } + } + let Some(found) = tries.iter().map(|t| senses_of(t)).find(|v| !v.is_empty()) else { return vec![] }; + let upper = w.chars().count() > 1 && w.chars().all(|c| !c.is_lowercase()); + let title = !upper && w.chars().next().is_some_and(char::is_uppercase); + found + .into_iter() + .map(|s| Sense { + word: s.word, + synonyms: s + .synonyms + .into_iter() + .map(|x| { + if upper { + x.to_uppercase() + } else if title { + let mut c = x.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } else { + x + } + }) + .collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn senses_endings_and_case() { + let g = lookup("goal"); + assert_eq!(g.len(), 1); + assert!(g[0].synonyms.contains(&"objective".to_string())); + assert_eq!(lookup("change").len(), 2, "verb and noun senses"); + assert_eq!(lookup("Goals")[0].synonyms[0], "Aim"); + assert_eq!(lookup("GOAL")[0].synonyms[0], "AIM"); + assert_eq!(lookup("improved")[0].word, "improve"); + assert_eq!(lookup("growing")[0].word, "grow"); + assert_eq!(lookup("strategies")[0].word, "strategy"); + assert_eq!(lookup("“goal,”")[0].word, "goal", "punctuation around the word"); + assert!(lookup("").is_empty() && lookup("zzxq").is_empty() && lookup(" ").is_empty()); + assert!(lookup(&"a".repeat(10_000)).is_empty()); + } + + #[test] + fn data_is_well_formed() { + for l in DATA.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let (h, s) = l.split_once('|').unwrap_or_else(|| panic!("no `|`: {l}")); + assert_eq!(h, h.trim().to_lowercase(), "{l}"); + let syn: Vec<&str> = s.split(',').map(str::trim).collect(); + assert!(syn.len() >= 2 && syn.iter().all(|x| !x.is_empty() && *x != h), "{l}"); + let mut d = syn.clone(); + d.sort(); + d.dedup(); + assert_eq!(d.len(), syn.len(), "duplicate synonym: {l}"); + } + } + + #[test] + fn commands_look_up_and_replace_the_word_at_the_caret() { + use serde_json::json; + let mut s = crate::Session::with_new(); + let id = s.execute("insert.textBox", &json!({"rect": [50, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let r = s.execute("review.thesaurus", &json!({})).unwrap(); + assert_eq!(r["word"], "Goal"); + assert_eq!(r["senses"][0]["synonyms"][0], "Aim"); + s.execute("review.replaceWord", &json!({"with": "Aim"})).unwrap(); + let text = |s: &crate::Session| { + s.doc().unwrap().shapes().iter().find(|x| x.id.0 as u64 == id).and_then(|x| x.text.as_ref()).map(|t| t.paragraphs[0].text()).unwrap() + }; + assert_eq!(text(&s), "Our Aim is growth."); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s), "Our Goal is growth."); + assert_eq!(s.execute("review.thesaurus", &json!({"word": "growth."})).unwrap()["senses"][0]["word"], "growth"); + assert!(s.execute("review.replaceWord", &json!({"with": ""})).is_err()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..d1a82cb7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -146,6 +146,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.zoom", "Zoom…", None, "{percent}"), ("view.fit", "Fit to Window", None, "{}"), + ("review.thesaurusPane", "Thesaurus", Some("Shift+F7"), "{word?}: open the Thesaurus pane on the selected word"), ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), @@ -184,6 +185,8 @@ pub struct SlideApp { pub textures: textures::Textures, pub control_rx: Option>, pub dialog: Option, + /// The word looked up in the Thesaurus pane. + pub thesaurus_query: String, pub show: Option, /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, @@ -239,6 +242,7 @@ impl SlideApp { textures: textures::Textures::default(), control_rx: None, dialog: None, + thesaurus_query: String::new(), show: None, media: media::MediaHost::new(audio_out), status: None, @@ -473,6 +477,19 @@ impl SlideApp { self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); } "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } "view.pane" => { let pane = p.get("pane").and_then(Value::as_str).map(String::from); self.ui.pane = diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..0cf608e6 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => "Selection", "comments" => "Comments", "designer" => "Design Ideas", + "thesaurus" => "Thesaurus", _ => "Pane", }; ui.horizontal(|ui| { @@ -50,6 +51,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), + "thesaurus" => thesaurus(app, ui), _ => {} }); }); @@ -635,3 +637,42 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(8.0); } } + +/// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word +/// when editing text, otherwise looks that synonym up in turn. +fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let mut q = app.thesaurus_query.clone(); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + if edit.changed() { + app.thesaurus_query = q.clone(); + } + ui.add_space(6.0); + let senses = deckcraft_engine::thesaurus::lookup(&q); + if q.trim().is_empty() { + ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + return; + } + if senses.is_empty() { + ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); + return; + } + let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); + for sense in senses { + ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); + for syn in sense.synonyms { + let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); + if r.clicked() { + if editing { + run(app, "review.replaceWord", json!({"with": syn})); + } + app.thesaurus_query = syn; + } + } + ui.add_space(6.0); + } +} + +#[cfg(test)] +#[path = "thesaurus_tests.rs"] +mod thesaurus_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..d9c9f39c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1780,8 +1780,7 @@ fn record(app: &mut SlideApp, ui: &mut Ui) { fn review(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); - let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false); - let _ = r.on_hover_text("Thesaurus (coming soon)"); + big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); }); group(ui, |ui| { big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); diff --git a/crates/ui-egui/src/thesaurus_tests.rs b/crates/ui-egui/src/thesaurus_tests.rs new file mode 100644 index 00000000..985e6401 --- /dev/null +++ b/crates/ui-egui/src/thesaurus_tests.rs @@ -0,0 +1,54 @@ +//! Review › Thesaurus driven like a person: caret in a word, open the pane, click a synonym. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn thesaurus_pane_swaps_the_word_at_the_caret() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 100, 500, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Our Goal is growth."})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 6]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Review".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Thesaurus").click(); + h.run_steps(3); + assert_eq!(h.state().ui.pane.as_deref(), Some("thesaurus")); + assert_eq!(h.state().thesaurus_query, "Goal"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/thesaurus.png"); + } + h.get_by_label("Objective").click(); + h.run_steps(3); + let text = h + .state() + .session + .active() + .unwrap() + .shapes() + .iter() + .find(|x| x.id.0 as u64 == id) + .and_then(|x| x.text.as_ref()) + .map(|t| t.paragraphs[0].text()); + assert_eq!(text.as_deref(), Some("Our Objective is growth.")); + assert_eq!(h.state().thesaurus_query, "Objective", "the pane follows the new word"); + + // Typing a word that isn't listed says so instead of showing nothing. + h.state_mut().thesaurus_query = "zzxq".into(); + h.run_steps(2); + assert!(h.query_by_label_contains("No synonyms").is_some()); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..97480599 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); + // Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape"). + let spoken = label.replace('\n', " "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken)); let painter = ui.painter_at(rect.expand(1.0)); let (main_rect, arrow_rect) = if split { (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..fb8e3698 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -21,7 +21,7 @@ The summary between ` | Shapes | 88% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 81% | | Tables | 89% | | Charts | 65% | | SmartArt | 33% | @@ -155,7 +155,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Text direction (rotated, stacked) | P1 | D | | Text | Find and replace, replace fonts | P1 | P | | Text | Spelling check | P1 | P | -| Text | Thesaurus | P2 | M | +| Text | Thesaurus | P2 | P | | Text | AutoCorrect and smart quotes | P2 | M | | Text | Symbols and emoji | P1 | D | | Text | Equations | P2 | M | From 4ba7deb5ff19dfcc307d27643e74925a9b42a695 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:44:40 +0300 Subject: [PATCH 06/15] Text: Find and Replace across slides, tables and notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds text.find (match case, whole words; matches in shape text, table cells and speaker notes) and text.replace (one match or all, as one undo step, keeping the first run's formatting). Home gets Find and Replace buttons, Ctrl+F / Ctrl+H and Edit › Find… / Replace… open a Find and Replace dialog that steps through matches slide by slide. Co-authored-by: Cursor --- crates/engine/src/cmd/find.rs | 240 ++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/tests.rs | 26 ++++ crates/ui-egui/src/dialogs.rs | 81 ++++++++++++ crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/ribbon.rs | 6 + docs/ui-parity.md | 4 +- 8 files changed, 369 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/find.rs diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs new file mode 100644 index 00000000..4991cd1f --- /dev/null +++ b/crates/engine/src/cmd/find.rs @@ -0,0 +1,240 @@ +//! Home › Editing: Find and Replace across slide text, table cells and speaker notes. + +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.find", + "Find", + ["Home", "Editing"], + None, + "{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)", + has_doc, + find + ), + cmd!( + "text.replace", + "Replace", + ["Home", "Editing"], + None, + "{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}", + has_doc, + replace + ), + ] +} + +/// Where a text body lives on a slide. +#[derive(Clone, Copy, PartialEq, Debug)] +struct Loc { + shape: Option, + cell: Option<(usize, usize)>, +} + +/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes. +fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) { + fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) { + if depth > 64 { + return; + } + for sh in shapes { + let id = Some(u64::from(sh.id.0)); + if let Some(t) = &mut sh.text { + f(Loc { shape: id, cell: None }, t); + } + match &mut sh.kind { + ShapeKind::Table(t) => { + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text); + } + } + } + ShapeKind::Group { children, .. } => rec(children, depth + 1, f), + _ => {} + } + } + } + rec(&mut slide.shapes, 0, f); + f(Loc { shape: None, cell: None }, &mut slide.notes); +} + +struct Opts { + pat: Vec, + case: bool, + whole: bool, +} + +impl Opts { + fn from(p: &Value, cmd: &str) -> Result { + let pat: Vec = str_param(p, "text").unwrap_or("").chars().collect(); + if pat.is_empty() { + return Err(bad(cmd, "missing `text`")); + } + Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) }) + } + fn eq(&self, a: char, b: char) -> bool { + a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase())) + } + /// Non-overlapping matches as char ranges. + fn matches(&self, text: &[char]) -> Vec<(usize, usize)> { + let n = self.pat.len(); + let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_'); + let mut out = vec![]; + let mut i = 0; + while i + n <= text.len() { + let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b)) + && (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n)))); + if hit { + out.push((i, i + n)); + i += n; + } else { + i += 1; + } + } + out + } +} + +fn para_chars(p: &Paragraph) -> Vec { + p.runs.iter().flat_map(|r| r.text.chars()).collect() +} + +/// Matches that lie wholly in plain text runs (never inside a field, equation or break). +fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut off = 0; + for r in &p.runs { + let n = r.text.chars().count(); + spans.push((off, off + n, matches!(r.kind, RunKind::Text))); + off += n; + } + o.matches(¶_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect() +} + +/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting. +fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) { + let mut off = 0; + let mut placed = false; + for r in &mut p.runs { + let chars: Vec = r.text.chars().collect(); + let (s, e) = (off, off + chars.len()); + off = e; + if e <= a || s >= b { + continue; + } + let keep_head: String = chars[..a.saturating_sub(s)].iter().collect(); + let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect(); + r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") }; + placed = true; + } + if p.runs.len() > 1 { + p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text))); + } +} + +fn find(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.find")?; + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + let mut sl = (**sl).clone(); + bodies(&mut sl, &mut |loc, t| { + for (pi, para) in t.paragraphs.iter().enumerate() { + for (a, b) in para_matches(para, &o) { + out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b})); + } + } + }); + } + Ok(Value::Array(out)) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.replace")?; + let with = str_param(p, "with").unwrap_or("").to_string(); + let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> { + let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`"))); + let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))); + Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?)) + }); + let at = at.transpose()?; + let count = s.edit(|doc, _| { + let mut count = 0; + for (i, slp) in doc.slides.iter_mut().enumerate() { + if at.is_some_and(|a| a.0 != i) { + continue; + } + let mut sl = (**slp).clone(); + let mut changed = 0; + bodies(&mut sl, &mut |loc, t| { + if at.is_some_and(|a| a.1 != loc) { + return; + } + for (pi, para) in t.paragraphs.iter_mut().enumerate() { + if at.is_some_and(|a| a.2 != pi) { + continue; + } + for (a, b) in para_matches(para, &o).into_iter().rev() { + if at.is_some_and(|x| x.3 != a) { + continue; + } + splice(para, a, b, &with); + changed += 1; + } + } + }); + if changed > 0 { + *slp = Arc::new(sl); + count += changed; + } + } + if count == 0 { + return Err(bad("text.replace", "no matches")); + } + Ok(count) + })?; + Ok(json!({"replaced": count})) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::{Run, RunProps}; + + fn opts(t: &str, case: bool, whole: bool) -> Opts { + Opts { pat: t.chars().collect(), case, whole } + } + + #[test] + fn matching_honours_case_and_whole_words() { + let t: Vec = "Cat cat category".chars().collect(); + assert_eq!(opts("cat", false, false).matches(&t).len(), 3); + assert_eq!(opts("cat", true, false).matches(&t).len(), 2); + assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]); + } + + #[test] + fn splice_across_runs_keeps_the_first_runs_format() { + let bold = RunProps { bold: Some(true), ..Default::default() }; + let mut p = Paragraph { + runs: vec![ + Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text }, + Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text }, + ], + ..Default::default() + }; + splice(&mut p, 1, 5, "ola"); + assert_eq!(para_chars(&p).iter().collect::(), "Hola world"); + assert_eq!(p.runs[0].text, "Hola"); + assert_eq!(p.runs[0].props, bold); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..7e0b7e36 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ pub mod arrange; pub mod design; pub mod edit; pub mod file; +pub mod find; pub mod format; pub mod insert; pub mod inspect; @@ -139,6 +140,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(transition::specs()); v.extend(animation::specs()); v.extend(table::specs()); + v.extend(find::specs()); v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..e02bc740 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,29 @@ 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 find_and_replace_across_shapes_and_notes() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old plan, old team\nboldly"})).unwrap(); + s.execute("slide.notes", &json!({"text": "Discuss the old plan"})).unwrap(); + let q = json!({"text": "old"}); + let found = s.execute("text.find", &q).unwrap(); + let n = found.as_array().unwrap().len(); + assert!(n >= 3, "{found}"); + let whole = s.execute("text.find", &json!({"text": "old", "wholeWords": true, "matchCase": true})).unwrap(); + assert_eq!(whole.as_array().unwrap().len(), n - 2, "Old and boldly are not whole lower-case matches"); + + let first = found[0].clone(); + assert_eq!((first["para"].as_u64(), first["start"].as_u64()), (Some(0), Some(0))); + s.execute("text.replace", &json!({"text": "old", "with": "New", "at": first})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); + + let r = s.execute("text.replace", &json!({"text": "old", "with": "new", "wholeWords": true})).unwrap(); + assert_eq!(r["replaced"].as_u64(), Some(n as u64 - 2)); + let text = s.doc().unwrap().doc.slides[0].shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::>().join("|"); + assert!(text.contains("New plan, new team\nboldly"), "{text}"); + assert!(s.execute("text.replace", &json!({"text": "zebra", "with": "x"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..24e5264d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -49,6 +49,7 @@ fn title(id: &str) -> &'static str { "symbol" => "Symbol", "equation" => "Equation", "spelling" => "Spelling", + "find" => "Find and Replace", "accessibility" => "Accessibility Checker", "language" => "Language", "customShow" => "Custom Shows", @@ -509,6 +510,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "find" => find_dialog(app, ui, d), "spelling" => { let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); @@ -829,3 +831,82 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. +fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let replace_mode = d.get("replace", "0") == "1"; + field(ui, d, "Find what:", "text", ""); + if replace_mode { + field(ui, d, "Replace with:", "with", ""); + } + let case = check(ui, d, "Match case", "matchCase", false); + let whole = check(ui, d, "Find whole words only", "wholeWords", false); + let text = d.get("text", ""); + let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); + let mut close = false; + let mut find_next = false; + ui.add_space(6.0); + ui.horizontal(|ui| { + let has = !text.is_empty(); + if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + find_next = true; + } + if replace_mode { + if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); + if let Some(at) = cur { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + p["at"] = at; + if app.session.execute("text.replace", &p).is_ok() { + let n = d.get("next", "1").parse::().unwrap_or(1); + d.fields.insert("next".into(), n.saturating_sub(1).to_string()); + } + } + find_next = true; + } + if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + let msg = match app.session.execute("text.replace", &p) { + Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), + Err(_) => format!("DeckCraft can't find “{text}”."), + }; + d.fields.insert("status".into(), msg); + d.fields.remove("cur"); + } + } else if ui.button("Replace…").clicked() { + d.fields.insert("replace".into(), "1".into()); + } + if ui.button("Close").clicked() { + close = true; + } + }); + if find_next { + let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.remove("cur"); + } else { + let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); + let m = &list[n]; + let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + d.fields.insert("cur".into(), m.to_string()); + d.fields.insert("next".into(), (n + 1).to_string()); + let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + d.fields.insert( + "status".into(), + format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + ); + } + } + let status = d.get("status", ""); + if !status.is_empty() { + ui.add_space(4.0); + ui.label(status); + } + close +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..9ac7f431 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -168,6 +168,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), + ("app.find", "Find…", Some("Cmd+F"), "{}"), + ("app.replace", "Replace…", Some("Cmd+H"), "{}"), ("app.links", "Community Links", None, "{}"), ]; @@ -541,6 +543,12 @@ impl SlideApp { "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..ce1e2b39 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -61,10 +61,8 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); return true; } - (F, true, false, false) => { - app.palette = Some((String::new(), 0)); - return true; - } + (F, true, false, false) => "app.find", + (H, true, false, false) => "app.replace", _ => return false, }; let _ = app.run(id, json!({})); @@ -176,7 +174,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Duplicate", "edit.duplicate"), ("Delete Slide", "slide.delete"), ("-", ""), - ("Find…", "app.palette"), + ("Find…", "app.find"), + ("Replace…", "app.replace"), ], ), ( diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..a1e57fca 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -998,6 +998,12 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + }); + }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); }); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..8d3d2dba 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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`): Find and Replace, 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 | ## 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, Replace Fonts, Action Settings, Print, Insert Object) | 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 | From b763579d94a1cfc6fba3a7a50c3ee8ae78ac5ffb Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:55:59 +0300 Subject: [PATCH 07/15] View: movable drawing guides saved with the presentation View > Guides used to paint two fixed centre lines. Guides now belong to the presentation (DrawGuide, read from and written to p:guideLst in viewProps.xml, in eighths of a point), so they survive a save and reopen in PowerPoint. - Turning Guides on gives a deck without guides PowerPoint's two centre guides. - A guide can be dragged anywhere away from shapes. It moves in whole points, unless Alt is held or snapping is off, and shows its distance from the centre while it moves. - A guide dropped off the slide is deleted. - The slide context menu's Grid and Guides submenu has Add Vertical Guide and Add Horizontal Guide. - While guides are shown, moving and resizing shapes snaps to them. New commands guide.add, guide.move and guide.delete are undoable; guide.list is a query. Co-authored-by: Cursor --- crates/engine/src/cmd/arrange.rs | 85 +++++++++++++++++++++++++++ crates/engine/src/cmd/guides_tests.rs | 76 ++++++++++++++++++++++++ crates/engine/src/tools.rs | 64 ++++++++++++++++++++ crates/model/src/defaults.rs | 1 + crates/model/src/lib.rs | 12 ++++ crates/pptx/src/read/mod.rs | 14 +++++ crates/pptx/src/write/mod.rs | 25 +++++++- crates/ui-egui/src/canvas.rs | 82 ++++++++++++++++++++++---- crates/ui-egui/src/guides_tests.rs | 69 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 8 ++- 10 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/cmd/guides_tests.rs create mode 100644 crates/ui-egui/src/guides_tests.rs diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 0c91bbaa..fb2c8c53 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -9,6 +9,18 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ + cmd!( + "guide.add", + "Add Guide", + ["View", "Show", "Guides"], + None, + "{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none", + has_doc, + guide_add + ), + cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move), + cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete), + cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list), cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)), cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)), cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)), @@ -338,3 +350,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +/// Guides are kept on the slide; a file can hold at most this many. +const MAX_GUIDES: usize = 200; + +fn guide_limit(s: &Session, vertical: bool) -> Result { + let size = s.doc()?.doc.slide_size; + Ok(if vertical { size.width } else { size.height }) +} + +fn guide_add(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "defaults", false) { + if !s.doc()?.doc.guides.is_empty() { + return Ok(json!({"added": 0})); + } + let size = s.doc()?.doc.slide_size; + return s.edit(|doc, _| { + doc.guides = vec![ + deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 }, + deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 }, + ]; + Ok(json!({"added": 2})) + }); + } + let vertical = bool_or(p, "vertical", true); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit); + if s.doc()?.doc.guides.len() >= MAX_GUIDES { + return Err(bad("guide.add", "too many guides")); + } + s.edit(|doc, _| { + doc.guides.push(deckcraft_model::DrawGuide { vertical, pos }); + Ok(json!({"index": doc.guides.len() - 1})) + }) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?; + if i >= s.doc()?.doc.guides.len() { + return Err(bad(cmd, "no such guide")); + } + Ok(i) +} + +fn guide_move(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.move")?; + let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical); + let limit = guide_limit(s, vertical)?; + let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit); + s.edit(|doc, _| { + if let Some(g) = doc.guides.get_mut(i) { + g.pos = pos; + } + Ok(json!({"index": i, "pos": pos})) + }) +} + +fn guide_delete(s: &mut Session, p: &Value) -> Result { + let i = guide_index(s, p, "guide.delete")?; + s.edit(|doc, _| { + if i < doc.guides.len() { + doc.guides.remove(i); + } + ok() + }) +} + +fn guide_list(s: &mut Session, _p: &Value) -> Result { + Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default()) +} + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/engine/src/cmd/guides_tests.rs b/crates/engine/src/cmd/guides_tests.rs new file mode 100644 index 00000000..3211b6bb --- /dev/null +++ b/crates/engine/src/cmd/guides_tests.rs @@ -0,0 +1,76 @@ +//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and +//! the PPTX round trip. + +use serde_json::json; + +use crate::Session; +use crate::tools::{Mods, PointerEvent, PointerKind}; + +fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0 }).unwrap(); +} + +#[test] +fn add_move_delete_and_undo() { + let mut s = Session::with_new(); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2); + assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none"); + let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap(); + assert_eq!(i, 2); + s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide"); + assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err()); + assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err()); + s.execute("guide.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 3); +} + +#[test] +fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + // Hidden guides can't be grabbed: the press starts a marquee instead. + ev(&mut s, PointerKind::Down, 300.0, 50.0); + ev(&mut s, PointerKind::Up, 300.0, 50.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0); + s.tool.guides_visible = true; + ev(&mut s, PointerKind::Down, 301.0, 50.0); + ev(&mut s, PointerKind::Drag, 350.4, 60.0); + assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging"); + ev(&mut s, PointerKind::Up, 350.4, 60.0); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0); + assert_eq!(s.tool.guide_drag, None); + + // A shape dragged near the guide snaps its left edge onto it. + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap(); + s.prefs.snap_to_grid = false; + ev(&mut s, PointerKind::Down, 140.0, 230.0); + ev(&mut s, PointerKind::Drag, 200.0, 230.0); + ev(&mut s, PointerKind::Drag, 388.0, 231.0); + ev(&mut s, PointerKind::Up, 388.0, 231.0); + let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap(); + assert!((x.x - 350.0).abs() < 0.01, "{x:?}"); + + // Dropped off the slide: gone, and undo brings it back. + ev(&mut s, PointerKind::Down, 350.0, 10.0); + ev(&mut s, PointerKind::Drag, -40.0, 10.0); + ev(&mut s, PointerKind::Up, -40.0, 10.0); + assert!(s.doc().unwrap().doc.guides.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides.len(), 1); +} + +#[test] +fn guides_round_trip_through_pptx() { + let mut s = Session::with_new(); + s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap(); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + assert_eq!(back.guides, s.doc().unwrap().doc.guides); + let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap(); + assert!(none.guides.is_empty()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..282be6ea 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -123,6 +123,10 @@ enum Drag { start: Point, add: bool, }, + /// Dragging drawing guide `index`. + Guide { + index: usize, + }, Create { start: Point, }, @@ -178,6 +182,12 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. + pub guides_visible: bool, + /// The guide being dragged and where it is now (slide points); `None` once dropped. + pub guide_drag: Option<(usize, f64)>, + /// The drawing guide under the pointer, for the cursor. + pub guide_hover: Option, } impl ToolState { @@ -286,6 +296,8 @@ impl Session { return Ok(Value::Null); } self.tool.hover = self.hit_test(p, tol); + self.tool.guide_hover = + if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); if line_tool { self.update_sites(p, tol, None); @@ -451,6 +463,14 @@ impl Session { let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); } } + None if self.guide_at(p, tol).is_some() => { + // Away from every shape a press on a guide picks the guide up. + if let Some(index) = self.guide_at(p, tol) { + let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); + self.tool.drag = Some(Drag::Guide { index }); + self.tool.guide_drag = Some((index, pos)); + } + } None => { let add = mods.shift || mods.cmd; self.select(|_, sel| { @@ -470,6 +490,13 @@ impl Session { fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { + Drag::Guide { index } => { + let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); + let pos = if vertical { p.x } else { p.y }; + // Guides move in whole points unless snapping is off, as in PowerPoint. + let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; + self.tool.guide_drag = Some((index, pos)); + } Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { let mut d = p - start; if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { @@ -668,6 +695,18 @@ impl Session { self.tool.glue = None; let mut out = Value::Null; match drag { + Some(Drag::Guide { index }) => { + // Dropped off the slide: the guide is deleted. + let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); + let size = self.doc()?.doc.slide_size; + let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); + let limit = if vertical { size.width } else { size.height }; + out = if pos < 0.0 || pos > limit { + self.execute("guide.delete", &json!({"index": index}))? + } else { + self.execute("guide.move", &json!({"index": index, "pos": pos}))? + }; + } Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { // Plain click on text: caret at the click. let st = self.doc()?; @@ -942,6 +981,22 @@ impl Session { }) } + /// The drawing guide within `tol` of `p` (only while guides are shown). + pub fn guide_at(&self, p: Point, tol: f64) -> Option { + if !self.tool.guides_visible { + return None; + } + let st = self.active()?; + st.doc + .guides + .iter() + .enumerate() + .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) + .filter(|(_, d)| *d <= tol.max(2.0)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to /// apply and the guides to draw. fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { @@ -960,6 +1015,15 @@ impl Session { (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), (size.height, Rect::new(0.0, size.height, size.width, size.height)), ]; + if self.tool.guides_visible { + for g in &st.doc.guides { + if g.vertical { + xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); + } else { + ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); + } + } + } for sh in st.shapes() { if exclude.contains(&sh.id) || sh.hidden { continue; diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8432ce4a..3a7a4d3c 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -513,6 +513,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen next_id: next.max(256), embedded_fonts: vec![], table_styles: vec![], + guides: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..b884cdf2 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -843,11 +843,23 @@ pub struct Presentation { /// Table styles defined in the file (`ppt/tableStyles.xml`). #[serde(skip_serializing_if = "Vec::is_empty")] pub table_styles: Vec, + /// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } +/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal +/// one from its top. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawGuide { + pub vertical: bool, + pub pos: f64, +} + impl Default for Presentation { fn default() -> Self { defaults::new_presentation(None) diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..0b64fb86 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -357,6 +357,20 @@ pub fn import(bytes: &[u8]) -> Result { p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); } } + // Drawing guides (eighths of a point). + if let Some(r) = pres_part.rels.first_of("viewProps") + && let Some(d) = pkg.xml(&r.target) + && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) + { + p.guides = lst + .children_named("guide") + .filter_map(|g| { + let pos = g.attr("pos").and_then(|v| v.parse::().ok()).unwrap_or(0.0) / 8.0; + pos.is_finite().then_some(deckcraft_model::DrawGuide { vertical: g.attr("orient") != Some("horz"), pos }) + }) + .take(1000) + .collect(); + } // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..03fad19b 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -511,7 +511,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { pres.rels.add("viewProps", "viewProps.xml", false); pres.rels.add("tableStyles", "tableStyles.xml", false); x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p)); - x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); + x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml(p)); let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids); @@ -767,13 +767,34 @@ fn pres_props_xml(p: &Presentation) -> Vec { w.finish() } -fn view_props_xml() -> Vec { +fn view_props_xml(p: &Presentation) -> Vec { let mut w = W::new(); root_open(&mut w, "p:viewPr", A::new()); w.open0("p:normalViewPr"); w.empty("p:restoredLeft", A::new().a("sz", 15620)); w.empty("p:restoredTop", A::new().a("sz", 94660)); w.close("p:normalViewPr"); + if !p.guides.is_empty() { + // Drawing guides; `pos` is in eighths of a point (576 per inch). + w.open0("p:slideViewPr"); + w.open0("p:cSldViewPr"); + w.open0("p:cViewPr"); + w.open0("p:scale"); + w.empty("a:sx", A::new().a("n", 100).a("d", 100)); + w.empty("a:sy", A::new().a("n", 100).a("d", 100)); + w.close("p:scale"); + w.empty("p:origin", A::new().a("x", 0).a("y", 0)); + w.close("p:cViewPr"); + w.open0("p:guideLst"); + for g in p.guides.iter().take(1000) { + let pos = (g.pos * 8.0).round().clamp(-1.0e9, 1.0e9) as i64; + let a = if g.vertical { A::new().a("pos", pos) } else { A::new().a("orient", "horz").a("pos", pos) }; + w.empty("p:guide", a); + } + w.close("p:guideLst"); + w.close("p:cSldViewPr"); + w.close("p:slideViewPr"); + } w.empty("p:gridSpacing", A::new().a("cx", 76200).a("cy", 76200)); w.close("p:viewPr"); w.finish() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..7f69d902 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -148,16 +148,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { y += step; } } + app.session.tool.guides_visible = app.ui.guides; if app.ui.guides { - let c = slide_rect.center(); - painter.line_segment( - [pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); - painter.line_segment( - [pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)], - Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), - ); + draw_guides(app, &painter, xf, slide_rect, &t); } if app.ui.ruler { ruler(&painter, avail, slide_rect, scale, &t); @@ -366,6 +359,54 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } } +/// The presentation's drawing guides; the one being dragged at its new place, labelled with its +/// distance from the slide's centre in points. +fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let size = st.doc.slide_size; + let drag = app.session.tool.guide_drag; + for (i, g) in st.doc.guides.iter().enumerate() { + let (pos, active) = match drag { + Some((di, p)) if di == i => (p, true), + _ => (g.pos, app.session.tool.guide_hover == Some(i)), + }; + let col = if active { t.accent } else { Color32::from_rgba_unmultiplied(120, 120, 120, 170) }; + let stroke = Stroke::new(1.0, col); + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(pos, 0.0)).x; + (pos2(x, slide.min.y), pos2(x, slide.max.y)) + } else { + let y = xf.to_screen(Point::new(0.0, pos)).y; + (pos2(slide.min.x, y), pos2(slide.max.x, y)) + }; + // Dashed, like PowerPoint's guides. + let len = (b - a).length(); + let dir = (b - a) / len.max(1.0); + let mut d = 0.0; + while d < len { + painter.line_segment([a + dir * d, a + dir * (d + 4.0).min(len)], stroke); + d += 7.0; + } + if drag.is_some_and(|(di, _)| di == i) { + let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; + let off = pos - centre; + let side = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => " left of centre", + (true, _, true) => " right of centre", + (false, true, _) => " above centre", + (false, _, true) => " below centre", + _ => " (centre)", + }; + let label = format!("{:.0} pt{side}", off.abs()); + let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; + let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); + let r = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(r, CornerRadius::same(3), t.card); + painter.galley(at, galley, t.text); + } + } +} + fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); @@ -413,7 +454,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { } } (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, - _ => egui::CursorIcon::Default, + _ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) { + Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal, + Some(_) => egui::CursorIcon::ResizeVertical, + None => egui::CursorIcon::Default, + }, }; if resp.hovered() { ui.ctx().set_cursor_icon(cursor); @@ -682,6 +727,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); if ui.button("Reset Slide").clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); @@ -697,3 +755,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +#[path = "guides_tests.rs"] +mod guides_tests; diff --git a/crates/ui-egui/src/guides_tests.rs b/crates/ui-egui/src/guides_tests.rs new file mode 100644 index 00000000..4180e503 --- /dev/null +++ b/crates/ui-egui/src/guides_tests.rs @@ -0,0 +1,69 @@ +//! Drawing guides driven with the mouse: View › Guides adds the centre guides, dragging one +//! moves it (with its distance from the centre shown), dropping it off the slide deletes it. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn turn_guides_on_drag_one_and_drop_it_off_the_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.state_mut().run("view.guides", json!({"on": true})).unwrap(); + h.run_steps(2); + let guides = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.guides.clone(); + assert_eq!(guides(&h).len(), 2, "the two centre guides"); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + + // Grab the vertical guide away from the horizontal one and drag it left. + let start = at(480.0, 100.0); + h.hover_at(start); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(480.0 - 18.0 * i as f32, 100.0))); + h.run_steps(1); + } + assert_eq!(h.state().session.tool.guide_drag, Some((0, 300.0))); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/guides.png"); + } + h.event(egui::Event::PointerButton { pos: at(300.0, 100.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h)[0].pos, 300.0); + + // Off the top of the slide with the horizontal guide: deleted. + let g = at(700.0, 270.0); + h.hover_at(g); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: g, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(at(700.0, 270.0 - 30.0 * i as f32))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: at(700.0, -30.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(guides(&h).len(), 1); + assert!(guides(&h)[0].vertical); + + // Hidden guides stay in the file but aren't drawn or grabbed. + h.state_mut().run("view.guides", json!({"on": false})).unwrap(); + h.run_steps(2); + assert!(!h.state().session.tool.guides_visible); + assert_eq!(guides(&h).len(), 1); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..cb0e7b31 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -457,7 +457,13 @@ impl SlideApp { "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } "view.zoomIn" | "view.zoomOut" => { let cur = self.ui.zoom.unwrap_or(self.canvas_scale); let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; From 8a36a6990e2e800bdedcf5c82220daaf64406c3b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:13:17 +0300 Subject: [PATCH 08/15] View: indent and tab markers on the ruler While you edit text, the horizontal ruler now shows the text area's span and the indent and tab markers of the paragraph with the caret. - First-line indent: the top triangle. - Hanging indent: the bottom triangle. Dragging it moves the left indent while the first line stays put. - Left indent: the box under the hanging triangle. Dragging it moves both. - Tab stops are drawn as left, centre, right and decimal marks. - A click on the ruler adds a left tab, dragging a tab moves it, and dragging a tab down off the ruler removes it. Every change goes through a command (format.paragraph or the new format.tabs). Each is one undo step and applies to the selected paragraphs, and text editing goes on throughout. format.ruler is a new query that reports the text area's position and the paragraph's resolved indents and tabs. Rotated and vertical text show no markers. Co-authored-by: Cursor --- crates/engine/src/cmd/format.rs | 92 ++++++++++++++++ crates/engine/src/cmd/ruler_tests.rs | 59 +++++++++++ crates/ui-egui/src/canvas.rs | 153 +++++++++++++++++++++++++++ crates/ui-egui/src/ruler_tests.rs | 85 +++++++++++++++ docs/parity-checklist.md | 8 +- 5 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/ruler_tests.rs create mode 100644 crates/ui-egui/src/ruler_tests.rs diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..8581cb5d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -139,6 +139,16 @@ pub fn specs() -> Vec { has_text_or_shapes, paragraph ), + cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler), + cmd!( + "format.tabs", + "Tabs…", + ["Home", "Paragraph"], + None, + "{add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", + has_text_or_shapes, + tabs + ), cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), cmd!( "format.direction", @@ -682,3 +692,85 @@ fn state(s: &mut Session, _p: &Value) -> Result { "painter": s.painter.is_some(), })) } + +/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, +/// and where its text area sits on the slide. +fn ruler(s: &mut Session, _p: &Value) -> Result { + let props = current_para(s); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; + let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { + (false, Some(sh)) => { + let ctx = super::text::ctx_for(&st.doc, &st.selection); + let x = match &ctx { + Some(c) => resolve::xfrm(c, sh), + None => sh.xfrm.unwrap_or_default(), + }; + let body = match &ctx { + Some(c) => resolve::body(c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); + (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) + } + _ => (0.0, 0.0, 0.0, true), + }; + let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + Ok(json!({ + "x": x, "y": y, "width": w, "rotated": rotated, + "marginLeft": props.margin_left.unwrap_or(0.0), + "indent": props.indent.unwrap_or(0.0), + "tabs": tabs, + "defaultTab": props.default_tab.unwrap_or(72.0), + })) +} + +const MAX_TABS: usize = 32; + +fn tabs(s: &mut Session, p: &Value) -> Result { + let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); + let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => "l", + }; + let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); + let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); + let remove = fin(p.get("remove").and_then(Value::as_f64)); + let clear = bool_or(p, "clear", false); + let default_tab = fin(p.get("defaultTab").and_then(Value::as_f64)).filter(|v| *v >= 1.0); + if add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { + return Err(bad("format.tabs", "nothing to change")); + } + // The stops as the caret's paragraph shows them (inherited ones included), so editing a + // placeholder's tab that came from its layout works like editing one set on the paragraph. + let base = current_para(s).tabs.unwrap_or_default(); + let near = |a: f64, b: f64| (a - b).abs() < 0.5; + let mut stops = if clear { vec![] } else { base }; + if let Some(at) = remove { + stops.retain(|t| !near(t.pos, at)); + } + if let Some((from, to)) = mv + && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) + { + t.pos = to; + } + if let Some((pos, align)) = add { + stops.retain(|t| !near(t.pos, pos)); + stops.push(deckcraft_model::text::TabStop { pos, align }); + } + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + stops.dedup_by(|a, b| near(a.pos, b.pos)); + stops.truncate(MAX_TABS); + format_para(s, &move |para| { + para.props.tabs = Some(stops.clone()); + if let Some(d) = default_tab { + para.props.default_tab = Some(d); + } + }) +} + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs new file mode 100644 index 00000000..1d23154e --- /dev/null +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -0,0 +1,59 @@ +//! The ruler's data and tab-stop editing. + +use serde_json::json; + +use crate::Session; + +fn editing() -> (Session, u64) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "a\tb"})).unwrap(); + (s, id) +} + +#[test] +fn ruler_reports_the_text_area_and_indents() { + let (mut s, _) = editing(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}"); + assert_eq!(r["rotated"], false); + assert_eq!(r["tabs"].as_array().unwrap().len(), 0); + s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap(); + let r = s.execute("format.ruler", &json!({})).unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0))); + s.execute("text.exit", &json!({})).unwrap(); + assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text"); +} + +#[test] +fn tabs_add_move_remove_clear_and_undo() { + let (mut s, _) = editing(); + let tabs = |s: &mut Session| -> Vec<(f64, String)> { + s.execute("format.ruler", &json!({})).unwrap()["tabs"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string())) + .collect() + }; + s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap(); + s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted"); + s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap(); + assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced"); + s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap(); + s.execute("format.tabs", &json!({"remove": 72.2})).unwrap(); + assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tabs(&mut s).len(), 2); + s.execute("format.tabs", &json!({"clear": true})).unwrap(); + assert!(tabs(&mut s).is_empty()); + assert!(s.execute("format.tabs", &json!({})).is_err()); + assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); + for i in 0..100 { + s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); + } + assert!(tabs(&mut s).len() <= 32); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..073f9f41 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -161,6 +161,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } if app.ui.ruler { ruler(&painter, avail, slide_rect, scale, &t); + ruler_markers(app, ui, &painter, avail, xf, &t); } pointer(app, ui, &resp, xf); overlays(app, ui, &painter, xf, &t); @@ -366,6 +367,154 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } } +/// What is being dragged on the ruler. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RulerDrag { + /// First-line indent (the top triangle). + First, + /// Hanging indent (the bottom triangle): moves the left indent, the first line stays put. + Hanging, + /// Left indent (the box under the hanging triangle): moves both together. + Left, + /// The tab stop that was at this position (pt from the text area's left). + Tab(f64), +} + +/// The paragraph's indents and tab stops on the horizontal ruler while text is being edited, +/// draggable as in PowerPoint; a click on the ruler adds a left tab, dragging a tab off the +/// ruler removes it. +fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect, xf: Xf, t: &Tokens) { + if app.session.active().is_none_or(|d| d.selection.text.is_none()) { + return; + } + let Ok(info) = app.session.execute("format.ruler", &json!({})) else { return }; + if info["rotated"].as_bool().unwrap_or(true) { + return; + } + let f = |k: &str| info[k].as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (x0, width, ml, ind) = (f("x"), f("width"), f("marginLeft"), f("indent")); + let tabs: Vec<(f64, String)> = info["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some((t["pos"].as_f64()?, t["align"].as_str().unwrap_or("l").to_string()))).collect()) + .unwrap_or_default(); + let top = Rect::from_min_max(pos2(avail.min.x + 16.0, avail.min.y), pos2(avail.max.x, avail.min.y + 16.0)); + let sx = |q: f64| xf.to_screen(Point::new(x0 + q, 0.0)).x; + let to_q = |x: f32| ((xf.to_slide(pos2(x, 0.0)).x - x0).clamp(0.0, width.max(0.0)) * 2.0).round() / 2.0; + // The text area's span, outlined on the ruler. + let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); + p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); + + let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag()); + let id = ui.id().with("ruler_drag"); + let mut drag: Option = ui.data(|d| d.get_temp::(id)); + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + if resp.drag_started() + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let near = |q: f64| (sx(q) - at.x).abs() <= 5.0; + let y = at.y - top.min.y; + drag = if let Some((pos, _)) = tabs.iter().find(|(pos, _)| near(*pos) && y >= 8.0) { + Some(RulerDrag::Tab(*pos)) + } else if y < 6.0 && near(ml + ind) { + Some(RulerDrag::First) + } else if (6.0..11.0).contains(&y) && near(ml) { + Some(RulerDrag::Hanging) + } else if y >= 11.0 && near(ml) { + Some(RulerDrag::Left) + } else { + None + }; + ui.data_mut(|d| match drag { + Some(v) => { + d.insert_temp(id, v); + } + None => { + d.remove::(id); + } + }); + } + // Where each marker is drawn: the dragged one follows the pointer. + let live = drag.zip(pointer.filter(|_| resp.dragged())).map(|(d, at)| (d, to_q(at.x), at.y > top.max.y + 20.0)); + let (mut first, mut left) = (ml + ind, ml); + match live { + Some((RulerDrag::First, q, _)) => first = q, + Some((RulerDrag::Hanging, q, _)) => left = q, + Some((RulerDrag::Left, q, _)) => { + first = q + ind; + left = q; + } + _ => {} + } + let col = t.text_dim; + let tri = |x: f32, y0: f32, down: bool| { + let (a, b) = if down { (y0, y0 + 5.0) } else { (y0 + 5.0, y0) }; + p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, a), pos2(x + 4.0, a), pos2(x, b)], t.card, Stroke::new(1.0, col))); + }; + tri(sx(first), top.min.y + 1.0, true); + tri(sx(left), top.min.y + 6.0, false); + p.rect( + Rect::from_min_max(pos2(sx(left) - 4.0, top.min.y + 11.5), pos2(sx(left) + 4.0, top.max.y - 0.5)), + CornerRadius::ZERO, + t.card, + Stroke::new(1.0, col), + egui::StrokeKind::Inside, + ); + for (pos, align) in &tabs { + let (q, gone) = match live { + Some((RulerDrag::Tab(from), q, off)) if (from - pos).abs() < 0.5 => (q, off), + _ => (*pos, false), + }; + if gone { + continue; + } + let (x, y) = (sx(q), top.max.y - 2.0); + let st = Stroke::new(1.5, t.text); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align.as_str() { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, t.text); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } + } + } + if drag.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + + // Commit on release; a click on bare ruler adds a left tab. + if resp.drag_stopped() { + if let (Some(d), Some(at)) = (drag, pointer) { + let q = to_q(at.x); + let r = match d { + RulerDrag::First => app.run("format.paragraph", json!({"indentFirst": q - ml})), + RulerDrag::Hanging => app.run("format.paragraph", json!({"indentLeft": q, "indentFirst": ml + ind - q})), + RulerDrag::Left => app.run("format.paragraph", json!({"indentLeft": q})), + RulerDrag::Tab(from) if at.y > top.max.y + 20.0 => app.run("format.tabs", json!({"remove": from})), + RulerDrag::Tab(from) => app.run("format.tabs", json!({"move": {"from": from, "to": q}})), + }; + if let Err(e) = r { + app.set_status(e); + } + } + ui.data_mut(|d| d.remove::(id)); + } else if resp.clicked() + && let Some(at) = resp.interact_pointer_pos() + && at.x >= area.min.x + && at.x <= area.max.x + { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": "l"}})); + } +} + fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); @@ -697,3 +846,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +#[path = "ruler_tests.rs"] +mod ruler_tests; diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs new file mode 100644 index 00000000..fd12d206 --- /dev/null +++ b/crates/ui-egui/src/ruler_tests.rs @@ -0,0 +1,85 @@ +//! The ruler's indent and tab markers driven with the mouse while editing text. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn drag(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=8 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, SlideApp>, at: Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn ruler(h: &mut Harness<'_, SlideApp>) -> serde_json::Value { + h.state_mut().session.execute("format.ruler", &json!({})).unwrap() +} + +#[test] +fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Name\tRole\tTeam"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + // The horizontal ruler runs along the top of the canvas. + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + + // First-line indent triangle (top) to 36 pt. + drag(&mut h, Pos2::new(rx(0.0), top + 3.0), Pos2::new(rx(36.0), top + 3.0)); + let r = ruler(&mut h); + assert!((r["indent"].as_f64().unwrap() - 36.0).abs() <= 1.0, "{r}"); + assert_eq!(r["marginLeft"].as_f64(), Some(0.0)); + + // Hanging triangle (middle) to 18 pt: the first line stays at 36. + drag(&mut h, Pos2::new(rx(0.0), top + 8.0), Pos2::new(rx(18.0), top + 8.0)); + let r = ruler(&mut h); + let (ml, ind) = (r["marginLeft"].as_f64().unwrap(), r["indent"].as_f64().unwrap()); + assert!((ml - 18.0).abs() <= 1.0 && (ml + ind - 36.0).abs() <= 1.0, "{r}"); + + // Click on the ruler: a left tab; drag it along; drag it off to remove it. + click(&mut h, Pos2::new(rx(150.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + let pos = r["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 150.0).abs() <= 1.0); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(220.0), top + 12.0)); + let pos = ruler(&mut h)["tabs"][0]["pos"].as_f64().unwrap() as f32; + assert!((pos - 220.0).abs() <= 1.0, "{pos}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler.png"); + } + assert!(h.state().session.active().unwrap().selection.text.is_some(), "still editing the text"); + drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); + assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..a76ca062 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 | @@ -16,7 +16,7 @@ The summary between ` | Application shell | 77% | | Files | 71% | | Slides | 90% | -| Views | 90% | +| Views | 95% | | Masters & themes | 85% | | Shapes | 88% | | Format | 83% | @@ -86,7 +86,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Views | Notes Page | P1 | D | | Views | Reading View | P1 | D | | Views | Zoom, fit to window | P0 | D | -| Views | Rulers, gridlines, guides | P1 | P | +| Views | Rulers, gridlines, guides | P1 | D | | Views | Smart guides | P1 | D | | Views | Grayscale / black and white view | P2 | D | | Views | New window / arrange windows | P2 | M | From 4b2548c99b2ff73830727430e632a80f494e920e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:22:16 +0300 Subject: [PATCH 09/15] Text: Tabs dialog and the ruler's tab selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format > Tabs opens from Home > Line Spacing > Tabs…, from the Paragraph dialog, or by double-clicking a tab stop on the ruler. It lists the selected paragraphs' tab stops (inches) with their alignment. Set adds or replaces a stop at a position with Left, Center, Right or Decimal alignment, Clear removes one, Clear All removes them all, and you can change the default tab spacing. OK applies everything in one undo step through format.tabs, which now takes `set`. Cancel changes nothing. The ruler's corner has PowerPoint's tab selector. A click cycles left, center, right and decimal, and a click on the ruler then sets that kind of stop. Presses on the rulers no longer reach the slide, so they don't end text editing. Co-authored-by: Cursor --- crates/engine/src/cmd/format.rs | 12 +++- crates/engine/src/cmd/ruler_tests.rs | 3 + crates/ui-egui/src/canvas.rs | 77 +++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 92 ++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 3 + crates/ui-egui/src/ruler_tests.rs | 75 +++++++++++++++++++++++ docs/parity-checklist.md | 6 +- 7 files changed, 243 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 8581cb5d..0251e6ab 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -145,7 +145,7 @@ pub fn specs() -> Vec { "Tabs…", ["Home", "Paragraph"], None, - "{add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", + "{set?: [{pos, align?}] (replaces all), add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs (pos in pt from the text area's left)", has_text_or_shapes, tabs ), @@ -738,16 +738,22 @@ fn tabs(s: &mut Session, p: &Value) -> Result { let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); let remove = fin(p.get("remove").and_then(Value::as_f64)); + let set: Option> = p.get("set").and_then(Value::as_array).map(|a| { + a.iter() + .take(MAX_TABS * 4) + .filter_map(|t| Some(deckcraft_model::text::TabStop { pos: fin(t.get("pos").and_then(Value::as_f64))?, align: align_of(t).to_string() })) + .collect() + }); let clear = bool_or(p, "clear", false); let default_tab = fin(p.get("defaultTab").and_then(Value::as_f64)).filter(|v| *v >= 1.0); - if add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { + if set.is_none() && add.is_none() && mv.is_none() && remove.is_none() && !clear && default_tab.is_none() { return Err(bad("format.tabs", "nothing to change")); } // The stops as the caret's paragraph shows them (inherited ones included), so editing a // placeholder's tab that came from its layout works like editing one set on the paragraph. let base = current_para(s).tabs.unwrap_or_default(); let near = |a: f64, b: f64| (a - b).abs() < 0.5; - let mut stops = if clear { vec![] } else { base }; + let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; if let Some(at) = remove { stops.retain(|t| !near(t.pos, at)); } diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs index 1d23154e..95c87c6c 100644 --- a/crates/engine/src/cmd/ruler_tests.rs +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -56,4 +56,7 @@ fn tabs_add_move_remove_clear_and_undo() { s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); } assert!(tabs(&mut s).len() <= 32); + s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap(); + assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped"); + assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0)); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 073f9f41..f29f62f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -467,25 +467,29 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect if gone { continue; } - let (x, y) = (sx(q), top.max.y - 2.0); - let st = Stroke::new(1.5, t.text); - p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); - match align.as_str() { - "r" => { - p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); - } - "ctr" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - } - "dec" => { - p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); - p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, t.text); - } - _ => { - p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); - } - } + tab_glyph(p, pos2(sx(q), top.max.y - 2.0), align, t.text); } + // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click + // on the ruler then sets that kind of stop. + let kind_id = ui.id().with("ruler_tab_kind"); + let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); + let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); + let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click()); + const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; + if cr.clicked() { + kind = (kind + 1) % KINDS.len(); + ui.data_mut(|d| d.insert_temp(kind_id, kind)); + } + let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label)); + let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + p.rect_stroke( + corner.shrink(2.0), + CornerRadius::same(2), + Stroke::new(1.0, if cr.hovered() { t.accent } else { t.text_faint }), + egui::StrokeKind::Inside, + ); + tab_glyph(p, pos2(corner.center().x - 1.0, corner.center().y + 3.0), kind_align, t.text); if drag.is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } @@ -511,7 +515,37 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect && at.x >= area.min.x && at.x <= area.max.x { - let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": "l"}})); + let near_tab = tabs.iter().any(|(pos, _)| (sx(*pos) - at.x).abs() <= 5.0); + if resp.double_clicked() || near_tab { + // A double-click on a stop opens the Tabs dialog, as in PowerPoint. + if resp.double_clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + } else { + let _ = app.run("format.tabs", json!({"add": {"pos": to_q(at.x), "align": kind_align}})); + } + } +} + +/// A tab-stop mark with its foot at `at`: └ left, ┴ center, ┘ right, ┴ with a dot decimal. +fn tab_glyph(p: &egui::Painter, at: Pos2, align: &str, col: Color32) { + let (x, y) = (at.x, at.y); + let st = Stroke::new(1.5, col); + p.line_segment([pos2(x, y - 6.0), pos2(x, y)], st); + match align { + "r" => { + p.line_segment([pos2(x - 5.0, y), pos2(x, y)], st); + } + "ctr" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + } + "dec" => { + p.line_segment([pos2(x - 4.0, y), pos2(x + 4.0, y)], st); + p.circle_filled(pos2(x + 3.0, y - 4.0), 1.0, col); + } + _ => { + p.line_segment([pos2(x, y), pos2(x + 5.0, y)], st); + } } } @@ -519,6 +553,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + // The rulers have their own controls; a press there mustn't reach the slide (and end text + // editing). A drag already under way keeps going over them. + if app.ui.ruler && !app.session.tool.dragging() && (pos.y < resp.rect.min.y + 16.0 || pos.x < resp.rect.min.x + 16.0) { + return; + } let p = xf.to_slide(pos); let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; let primary = ui.input(|i| i.pointer.primary_pressed()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..dd4089c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", "about" => "About DeckCraft", "preferences" => "Preferences", "export" => "Export", @@ -388,6 +389,12 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { field(ui, d, "Spacing before (pt):", "before", "0"); field(ui, d, "Spacing after (pt):", "after", "0"); field(ui, d, "Line spacing (lines):", "lines", "1.0"); + ui.add_space(4.0); + if ui.button("Tabs…").clicked() { + app.dialog = None; + *d = Dialog::new("tabs"); + return false; + } let (ok, cancel) = buttons(ui, "OK"); if ok { run( @@ -398,6 +405,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -829,3 +837,87 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; + +/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a +/// list (inches, as PowerPoint's dialog) and applied together with OK. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + if !d.fields.contains_key("stops") { + let r = app.session.execute("format.ruler", &json!({})).unwrap_or_default(); + let stops: Vec = r["tabs"] + .as_array() + .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) + .unwrap_or_default(); + d.fields.insert("stops".into(), stops.join("|")); + d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0)); + } + let mut stops: Vec<(f64, String)> = d + .get("stops", "") + .split('|') + .filter_map(|x| { + let (p, a) = x.split_once(':')?; + Some((p.parse::().ok().filter(|v| v.is_finite())?, a.to_string())) + }) + .collect(); + field(ui, d, "Default tab stops (in):", "default", "1"); + ui.add_space(6.0); + ui.label("Tab stops:"); + let mut remove = None; + egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { + if stops.is_empty() { + ui.label("(none)"); + ui.end_row(); + } + for (i, (pos, align)) in stops.iter().enumerate() { + ui.label(format!("{pos:.2}\"")); + ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); + if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + stops.remove(i); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Tab stop position (in):"); + let mut v = d.get("newpos", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)); + d.fields.insert("newpos".into(), v); + }); + let cur = d.get("newalign", "l"); + ui.horizontal(|ui| { + ui.label("Alignment:"); + for (k, label) in TAB_KINDS { + if ui.radio(cur == k, label).clicked() { + d.fields.insert("newalign".into(), k.into()); + } + } + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) + { + stops.retain(|(q, _)| (q - p).abs() > 0.005); + stops.push((p, d.get("newalign", "l"))); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.fields.insert("newpos".into(), String::new()); + } + if ui.button("Clear All").clicked() { + stops.clear(); + } + }); + d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::>().join("|")); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let set: Vec = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect(); + let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; + if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { + app.set_status(e); + } + } + ok || cancel +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..3df4a781 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -594,6 +594,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { if ui.button("Line Spacing Options…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); egui::Popup::menu(&r).show(|ui| { diff --git a/crates/ui-egui/src/ruler_tests.rs b/crates/ui-egui/src/ruler_tests.rs index fd12d206..7c313563 100644 --- a/crates/ui-egui/src/ruler_tests.rs +++ b/crates/ui-egui/src/ruler_tests.rs @@ -83,3 +83,78 @@ fn drag_indents_add_move_and_remove_tabs_on_the_ruler() { drag(&mut h, Pos2::new(rx(pos), top + 12.0), Pos2::new(rx(pos), top + 80.0)); assert!(ruler(&mut h)["tabs"].as_array().unwrap().is_empty()); } + +fn editing_harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [100, 150, 500, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.insert", &json!({"text": "Item\t9.50"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.ruler = true; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn the_tab_selector_picks_the_kind_of_stop_a_click_sets() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let x0 = ruler(&mut h)["x"].as_f64().unwrap() as f32; + let top = h.state().canvas_rect.unwrap().min.y; + let rx = |q: f32| slide.min.x + (x0 + q) * k; + let corner = h.state().canvas_rect.unwrap().min + egui::vec2(8.0, 8.0); + assert!(h.query_by_label("Left Tab").is_some()); + for _ in 0..3 { + click(&mut h, corner); + } + assert!(h.query_by_label("Decimal Tab").is_some()); + click(&mut h, Pos2::new(rx(200.0), top + 12.0)); + let r = ruler(&mut h); + assert_eq!(r["tabs"][0]["align"], "dec", "{r}"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ruler_decimal.png"); + } +} + +#[test] +fn tabs_dialog_sets_clears_and_applies_on_ok() { + use egui_kittest::kittest::Queryable; + let mut h = editing_harness(); + h.state_mut().session.execute("format.tabs", &json!({"add": {"pos": 36}})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("tabs")); + h.run_steps(3); + assert!(h.query_by_label_contains("0.50\"").is_some(), "the existing stop is listed"); + let d = h.state_mut().dialog.as_mut().unwrap(); + d.fields.insert("newpos".into(), "2".into()); + d.fields.insert("default".into(), "0.5".into()); + h.run_steps(1); + h.get_by_label("Center").click(); + h.run_steps(2); + h.get_by_label("Set").click(); + h.run_steps(2); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/tabs_dialog.png"); + } + // Nothing changes until OK. + assert_eq!(ruler(&mut h)["tabs"].as_array().unwrap().len(), 1); + h.query_all_by_label("Clear").next().expect("a Clear button per stop").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let r = ruler(&mut h); + assert_eq!(r["tabs"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!((r["tabs"][0]["pos"].as_f64(), r["tabs"][0]["align"].as_str()), (Some(144.0), Some("ctr"))); + assert_eq!(r["defaultTab"].as_f64(), Some(36.0)); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ruler(&mut h)["tabs"][0]["pos"].as_f64(), Some(36.0), "one undo step"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a76ca062..50e56824 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 14 | 14 | 26 | 39% | | Area | Parity | |---|---| @@ -21,7 +21,7 @@ The summary between ` | Shapes | 88% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 81% | | Tables | 89% | | Charts | 65% | | SmartArt | 33% | @@ -148,7 +148,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Bullets and numbering | P0 | D | | Text | Picture bullets, custom bullet symbols | P2 | P | | Text | List levels (Tab / Shift+Tab) | P0 | D | -| Text | Tab stops | P2 | P | +| Text | Tab stops | P2 | D | | Text | Columns | P2 | D | | Text | Vertical anchor, margins, wrap | P0 | D | | Text | Autofit (shrink on overflow, resize shape) | P0 | D | From 68ab3a4839e01e77caf96a39a7f8e21fd24f6f95 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:41:28 +0300 Subject: [PATCH 10/15] UI: Font dialog sets character formatting in one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format.fontDialog applies family, size, bold, italic, underline, colour, strikethrough, baseline, caps and spacing together, so one undo restores them. With no fields it asks the UI to open the dialog, seeded from the selection. Home › Font, the shape menu and Cmd+T open it. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/engine/src/cmd/format.rs | 337 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/dialogs.rs | 212 ++++++++++++++- crates/ui-egui/src/font_dialog_tests.rs | 65 +++++ crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/ribbon.rs | 3 + docs/architecture.md | 7 +- docs/gaps.md | 8 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 11 +- 11 files changed, 642 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/font_dialog_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..15729ba9 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:** trivial (progress log: Font dialog; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **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 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% | | 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: Font dialog | | 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/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..f1c3921d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -69,6 +69,15 @@ pub fn specs() -> Vec { spacing ), cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear), + cmd!( + "format.fontDialog", + "Font…", + ["Home", "Font"], + Some("Cmd+T"), + "{family?, size?: pt, bold?: bool, italic?: bool, underline?: none|sng|dbl|heavy|dotted|dash|wavy, color?, strike?: none|sng|dbl, baseline?: none|super|sub|, caps?: none|small|all, spacing?: pt} — with none of these, opens the Font dialog", + has_text_or_shapes, + font_dialog + ), cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow), cmd!( "format.textOutline", @@ -309,6 +318,213 @@ fn spacing(s: &mut Session, p: &Value) -> Result { let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); format_run(s, &move |r| r.spacing = Some(v)) } + +/// Character formatting applied together, so the Font dialog is one undo step. +struct FontEdits { + family: Option, + size: Option, + bold: Option, + italic: Option, + underline: Option, + color: Option, + strike: Option, + baseline: Option, + caps: Option, + spacing: Option, +} + +impl FontEdits { + fn is_empty(&self) -> bool { + self.family.is_none() + && self.size.is_none() + && self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.color.is_none() + && self.strike.is_none() + && self.baseline.is_none() + && self.caps.is_none() + && self.spacing.is_none() + } + + fn write(&self, r: &mut RunProps) { + if let Some(f) = &self.family { + r.font = Some(f.clone()); + } + if let Some(v) = self.size { + r.size = Some(v); + } + if let Some(v) = self.bold { + r.bold = Some(v); + } + if let Some(v) = self.italic { + r.italic = Some(v); + } + if let Some(u) = &self.underline { + r.underline = Some(u.clone()); + } + if let Some(c) = &self.color { + r.fill = Some(Fill::solid(c.clone())); + } + if let Some(v) = self.strike { + r.strike = Some(v); + } + if let Some(v) = self.baseline { + r.baseline = Some(v); + } + if let Some(v) = self.caps { + r.caps = Some(v); + } + if let Some(v) = self.spacing { + r.spacing = Some(v); + } + } +} + +fn font_bool(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), + } +} + +fn font_str<'a>(p: &'a Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), + } +} + +fn font_edits(p: &Value) -> Result { + let family = match font_str(p, "family")? { + None => None, + Some(s) => { + let t = s.trim(); + if t.is_empty() || t.chars().count() > 200 { + return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); + } + Some(t.to_string()) + } + }; + let size = match p.get("size") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; + Some(n.clamp(1.0, 4000.0)) + } + }; + let underline = match font_str(p, "underline")? { + None => None, + Some(s) => { + if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { + return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); + } + Some(s.to_string()) + } + }; + let color = + if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; + let strike = match font_str(p, "strike")? { + None => None, + Some("none") => Some(Strike::None), + Some("sng" | "single") => Some(Strike::Single), + Some("dbl" | "double") => Some(Strike::Double), + Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), + }; + let baseline = match p.get("baseline") { + None => None, + Some(Value::String(s)) => match s.as_str() { + "none" => Some(0.0), + "super" | "superscript" => Some(0.3), + "sub" | "subscript" => Some(-0.25), + _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }, + Some(Value::Number(n)) => { + let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; + Some(f.clamp(-1.0, 1.0)) + } + Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }; + let caps = match font_str(p, "caps")? { + None => None, + Some("none") => Some(Caps::None), + Some("small") => Some(Caps::Small), + Some("all") => Some(Caps::All), + Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), + }; + let spacing = match p.get("spacing") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; + Some(n.clamp(-100.0, 400.0)) + } + }; + Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) +} + +/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. +fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { + use deckcraft_model::ColorBase; + let mut o = serde_json::Map::new(); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().to_string(), json!(v)); + } + } + match &c.base { + ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), + ColorBase::Rgb { rgb } => { + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + Value::Object(o) + } + ColorBase::Scheme { slot } => { + o.insert("scheme".into(), json!(slot.xml_name())); + Value::Object(o) + } + ColorBase::Preset { name } => json!(name), + ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), + } +} + +fn font_dialog_params(s: &Session) -> Value { + let r = current_run(s); + let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); + let baseline = r.baseline.unwrap_or(0.0); + let mut v = json!({ + "family": family, + "size": r.size.unwrap_or(18.0), + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), + "strike": match r.strike { + Some(Strike::Double) => "dbl", + Some(Strike::Single) => "sng", + _ => "none", + }, + "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, + "caps": match r.caps { + Some(Caps::Small) => "small", + Some(Caps::All) => "all", + _ => "none", + }, + "spacing": r.spacing.unwrap_or(0.0), + }); + if let Some(Fill::Solid { color }) = &r.fill { + v["color"] = color_command_value(color); + } + v +} + +fn font_dialog(s: &mut Session, p: &Value) -> Result { + let edits = font_edits(p)?; + if edits.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); + return ok(); + } + format_run(s, &|r| edits.write(r)) +} fn clear(s: &mut Session, _p: &Value) -> Result { format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) } @@ -682,3 +898,124 @@ fn state(s: &mut Session, _p: &Value) -> Result { "painter": s.painter.is_some(), })) } + +#[cfg(test)] +mod tests { + use deckcraft_model::text::{Caps, Strike}; + use deckcraft_model::{ColorBase, Fill, SchemeSlot}; + use serde_json::json; + + use super::*; + use crate::{Session, UiRequest}; + + fn shape() -> Session { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hello"})).unwrap(); + s + } + + fn props(s: &Session) -> RunProps { + s.doc() + .unwrap() + .selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .unwrap() + .props + .clone() + } + + #[test] + fn font_dialog_applies_character_formatting_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.fontDialog", + &json!({ + "family": "Liberation Serif", + "size": 28, + "bold": true, + "italic": false, + "underline": "wavy", + "color": "#112233", + "strike": "dbl", + "baseline": "super", + "caps": "small", + "spacing": 3 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Font…"); + let r = props(&s); + assert_eq!(r.font.as_deref(), Some("Liberation Serif")); + assert_eq!(r.size, Some(28.0)); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.italic, Some(false)); + assert_eq!(r.underline.as_deref(), Some("wavy")); + assert!(matches!(r.fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Rgb { .. }))); + assert_eq!(r.strike, Some(Strike::Double)); + assert_eq!(r.baseline, Some(0.3)); + assert_eq!(r.caps, Some(Caps::Small)); + assert_eq!(r.spacing, Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert!(r.font.is_none() && r.size.is_none() && r.bold.is_none() && r.underline.is_none() && r.fill.is_none()); + assert_eq!(r.strike, None); + assert_eq!(r.caps, None); + } + + #[test] + fn font_dialog_changes_only_the_fields_it_is_given() { + let mut s = shape(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("format.fontDialog", &json!({"size": 22, "color": {"scheme": "accent2", "lumMod": 50000}})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert_eq!(r.size, Some(22.0)); + assert!(r.font.is_none() && r.italic.is_none()); + let Some(Fill::Solid { color }) = r.fill else { panic!("solid fill") }; + assert!(matches!(color.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 })); + assert!(color.mods.iter().any(|m| m.xml_name() == "lumMod")); + s.execute("edit.undo", &json!({})).unwrap(); + let r = props(&s); + assert_eq!(r.bold, Some(true)); + assert!(r.size.is_none() && r.fill.is_none()); + } + + #[test] + fn font_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + s.execute("format.italic", &json!({"on": true})).unwrap(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.fontDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "font"); + assert_eq!(params["italic"], true); + assert_eq!(params["bold"], false); + assert!(params["family"].as_str().is_some_and(|f| !f.is_empty())); + assert!(params["size"].as_f64().is_some_and(|n| n > 0.0)); + } + + #[test] + fn font_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("format.fontDialog", &json!({"underline": "sparkle"})).unwrap_err(); + assert!(err.to_string().contains("underline"), "{err}"); + assert!(s.execute("format.fontDialog", &json!({"size": "large"})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"bold": 1})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"family": " "})).is_err()); + assert!(s.execute("format.fontDialog", &json!({"color": "not-a-color"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert!(props(&s).underline.is_none()); + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err()); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..9959882d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -635,6 +635,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("text.edit", json!({})); ui.close(); } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..7348b5bd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,4 +1,4 @@ -//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the +//! Dialogs (Insert Table, Slide Size, Font, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; @@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "font" => "Font", "about" => "About DeckCraft", "preferences" => "Preferences", "export" => "Export", @@ -382,6 +383,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "font" => font_dialog(app, ui, d), "paragraph" => { field(ui, d, "Before text (in):", "left", "0"); field(ui, d, "Special — hanging (in):", "hang", "0"); @@ -829,3 +831,211 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { Stroke::NONE, )); } + +fn seed_once(d: &mut Dialog, key: &str, val: String) { + d.fields.entry(key.to_string()).or_insert(val); +} + +fn json_text(v: Option<&Value>, default: &str) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.as_f64().map(|x| crate::widgets::fmt_num(x, "")).unwrap_or_else(|| default.to_string()), + Some(Value::Bool(true)) => "1".into(), + Some(Value::Bool(false)) => "0".into(), + _ => default.to_string(), + } +} + +fn color_label(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(o) => o.get("scheme").or_else(|| o.get("rgb")).and_then(Value::as_str).unwrap_or("").to_string(), + _ => String::new(), + } +} + +fn seed_font_fields(d: &mut Dialog) { + seed_once(d, "family", json_text(d.params.get("family"), "")); + seed_once(d, "size", json_text(d.params.get("size"), "18")); + seed_once(d, "bold", json_text(d.params.get("bold"), "0")); + seed_once(d, "italic", json_text(d.params.get("italic"), "0")); + seed_once(d, "underline", json_text(d.params.get("underline"), "none")); + seed_once(d, "strike", json_text(d.params.get("strike"), "none")); + seed_once(d, "baseline", json_text(d.params.get("baseline"), "none")); + seed_once(d, "caps", json_text(d.params.get("caps"), "none")); + seed_once(d, "spacing", json_text(d.params.get("spacing"), "0")); + let shown = d.params.get("color").map(color_label).unwrap_or_default(); + seed_once(d, "color", shown.clone()); + seed_once(d, "colorSeed", shown); +} + +fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) { + let cur = d.get(key, ""); + let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); + egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(cur == *v, *l).clicked() { + d.fields.insert((*key).into(), (*v).into()); + } + } + }); +} + +fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { + let mut v = d.get(key, ""); + let l = ui.label(label); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id); + d.fields.insert(key.into(), v.clone()); + v +} + +fn finite_pt(text: &str) -> Option { + text.trim().trim_end_matches("pt").trim().parse::().ok().filter(|v| v.is_finite()) +} + +/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together. +fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_font_fields(d); + let mut fams = crate::ribbon::font_families(app); + let family = d.get("family", ""); + if !family.is_empty() && !fams.iter().any(|f| f == &family) { + fams.insert(0, family.clone()); + } + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui( + ui, + |ui| { + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for f in &fams { + if ui.selectable_label(family == *f, f).clicked() { + d.fields.insert("family".into(), f.clone()); + } + } + }); + }, + ); + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Size", "size", 64.0); + ui.add_space(16.0); + check(ui, d, "Bold", "bold", false); + check(ui, d, "Italic", "italic", false); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + choice( + ui, + d, + "font_dialog_underline", + "underline", + &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], + ); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "Color", "color", 110.0); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); + ui.add_space(8.0); + ui.label("Caps"); + choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + let mut spacing = d.get("spacing", "0"); + let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); + let mut picked = None; + egui::ComboBox::from_id_salt("font_dialog_spacing") + .selected_text(match spacing.as_str() { + "-3" => "Very tight", + "-1.5" => "Tight", + "0" => "Normal", + "3" => "Loose", + "6" => "Very loose", + other => other, + }) + .width(120.0) + .show_ui(ui, |ui| { + for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { + if ui.selectable_label(spacing == v, l).clicked() { + picked = Some(v.to_string()); + } + } + }); + let _ = edited; + if let Some(v) = picked { + d.fields.insert("spacing".into(), v); + } else { + d.fields.insert("spacing".into(), spacing); + } + }); + let bold = d.get("bold", "0") == "1"; + let italic = d.get("italic", "0") == "1"; + let underline = d.get("underline", "none"); + let strike = d.get("strike", "none"); + let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; + let mut sample = egui::RichText::new("AaBbCcDd").size(shown); + if bold { + sample = sample.strong(); + } + if italic { + sample = sample.italics(); + } + if underline != "none" { + sample = sample.underline(); + } + if strike != "none" { + sample = sample.strikethrough(); + } + ui.add_space(6.0); + ui.label(sample); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let family = d.get("family", ""); + let family = family.trim(); + if family.is_empty() { + app.set_status("Choose a font"); + return false; + } + let Some(size) = finite_pt(&d.get("size", "")) else { + app.set_status("Font size must be a number"); + return false; + }; + let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { + app.set_status("Character spacing must be a number"); + return false; + }; + let mut params = json!({ + "family": family, + "size": size, + "bold": d.get("bold", "0") == "1", + "italic": d.get("italic", "0") == "1", + "underline": d.get("underline", "none"), + "strike": d.get("strike", "none"), + "baseline": d.get("baseline", "none"), + "caps": d.get("caps", "none"), + "spacing": spacing, + }); + let color = d.get("color", ""); + let seed = d.get("colorSeed", ""); + if color == seed { + if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) { + params["color"] = c.clone(); + } + } else if !color.trim().is_empty() { + params["color"] = json!(color.trim()); + } + let _ = app.run("format.fontDialog", params); + } + ok || cancel +} + +#[cfg(test)] +#[path = "font_dialog_tests.rs"] +mod font_dialog_tests; diff --git a/crates/ui-egui/src/font_dialog_tests.rs b/crates/ui-egui/src/font_dialog_tests.rs new file mode 100644 index 00000000..06ccfc02 --- /dev/null +++ b/crates/ui-egui/src/font_dialog_tests.rs @@ -0,0 +1,65 @@ +//! The Font dialog: Home › Font › Font… and Cmd+T open it, OK applies one undo step, Cancel does not. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + // A selected shape, not a caret: the dialog then formats the text that is already there. + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn bold_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.bold) +} + +#[test] +fn font_button_applies_bold_from_the_dialog() { + let mut h = harness(); + h.get_by_label("Font…").click(); + // The opening frame ignores the click that summoned the window; the next one is live. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font")); + // A pointer click on this checkbox does not register through kittest's per-event frames; + // the accessibility click does, and it is the same toggle a pointer click runs. + h.get_by_label("Bold").click_accesskit(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(bold_of(&h), Some(true)); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Font…"); +} + +#[test] +fn command_t_opens_the_font_dialog_and_cancel_keeps_the_text() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T); + h.run_steps(1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font"), "status {:?}", h.state().status); + h.get_by_label("Cancel").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(bold_of(&h), None); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..c294278f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -222,6 +222,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ( "Format", vec![ + ("Font…", "format.fontDialog"), ("Bold", "format.bold"), ("Italic", "format.italic"), ("Underline", "format.underline"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..d67aaf0a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -502,6 +502,9 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.color", json!({"color": cref_param(&c)})); } }); + if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + run(app, "format.fontDialog", json!({})); + } }); }); }); diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..c844d460 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 (Font dialog, 227 commands, 28 dialogs; previous: minor, 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 @@ -20,8 +20,8 @@ nothing below L6 uses egui, eframe, winit or rfd. | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | 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 | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (227 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, 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 | Font dialog; command registry 227; 28 dialogs | | 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..4368c0d4 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 (Font dialog; previous: minor, 3-D bevel, camera and face lighting rendered) · **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,9 @@ 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. The Font dialog is in (family, size, style, underline, + colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets + and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | | 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..a31d0964 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:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **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; @@ -239,7 +239,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline text, AutoRecover. -- **Automation:** 226 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. - **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09). @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages 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..6362973b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,7 +2,7 @@ > **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-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 (Font dialog; previous: minor, text editing keys observed in PowerPoint for Windows, #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`): Font, 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 | ## Direct manipulation @@ -91,8 +91,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). ## Keyboard shortcuts Every common PowerPoint for Mac shortcut is bound (checklist row done): new slide, duplicate, -group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, show from -start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag +group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, the Font +dialog (Cmd+T), show from start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag precision without snapping, F6 pane cycling, ribbon KeyTips (Windows). ### Text editing keys @@ -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 (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 | | 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 | Font dialog from Home › Font, the shape menu and Cmd+T | | 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 | From e4022e67025505e732747e8dd5e87a6c7e60357c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:59:40 +0300 Subject: [PATCH 11/15] UI: Paragraph dialog sets alignment, indents and spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format.paragraphDialog applies alignment, first-line and hanging indents, spacing and line spacing in one undo step. With no fields it opens the dialog from the Home › Paragraph launcher. Tabs… opens a standalone Tabs dialog and returns to Paragraph. Co-authored-by: Cursor --- ROADMAP.md | 4 +- crates/engine/src/cmd/format.rs | 362 ++++++++++++++++++- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/dialogs.rs | 356 +++++++++++++++++- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/paragraph_dialog_tests.rs | 88 +++++ crates/ui-egui/src/ribbon.rs | 5 +- docs/architecture.md | 7 +- docs/gaps.md | 10 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 9 +- 11 files changed, 816 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/src/paragraph_dialog_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 15729ba9..63e7e18e 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Font dialog; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Paragraph dialog; previous: trivial, Font dialog; ready stays ~55%; stage stays alpha) · **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 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step. Breadth stays 79% | | 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% | | 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 | @@ -151,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: Paragraph dialog | | 2026-10-14 | trivial | Progress log: Font dialog | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index f1c3921d..c6dec0e0 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -3,7 +3,7 @@ use deckcraft_model::edit as ed; use deckcraft_model::resolve; -use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TextDir}; +use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir}; use deckcraft_model::{Effects, Fill, Line, Shape}; use serde_json::{Value, json}; @@ -148,6 +148,24 @@ pub fn specs() -> Vec { has_text_or_shapes, paragraph ), + cmd!( + "format.paragraphDialog", + "Paragraph…", + ["Home", "Paragraph"], + None, + "{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog", + has_text_or_shapes, + paragraph_dialog + ), + cmd!( + "format.tabs", + "Tabs…", + ["Home", "Paragraph"], + None, + "{set?: [{pos: pt, align: l|ctr|r|dec}], defaultTab?: pt} — with neither, opens the Tabs dialog", + has_text_or_shapes, + tabs + ), cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), cmd!( "format.direction", @@ -758,6 +776,229 @@ fn paragraph(s: &mut Session, p: &Value) -> Result { }) } +const PARA_DIALOG_KEYS: &[&str] = + &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; + +fn para_dialog_has_fields(p: &Value) -> bool { + PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) +} + +fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; + Ok(Some(n)) + } + } +} + +fn clamp_pt(v: f64) -> f64 { + v.clamp(0.0, 1584.0) +} + +fn parse_align(cmd: &str, s: &str) -> Result { + Ok(match s { + "left" | "l" => Align::Left, + "center" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), + }) +} + +fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "left", + Align::Center => "center", + Align::Right => "right", + Align::Justify => "justify", + Align::JustLow => "justLow", + Align::Distributed => "distributed", + } +} + +fn spacing_parts(s: Option) -> (&'static str, f64) { + match s { + Some(Spacing::Pct(v)) => ("pct", v), + Some(Spacing::Pts(v)) => ("pts", v), + None => ("pts", 0.0), + } +} + +fn paragraph_dialog_params(s: &Session) -> Value { + let pp = current_para(s); + let indent = pp.indent.unwrap_or(0.0); + let (special, by) = if indent > 0.05 { + ("first", indent) + } else if indent < -0.05 { + ("hanging", -indent) + } else { + ("none", 0.0) + }; + let (line_kind, line_value) = match pp.line_spacing { + Some(Spacing::Pts(v)) => ("exactly", v), + Some(Spacing::Pct(v)) => ("multiple", v), + None => ("multiple", 1.0), + }; + let (before_kind, before) = spacing_parts(pp.space_before); + let (after_kind, after) = spacing_parts(pp.space_after); + let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({ + "align": align_name(pp.align.unwrap_or(Align::Left)), + "indentLeft": pp.margin_left.unwrap_or(0.0), + "indentRight": pp.margin_right.unwrap_or(0.0), + "special": special, + "specialBy": by, + "spaceBefore": before, + "spaceBeforeKind": before_kind, + "spaceAfter": after, + "spaceAfterKind": after_kind, + "lineKind": line_kind, + "lineValue": line_value, + "tabs": tabs, + "defaultTab": pp.default_tab.unwrap_or(72.0), + }) +} + +fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { + if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } +} + +fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.paragraphDialog"; + if !para_dialog_has_fields(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); + return ok(); + } + let align = match p.get("align") { + None => None, + Some(Value::String(a)) => Some(parse_align(CMD, a)?), + Some(_) => return Err(bad(CMD, "`align` must be a string")), + }; + let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); + let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); + let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); + let indent = match p.get("special") { + None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), + Some(Value::String(kind)) => { + let by = special_by.unwrap_or(0.0); + Some(match kind.as_str() { + "none" => 0.0, + "first" | "firstLine" => by, + "hanging" | "hang" => -by, + _ => return Err(bad(CMD, "`special` must be none, first or hanging")), + }) + } + Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), + }; + let space_before = finite_key(p, CMD, "spaceBefore")?; + let space_after = finite_key(p, CMD, "spaceAfter")?; + let before_kind = str_param(p, "spaceBeforeKind"); + let after_kind = str_param(p, "spaceAfterKind"); + for kind in [before_kind, after_kind].into_iter().flatten() { + if !matches!(kind, "pts" | "pct") { + return Err(bad(CMD, "spacing kind must be pts or pct")); + } + } + let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); + let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); + format_para(s, &move |para| { + if align.is_some() { + para.props.align = align; + } + if indent_left.is_some() { + para.props.margin_left = indent_left; + } + if indent_right.is_some() { + para.props.margin_right = indent_right; + } + if indent.is_some() { + para.props.indent = indent; + } + if let Some(v) = space_before { + para.props.space_before = Some(write_spacing(before_kind, v)); + } + if let Some(v) = space_after { + para.props.space_after = Some(write_spacing(after_kind, v)); + } + if let Some(v) = line_mult { + para.props.line_spacing = Some(Spacing::Pct(v)); + } + if let Some(v) = line_pt { + para.props.line_spacing = Some(Spacing::Pts(v)); + } + }) +} + +fn tab_align(s: &str) -> Option<&'static str> { + Some(match s { + "l" | "left" => "l", + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => return None, + }) +} + +fn tabs_params(s: &Session) -> Value { + let pp = current_para(s); + let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) +} + +fn tabs(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.tabs"; + let set = p.get("set"); + let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); + if set.is_none() && default_tab.is_none() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); + return ok(); + } + let stops = match set { + None => None, + Some(Value::Array(items)) => { + if items.len() > 64 { + return Err(bad(CMD, "`set` has more than 64 tab stops")); + } + let mut out: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let Some(obj) = item.as_object() else { + return Err(bad(CMD, format!("tab stop {i} must be an object"))); + }; + let pos = obj + .get("pos") + .and_then(Value::as_f64) + .filter(|n| n.is_finite()) + .map(clamp_pt) + .ok_or_else(|| bad(CMD, format!("tab stop {i} needs a finite `pos`")))?; + let align = match obj.get("align").and_then(Value::as_str) { + None => "l", + Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, + }; + if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { + *prev = TabStop { pos, align: align.to_string() }; + } else { + out.push(TabStop { pos, align: align.to_string() }); + } + } + out.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + Some(out) + } + Some(_) => return Err(bad(CMD, "`set` must be an array")), + }; + format_para(s, &move |para| { + if let Some(stops) = &stops { + para.props.tabs = Some(stops.clone()); + } + if let Some(v) = default_tab { + para.props.default_tab = Some(v); + } + }) +} + fn anchor(s: &mut Session, p: &Value) -> Result { let a = match str_param(p, "anchor").unwrap_or("top") { "middle" | "center" | "ctr" => Anchor::Middle, @@ -901,7 +1142,7 @@ fn state(s: &mut Session, _p: &Value) -> Result { #[cfg(test)] mod tests { - use deckcraft_model::text::{Caps, Strike}; + use deckcraft_model::text::{Align, Caps, ParaProps, Spacing, Strike}; use deckcraft_model::{ColorBase, Fill, SchemeSlot}; use serde_json::json; @@ -1018,4 +1259,121 @@ mod tests { s.execute("edit.deselect", &json!({})).unwrap(); assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err()); } + + fn para(s: &Session) -> ParaProps { + s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).and_then(|t| t.paragraphs.first()).unwrap().props.clone() + } + + #[test] + fn paragraph_dialog_applies_alignment_indent_and_spacing_in_one_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.paragraphDialog", + &json!({ + "align": "justify", + "indentLeft": 36.0, + "indentRight": 18.0, + "special": "hanging", + "specialBy": 18.0, + "spaceBefore": 6.0, + "spaceAfter": 8.0, + "lineSpacing": 1.5 + }), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…"); + let p = para(&s); + assert_eq!(p.align, Some(Align::Justify)); + assert_eq!(p.margin_left, Some(36.0)); + assert_eq!(p.margin_right, Some(18.0)); + assert_eq!(p.indent, Some(-18.0)); + assert_eq!(p.space_before, Some(Spacing::Pts(6.0))); + assert_eq!(p.space_after, Some(Spacing::Pts(8.0))); + assert_eq!(p.line_spacing, Some(Spacing::Pct(1.5))); + s.execute("edit.undo", &json!({})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote"); + assert!(p.margin_left.is_none() && p.indent.is_none() && p.space_before.is_none() && p.line_spacing.is_none()); + } + + #[test] + fn paragraph_dialog_first_line_and_exact_spacing_replace_only_those_fields() { + let mut s = shape(); + s.execute("format.paragraphDialog", &json!({"align": "right"})).unwrap(); + s.execute("format.paragraphDialog", &json!({"special": "first", "specialBy": 27.0, "lineSpacingPt": 20.0})).unwrap(); + let p = para(&s); + assert_eq!(p.align, Some(Align::Right)); + assert_eq!(p.indent, Some(27.0)); + assert_eq!(p.line_spacing, Some(Spacing::Pts(20.0))); + assert!(p.margin_left.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).align, Some(Align::Right)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn paragraph_dialog_with_no_fields_asks_the_ui_to_open() { + let mut s = shape(); + let rev = s.doc().unwrap().revision; + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.paragraphDialog", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "paragraph"); + assert_eq!(params["align"], "center", "a new shape's text is centred"); + assert_eq!(params["special"], "none"); + assert_eq!(params["lineKind"], "multiple"); + assert!(params["lineValue"].as_f64().is_some_and(|v| v > 0.0)); + } + + #[test] + fn paragraph_dialog_rejects_bad_fields_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + assert!(s.execute("format.paragraphDialog", &json!({"align": "sparkle"})).unwrap_err().to_string().contains("align")); + assert!(s.execute("format.paragraphDialog", &json!({"special": "middle"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"indentLeft": "wide"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"lineSpacing": "double"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"spaceBeforeKind": "lines", "spaceBefore": 4.0})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert_eq!(para(&s).align, Some(Align::Center)); + assert!(para(&s).indent.is_none()); + } + + #[test] + fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute( + "format.tabs", + &json!({"set": [{"pos": 144.0, "align": "center"}, {"pos": 72.0, "align": "dec"}, {"pos": 72.02, "align": "right"}], "defaultTab": 36.0}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Tabs…"); + let p = para(&s); + let tabs = p.tabs.unwrap(); + assert_eq!(tabs.len(), 2, "stops within 0.05 pt collapse"); + assert_eq!(tabs[0].pos, 72.02); + assert_eq!(tabs[0].align, "r"); + assert_eq!(tabs[1].align, "ctr"); + assert_eq!(p.default_tab, Some(36.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(para(&s).tabs.is_none() && para(&s).default_tab.is_none()); + + let n = s.doc().unwrap().history.undo.len(); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") }; + assert_eq!(id, "tabs"); + assert!(params["tabs"].as_array().is_some_and(|a| a.is_empty())); + assert!(s.execute("format.tabs", &json!({"set": [{"pos": 10.0, "align": "sparkle"}]})).is_err()); + assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9959882d..0e22789c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -639,6 +639,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("format.fontDialog", json!({})); ui.close(); } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7348b5bd..70924563 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,4 +1,4 @@ -//! Dialogs (Insert Table, Slide Size, Font, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the +//! Dialogs (Insert Table, Slide Size, Font, Paragraph, Tabs, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the //! About window. The start screen is File › Home (`backstage.rs`). use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; @@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str { "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", + "tabs" => "Tabs", "font" => "Font", "about" => "About DeckCraft", "preferences" => "Preferences", @@ -75,8 +76,17 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { ui.set_min_width(340.0); close = body(app, ui, &mut d); }); - if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if open && !close && !escape { app.dialog = Some(d); + } else if app.dialog.is_none() + && let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned() + { + // Tabs… opened from Paragraph: closing it (OK, Cancel, Esc or the window) returns there. + let id = d.params.get("returnId").and_then(Value::as_str).unwrap_or("paragraph"); + let mut back = Dialog::new(id); + back.params = ret; + app.dialog = Some(back); } } @@ -384,22 +394,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "font" => font_dialog(app, ui, d), - "paragraph" => { - field(ui, d, "Before text (in):", "left", "0"); - field(ui, d, "Special — hanging (in):", "hang", "0"); - field(ui, d, "Spacing before (pt):", "before", "0"); - field(ui, d, "Spacing after (pt):", "after", "0"); - field(ui, d, "Line spacing (lines):", "lines", "1.0"); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "format.paragraph", - json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}), - ); - } - ok || cancel - } + "paragraph" => paragraph_dialog(app, ui, d), + "tabs" => tabs_dialog(app, ui, d), "export" => { ui.label("Export the presentation as:"); let fmt = d.get("fmt", "pdf"); @@ -1036,6 +1032,330 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } +fn pt_text(v: Option<&Value>, default: f64) -> String { + match v.and_then(Value::as_f64).filter(|n| n.is_finite()) { + Some(n) => crate::widgets::fmt_num(n, ""), + None => crate::widgets::fmt_num(default, ""), + } +} + +fn inches_text(v: Option<&Value>) -> String { + crate::widgets::fmt_num(v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) / 72.0, "") +} + +fn seed_paragraph_fields(d: &mut Dialog) { + seed_once(d, "align", json_text(d.params.get("align"), "left")); + seed_once(d, "before", inches_text(d.params.get("indentLeft"))); + seed_once(d, "after", inches_text(d.params.get("indentRight"))); + seed_once(d, "special", json_text(d.params.get("special"), "none")); + seed_once(d, "by", inches_text(d.params.get("specialBy"))); + seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0)); + seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); + seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); + seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); +} + +fn paragraph_return_params(d: &mut Dialog) -> Value { + let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0; + let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0; + let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0; + let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0); + let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0); + let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); + let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); + let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); + let v = json!({ + "align": d.get("align", "left"), + "indentLeft": before, + "indentRight": after, + "special": d.get("special", "none"), + "specialBy": by, + "spaceBefore": space_before, + "spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfter": space_after, + "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "lineKind": d.get("lineKind", "multiple"), + "lineValue": line_value, + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + }); + v +} + +fn line_preset_label(kind: &str, value: &str) -> &'static str { + if kind == "exactly" { + "Exactly" + } else { + match value { + "1" => "Single", + "1.5" => "1.5 lines", + "2" => "Double", + _ => "Multiple", + } + } +} + +/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together. +fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(460.0); + seed_paragraph_fields(d); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + choice( + ui, + d, + "paragraph_align", + "align", + &[ + ("left", "Left"), + ("center", "Center"), + ("right", "Right"), + ("justify", "Justify"), + ("justLow", "Justify low"), + ("distributed", "Distributed"), + ], + ); + }); + ui.label(egui::RichText::new("Indentation").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Before text", "before", 64.0); + ui.label("in"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "After text", "after", 64.0); + ui.label("in"); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "By", "by", 64.0); + ui.label("in"); + }); + ui.label(egui::RichText::new("Spacing").strong()); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); + ui.label("pt"); + ui.add_space(12.0); + let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); + ui.label("pt"); + }); + let mut picked: Option<(&'static str, Option<&'static str>)> = None; + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let kind = d.get("lineKind", "multiple"); + let value = d.get("lineValue", "1"); + egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + for (label, k, v) in [ + ("Single", "multiple", Some("1")), + ("1.5 lines", "multiple", Some("1.5")), + ("Double", "multiple", Some("2")), + ("Multiple", "multiple", None), + ("Exactly", "exactly", None), + ] { + if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + picked = Some((k, v)); + } + } + }); + ui.add_space(8.0); + let _ = labeled_text(ui, d, "At", "lineValue", 64.0); + }); + if let Some((kind, value)) = picked { + d.fields.insert("lineKind".into(), kind.into()); + if let Some(v) = value { + d.fields.insert("lineValue".into(), v.into()); + } else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) { + d.fields.insert("lineValue".into(), "18".into()); + } else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) { + d.fields.insert("lineValue".into(), "1".into()); + } + } + let mut open_tabs = false; + ui.horizontal(|ui| { + if ui.button("Tabs…").clicked() { + open_tabs = true; + } + }); + let (ok, cancel) = buttons(ui, "OK"); + if open_tabs { + let mut tabs = Dialog::new("tabs"); + tabs.params = json!({ + "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), + "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), + "returnId": "paragraph", + "returnParams": paragraph_return_params(d), + }); + app.dialog = Some(tabs); + return true; + } + if ok { + let Some(before_in) = finite_pt(&d.get("before", "")) else { + app.set_status("Indent before text must be a number of inches"); + return false; + }; + let Some(after_in) = finite_pt(&d.get("after", "")) else { + app.set_status("Indent after text must be a number of inches"); + return false; + }; + let Some(by_in) = finite_pt(&d.get("by", "")) else { + app.set_status("Special indent must be a number of inches"); + return false; + }; + let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { + app.set_status("Spacing before must be a number"); + return false; + }; + let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { + app.set_status("Spacing after must be a number"); + return false; + }; + let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { + app.set_status("Line spacing must be a number"); + return false; + }; + let mut params = json!({ + "align": d.get("align", "left"), + "indentLeft": before_in * 72.0, + "indentRight": after_in * 72.0, + "special": d.get("special", "none"), + "specialBy": by_in * 72.0, + "spaceBefore": space_before, + "spaceAfter": space_after, + "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + }); + if d.get("lineKind", "multiple") == "exactly" { + params["lineSpacingPt"] = json!(line_value); + } else { + params["lineSpacing"] = json!(line_value); + } + if app.run("format.paragraphDialog", params).is_err() { + return false; + } + } + ok || cancel +} + +fn tab_rows(v: &Value) -> Vec<(f64, String)> { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|t| { + let pos = t.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite())?; + let align = t.get("align").and_then(Value::as_str).unwrap_or("l").to_string(); + Some((pos, align)) + }) + .collect() + }) + .unwrap_or_default() +} + +fn tab_label(align: &str) -> &'static str { + match align { + "ctr" => "Center", + "r" => "Right", + "dec" => "Decimal", + _ => "Left", + } +} + +/// Standalone Tabs dialog (the ruler dialog from the open tabs work is not on main). +/// OK applies the stops in one step. Opened from Paragraph, closing returns there. +fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.set_min_width(420.0); + if d.params.get("editing").is_none() { + d.params["edit"] = d.params.get("tabs").cloned().unwrap_or_else(|| json!([])); + d.params["editing"] = json!(true); + } + seed_once(d, "pos", "0.5".to_string()); + seed_once(d, "stopAlign", "l".to_string()); + seed_once(d, "defaultTab", inches_text(d.params.get("defaultTab"))); + seed_once(d, "sel", String::new()); + let rows = tab_rows(d.params.get("edit").unwrap_or(&Value::Null)); + ui.label("Tab stop position"); + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + if rows.is_empty() { + ui.label(egui::RichText::new("No custom stops").weak()); + } + for (i, (pos, align)) in rows.iter().enumerate() { + let label = format!("{} in {}", crate::widgets::fmt_num(pos / 72.0, ""), tab_label(align)); + let on = d.get("sel", "") == i.to_string(); + if ui.selectable_label(on, label).clicked() { + d.fields.insert("sel".into(), i.to_string()); + d.fields.insert("pos".into(), crate::widgets::fmt_num(pos / 72.0, "")); + d.fields.insert("stopAlign".into(), align.clone()); + } + } + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Position", "pos", 64.0); + ui.label("in"); + ui.add_space(8.0); + choice(ui, d, "tabs_align", "stopAlign", &[("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]); + }); + ui.horizontal(|ui| { + if ui.button("Set").clicked() + && let Some(inches) = finite_pt(&d.get("pos", "")) + { + let pos = (inches * 72.0).clamp(0.0, 1584.0); + let align = d.get("stopAlign", "l"); + let mut rows = tab_rows(d.params.get("edit").unwrap_or(&Value::Null)); + let sel = d.get("sel", "").parse::().ok(); + if let Some(i) = sel.filter(|i| *i < rows.len()) { + rows[i] = (pos, align); + } else if rows.len() < 64 { + rows.push((pos, align)); + d.fields.insert("sel".into(), (rows.len() - 1).to_string()); + } + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + d.params["edit"] = json!(rows.iter().map(|(p, a)| json!({"pos": p, "align": a})).collect::>()); + } + if ui.button("Clear").clicked() + && let Some(i) = d.get("sel", "").parse::().ok() + { + let mut rows = tab_rows(d.params.get("edit").unwrap_or(&Value::Null)); + if i < rows.len() { + rows.remove(i); + d.params["edit"] = json!(rows.iter().map(|(p, a)| json!({"pos": p, "align": a})).collect::>()); + d.fields.insert("sel".into(), String::new()); + } + } + if ui.button("Clear All").clicked() { + d.params["edit"] = json!([]); + d.fields.insert("sel".into(), String::new()); + } + }); + ui.horizontal(|ui| { + let _ = labeled_text(ui, d, "Default", "defaultTab", 64.0); + ui.label("in"); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let Some(default_in) = finite_pt(&d.get("defaultTab", "")) else { + app.set_status("Default tab stops must be a number of inches"); + return false; + }; + let set: Vec = + tab_rows(d.params.get("edit").unwrap_or(&Value::Null)).into_iter().map(|(p, a)| json!({"pos": p, "align": a})).collect(); + if app.run("format.tabs", json!({"set": set, "defaultTab": (default_in * 72.0).clamp(1.0, 1584.0)})).is_err() { + return false; + } + let applied = d.params.get("edit").cloned().unwrap_or_else(|| json!([])); + let default_pt = (default_in * 72.0).clamp(1.0, 1584.0); + if let Some(ret) = d.params.get_mut("returnParams") { + ret["tabs"] = applied; + ret["defaultTab"] = json!(default_pt); + } + } + ok || cancel +} + #[cfg(test)] #[path = "font_dialog_tests.rs"] mod font_dialog_tests; + +#[cfg(test)] +#[path = "paragraph_dialog_tests.rs"] +mod paragraph_dialog_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c294278f..82ec182d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -223,6 +223,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { "Format", vec![ ("Font…", "format.fontDialog"), + ("Paragraph…", "format.paragraphDialog"), ("Bold", "format.bold"), ("Italic", "format.italic"), ("Underline", "format.underline"), diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs new file mode 100644 index 00000000..cba8fe53 --- /dev/null +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -0,0 +1,88 @@ +//! The Paragraph dialog: Home › Paragraph › Paragraph… opens it, OK applies one undo step, +//! and Tabs… edits stops then returns to Paragraph. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap(); + let _ = s.execute("text.exit", &json!({})); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn align_of(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.align.map(|a| a.xml().to_string())) +} + +fn tabs_of(h: &Harness<'_, SlideApp>) -> Vec<(f64, String)> { + let st = h.state().session.active().unwrap(); + st.selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.tabs.clone()) + .unwrap_or_default() + .into_iter() + .map(|t| (t.pos, t.align)) + .collect() +} + +#[test] +fn paragraph_button_aligns_text_in_one_undo() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph")); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("OK").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + assert_eq!(align_of(&h).as_deref(), Some("r")); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…"); +} + +#[test] +fn tabs_from_paragraph_applies_a_stop_and_returns() { + let mut h = harness(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); + h.get_by_label("Tabs…").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.params["editing"] = json!(true); + d.params["edit"] = json!([{"pos": 144.0, "align": "ctr"}]); + } + h.get_by_label("OK").click_accesskit(); + // The Tabs OK frame restores Paragraph; the next frame fills its fields. + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph"), "status {:?}", h.state().status); + assert_eq!(h.state().dialog.as_ref().unwrap().fields.get("align").map(String::as_str), Some("right")); + assert_eq!(tabs_of(&h), vec![(144.0, "ctr".into())]); + assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Tabs…"); + h.get_by_label("Cancel").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote"); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d67aaf0a..5fc6f0c1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -595,7 +595,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } if ui.button("Line Spacing Options…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + run(app, "format.paragraphDialog", json!({})); } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); @@ -636,6 +636,9 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } }); + if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { + run(app, "format.paragraphDialog", json!({})); + } }); }); }); diff --git a/docs/architecture.md b/docs/architecture.md index c844d460..6397b721 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog, 227 commands, 28 dialogs; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph and Tabs dialogs, 229 commands, 29 dialogs; previous: minor, Font dialog) · **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 @@ -20,8 +20,8 @@ nothing below L6 uses egui, eframe, winit or rfd. | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | 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 (227 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, 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 | +| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (229 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, 29 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,6 +78,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paragraph and Tabs dialogs; command registry 229; 29 dialogs | | 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 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 4368c0d4..42795059 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph dialog; previous: minor, Font dialog) · **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,9 +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**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline, - colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets - and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h. +- **Dialogs**: 29 dialogs vs PowerPoint's ~60. The Font dialog and the Paragraph dialog are in + (Paragraph: alignment, first-line and hanging indents, spacing before/after, line spacing, and a + Tabs dialog, each OK one undo step). Still missing: Bullets and Numbering, Find/Replace, Replace + Fonts, Action Settings, Print, Insert Object…. 4–8 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware @@ -257,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paragraph dialog sets alignment, indentation, spacing and line spacing in one step; its Tabs… button opens a standalone Tabs dialog | | 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step | | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a31d0964..efcc3684 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **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; @@ -239,7 +239,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc - **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline text, AutoRecover. -- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel +- **Automation:** 229 commands, every one reachable from `deckcraft-cli`, the JSON control channel and MCP. - **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09). @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged | | 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6362973b..ac0da780 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,7 +2,7 @@ > **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 (Font dialog; previous: 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 (Paragraph dialog; previous: minor, Font dialog) · **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…) | 28 (`crates/ui-egui/src/dialogs.rs`): Font, 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…) | 29 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -82,7 +82,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes | -| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | +| Rulers with indent markers and tab stops | yes | rulers yes; tab stops are set in the Tabs dialog, not on the ruler | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | | Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | @@ -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 (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 | +| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 4–8 | | 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 | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog | | 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T | | 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) | From 7acb9faad1c90516cc5e883b29a8da6c179ce67b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:38:03 -0400 Subject: [PATCH 12/15] Fix up merged PRs: one format.tabs and one Tabs dialog for #567/#569 and #637 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #569 and #637 each added a `format.tabs` command and a Tabs dialog. Keep one of each: - format.tabs takes #567/#569's add/move/remove/clear/set plus #637's strict `set` (unknown alignment or non-object entries are errors, stops within 0.05 pt collapse, last wins) and opens the Tabs dialog when called with no fields, as #637 did. A defaultTab-only call no longer rewrites the stops. - The Tabs dialog is #569's; it now seeds from the dialog's params when Paragraph (or format.tabs) opened it, and on OK hands the applied stops back to Paragraph. - Tests adapted: an empty format.tabs opens the dialog; the Paragraph → Tabs test sets stops through #569's dialog fields. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/ruler_tests.rs | 4 +++- crates/ui-egui/src/dialogs.rs | 8 +++++++- crates/ui-egui/src/paragraph_dialog_tests.rs | 3 +-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/ruler_tests.rs b/crates/engine/src/cmd/ruler_tests.rs index 95c87c6c..e920ce93 100644 --- a/crates/engine/src/cmd/ruler_tests.rs +++ b/crates/engine/src/cmd/ruler_tests.rs @@ -50,7 +50,9 @@ fn tabs_add_move_remove_clear_and_undo() { assert_eq!(tabs(&mut s).len(), 2); s.execute("format.tabs", &json!({"clear": true})).unwrap(); assert!(tabs(&mut s).is_empty()); - assert!(s.execute("format.tabs", &json!({})).is_err()); + s.ui_requests.clear(); + s.execute("format.tabs", &json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog"); assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash"); for i in 0..100 { s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb890e1f..e570233b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2773,7 +2773,9 @@ const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "R /// list (inches, as PowerPoint's dialog) and applied together with OK. fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !d.fields.contains_key("stops") { - let r = app.session.execute("format.ruler", &json!({})).unwrap_or_default(); + // Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the + // dialog; otherwise they are read from the caret's paragraph. + let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() }; let stops: Vec = r["tabs"] .as_array() .map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect()) @@ -2846,6 +2848,10 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0; if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) { app.set_status(e); + } else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) { + // Back in Paragraph, its Tabs… shows what was just applied. + ret.insert("tabs".into(), json!(set)); + ret.insert("defaultTab".into(), json!(dt)); } } ok || cancel diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs index cba8fe53..fb8e796b 100644 --- a/crates/ui-egui/src/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -71,8 +71,7 @@ fn tabs_from_paragraph_applies_a_stop_and_returns() { assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); { let d = h.state_mut().dialog.as_mut().unwrap(); - d.params["editing"] = json!(true); - d.params["edit"] = json!([{"pos": 144.0, "align": "ctr"}]); + d.fields.insert("stops".into(), "2:ctr".into()); } h.get_by_label("OK").click_accesskit(); // The Tabs OK frame restores Paragraph; the next frame fills its fields. From e90e26545388df50393872fec36db652a2c76ee0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:45:12 -0400 Subject: [PATCH 13/15] Fix up merged PRs: Grid Options guides follow #559, and its dialog test finds its own Spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turning guides on in Grid Options (#536) now adds the two centre guides to a presentation without any, as View › Guides does since #559 stopped drawing fixed centre lines. - The Grid Options dialog test looked for any "Spacing" label; main's ribbon has Line Spacing too, so it looks for "Spacing (pt)". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f8f188b1..d27f163c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -588,6 +588,10 @@ impl SlideApp { } if let Some(b) = guides { self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } } } else { self.dialog = Some(dialogs::Dialog::new("gridOptions")); @@ -1690,7 +1694,7 @@ mod tests { harness.run_steps(2); assert!(harness.query_by_label_contains("Display grid on screen").is_some()); assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); - assert!(harness.query_by_label_contains("Spacing").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); let show_grid = harness .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") .next() From e6862f18ae91969a98a0305e272e0971c1d707e8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:49:10 -0400 Subject: [PATCH 14/15] Fix up merged PRs: WordArt warp handles on main's warp catalog and hover hit list - #519's handle test iterated the old `ALL` preset list; main has `CATALOG` now. - The hover hit-test match lists every Hit variant; add #519's Hit::Warp. - Drop the duplicate "tabs" dialog title that #569 and #637 both added. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 3 ++- crates/geom/src/warp/tests.rs | 2 +- crates/ui-egui/src/dialogs.rs | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 418c1de4..7dbf9038 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1104,7 +1104,8 @@ impl Session { | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. } | Hit::Handle { id, .. } - | Hit::Vertex { id, .. }, + | Hit::Vertex { id, .. } + | Hit::Warp { id, .. }, ) => Some(*id), None => None, }; diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index ac231cf0..c04ddd10 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -281,7 +281,7 @@ fn hostile_adjusts_and_boxes_stay_finite() { #[test] fn every_preset_has_one_handle_per_adjust() { let r = rect(); - for name in ALL { + for name in CATALOG.iter().map(|p| p.id) { let hs = handles(name, &[], r); assert_eq!(hs.len(), adjust_count(name), "{name}"); let defaults = adjust_defaults(name); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e3b23245..c3010053 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -47,7 +47,6 @@ fn title(id: &str) -> &'static str { "paragraph" => "Paragraph", "tabs" => "Tabs", "font" => "Font", - "tabs" => "Tabs", "about" => "About DeckCraft", "preferences" => "Preferences", "gridOptions" => "Grid Options", From 320e3a971a9432de2634a0a3cbb360a5c665c36c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:04:16 -0400 Subject: [PATCH 15/15] Fix up merged PRs: one Editing group on Home, fmt, docs after the format/canvas/dialog merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home: #556's Find and Replace join main's Replace Fonts… in one Editing group with the Selection Pane, as PowerPoint lays it out; the separate group pushed Replace Fonts… out of reach at 1400 px. The Replace Fonts test picks the dialog's Replace button, not the ribbon's. - cargo fmt. - docs: parity summary regenerated with `cargo xtask parity`; ui-parity's ruler row says tab stops are set on the ruler (#567/#569) as well as in the Tabs dialog. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/review.rs | 1 - crates/ui-egui/src/canvas.rs | 1 - crates/ui-egui/src/lib.rs | 4 +-- crates/ui-egui/src/replacefonts_tests.rs | 3 ++- crates/ui-egui/src/ribbon.rs | 9 +++---- docs/parity-checklist.md | 34 +++++------------------- docs/ui-parity.md | 2 +- 7 files changed, 15 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 13f4bde0..0becb304 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -398,7 +398,6 @@ fn replace_word(s: &mut Session, p: &Value) -> Result { s.execute("text.insert", &json!({"text": with})) } - fn comparing(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a732b413..e65b3c51 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1154,7 +1154,6 @@ fn grid_fits(span: f32, step: f32) -> bool { span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0 } - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e1892a08..61b31f1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1401,12 +1401,12 @@ mod animpane_tests; #[cfg(test)] mod browsetheme_tests; #[cfg(test)] +mod compare_tests; +#[cfg(test)] mod eyedropper_tests; #[cfg(test)] mod huge_deck_tests; #[cfg(test)] -mod compare_tests; -#[cfg(test)] mod ime_tests; #[cfg(test)] mod replacefonts_tests; diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index cec694bf..205c65aa 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -50,7 +50,8 @@ fn ribbon_button_opens_the_dialog_and_replaces() { d.fields.insert("to".into(), "Lato".into()); } h.run_steps(2); - h.get_by_label("Replace").click(); + // The ribbon has a Replace button too (Find and Replace); the dialog's is drawn last. + h.query_all_by_label("Replace").last().expect("the dialog's Replace button").click(); h.run_steps(3); assert_eq!(run_font(&h).as_deref(), Some("Lato")); // The dialog stays open with a confirmation, like PowerPoint's. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index eb71d4d9..0298ea06 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1160,18 +1160,17 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. group(ui, |ui| { ui.vertical(|ui| { small(app, ui, Icon::Search, "Find", "app.find", json!({})); small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } }); - }); - group(ui, |ui| { // Also under Arrange, but that is disabled until something is selected (#132). big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - } }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index fe541d2e..8992c00c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,55 +3,33 @@ > **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: 80%** over 191 features. +**Weighted breadth parity: 82%** over 193 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 39 | 20 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 14 | 15 | 25 | 40% | -| P1 | 65 | 39 | 21 | 5 | 76% | -| P2 | 54 | 14 | 17 | 23 | 42% | -| P2 | 54 | 14 | 14 | 26 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | -| P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 16 | 25 | 39% | +| P1 | 66 | 43 | 18 | 5 | 79% | +| P2 | 55 | 16 | 20 | 19 | 47% | | Area | Parity | |---|---| | Application shell | 81% | | Files | 71% | | Slides | 90% | -| Views | 93% | -| Views | 95% | -| Masters & themes | 85% | -| Views | 90% | +| Views | 98% | | Masters & themes | 88% | -| Shapes | 88% | | Shapes | 91% | | Format | 83% | | Arrange | 100% | -| Text | 80% | +| Text | 82% | | Tables | 95% | -| Text | 81% | -| Text | 81% | -| Tables | 89% | | Charts | 65% | -| SmartArt | 33% | -| Pictures | 52% | | SmartArt | 50% | -| Pictures | 48% | +| Pictures | 52% | | Media | 67% | | Transitions | 100% | | Animations | 79% | | Slide show | 68% | -| Review | 60% | -| Slide show | 66% | | Review | 70% | | Draw | 33% | | Undo & clipboard | 100% | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1e07a75..f5b43424 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -139,7 +139,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | | Exact size/position, lock aspect | yes | yes | -| Rulers with indent markers and tab stops | yes | rulers yes; tab stops are set in the Tabs dialog, not on the ruler | +| 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 | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes |