diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..c97f935d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Numbering", ["Home", "Paragraph"], None, - "{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n}", + "{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}", has_text_or_shapes, numbering ), @@ -442,7 +442,7 @@ fn bullets(s: &mut Session, p: &Value) -> Result { let on = on_param(p, Some(cur)); let ch = str_param(p, "char").unwrap_or("•").to_string(); let col = color_param(p, "color"); - let size = f64_param(p, "size").map(|v| v / 100.0); + let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); let explicit_char = p.get("char").is_some(); format_para(s, &move |para| { if on { @@ -471,9 +471,17 @@ fn numbering(s: &mut Session, p: &Value) -> Result { let on = on_param(p, Some(cur)); let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string(); let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1); + let col = color_param(p, "color"); + let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); format_para(s, &move |para| { if on { para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start }); + if col.is_some() { + para.props.bullet_color = col.clone(); + } + if size.is_some() { + para.props.bullet_size = size; + } para.props.margin_left = Some(27.0 + 36.0 * para.level as f64); para.props.indent = Some(-27.0); } else { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..ca2b6a78 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,23 @@ 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"); } + +/// Bullets and Numbering dialog: numbering takes a colour and a size like bullets do, and sizes +/// stay within PowerPoint's 25–400 %. +#[test] +fn numbering_takes_color_and_size() { + let mut s = Session::with_new(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": "One\nTwo"})).unwrap(); + s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap(); + let para = |s: &Session| { + let st = s.doc().unwrap(); + let id = st.selection.shapes[0]; + st.shape(id).unwrap().text.as_ref().unwrap().paragraphs[0].props.clone() + }; + let pp = para(&s); + assert!(matches!(&pp.bullet, Some(deckcraft_model::text::Bullet::AutoNum { scheme, start_at: 3 }) if scheme == "romanUcPeriod")); + assert_eq!(pp.bullet_size, Some(1.5)); + assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); + s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap(); + assert_eq!(para(&s).bullet_size, Some(4.0)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..39086c94 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -55,6 +55,7 @@ fn title(id: &str) -> &'static str { "chartData" => "Chart Data", "trim" => "Trim Media", "smartart" => "Choose a SmartArt Graphic", + "bullets" => "Bullets and Numbering", "quit" => "DeckCraft", _ => "DeckCraft", } @@ -344,6 +345,107 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "bullets" => { + // The current paragraph's list, as the dialog's starting point. + let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); + let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); + let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); + ui.horizontal(|ui| { + for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + let style_key = if tab == "numbered" { "scheme" } else { "char" }; + let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "•" }); + let styles: &[(&str, &str)] = if tab == "numbered" { + &[ + ("arabicPeriod", "1. 2. 3."), + ("arabicParenR", "1) 2) 3)"), + ("romanUcPeriod", "I. II. III."), + ("romanLcPeriod", "i. ii. iii."), + ("alphaUcPeriod", "A. B. C."), + ("alphaLcParenR", "a) b) c)"), + ("alphaLcPeriod", "a. b. c."), + ] + } else { + &[("•", "•"), ("○", "○"), ("■", "■"), ("□", "□"), ("◆", "◆"), ("➢", "➢"), ("✓", "✓"), ("–", "–"), ("★", "★")] + }; + egui::Grid::new("bullet-styles").show(ui, |ui| { + for (i, (k, l)) in styles.iter().enumerate() { + let r = ui.add_sized( + vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), + egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), + ); + if r.clicked() { + d.fields.insert(style_key.into(), (*k).into()); + } + if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { + ui.end_row(); + } + } + }); + if tab == "bulleted" { + field(ui, d, "Custom character:", "custom", ""); + } else { + field(ui, d, "Start at:", "start", "1"); + } + field(ui, d, "Size (% of text):", "size", "100"); + let colors = [ + ("", "Automatic"), + ("tx1", "Text"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), + ]; + let color = d.get("color", ""); + ui.horizontal(|ui| { + ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + egui::ComboBox::from_id_salt("bullet-color") + .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) + .show_ui(ui, |ui| { + for (k, l) in colors { + if ui.selectable_label(color == k, l).clicked() { + d.fields.insert("color".into(), k.into()); + } + } + }); + }); + ui.horizontal(|ui| { + if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { + d.fields.insert("none".into(), "1".into()); + } + }); + let (ok, cancel) = buttons(ui, "OK"); + let none = d.fields.contains_key("none"); + if none { + run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); + } else if ok { + let mut p = json!({"on": true}); + if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { + p["size"] = json!(size.clamp(25.0, 400.0)); + } + if !color.is_empty() { + p["color"] = json!(color); + } + if tab == "numbered" { + p["scheme"] = json!(d.get("scheme", "arabicPeriod")); + p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); + run(app, "format.numbering", p); + } else { + let custom = d.get("custom", ""); + let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "•")); + p["char"] = json!(ch); + run(app, "format.bullets", p); + } + } + ok || cancel || none + } "altText" => { let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).unwrap_or_default(); ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..4bf8e755 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -556,6 +556,12 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.bullets", json!({"on": true, "char": ch})); } } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "bulleted".into()); + app.dialog = Some(dlg); + } }); } let (m, a) = small_button_ex(ui, Icon::Numbering, "", en, b("numbering"), true); @@ -580,6 +586,12 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.numbering", json!({"on": true, "scheme": sch})); } } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "numbered".into()); + app.dialog = Some(dlg); + } }); } toggle(app, ui, Icon::DecreaseIndent, "Decrease List Level", "format.outdent", false); diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..af8eb5dc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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, 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.