From 0a719bf88809d1d981c8290a0726f9305fe2f5a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 13:36:24 +0200 Subject: [PATCH 1/3] Hyphenation Settings: InDesign's dialog from the Paragraph panel menu, and in Paragraph Style Options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) opens InDesign's Hyphenation Settings for the selected paragraphs: Hyphenate; Words with at Least, After First and Before Last (letters), Hyphen Limit (hyphens) and Hyphenation Zone; the Better Spacing ↔ Fewer Hyphens slider in InDesign's eleven steps; Hyphenate Capitalized Words, Last Word and Across Column; Preview, which shows the edits without an undo step. OK applies them as one step; mixed values show blank and are left alone unless edited. Paragraph Style Options › Hyphenation uses the same form (it had only Hyphenate and four numbers). The panel menu lists its items in InDesign's order. The new strings are translated in the shared and per-language tables. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 249 ++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 27 +++ crates/ui-egui/src/i18n/ar.rs | 9 + crates/ui-egui/src/i18n/cs.rs | 9 + crates/ui-egui/src/i18n/de.rs | 9 + crates/ui-egui/src/i18n/it.rs | 9 + crates/ui-egui/src/i18n/ja.rs | 9 + crates/ui-egui/src/i18n/ka.rs | 9 + crates/ui-egui/src/i18n/pl.rs | 9 + crates/ui-egui/src/i18n/pt_br.rs | 9 + crates/ui-egui/src/i18n/ru.rs | 9 + crates/ui-egui/src/i18n/uk.rs | 9 + crates/ui-egui/src/menus.rs | 20 +- crates/ui-egui/src/panels/properties.rs | 6 +- 15 files changed, 369 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..0b7d97994 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. Hyphenate Across Column is kept and round-trips through IDML, but composition doesn't apply it yet. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..999aed488 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2061,6 +2061,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }, ), "justification" => crate::i18n::tr(&app.ui.language, "Justification"), + "hyphenation" => crate::i18n::tr(&app.ui.language, "Hyphenation Settings"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), "polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"), @@ -2102,6 +2103,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "alert" => 440.0, "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, "newDocument" => 960.0, + "hyphenation" => 560.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, _ => 640.0, @@ -2354,6 +2356,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let current = d.fields.get("current").cloned().unwrap_or_default(); justification(&app.ui.language, ui, &mut d, "", ¤t); } + "hyphenation" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + let units = app.session.active().map_or(Unit::Picas, |st| st.doc.settings.horizontal_units); + let preview = crate::i18n::tr(&app.ui.language, "Preview").to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| hyphenation(&app.ui.language, ui, &mut d, "", ¤t, units)); + ui.add_space(24.0); + check(ui, &mut d, "preview", &preview); + }); + hyphenation_preview(app, &mut d); + } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), @@ -3162,6 +3175,17 @@ pub fn confirm(app: &mut DesignApp) -> Result { } app.run("type.para", json!({"attrs": attrs})) } + "hyphenation" => { + // The preview goes; the edits are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let attrs = hyphenation_edits(&d); + if attrs.is_empty() { + return Ok(Value::Null); + } + app.run("type.para", json!({"attrs": attrs})) + } "justification" => { let current = d.fields.get("current").cloned().unwrap_or_default(); if let Err(e) = check_justification(&d, "", ¤t) { @@ -4171,27 +4195,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } "tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc), "rules" => paragraph_rules(app, ui, d, "p.", &pv), - "hyph" => { - let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true); - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - d.fields.insert("p.hyphenate".into(), json!(h)); - } - egui::Grid::new("psh").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (label, key) in [ - ("Words with at Least:", "hyphMinWord"), - ("After First:", "hyphAfterFirst"), - ("Before Last:", "hyphBeforeLast"), - ("Hyphen Limit:", "hyphLimit"), - ] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); - if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { - d.fields.insert(format!("p.{key}"), json!(n.max(0.0) as u64)); - } - ui.end_row(); - } - }); - } + "hyph" => hyphenation(&app.ui.language, ui, d, "p.", &pv, units), "justify" => justification(&app.ui.language, ui, d, "p.", &pv), "color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc), "advanced" => advanced_character_formats(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }), @@ -5785,6 +5789,133 @@ fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(), /// `current` holds the resolved paragraph attributes, `null` where the selected paragraphs differ /// (the field shows empty); each edit is stored as `{prefix}{attr}`, so OK changes only the fields /// edited. +/// Paragraph attributes the Hyphenation Settings dialog sets. +pub const HYPH_ATTRS: [&str; 10] = [ + "hyphenate", + "hyphMinWord", + "hyphAfterFirst", + "hyphBeforeLast", + "hyphLimit", + "hyphZone", + "hyphWeight", + "hyphCapitalized", + "hyphLastWord", + "hyphAcrossColumn", +]; + +/// InDesign's Hyphenation Settings: Hyphenate; Words with at Least, After First and Before Last +/// (letters), Hyphen Limit (hyphens) and Hyphenation Zone; the Better Spacing ↔ Fewer Hyphens +/// slider (0–10, as InDesign's HyphenWeight); Hyphenate Capitalized Words, Last Word and Across +/// Column. Edited fields are stored as `{prefix}{key}`; the others show `current` (a mixed value +/// is null and shows blank). The Hyphenation dialog for the selected paragraphs and Paragraph +/// Style Options › Hyphenation both use it. +fn hyphenation(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value, units: Unit) { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone()); + // Labels right-aligned against their fields, as InDesign's. + let label_right = |ui: &mut egui::Ui, text: &str| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)) + }); + }; + let checkbox = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { + let v = cur(d, key); + let mut on = v.as_bool().unwrap_or(true); + if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, tr(label))).indeterminate(v.is_null())).changed() { + d.fields.insert(format!("{prefix}{key}"), json!(on)); + } + }; + checkbox(ui, d, "hyphenate", "Hyphenate"); + ui.add_space(4.0); + egui::Grid::new(("hyph", prefix)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + // InDesign's ranges. + for (label, key, unit, max) in [ + ("Words with at Least:", "hyphMinWord", "letters", 25.0), + ("After First:", "hyphAfterFirst", "letters", 15.0), + ("Before Last:", "hyphBeforeLast", "letters", 15.0), + ("Hyphen Limit:", "hyphLimit", "hyphens", 25.0), + ] { + label_right(ui, &tr(label)); + let min = if key == "hyphLimit" { 0.0 } else { 1.0 }; + if let Some(n) = crate::widgets::spin_number(ui, &format!("hy{prefix}{key}"), cur(d, key).as_f64(), "", 60.0, 0) { + d.fields.insert(format!("{prefix}{key}"), json!(n.round().clamp(min, max) as u64)); + } + crate::rtl::label(ui, tr(unit)); + ui.end_row(); + } + label_right(ui, &tr("Hyphenation Zone:")); + if let Some(n) = crate::widgets::spin_measure(ui, &format!("hy{prefix}zone"), cur(d, "hyphZone").as_f64(), units, 60.0) { + d.fields.insert(format!("{prefix}hyphZone"), json!(if n.is_finite() { n.clamp(0.0, 8640.0) } else { 36.0 })); + } + ui.end_row(); + }); + ui.add_space(6.0); + // Better Spacing ↔ Fewer Hyphens, in InDesign's eleven steps. + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Better Spacing")); + let w = cur(d, "hyphWeight").as_f64(); + let mut step = w.map_or(5.0, |w| (w * 10.0).round()).clamp(0.0, 10.0); + // The ticks go under the knob: their place in the paint order is taken first. + let ticks = ui.painter().add(egui::Shape::Noop); + let r = ui + .scope(|ui| { + ui.spacing_mut().slider_width = 180.0; + ui.add(egui::Slider::new(&mut step, 0.0..=10.0).step_by(1.0).show_value(false).handle_shape(egui::style::HandleShape::Circle)) + }) + .inner; + // The tick marks under the track. + let t = crate::theme::Tokens::get(ui.ctx()); + let inner = r.rect.shrink2(egui::vec2(r.rect.height() / 2.0, 0.0)); + let y = r.rect.center().y; + let marks: Vec = (0..=10) + .map(|i| { + let x = inner.left() + inner.width() * i as f32 / 10.0; + egui::Shape::line_segment([egui::pos2(x, y - 8.0), egui::pos2(x, y + 8.0)], egui::Stroke::new(1.0, t.text_dim)) + }) + .collect(); + ui.painter().set(ticks, egui::Shape::Vec(marks)); + if r.changed() { + d.fields.insert(format!("{prefix}hyphWeight"), json!(step / 10.0)); + } + crate::rtl::label(ui, tr("Fewer Hyphens")); + }); + ui.add_space(6.0); + egui::Grid::new(("hyphc", prefix)).num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| { + checkbox(ui, d, "hyphCapitalized", "Hyphenate Capitalized Words"); + checkbox(ui, d, "hyphLastWord", "Hyphenate Last Word"); + ui.end_row(); + checkbox(ui, d, "hyphAcrossColumn", "Hyphenate Across Column"); + ui.end_row(); + }); +} + +/// The Hyphenation dialog's edits, as `type.para` attrs. +fn hyphenation_edits(d: &Dialog) -> Map { + HYPH_ATTRS.iter().filter_map(|k| d.fields.get(*k).map(|v| (k.to_string(), v.clone()))).collect() +} + +/// Preview on: the edits show on the page (redone when a field changes) without an undo step; +/// off, the paragraphs go back. +fn hyphenation_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + let params = json!({"attrs": hyphenation_edits(d)}); + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() && app.session.begin_interaction("Hyphenation").is_err() { + return; + } + let _ = app.session.preview("type.para", params.clone()); + d.fields.insert(PREVIEWED.into(), params); +} + fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) { let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone()); egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { @@ -7606,6 +7737,82 @@ mod tests { app.run("type.selectionAttrs", json!({})).unwrap()["para"][key].clone() } + /// The hyphenation video (Graphd Tutorials, 0:39–1:13): the Paragraph panel menu's + /// Hyphenation… opens InDesign's Hyphenation Settings for the selected paragraphs. Preview + /// shows the slider moved to Fewer Hyphens on the page without an undo step, OK makes it one + /// step on the selected paragraph only, and Cancel puts it back. + #[test] + fn hyphenation_settings_preview_apply_and_cancel_on_the_selected_paragraph() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let sid = designcraft_doc::StoryId(story.as_u64().unwrap()); + let weight = |app: &DesignApp, para: usize| { + let d = &app.session.doc().unwrap().doc; + d.styles.resolve_para(&d.story(sid).unwrap().paras[para]).0.hyph_weight + }; + assert!(crate::menus::menu_enabled(&app, "app.hyphenationDialog")); + app.run("app.hyphenationDialog", json!({})).unwrap(); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.id.as_str(), d.fields["current"]["hyphWeight"].as_f64(), d.fields["current"]["hyphenate"].as_bool()), + ("hyphenation", Some(0.5), Some(true)) + ); + for label in [ + "Hyphenation Settings", + "Hyphenate", + "Words with at Least:", + "letters", + "Hyphen Limit:", + "hyphens", + "Hyphenation Zone:", + "Better Spacing", + "Fewer Hyphens", + "Hyphenate Capitalized Words", + "Hyphenate Last Word", + "Hyphenate Across Column", + "Preview", + ] { + assert!(labels.iter().any(|(t, _, _)| t == label), "{label}: {labels:?}"); + } + // Fewer Hyphens (9 of 10) with Preview: on the page, no undo step. + let before = undo_steps(&app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!(0.9)); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(weight(&app, 0), 0.9, "previewed"); + assert_eq!(undo_steps(&app), before, "a preview is no step"); + // Cancel: back as it was. + cancel(&mut app); + assert_eq!(weight(&app, 0), 0.5); + // Again, and OK: one step, the selected paragraph only. + app.run("app.hyphenationDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!(0.9)); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + assert_eq!((weight(&app, 0), weight(&app, 1)), (0.9, 0.5)); + assert_eq!(undo_steps(&app), before + 1, "one undo step"); + // Paragraph Style Options › Hyphenation has the same form. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": designcraft_doc::story::BASIC_PARAGRAPH, "section": "hyph"}))); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for label in ["Hyphenation Zone:", "Better Spacing", "Fewer Hyphens", "Hyphenate Last Word", "Hyphenate Across Column"] { + assert!(labels.iter().any(|(t, _, _)| t == label), "style options: {label}"); + } + app.ui.dialog = None; + app.run("edit.deselectAll", json!({})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.hyphenationDialog"), "no paragraph"); + } + #[test] fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() { let (mut app, story) = two_paragraphs(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index acf70ff32..8b840fad4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1000,6 +1000,33 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Hyphenation…", ["Silbentrennung …", "Césure…", "Separación por sílabas…", "ハイフネーション…", "连字…"]), + ( + "Hyphenation Settings", + ["Silbentrennungseinstellungen", "Paramètres de césure", "Configuración de separación por sílabas", "ハイフネーション設定", "连字设置"], + ), + ("hyphens", ["Trennstriche", "tirets", "guiones", "ハイフン", "连字符"]), + ("Hyphenation Zone:", ["Silbentrennungsbereich:", "Zone de césure :", "Zona de separación:", "ハイフネーション領域 :", "连字区:"]), + ("Better Spacing", ["Bessere Abstände", "Meilleur espacement", "Mejor espaciado", "スペースを優先", "更好的间距"]), + ("Fewer Hyphens", ["Weniger Trennstriche", "Moins de tirets", "Menos guiones", "ハイフンを減らす", "更少的连字符"]), + ( + "Hyphenate Capitalized Words", + [ + "Großgeschriebene Wörter trennen", + "Césure des mots en capitales", + "Separar palabras en mayúscula", + "大文字で始まる単語をハイフネーション", + "连字大写的单词", + ], + ), + ( + "Hyphenate Last Word", + ["Letztes Wort trennen", "Césure du dernier mot", "Separar última palabra", "最後の単語をハイフネーション", "连字最后一个单词"], + ), + ( + "Hyphenate Across Column", + ["Über Spalten trennen", "Césure entre colonnes", "Separar entre columnas", "段をまたいでハイフネーション", "跨栏连字"], + ), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..c7145d909 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Hyphenation…", "الواصلة…"), + ("Hyphenation Settings", "إعدادات الواصلة"), + ("hyphens", "واصلات"), + ("Hyphenation Zone:", "منطقة الواصلة:"), + ("Better Spacing", "تباعد أفضل"), + ("Fewer Hyphens", "واصلات أقل"), + ("Hyphenate Capitalized Words", "وصل الكلمات ذات الأحرف الكبيرة"), + ("Hyphenate Last Word", "وصل الكلمة الأخيرة"), + ("Hyphenate Across Column", "الوصل عبر الأعمدة"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..c9f223542 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Hyphenation…", "Dělení slov…"), + ("Hyphenation Settings", "Nastavení dělení slov"), + ("hyphens", "spojovníky"), + ("Hyphenation Zone:", "Zóna dělení:"), + ("Better Spacing", "Lepší mezery"), + ("Fewer Hyphens", "Méně spojovníků"), + ("Hyphenate Capitalized Words", "Dělit slova s velkými písmeny"), + ("Hyphenate Last Word", "Dělit poslední slovo"), + ("Hyphenate Across Column", "Dělit přes sloupce"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index d47657b09..48a1a6001 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Hyphenation…", "Silbentrennung …"), + ("Hyphenation Settings", "Silbentrennungseinstellungen"), + ("hyphens", "Trennstriche"), + ("Hyphenation Zone:", "Silbentrennungsbereich:"), + ("Better Spacing", "Bessere Abstände"), + ("Fewer Hyphens", "Weniger Trennstriche"), + ("Hyphenate Capitalized Words", "Großgeschriebene Wörter trennen"), + ("Hyphenate Last Word", "Letztes Wort trennen"), + ("Hyphenate Across Column", "Über Spalten trennen"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..65837d395 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Hyphenation…", "Sillabazione…"), + ("Hyphenation Settings", "Impostazioni sillabazione"), + ("hyphens", "trattini"), + ("Hyphenation Zone:", "Area di sillabazione:"), + ("Better Spacing", "Spaziatura migliore"), + ("Fewer Hyphens", "Meno trattini"), + ("Hyphenate Capitalized Words", "Sillaba parole maiuscole"), + ("Hyphenate Last Word", "Sillaba ultima parola"), + ("Hyphenate Across Column", "Sillaba tra colonne"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e23ef15c7..aa4fca1f6 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Hyphenation…", "ハイフネーション…"), + ("Hyphenation Settings", "ハイフネーション設定"), + ("hyphens", "ハイフン"), + ("Hyphenation Zone:", "ハイフネーション領域 :"), + ("Better Spacing", "スペースを優先"), + ("Fewer Hyphens", "ハイフンを減らす"), + ("Hyphenate Capitalized Words", "大文字で始まる単語をハイフネーション"), + ("Hyphenate Last Word", "最後の単語をハイフネーション"), + ("Hyphenate Across Column", "段をまたいでハイフネーション"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..40abcf9ad 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Hyphenation…", "დამარცვლა…"), + ("Hyphenation Settings", "დამარცვლის პარამეტრები"), + ("hyphens", "დეფისი"), + ("Hyphenation Zone:", "დამარცვლის ზონა:"), + ("Better Spacing", "უკეთესი დაშორება"), + ("Fewer Hyphens", "ნაკლები დეფისი"), + ("Hyphenate Capitalized Words", "მთავრული სიტყვების დამარცვლა"), + ("Hyphenate Last Word", "ბოლო სიტყვის დამარცვლა"), + ("Hyphenate Across Column", "სვეტებს შორის დამარცვლა"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..5429ccb67 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Hyphenation…", "Dzielenie wyrazów…"), + ("Hyphenation Settings", "Ustawienia dzielenia wyrazów"), + ("hyphens", "dywizy"), + ("Hyphenation Zone:", "Strefa dzielenia:"), + ("Better Spacing", "Lepsze odstępy"), + ("Fewer Hyphens", "Mniej dywizów"), + ("Hyphenate Capitalized Words", "Dziel wyrazy pisane wielką literą"), + ("Hyphenate Last Word", "Dziel ostatni wyraz"), + ("Hyphenate Across Column", "Dziel między łamami"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..4513a107f 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Hyphenation…", "Hifenização…"), + ("Hyphenation Settings", "Configurações de hifenização"), + ("hyphens", "hifens"), + ("Hyphenation Zone:", "Zona de hifenização:"), + ("Better Spacing", "Melhor espaçamento"), + ("Fewer Hyphens", "Menos hifens"), + ("Hyphenate Capitalized Words", "Hifenizar palavras em maiúsculas"), + ("Hyphenate Last Word", "Hifenizar última palavra"), + ("Hyphenate Across Column", "Hifenizar entre colunas"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..599537cac 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Hyphenation…", "Расстановка переносов…"), + ("Hyphenation Settings", "Параметры расстановки переносов"), + ("hyphens", "дефисов"), + ("Hyphenation Zone:", "Зона переносов:"), + ("Better Spacing", "Лучшие интервалы"), + ("Fewer Hyphens", "Меньше переносов"), + ("Hyphenate Capitalized Words", "Переносить слова с прописной буквы"), + ("Hyphenate Last Word", "Переносить последнее слово"), + ("Hyphenate Across Column", "Переносить между колонками"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..060a7d43b 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Hyphenation…", "Перенесення…"), + ("Hyphenation Settings", "Параметри перенесення"), + ("hyphens", "дефісів"), + ("Hyphenation Zone:", "Зона перенесення:"), + ("Better Spacing", "Кращі інтервали"), + ("Fewer Hyphens", "Менше перенесень"), + ("Hyphenate Capitalized Words", "Переносити слова з великої літери"), + ("Hyphenate Last Word", "Переносити останнє слово"), + ("Hyphenate Across Column", "Переносити між колонками"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb43f8cb9..0f56aac76 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -109,6 +109,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"), ("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"), + ( + "app.hyphenationDialog", + "Hyphenation…", + None, + "{} — Hyphenation Settings of the selected paragraphs (type.para): limits, zone, Better Spacing ↔ Fewer Hyphens, Preview", + ), ( "app.justificationDialog", "Justification…", @@ -1291,6 +1297,18 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else { + return Some(Err("select text or a text frame".into())); + }; + let mixed: Vec<&str> = a["paraMixed"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let current: serde_json::Map = crate::dialogs::HYPH_ATTRS + .iter() + .map(|k| (k.to_string(), if mixed.contains(k) { Value::Null } else { a["para"][*k].clone() })) + .collect(); + app.ui.dialog = Some(crate::dialogs::Dialog::new("hyphenation", json!({"current": current}))); + Ok(Value::Null) + } "app.justificationDialog" => { let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else { return Some(Err("select text or a text frame".into())); @@ -2438,7 +2456,7 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if is_window_control(id) { return window_control_available(app, id); } - if id == "app.justificationDialog" { + if matches!(id, "app.justificationDialog" | "app.hyphenationDialog") { return enabled(app, "type.para"); } if id.starts_with("fit.") { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 1cbd623a6..d2c1cb95b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1743,11 +1743,13 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { crate::menus::menu_button(ui, "☰", |ui| { + // In the order of InDesign's Paragraph panel menu. for (label, id) in [ - ("Paragraph Rules…", "app.paragraphRulesDialog"), ("Justification…", "app.justificationDialog"), - ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), ("Span Columns…", "app.spanColumnsDialog"), + ("Hyphenation…", "app.hyphenationDialog"), + ("Paragraph Rules…", "app.paragraphRulesDialog"), + ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), ("Bullets and Numbering…", "app.bulletsNumberingDialog"), ] { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { From be549959adfc5b7c7bafdfc451b06003ec30bd0b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 14:59:48 +0200 Subject: [PATCH 2/3] i18n: Hyphenate Capitalized Words means words with an initial capital (it, pt-BR, cs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/cs.rs | 2 +- crates/ui-egui/src/i18n/it.rs | 2 +- crates/ui-egui/src/i18n/pt_br.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index c9f223542..50af0c218 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -37,7 +37,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hyphenation Zone:", "Zóna dělení:"), ("Better Spacing", "Lepší mezery"), ("Fewer Hyphens", "Méně spojovníků"), - ("Hyphenate Capitalized Words", "Dělit slova s velkými písmeny"), + ("Hyphenate Capitalized Words", "Dělit slova s velkým počátečním písmenem"), ("Hyphenate Last Word", "Dělit poslední slovo"), ("Hyphenate Across Column", "Dělit přes sloupce"), ("Reverse Path", "Obrátit cestu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 65837d395..125f6013d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -34,7 +34,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hyphenation Zone:", "Area di sillabazione:"), ("Better Spacing", "Spaziatura migliore"), ("Fewer Hyphens", "Meno trattini"), - ("Hyphenate Capitalized Words", "Sillaba parole maiuscole"), + ("Hyphenate Capitalized Words", "Sillaba parole con iniziale maiuscola"), ("Hyphenate Last Word", "Sillaba ultima parola"), ("Hyphenate Across Column", "Sillaba tra colonne"), ("Paste Vector Art", "Incolla grafica vettoriale"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 4513a107f..5fa35b6b8 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -37,7 +37,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hyphenation Zone:", "Zona de hifenização:"), ("Better Spacing", "Melhor espaçamento"), ("Fewer Hyphens", "Menos hifens"), - ("Hyphenate Capitalized Words", "Hifenizar palavras em maiúsculas"), + ("Hyphenate Capitalized Words", "Hifenizar palavras iniciadas por maiúscula"), ("Hyphenate Last Word", "Hifenizar última palavra"), ("Hyphenate Across Column", "Hifenizar entre colunas"), ("Paste Vector Art", "Colar arte vetorial"), From 08b356cf44547f86767d2c198b2d577794654e9f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 17:15:22 +0200 Subject: [PATCH 3/3] Hyphenation Settings: a mixed checkbox turns on first; refused settings show why A checkbox that differs across the selected paragraphs starts off, so the first click turns it on for all (egui toggles the value under the indeterminate look). Settings type.para refuses no longer count as previewed: the paragraphs go back, the reason shows and they are not tried again until they change; OK keeps the dialog open with the reason. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 81 ++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf8dd66aa..0262bd396 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2373,6 +2373,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { check(ui, &mut d, "preview", &preview); }); hyphenation_preview(app, &mut d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(ui.visuals().error_fg_color)); + } } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), @@ -3191,7 +3195,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { if attrs.is_empty() { return Ok(Value::Null); } - app.run("type.para", json!({"attrs": attrs})) + app.run("type.para", json!({"attrs": attrs})).inspect_err(|e| { + // The dialog stays open, with the reason, to be corrected. + app.status(e.clone()); + let mut d = d; + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + }) } "justification" => { let current = d.fields.get("current").cloned().unwrap_or_default(); @@ -5827,7 +5838,8 @@ fn hyphenation(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, curr }; let checkbox = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { let v = cur(d, key); - let mut on = v.as_bool().unwrap_or(true); + // Mixed shows indeterminate and starts off, so the first click turns it on for all. + let mut on = v.as_bool().unwrap_or(false); if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, tr(label))).indeterminate(v.is_null())).changed() { d.fields.insert(format!("{prefix}{key}"), json!(on)); } @@ -5913,16 +5925,31 @@ fn hyphenation_preview(app: &mut DesignApp, d: &mut Dialog) { return; } let params = json!({"attrs": hyphenation_edits(d)}); - if shown.as_ref() == Some(¶ms) { + if shown.as_ref() == Some(¶ms) || d.fields.get(PREVIEW_FAILED) == Some(¶ms) { return; } if shown.is_none() && app.session.begin_interaction("Hyphenation").is_err() { return; } - let _ = app.session.preview("type.para", params.clone()); - d.fields.insert(PREVIEWED.into(), params); + match app.session.preview("type.para", params.clone()) { + Ok(_) => { + d.fields.remove("status"); + d.fields.remove(PREVIEW_FAILED); + d.fields.insert(PREVIEWED.into(), params); + } + // Rejected settings: the paragraphs go back, and the reason shows until they change. + Err(e) => { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e.to_string())); + d.fields.insert(PREVIEW_FAILED.into(), params); + } + } } +/// The hyphenation settings a preview was refused for (not tried again until they change). +const PREVIEW_FAILED: &str = "previewFailed"; + fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) { let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone()); egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { @@ -7820,6 +7847,50 @@ mod tests { assert!(!crate::menus::menu_enabled(&app, "app.hyphenationDialog"), "no paragraph"); } + /// Hyphenate differs across the selection: it shows mixed, and the first click turns it on for + /// all. Settings the command refuses show why: a preview goes back and isn't retried until they + /// change, and OK keeps the dialog open. + #[test] + fn hyphenation_settings_mixed_checkbox_and_refused_settings() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + app.run("type.para", json!({"attrs": {"hyphenate": false}})).unwrap(); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap(); + app.run("app.hyphenationDialog", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["current"]["hyphenate"].is_null(), "mixed"); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let at = labels.iter().find(|(t, _, _)| t == "Hyphenate").map(|(_, r, _)| r.left_center() - egui::vec2(10.0, 0.0)).unwrap(); + click_dialog(&mut app, &ctx, screen, at); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphenate"), Some(&json!(true)), "the first click turns it on"); + // A refused setting (as a control-channel caller could send it): no preview, the reason. + let before = undo_steps(&app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!("heavy")); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(!d.fields.contains_key(PREVIEWED) && d.fields.contains_key("status"), "{:?}", d.fields); + assert!(app.session.doc().unwrap().interaction.is_none(), "the preview went back"); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(app.session.doc().unwrap().interaction.is_none(), "not tried again"); + // OK with it: an error, and the dialog stays open with the reason. + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().expect("the dialog stays open"); + assert!(d.fields.contains_key("status")); + assert_eq!(undo_steps(&app), before); + // Corrected, it previews and applies. + app.ui.dialog.as_mut().unwrap().fields.insert("hyphWeight".into(), json!(0.9)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("status")); + confirm(&mut app).unwrap(); + assert_eq!(para_attr(&mut app, &story, 0, "hyphenate"), true); + assert_eq!(para_attr(&mut app, &story, 4, "hyphWeight"), 0.9); + } + #[test] fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() { let (mut app, story) = two_paragraphs();