diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..1b9aee83 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -59,6 +59,15 @@ pub fn specs() -> Vec { case ), cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps), + cmd!( + "format.language", + "Language", + ["Review", "Language"], + None, + "{lang: BCP 47 tag (en-US, fr-FR…)} — the proofing language of the selected text", + has_text_or_shapes, + language + ), cmd!( "format.spacing", "Character Spacing", @@ -297,6 +306,14 @@ fn highlight(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color"); format_run(s, &move |r| r.highlight = c.clone()) } +fn language(s: &mut Session, p: &Value) -> Result { + let l = str_param(p, "lang") + .map(str::trim) + .filter(|l| !l.is_empty() && l.len() <= 35) + .ok_or_else(|| bad("format.language", "missing or invalid `lang`"))? + .to_string(); + format_run(s, &move |r| r.lang = Some(l.clone())) +} fn caps(s: &mut Session, p: &Value) -> Result { let c = match str_param(p, "caps").unwrap_or("none") { "small" => Caps::Small, @@ -678,6 +695,7 @@ fn state(s: &mut Session, _p: &Value) -> Result { "align": pp.align.map(|a| a.xml()), "bullets": matches!(pp.bullet, Some(Bullet::Char { .. })), "numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })), + "lang": r.lang, "lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(), "painter": s.painter.is_some(), })) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..ad87d48f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,17 @@ 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 format_language_marks_the_selected_text() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola mundo"})).unwrap(); + s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); + s.execute("format.language", &json!({"lang": "es-ES"})).unwrap(); + assert_eq!(s.execute("format.state", &json!({})).unwrap()["lang"], "es-ES"); + let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); + let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect(); + assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..74ec0c91 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -556,21 +556,32 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "language" => { ui.label("Mark selected text as:"); - for l in [ - "English (United States)", - "English (United Kingdom)", - "Español", - "Français", - "Deutsch", - "Italiano", - "Português", - "日本語", - "中文", - "العربية", - ] { - let _ = ui.selectable_label(l.starts_with("English (United States)"), l); + if !d.fields.contains_key("lang") { + let cur = app.session.execute("format.state", &json!({})).ok().and_then(|v| v.get("lang").and_then(Value::as_str).map(String::from)); + d.fields.insert("lang".into(), cur.unwrap_or_else(|| "en-US".into())); } + let mut lang = d.get("lang", "en-US"); + for (tag, name) in [ + ("en-US", "English (United States)"), + ("en-GB", "English (United Kingdom)"), + ("es-ES", "Español"), + ("fr-FR", "Français"), + ("de-DE", "Deutsch"), + ("it-IT", "Italiano"), + ("pt-PT", "Português"), + ("ja-JP", "日本語"), + ("zh-CN", "中文"), + ("ar-SA", "العربية"), + ] { + if ui.selectable_label(lang.eq_ignore_ascii_case(tag), name).clicked() { + lang = tag.to_string(); + } + } + d.fields.insert("lang".into(), lang.clone()); let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "format.language", json!({"lang": lang})); + } ok || cancel } "customShow" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..19b7aec9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1408,4 +1408,33 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + /// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text. + #[test] + fn the_language_dialog_marks_the_selected_text() { + 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); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap(); + harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + // The anchored window settles over its first frames; click once it stays put. + harness.run_steps(4); + harness.get_by_label("Français").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR")); + } }