From e4022e67025505e732747e8dd5e87a6c7e60357c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:59:40 +0300 Subject: [PATCH] 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) |