From 606b28dbecbb5c7e30bc76cdca62721fa98bcda0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 21:30:54 +0200 Subject: [PATCH 01/79] Control panel: font size, leading and tracking have a spinner and a list of values Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 90 +++++++++++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..2f5365795 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-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..e84376584 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::panels; use crate::theme::{Tokens, semibold}; -use crate::widgets::{caption, measure, number}; +use crate::widgets::{NumField, caption, measure, number}; use crate::{DesignApp, icons}; /// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The @@ -564,6 +564,11 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Control panel's font sizes (and leadings), as InDesign lists them. +const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0]; +/// The Control panel's tracking values (1/1000 em). +const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; + fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let attrs = panels::text_attrs(app); @@ -585,22 +590,36 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { panels::font_style_picker(app, ui, &fam, &sty, 170.0); }); vsep(ui); + // Size, leading and tracking as InDesign's Control panel has them: a spinner on the left and + // a list of common values on the right; automatic leading in parentheses. egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| { - caption(ui, "𝐓"); - if let Some(v) = number(ui, "csize", chars["size"].as_f64(), " pt", 54.0, 2) { + let (sz, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), sz, "font-size", t.icon); + if let Some(v) = NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).spinner().presets(SIZES).range(0.1, 1296.0).show(ui) + { let _ = app.run("type.char", json!({"attrs": {"size": v}})); } - caption(ui, crate::i18n::tr(&app.ui.language, "VA")); - if let Some(v) = number(ui, "ctrack", chars["tracking"].as_f64(), "", 48.0, 0) { + let (tr, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), tr, "tracking", t.icon); + if let Some(v) = NumField::number("ctrack", chars["tracking"].as_f64(), "", 0) + .width(76.0) + .spinner() + .presets(TRACKING) + .step(5.0) + .range(-1000.0, 10000.0) + .show(ui) + { let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); } ui.end_row(); - caption(ui, "Ā"); - let lead = match chars["leading"]["kind"].as_str() { - Some("points") => chars["leading"]["value"].as_f64(), - _ => chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)), - }; - if let Some(v) = number(ui, "clead", lead, " pt", 54.0, 2) { + let (ld, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), ld, "leading", t.icon); + let auto = chars["leading"]["kind"].as_str() != Some("points"); + let lead = + if auto { chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)) } else { chars["leading"]["value"].as_f64() }; + let lead = lead.map(|v| (v * 100.0).round() / 100.0); + let key = if auto { "cleadauto" } else { "clead" }; + if let Some(v) = NumField::number(key, lead, " pt", 2).width(84.0).spinner().presets(SIZES).range(0.0, 5000.0).parens(auto).show(ui) { let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); } caption(ui, crate::i18n::tr(&app.ui.language, "IT")); @@ -1304,6 +1323,55 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + /// Zero2Graphic's page layout video (0:48): with the Type tool, the Control panel's font size + /// has a list of sizes; the instructor picks 72 pt from it. Leading has the same list. + #[test] + fn the_type_control_panel_lists_font_sizes_and_leadings() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Headline"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(1800.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = |r: &egui::Rect| bar.y_range().contains(r.center().y); + let field = |h: &egui_kittest::Harness<'static, test_window::Window>, value: &str| { + h.query_all_by_value(value).map(|n| n.rect()).find(in_bar).unwrap_or_else(|| panic!("no {value:?} field in the Control panel")) + }; + let press = |h: &mut egui_kittest::Harness<'static, test_window::Window>, p: egui::Pos2| { + h.hover_at(p); + h.drag_at(p); + h.drop_at(p); + }; + let size_of = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + st.doc.story(sid).unwrap().format_after(0).over.size + }; + // The ⌄ segment right of the size field opens the list; choosing 72 pt sets the size. + let size = field(&h, "12 pt"); + let chevron = pos2(size.max.x + 1.0 + crate::widgets::SEG_W / 2.0, size.center().y); + press(&mut h, chevron); + h.run_steps(3); + h.get_by_label("72 pt").click(); + h.run_steps(3); + assert_eq!(size_of(&h), Some(72.0)); + // Auto leading shows in parentheses, with the same list beside it. + let lead = field(&h, "(86.4 pt)"); + press(&mut h, pos2(lead.max.x + 1.0 + crate::widgets::SEG_W / 2.0, lead.center().y)); + h.run_steps(3); + h.get_by_label("60 pt").click(); + h.run_steps(3); + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(60.0))); + // The spinner left of the size steps it by a point. + let size = field(&h, "72 pt"); + press(&mut h, pos2(size.min.x - 5.0 - crate::widgets::SEG_W / 2.0, size.min.y + 3.0)); + h.run_steps(3); + assert_eq!(size_of(&h), Some(73.0)); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the From aa3b7b7a24165d60980500a19ee2b6ed4b39f6ab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 00:52:13 +0200 Subject: [PATCH 02/79] Typing the automatic leading over its value fixes it A field showing an automatic value in parentheses commits what is typed over it even when it is the same number, so typing 87.6 over (87.6 pt) sets fixed leading, as InDesign does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 13 +++++++++++++ crates/ui-egui/src/widgets.rs | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e84376584..cd937bc47 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1370,6 +1370,19 @@ mod tests { press(&mut h, pos2(size.min.x - 5.0 - crate::widgets::SEG_W / 2.0, size.min.y + 3.0)); h.run_steps(3); assert_eq!(size_of(&h), Some(73.0)); + // Typing the automatic value itself over "(87.6 pt)" fixes the leading at it. + h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap(); + h.run_steps(3); + let lead = field(&h, "(87.6 pt)"); + press(&mut h, lead.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("87.6".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(87.6))); } #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fb9876aa2..0d6596114 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -227,8 +227,11 @@ impl<'a> NumField<'a> { ui.data_mut(|d| d.insert_temp(key, buf.clone())); } if r.lost_focus() { + // An automatic value (in parentheses) typed over commits even when it's the same + // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. + let typed_over_auto = self.parens && !buf.trim_start().starts_with('('); if let Some(v) = self.parse(&buf).map(clamp) - && self.value.is_none_or(|old| (old - v).abs() > 1e-9) + && (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9)) { out = Some(v); } else if buf.trim().is_empty() && self.value.is_some() { From babb86c3ae92f4467ba815952423cf43a4b1f76f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 03:54:27 +0200 Subject: [PATCH 03/79] Escape cancels a value typed over an automatic one Typing over "(87.6 pt)" and pressing Escape leaves the leading automatic; only Enter or leaving the field commits it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 12 ++++++++++++ crates/ui-egui/src/widgets.rs | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index cd937bc47..851808a25 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1383,6 +1383,18 @@ mod tests { let st = h.state().app.session.doc().unwrap(); let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(87.6))); + // Escape cancels: the same typing, then Escape, leaves the leading automatic. + h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap(); + h.run_steps(3); + let auto = h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading; + let lead = field(&h, "(87.6 pt)"); + press(&mut h, lead.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("87.6".into())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic"); } #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0d6596114..a6e0aa64f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -228,8 +228,9 @@ impl<'a> NumField<'a> { } if r.lost_focus() { // An automatic value (in parentheses) typed over commits even when it's the same - // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. - let typed_over_auto = self.parens && !buf.trim_start().starts_with('('); + // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape cancels it. + let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let typed_over_auto = self.parens && !escaped && !buf.trim_start().starts_with('('); if let Some(v) = self.parse(&buf).map(clamp) && (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9)) { From 578a1ce169466caf07242cbc2aade780228a7ced Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:54:22 +0200 Subject: [PATCH 04/79] =?UTF-8?q?Properties:=20=E2=80=A2=E2=80=A2=E2=80=A2?= =?UTF-8?q?=20expands=20Character=20and=20Paragraph=20in=20place,=20as=20i?= =?UTF-8?q?n=20InDesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Character's ••• shows vertical and horizontal scale, baseline shift, skew, language and OpenType (the Typography panel), and the All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough buttons. Paragraph's ••• shows the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. ••• again folds the section. It used to open the Character or Paragraph panel. The language field moves under •••, where InDesign has it. New icons for the scales, baseline shift, skew, indents, spacing and drop caps; widgets::checkbox and ••• report their labels to accessibility. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 41 +++++ crates/ui-egui/src/i18n/ar.rs | 11 ++ crates/ui-egui/src/i18n/cs.rs | 11 ++ crates/ui-egui/src/i18n/it.rs | 11 ++ crates/ui-egui/src/i18n/ka.rs | 11 ++ crates/ui-egui/src/i18n/pl.rs | 11 ++ crates/ui-egui/src/i18n/pt_br.rs | 11 ++ crates/ui-egui/src/i18n/ru.rs | 11 ++ crates/ui-egui/src/i18n/uk.rs | 11 ++ crates/ui-egui/src/icons.rs | 71 ++++++++ crates/ui-egui/src/panels/properties.rs | 224 ++++++++++++++++++++++-- crates/ui-egui/src/widgets.rs | 4 + 13 files changed, 418 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..8721cce1b 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-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..8dd4709f9 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,47 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Vertical Scale", ["Vertikal skalieren", "Échelle verticale", "Escala vertical", "垂直比率", "垂直缩放"]), + ("Horizontal Scale", ["Horizontal skalieren", "Échelle horizontale", "Escala horizontal", "水平比率", "水平缩放"]), + ("Baseline Shift", ["Grundlinienversatz", "Décalage vertical", "Desplazamiento vertical", "ベースラインシフト", "基线偏移"]), + ( + "Skew (False Italic)", + ["Verzerren (falscher Kursivstil)", "Inclinaison (faux italique)", "Sesgar (cursiva falsa)", "歪み(斜体)", "倾斜(伪斜体)"], + ), + ("Left Indent", ["Einzug links", "Retrait gauche", "Sangría izquierda", "左インデント", "左缩进"]), + ("Right Indent", ["Einzug rechts", "Retrait droit", "Sangría derecha", "右インデント", "右缩进"]), + ( + "First Line Left Indent", + ["Einzug links in erster Zeile", "Retrait gauche de première ligne", "Sangría izquierda en primera línea", "1行目左インデント", "首行左缩进"], + ), + ( + "Last Line Right Indent", + ["Einzug rechts in letzter Zeile", "Retrait droit de dernière ligne", "Sangría derecha en última línea", "最終行右インデント", "末行右缩进"], + ), + ( + "Drop Cap Number of Lines", + ["Initiale: Anzahl der Zeilen", "Lettrine (nombre de lignes)", "Capitular: número de líneas", "ドロップキャップの行数", "首字下沉行数"], + ), + ( + "Drop Cap One or More Characters", + [ + "Initiale: ein oder mehrere Zeichen", + "Lettrine (un ou plusieurs caractères)", + "Capitular: uno o más caracteres", + "ドロップキャップの文字数", + "首字下沉一个或多个字符", + ], + ), + ( + "Align to Baseline Grid", + [ + "An Grundlinienraster ausrichten", + "Aligner sur la grille de ligne de base", + "Alinear con cuadrícula base", + "ベースライングリッドに揃える", + "对齐基线网格", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..9ce202c6e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Vertical Scale", "المقياس الرأسي"), + ("Horizontal Scale", "المقياس الأفقي"), + ("Baseline Shift", "إزاحة خط الأساس"), + ("Skew (False Italic)", "إمالة (مائل زائف)"), + ("Left Indent", "مسافة بادئة يسرى"), + ("Right Indent", "مسافة بادئة يمنى"), + ("First Line Left Indent", "مسافة بادئة يسرى للسطر الأول"), + ("Last Line Right Indent", "مسافة بادئة يمنى للسطر الأخير"), + ("Drop Cap Number of Lines", "عدد أسطر الحرف الاستهلالي"), + ("Drop Cap One or More Characters", "حرف استهلالي واحد أو أكثر"), + ("Align to Baseline Grid", "محاذاة إلى شبكة خط الأساس"), ("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 e77e72a1c..d6931c30e 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Vertical Scale", "Svislá změna velikosti"), + ("Horizontal Scale", "Vodorovná změna velikosti"), + ("Baseline Shift", "Posun účaří"), + ("Skew (False Italic)", "Zkosení (nepravá kurzíva)"), + ("Left Indent", "Odsazení zleva"), + ("Right Indent", "Odsazení zprava"), + ("First Line Left Indent", "Odsazení prvního řádku zleva"), + ("Last Line Right Indent", "Odsazení posledního řádku zprava"), + ("Drop Cap Number of Lines", "Počet řádků iniciály"), + ("Drop Cap One or More Characters", "Iniciála: jeden nebo více znaků"), + ("Align to Baseline Grid", "Zarovnat na mřížku účaří"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..9390fba86 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Vertical Scale", "Scala verticale"), + ("Horizontal Scale", "Scala orizzontale"), + ("Baseline Shift", "Spostamento linea di base"), + ("Skew (False Italic)", "Inclinazione (falso corsivo)"), + ("Left Indent", "Rientro sinistro"), + ("Right Indent", "Rientro destro"), + ("First Line Left Indent", "Rientro sinistro prima riga"), + ("Last Line Right Indent", "Rientro destro ultima riga"), + ("Drop Cap Number of Lines", "Capolettera: numero di righe"), + ("Drop Cap One or More Characters", "Capolettera: uno o più caratteri"), + ("Align to Baseline Grid", "Allinea alla griglia di base"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..2085b64de 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Vertical Scale", "ვერტიკალური მასშტაბი"), + ("Horizontal Scale", "ჰორიზონტალური მასშტაბი"), + ("Baseline Shift", "საბაზო ხაზის წანაცვლება"), + ("Skew (False Italic)", "დახრა (ცრუ კურსივი)"), + ("Left Indent", "მარცხენა შეწევა"), + ("Right Indent", "მარჯვენა შეწევა"), + ("First Line Left Indent", "პირველი სტრიქონის მარცხენა შეწევა"), + ("Last Line Right Indent", "ბოლო სტრიქონის მარჯვენა შეწევა"), + ("Drop Cap Number of Lines", "ინიციალის სტრიქონების რაოდენობა"), + ("Drop Cap One or More Characters", "ინიციალი: ერთი ან მეტი სიმბოლო"), + ("Align to Baseline Grid", "საბაზო ბადეზე სწორება"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..ee304632e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Vertical Scale", "Skala pionowa"), + ("Horizontal Scale", "Skala pozioma"), + ("Baseline Shift", "Przesunięcie linii bazowej"), + ("Skew (False Italic)", "Pochylenie (fałszywa kursywa)"), + ("Left Indent", "Wcięcie z lewej"), + ("Right Indent", "Wcięcie z prawej"), + ("First Line Left Indent", "Wcięcie pierwszego wiersza z lewej"), + ("Last Line Right Indent", "Wcięcie ostatniego wiersza z prawej"), + ("Drop Cap Number of Lines", "Inicjał: liczba wierszy"), + ("Drop Cap One or More Characters", "Inicjał: jeden lub więcej znaków"), + ("Align to Baseline Grid", "Wyrównaj do siatki linii bazowych"), ("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 8521de594..89bc29f6a 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Vertical Scale", "Escala vertical"), + ("Horizontal Scale", "Escala horizontal"), + ("Baseline Shift", "Deslocamento da linha de base"), + ("Skew (False Italic)", "Inclinação (falso itálico)"), + ("Left Indent", "Recuo à esquerda"), + ("Right Indent", "Recuo à direita"), + ("First Line Left Indent", "Recuo à esquerda da primeira linha"), + ("Last Line Right Indent", "Recuo à direita da última linha"), + ("Drop Cap Number of Lines", "Capitular: número de linhas"), + ("Drop Cap One or More Characters", "Capitular: um ou mais caracteres"), + ("Align to Baseline Grid", "Alinhar à grade de linha de base"), ("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 251555ca5..6706b9fb1 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Vertical Scale", "Масштаб по вертикали"), + ("Horizontal Scale", "Масштаб по горизонтали"), + ("Baseline Shift", "Смещение базовой линии"), + ("Skew (False Italic)", "Наклон (ложный курсив)"), + ("Left Indent", "Отступ слева"), + ("Right Indent", "Отступ справа"), + ("First Line Left Indent", "Отступ первой строки слева"), + ("Last Line Right Indent", "Отступ последней строки справа"), + ("Drop Cap Number of Lines", "Буквица: число строк"), + ("Drop Cap One or More Characters", "Буквица: один или несколько символов"), + ("Align to Baseline Grid", "Выровнять по сетке базовых линий"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..f3402b9d6 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Vertical Scale", "Масштаб по вертикалі"), + ("Horizontal Scale", "Масштаб по горизонталі"), + ("Baseline Shift", "Зсув базової лінії"), + ("Skew (False Italic)", "Нахил (несправжній курсив)"), + ("Left Indent", "Відступ ліворуч"), + ("Right Indent", "Відступ праворуч"), + ("First Line Left Indent", "Відступ першого рядка ліворуч"), + ("Last Line Right Indent", "Відступ останнього рядка праворуч"), + ("Drop Cap Number of Lines", "Буквиця: кількість рядків"), + ("Drop Cap One or More Characters", "Буквиця: один або кілька символів"), + ("Align to Baseline Grid", "Вирівняти за сіткою базових ліній"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..446b412fe 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -938,6 +938,77 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]); } } + "vscale" => { + pen.text(13.0, 10.5, "T", 15.0); + pen.line(&[(4.0, 3.0), (4.0, 17.0)]); + pen.line(&[(2.5, 5.0), (4.0, 3.0), (5.5, 5.0)]); + pen.line(&[(2.5, 15.0), (4.0, 17.0), (5.5, 15.0)]); + } + "hscale" => { + pen.text(10.0, 8.0, "T", 13.0); + pen.line(&[(3.0, 16.5), (17.0, 16.5)]); + pen.line(&[(5.0, 14.5), (3.0, 16.5), (5.0, 18.5)]); + pen.line(&[(15.0, 14.5), (17.0, 16.5), (15.0, 18.5)]); + } + "baseline-shift" => { + pen.text(7.0, 11.0, "A", 13.0); + pen.text(15.5, 5.5, "a", 8.0); + pen.line(&[(15.5, 17.5), (15.5, 10.0)]); + pen.line(&[(13.5, 12.0), (15.5, 10.0), (17.5, 12.0)]); + } + "skew" => { + pen.w = 1.8; + pen.line(&[(7.0, 4.0), (17.0, 4.0)]); + pen.line(&[(12.0, 4.0), (8.0, 17.0)]); + } + "indent-left" | "indent-right" | "indent-first" | "indent-last" => { + // Text lines, and an arrow at the side (or line) indented. + let (x0, x1) = match name { + "indent-left" => (9.0, 18.0), + "indent-right" => (2.0, 11.0), + _ => (2.0, 18.0), + }; + for (k, y) in [4.0, 8.0, 12.0, 16.0].into_iter().enumerate() { + let (a, z) = match (name, k) { + ("indent-first", 0) => (9.0, x1), + ("indent-last", 3) => (x0, 11.0), + _ => (x0, x1), + }; + pen.line(&[(a, y), (z, y)]); + } + let (y, from, to) = match name { + "indent-left" => (10.0, 2.0, 7.0), + "indent-right" => (10.0, 18.0, 13.0), + "indent-first" => (4.0, 2.0, 7.0), + _ => (16.0, 18.0, 13.0), + }; + pen.line(&[(from, y), (to, y)]); + let d = if to > from { -2.0 } else { 2.0 }; + pen.line(&[(to + d, y - 2.0), (to, y), (to + d, y + 2.0)]); + } + "space-before" | "space-after" => { + let before = name == "space-before"; + let (bar, lines) = if before { (3.0, [11.0, 14.5, 18.0]) } else { (17.0, [2.0, 5.5, 9.0]) }; + pen.line(&[(2.0, bar), (18.0, bar)]); + for y in lines { + pen.line(&[(8.0, y), (18.0, y)]); + } + let (a, z) = if before { (5.0, 10.0) } else { (10.0, 15.0) }; + pen.line(&[(4.0, a), (4.0, z)]); + pen.line(&[(2.5, a + 1.5), (4.0, a), (5.5, a + 1.5)]); + pen.line(&[(2.5, z - 1.5), (4.0, z), (5.5, z - 1.5)]); + } + "drop-lines" | "drop-chars" => { + pen.text(5.5, 8.0, "A", 14.0); + if name == "drop-chars" { + pen.text(13.5, 4.5, "a", 8.0); + } + for (y, x) in [(if name == "drop-chars" { 8.5 } else { 3.5 }, 11.0), (8.5, 11.0), (13.5, 11.0), (17.5, 2.0)] { + if name == "drop-lines" || y > 5.0 { + pen.line(&[(x, y), (18.0, y)]); + } + } + } "corner" => { pen.w = 1.0; for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..8d7abdd9a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1312,21 +1312,132 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { }) { let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); } - // Language: hyphenation, spelling and typographer's quotes follow it. - ui.add_space(1.0); + // ••• shows the rest in place, as InDesign's Properties panel does. + let more = temp_bool(ui, "props_char_more", false); + if more { + character_more(app, ui, &c, fw); + } + if more_options(ui, &app.ui.language).clicked() { + set_temp_bool(ui, "props_char_more", !more); + } +} + +/// A percentage or other number field in one half of a two-field row, with its icon. +#[allow(clippy::too_many_arguments)] +fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option { + let t = Tokens::get(ui.ctx()); + let x = if right { half + 10.0 } else { 0.0 }; + let ir = sub(b, x, y + 1.0, 19.0, 19.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover()).on_hover_text(tip); + place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).show(ui)) +} + +/// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew, +/// language and OpenType, then All Caps, Small Caps, Superscript, Subscript, Underline and +/// Strikethrough, as InDesign lays them out. +fn character_more(app: &mut DesignApp, ui: &mut Ui, c: &Value, fw: f32) { + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); + let half = (fw - 10.0) / 2.0; + let b = block(ui, 2.0 * FIELD_H + 5.0); + const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0]; + let pct = |v: &Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0); + let field = |id, v, suffix, decimals| NumField::number(id, v, suffix, decimals).spinner(); + if let Some(v) = half_field( + ui, + b, + false, + 0.0, + half, + "vscale", + &tr("Vertical Scale"), + field("cvscale", pct(&c["vScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}})); + } + if let Some(v) = half_field( + ui, + b, + true, + 0.0, + half, + "hscale", + &tr("Horizontal Scale"), + field("chscale", pct(&c["hScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + } + if let Some(v) = half_field( + ui, + b, + false, + 26.0, + half, + "baseline-shift", + &tr("Baseline Shift"), + field("cbshift", c["baselineShift"].as_f64(), " pt", 2).range(-5000.0, 5000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); + } + if let Some(v) = + half_field(ui, b, true, 26.0, half, "skew", &tr("Skew (False Italic)"), field("cskew", c["skew"].as_f64(), "°", 1).range(-85.0, 85.0)) + { + let _ = app.run("type.char", json!({"attrs": {"skew": v}})); + } + // Language (hyphenation, spelling and typographer's quotes follow it) and OpenType. + ui.add_space(4.0); let lang = c["language"].as_str().unwrap_or("English: USA").to_string(); - let label = crate::i18n::tr(&app.ui.language, "Language"); - let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &lang), fw).on_hover_ui(|ui| { - crate::rtl::label(ui, label); + let label = tr("Language"); + let row = block(ui, FIELD_H); + let resp = place(ui, sub(row, 0.0, 0.0, half, FIELD_H), |ui| widgets::dropdown(ui, crate::i18n::tr(&lang_ui, &lang), half)).on_hover_ui(|ui| { + crate::rtl::label(ui, label.clone()); }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(fw); + ui.set_min_width(half); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang)); }); - if more_options(ui, &app.ui.language).clicked() { - show_panel(app, "character"); + if place(ui, sub(row, half + 10.0, 0.0, half, FIELD_H), |ui| widgets::outline_button(ui, &tr("OpenType"), half)).clicked() { + show_typography_panel(app); } + // The case and position buttons. + ui.add_space(8.0); + let caps = c["capitalization"].as_str().unwrap_or("normal").to_string(); + let pos = c["position"].as_str().unwrap_or("normal").to_string(); + let (ul, st) = (c["underline"].as_bool().unwrap_or(false), c["strikethrough"].as_bool().unwrap_or(false)); + let buttons: [(&str, &str, bool, Value); 6] = [ + ("TT", "All Caps", caps == "allCaps", json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})), + ("Tᴛ", "Small Caps", caps == "smallCaps", json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})), + ("T¹", "Superscript", pos == "superscript", json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})), + ("T₁", "Subscript", pos == "subscript", json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})), + ("T̲", "Underline", ul, json!({"underline": !ul})), + ("T", "Strikethrough", st, json!({"strikethrough": !st})), + ]; + let pitch = fw / 6.0; + let row = block(ui, 22.0); + for (i, (text, tip, on, attrs)) in buttons.into_iter().enumerate() { + let r = Rect::from_center_size(row.min + vec2(pitch * (i as f32 + 0.5), 11.0), vec2(26.0, 22.0)); + let resp = ui.interact(r, ui.id().with(("case", i)), Sense::click()); + let t = Tokens::get(ui.ctx()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let col = if on { t.text_strong } else { t.icon }; + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col); + // Strikethrough: the T struck through (a combining stroke doesn't draw in every font). + if i == 5 { + ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col)); + } + let tip = tr(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip)); + if resp.on_hover_text(&tip).clicked() { + let _ = app.run("type.char", json!({"attrs": attrs})); + } + } + ui.add_space(4.0); } /// Paragraph section: the 9 alignment buttons and •••. @@ -1356,9 +1467,63 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { } } }); - if more_options(ui, &app.ui.language).clicked() { - show_panel(app, "paragraph"); + let more = temp_bool(ui, "props_para_more", false); + if more { + paragraph_more(app, ui, &a["para"], fw); } + if more_options(ui, &app.ui.language).clicked() { + set_temp_bool(ui, "props_para_more", !more); + } +} + +/// Properties › Paragraph after •••: left and right indent, first line and last line indent, +/// space before and after, drop cap lines and characters, then Hyphenate and Align to Baseline +/// Grid, as InDesign lays them out. +fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); + let u = units(app); + let half = (fw - 10.0) / 2.0; + ui.add_space(4.0); + let b = block(ui, 4.0 * FIELD_H + 15.0); + let rows = [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")), + (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ]; + for (i, (l, r)) in rows.into_iter().enumerate() { + let y = i as f32 * (FIELD_H + 5.0); + for (right, (icon, tip, key)) in [(false, l), (true, r)] { + let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 }; + let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); + if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: v}})); + } + } + } + let y = 3.0 * (FIELD_H + 5.0); + for (right, icon, tip, key, max) in [ + (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64); + if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); + } + } + ui.add_space(4.0); + ui.horizontal(|ui| { + let grid = p["gridAlign"].as_str() == Some("allLines"); + if widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); + } + ui.add_space(12.0); + let mut h = p["hyphenate"].as_bool().unwrap_or(true); + if widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + }); + ui.add_space(4.0); } fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { @@ -3058,6 +3223,9 @@ mod tests { app, ); h.run_steps(3); + assert!(h.query_by_label("Language").is_none(), "under •••, as in InDesign"); + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); h.get_by_label("Language").click(); h.run_steps(2); h.get_by_label("French").click(); @@ -3065,6 +3233,40 @@ mod tests { assert_eq!(language(h.state_mut()), "French"); } + /// Properties › Character and Paragraph •••, as in InDesign: the sections expand in place, + /// with the scales, baseline shift, skew, language, OpenType and the case buttons, and the + /// indents, spacing and drop caps. + #[test] + fn more_options_expand_character_and_paragraph_in_place() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + let mut h = panel_harness(app, show); + h.run_steps(3); + for l in ["Vertical Scale", "Small Caps", "Drop Cap Number of Lines", "Space Before"] { + assert!(h.query_by_label(l).is_none(), "{l} hidden until •••"); + } + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("Small Caps").click(); + h.run_steps(3); + let _ = h.get_by_label("OpenType"); + let _ = h.get_by_label("Language"); + h.get_all_by_label("More Options").nth(1).unwrap().click(); + h.run_steps(3); + let _ = h.get_by_label("Hyphenate"); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + let a = text_attrs(h.state_mut()).unwrap(); + assert_eq!(a["chars"]["capitalization"], "smallCaps"); + assert_eq!(a["para"]["gridAlign"], "allLines"); + // ••• again folds the section. + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); + assert!(h.query_by_label("Small Caps").is_none()); + } + fn kerning(app: &mut DesignApp) -> Value { text_attrs(app).unwrap()["chars"]["kerning"].clone() } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fb9876aa2..cf7762cbd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -382,6 +382,8 @@ pub fn more_options(ui: &mut Ui, language: &str) -> Response { let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover()); let r = Rect::from_min_max(pos2(row.max.x - 26.0, row.min.y), pos2(row.max.x - 4.0, row.max.y)); let resp = ui.interact(r, ui.id().with(("more", row.min.y as i32)), Sense::click()); + let label = crate::i18n::tr(language, "More Options"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); for i in 0..3 { ui.painter().circle_filled(pos2(r.min.x + 4.0 + i as f32 * 7.0, r.center().y), 1.9, if resp.hovered() { t.text_strong } else { t.icon }); } @@ -567,6 +569,8 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { *on = !*on; resp.mark_changed(); } + let checked = *on; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); resp } From e177529f98bcebe70861b2bcaacd5feeac9a2c80 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:02:03 +0200 Subject: [PATCH 05/79] =?UTF-8?q?Control=20panel:=20the=20text=20controls?= =?UTF-8?q?=20have=20InDesign's=20Character=20(A)=20and=20Paragraph=20(?= =?UTF-8?q?=C2=B6)=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shows the Character Formatting Controls: font and style, size and leading, All Caps, Superscript, Underline / Small Caps, Subscript, Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, the character style and the language. ¶ shows the Paragraph Formatting Controls: the nine alignments, left and first line indents, right and last line indents, space before and after, the drop cap's lines and characters, the paragraph style, Hyphenate and Align to Baseline Grid. The fields' icons carry their names (tooltip and accessibility). It used to show a fixed mix of both. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 435 +++++++++++++++++++----- crates/ui-egui/src/i18n.rs | 21 ++ crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/cs.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ka.rs | 3 + crates/ui-egui/src/i18n/pl.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/ru.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + crates/ui-egui/src/panels/properties.rs | 4 +- 12 files changed, 407 insertions(+), 78 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b18c4328..4cff2db26 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-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 851808a25..ff1b281c7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::panels; use crate::theme::{Tokens, semibold}; -use crate::widgets::{NumField, caption, measure, number}; +use crate::widgets::{NumField, caption, measure}; use crate::{DesignApp, icons}; /// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The @@ -569,20 +569,95 @@ const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, /// The Control panel's tracking values (1/1000 em). const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; +/// Whether the text Control panel shows Paragraph Formatting Controls (¶) rather than Character +/// Formatting Controls (A). +const PARA_MODE: &str = "ctl_text_para_mode"; + +/// An icon and a field after it, as the Control panel lays its fields out. +fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.on_hover_text(tip); + field.spinner().show(ui) +} + +/// A column of two rows (the Control panel's two-row groups). +fn rows(ui: &mut egui::Ui, mut row: impl FnMut(&mut egui::Ui, usize)) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; + ui.horizontal(|ui| row(ui, 0)); + ui.horizontal(|ui| row(ui, 1)); + }); +} + +/// A small toggle button showing `text` (the case and position buttons). +fn text_toggle(ui: &mut egui::Ui, text: &str, strike: bool, on: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 20.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let col = if on { t.text_strong } else { t.icon }; + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col); + if strike { + ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); + resp.on_hover_text(tip).clicked() +} + +/// The text Control panel, as InDesign's: A shows the Character Formatting Controls (font, size +/// and leading, the case and position buttons, kerning and tracking, the scales, baseline shift +/// and skew, the character style and language); ¶ the Paragraph Formatting Controls (alignment, +/// the indents, space before and after, the drop cap, the paragraph style, Hyphenate and Align to +/// Baseline Grid). fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let attrs = panels::text_attrs(app); let chars = attrs.as_ref().map(|a| a["chars"].clone()).unwrap_or_default(); let para = attrs.as_ref().map(|a| a["para"].clone()).unwrap_or_default(); + let mode_id = egui::Id::new(PARA_MODE); + let para_mode = ui.data(|d| d.get_temp::(mode_id)).unwrap_or(false); ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new("A").font(semibold(15.0)).color(t.text_strong)); - }); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("¶").font(semibold(15.0)).color(t.text_dim)); - }); + ui.spacing_mut().item_spacing.y = 3.0; + for (para, text, tip) in [(false, "A", "Character Formatting Controls"), (true, "¶", "Paragraph Formatting Controls")] { + let on = para_mode == para; + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, semibold(15.0), if on { t.text_strong } else { t.text_dim }); + let tip = tr(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &tip)); + if resp.on_hover_text(&tip).clicked() { + ui.data_mut(|d| d.insert_temp(mode_id, para)); + } + } }); vsep(ui); + if para_mode { + control_paragraph(app, ui, &attrs, ¶, &tr); + } else { + control_character(app, ui, &attrs, &chars, ¶, &tr); + } +} + +fn control_character( + app: &mut DesignApp, + ui: &mut egui::Ui, + attrs: &Option, + chars: &serde_json::Value, + para: &serde_json::Value, + tr: &dyn Fn(&str) -> String, +) { ui.vertical(|ui| { let fam = chars["fontFamily"].as_str().unwrap_or("").to_string(); let sty = chars["fontStyle"].as_str().unwrap_or("").to_string(); @@ -590,95 +665,268 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { panels::font_style_picker(app, ui, &fam, &sty, 170.0); }); vsep(ui); - // Size, leading and tracking as InDesign's Control panel has them: a spinner on the left and - // a list of common values on the right; automatic leading in parentheses. - egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| { - let (sz, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); - icons::paint(ui.painter(), sz, "font-size", t.icon); - if let Some(v) = NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).spinner().presets(SIZES).range(0.1, 1296.0).show(ui) - { - let _ = app.run("type.char", json!({"attrs": {"size": v}})); + // Size and leading: a spinner on the left and a list of common values on the right; + // automatic leading in parentheses. + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "font-size", + &tr("Font Size"), + NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).presets(SIZES).range(0.1, 1296.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"size": v}})); + } + } else { + let auto = chars["leading"]["kind"].as_str() != Some("points"); + let lead = if auto { + chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)) + } else { + chars["leading"]["value"].as_f64() + }; + let lead = lead.map(|v| (v * 100.0).round() / 100.0); + let key = if auto { "cleadauto" } else { "clead" }; + if let Some(v) = icon_field( + ui, + "leading", + &tr("Leading"), + NumField::number(key, lead, " pt", 2).width(84.0).presets(SIZES).range(0.0, 5000.0).parens(auto), + ) { + let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); + } } - let (tr, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); - icons::paint(ui.painter(), tr, "tracking", t.icon); - if let Some(v) = NumField::number("ctrack", chars["tracking"].as_f64(), "", 0) - .width(76.0) - .spinner() - .presets(TRACKING) - .step(5.0) - .range(-1000.0, 10000.0) - .show(ui) - { - let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); + }); + vsep(ui); + // All Caps, Superscript, Underline / Small Caps, Subscript, Strikethrough. + let caps = chars["capitalization"].as_str().unwrap_or("normal").to_string(); + let pos = chars["position"].as_str().unwrap_or("normal").to_string(); + let (ul, st) = (chars["underline"].as_bool().unwrap_or(false), chars["strikethrough"].as_bool().unwrap_or(false)); + let mut set = None; + rows(ui, |ui, row| { + if row == 0 { + if text_toggle(ui, "TT", false, caps == "allCaps", &tr("All Caps")) { + set = Some(json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})); + } + if text_toggle(ui, "T¹", false, pos == "superscript", &tr("Superscript")) { + set = Some(json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})); + } + if text_toggle(ui, "T̲", false, ul, &tr("Underline")) { + set = Some(json!({"underline": !ul})); + } + } else { + if text_toggle(ui, "Tᴛ", false, caps == "smallCaps", &tr("Small Caps")) { + set = Some(json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})); + } + if text_toggle(ui, "T₁", false, pos == "subscript", &tr("Subscript")) { + set = Some(json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})); + } + if text_toggle(ui, "T", true, st, &tr("Strikethrough")) { + set = Some(json!({"strikethrough": !st})); + } } - ui.end_row(); - let (ld, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); - icons::paint(ui.painter(), ld, "leading", t.icon); - let auto = chars["leading"]["kind"].as_str() != Some("points"); - let lead = - if auto { chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)) } else { chars["leading"]["value"].as_f64() }; - let lead = lead.map(|v| (v * 100.0).round() / 100.0); - let key = if auto { "cleadauto" } else { "clead" }; - if let Some(v) = NumField::number(key, lead, " pt", 2).width(84.0).spinner().presets(SIZES).range(0.0, 5000.0).parens(auto).show(ui) { - let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); + }); + if let Some(a) = set { + let _ = app.run("type.char", json!({"attrs": a})); + } + vsep(ui); + rows(ui, |ui, row| { + if row == 0 { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, "kerning", t.icon); + resp.on_hover_text(tr("Kerning")); + crate::panels::properties::kerning_control(app, ui, "ckern", &chars["kerning"], 92.0); + } else { + if let Some(v) = icon_field( + ui, + "tracking", + &tr("Tracking"), + NumField::number("ctrack", chars["tracking"].as_f64(), "", 0).width(92.0).presets(TRACKING).step(5.0).range(-1000.0, 10000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); + } } - caption(ui, crate::i18n::tr(&app.ui.language, "IT")); - if let Some(v) = number(ui, "chs", chars["hScale"].as_f64().map(|v| v * 100.0), "%", 48.0, 0) { - let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + }); + vsep(ui); + const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0]; + let pct = |v: &serde_json::Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0); + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "vscale", + &tr("Vertical Scale"), + NumField::number("cvs", pct(&chars["vScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}})); + } + } else { + if let Some(v) = icon_field( + ui, + "hscale", + &tr("Horizontal Scale"), + NumField::number("chs", pct(&chars["hScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + } + } + }); + vsep(ui); + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "baseline-shift", + &tr("Baseline Shift"), + NumField::number("cbs", chars["baselineShift"].as_f64(), " pt", 2).width(76.0).range(-5000.0, 5000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); + } + } else { + if let Some(v) = icon_field( + ui, + "skew", + &tr("Skew (False Italic)"), + NumField::number("csk", chars["skew"].as_f64(), "°", 1).width(76.0).range(-85.0, 85.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"skew": v}})); + } } - ui.end_row(); }); vsep(ui); ui.vertical(|ui| { - let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - for (a, icon) in [(Align::Left, "align-left"), (Align::Center, "align-center"), (Align::Right, "align-right")] { - if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": a}})); + ui.spacing_mut().item_spacing.y = 3.0; + // The character style, then the language. + let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); + let names: Vec = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let mut pick = None; + egui::ComboBox::from_id_salt("ctl_char_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs))) + .width(150.0) + .show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cs, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + pick = Some(n.clone()); + } } - } - }); - ui.horizontal(|ui| { - for (a, icon) in [(Align::LeftJustified, "align-justify"), (Align::FullyJustified, "align-justify-all")] { - if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": a}})); - } - } + }); + if let Some(n) = pick { + let _ = app.run("style.character.apply", json!({"name": n})); + } + let lang = chars["language"].as_str().unwrap_or("English: USA").to_string(); + egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang)); }); }); +} + +fn control_paragraph( + app: &mut DesignApp, + ui: &mut egui::Ui, + attrs: &Option, + para: &serde_json::Value, + tr: &dyn Fn(&str) -> String, +) { + let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default(); + let mut align = None; + rows(ui, |ui, row| { + if row == 0 { + for (a, icon) in [ + (Align::Left, "palign-left"), + (Align::Center, "palign-center"), + (Align::Right, "palign-right"), + (Align::TowardsSpine, "palign-spine-towards"), + ] { + if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() { + align = Some(a); + } + } + } else { + for (a, icon) in [ + (Align::LeftJustified, "palign-justify-left"), + (Align::CenterJustified, "palign-justify-center"), + (Align::RightJustified, "palign-justify-right"), + (Align::FullyJustified, "palign-justify-all"), + (Align::AwayFromSpine, "palign-spine-away"), + ] { + if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() { + align = Some(a); + } + } + } + }); + if let Some(a) = align { + let _ = app.run("type.para", json!({"attrs": {"align": a}})); + } vsep(ui); let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas); - egui::Grid::new("ctl_para").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| { - caption(ui, "→|"); - if let Some(v) = measure(ui, "cli", para["leftIndent"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"leftIndent": v}})); + let mut set = None; + for ((ia, ta, ka), (ib, tb, kb)) in [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-first", "First Line Left Indent", "firstLineIndent")), + (("indent-right", "Right Indent", "rightIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ] { + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field(ui, ia, &tr(ta), NumField::measure(ka, para[ka].as_f64(), units).width(84.0).range(0.0, 10_000.0)) { + set = Some((ka, v)); + } + } else { + let min = if kb == "firstLineIndent" { -10_000.0 } else { 0.0 }; + if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) { + set = Some((kb, v)); + } + } + }); + vsep(ui); + } + if let Some((k, v)) = set { + let _ = app.run("type.para", json!({"attrs": {k: v}})); + } + let mut drop = None; + rows(ui, |ui, row| { + if row == 0 { + let max = designcraft_compose::MAX_DROP_CAP_LINES as f64; + if let Some(v) = icon_field( + ui, + "drop-lines", + &tr("Drop Cap Number of Lines"), + NumField::number("cdl", para["dropCapLines"].as_f64(), "", 0).width(64.0).range(0.0, max), + ) { + drop = Some(("dropCapLines", v)); + } + } else { + let max = designcraft_compose::MAX_DROP_CAP_CHARS as f64; + if let Some(v) = icon_field( + ui, + "drop-chars", + &tr("Drop Cap One or More Characters"), + NumField::number("cdc", para["dropCapChars"].as_f64(), "", 0).width(64.0).range(0.0, max), + ) { + drop = Some(("dropCapChars", v)); + } } - caption(ui, "↑¶"); - if let Some(v) = measure(ui, "csb", para["spaceBefore"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"spaceBefore": v}})); - } - ui.end_row(); - caption(ui, "→¶"); - if let Some(v) = measure(ui, "cfi", para["firstLineIndent"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"firstLineIndent": v}})); - } - caption(ui, "↓¶"); - if let Some(v) = measure(ui, "csa", para["spaceAfter"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"spaceAfter": v}})); - } - ui.end_row(); }); + if let Some((k, v)) = drop { + let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}})); + } vsep(ui); ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string(); let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0) + attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0); panels::para_style_picker(app, ui, &ps, ov > 0, 170.0); - let hy = para["hyphenate"].as_bool().unwrap_or(true); - let mut h = hy; - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); - } + ui.horizontal(|ui| { + let mut h = para["hyphenate"].as_bool().unwrap_or(true); + if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + let grid = para["gridAlign"].as_str() == Some("allLines"); + if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); + } + }); }); } @@ -1397,6 +1645,41 @@ mod tests { assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic"); } + /// The course's chapter 16: the text Control panel has InDesign's two modes. A shows the + /// Character Formatting Controls (the case buttons, kerning and tracking, the scales…), ¶ the + /// Paragraph Formatting Controls (alignment, indents, spacing, the drop cap…). + #[test] + fn the_type_control_panel_has_character_and_paragraph_modes() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| { + let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + let has = |h: &egui_kittest::Harness<'static, test_window::Window>, label: &str| h.query_all_by_label(label).any(|n| in_bar(n.rect())); + assert!(has(&h, "Small Caps") && has(&h, "Vertical Scale") && !has(&h, "Drop Cap Number of Lines")); + click(&mut h, "Small Caps"); + click(&mut h, "Paragraph Formatting Controls"); + assert!(has(&h, "Drop Cap Number of Lines") && has(&h, "Space Before") && !has(&h, "Small Caps")); + click(&mut h, "Align to Baseline Grid"); + click(&mut h, "Align center"); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!(a["chars"]["capitalization"], "smallCaps"); + assert_eq!(a["para"]["gridAlign"], "allLines"); + assert_eq!(a["para"]["align"], "center"); + click(&mut h, "Character Formatting Controls"); + assert!(has(&h, "Small Caps")); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8dd4709f9..816bbe7cd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,27 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ( + "Character Formatting Controls", + [ + "Steuerelemente für Zeichenformatierung", + "Commandes de mise en forme des caractères", + "Controles de formato de carácter", + "文字形式コントロール", + "字符格式控制", + ], + ), + ( + "Paragraph Formatting Controls", + [ + "Steuerelemente für Absatzformatierung", + "Commandes de mise en forme des paragraphes", + "Controles de formato de párrafo", + "段落形式コントロール", + "段落格式控制", + ], + ), + ("Font Size", ["Schriftgrad", "Corps", "Tamaño de fuente", "フォントサイズ", "字体大小"]), ("Vertical Scale", ["Vertikal skalieren", "Échelle verticale", "Escala vertical", "垂直比率", "垂直缩放"]), ("Horizontal Scale", ["Horizontal skalieren", "Échelle horizontale", "Escala horizontal", "水平比率", "水平缩放"]), ("Baseline Shift", ["Grundlinienversatz", "Décalage vertical", "Desplazamiento vertical", "ベースラインシフト", "基线偏移"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 9ce202c6e..d11fabebc 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Character Formatting Controls", "عناصر تحكم تنسيق الأحرف"), + ("Paragraph Formatting Controls", "عناصر تحكم تنسيق الفقرات"), + ("Font Size", "حجم الخط"), ("Vertical Scale", "المقياس الرأسي"), ("Horizontal Scale", "المقياس الأفقي"), ("Baseline Shift", "إزاحة خط الأساس"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index d6931c30e..b1004d9eb 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Character Formatting Controls", "Ovládací prvky formátování znaků"), + ("Paragraph Formatting Controls", "Ovládací prvky formátování odstavců"), + ("Font Size", "Velikost písma"), ("Vertical Scale", "Svislá změna velikosti"), ("Horizontal Scale", "Vodorovná změna velikosti"), ("Baseline Shift", "Posun účaří"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 9390fba86..172687d3d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Character Formatting Controls", "Controlli di formattazione carattere"), + ("Paragraph Formatting Controls", "Controlli di formattazione paragrafo"), + ("Font Size", "Dimensione font"), ("Vertical Scale", "Scala verticale"), ("Horizontal Scale", "Scala orizzontale"), ("Baseline Shift", "Spostamento linea di base"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 2085b64de..8bf351d74 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Character Formatting Controls", "სიმბოლოების ფორმატირების მართვა"), + ("Paragraph Formatting Controls", "აბზაცების ფორმატირების მართვა"), + ("Font Size", "შრიფტის ზომა"), ("Vertical Scale", "ვერტიკალური მასშტაბი"), ("Horizontal Scale", "ჰორიზონტალური მასშტაბი"), ("Baseline Shift", "საბაზო ხაზის წანაცვლება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ee304632e..10e1a42e4 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Character Formatting Controls", "Elementy formatowania znaków"), + ("Paragraph Formatting Controls", "Elementy formatowania akapitów"), + ("Font Size", "Rozmiar czcionki"), ("Vertical Scale", "Skala pionowa"), ("Horizontal Scale", "Skala pozioma"), ("Baseline Shift", "Przesunięcie linii bazowej"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 89bc29f6a..09c714a6b 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Character Formatting Controls", "Controles de formatação de caracteres"), + ("Paragraph Formatting Controls", "Controles de formatação de parágrafos"), + ("Font Size", "Tamanho da fonte"), ("Vertical Scale", "Escala vertical"), ("Horizontal Scale", "Escala horizontal"), ("Baseline Shift", "Deslocamento da linha de base"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 6706b9fb1..0ba1dad67 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Character Formatting Controls", "Элементы управления форматированием символов"), + ("Paragraph Formatting Controls", "Элементы управления форматированием абзацев"), + ("Font Size", "Кегль"), ("Vertical Scale", "Масштаб по вертикали"), ("Horizontal Scale", "Масштаб по горизонтали"), ("Baseline Shift", "Смещение базовой линии"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3402b9d6..905841457 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Character Formatting Controls", "Елементи керування форматуванням символів"), + ("Paragraph Formatting Controls", "Елементи керування форматуванням абзаців"), + ("Font Size", "Кегль"), ("Vertical Scale", "Масштаб по вертикалі"), ("Horizontal Scale", "Масштаб по горизонталі"), ("Baseline Shift", "Зсув базової лінії"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8d7abdd9a..ff8447aba 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1634,7 +1634,7 @@ pub(crate) const LANGUAGES: &[&str] = &[ /// The Language list (Character panel, Properties ▸ Character): picking one sets the language of /// the selected text. -fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { +pub(crate) fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { for l in LANGUAGES { if ui.selectable_label(*l == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { let _ = app.run("type.char", json!({"attrs": {"language": l}})); @@ -1646,7 +1646,7 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { /// Kerning for the text selection, or at a caret for the pair at the caret: the label shows /// Metrics, Optical, 0 or the manual value; its menu sets one of the first three or a manual value /// in 1/1000 em (typed or stepped). -fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { +pub(crate) fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { let kind = k["kind"].as_str().unwrap_or("metrics"); let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite()); let label = match (kind, manual) { From 6cb9466129855db5e69dda24d504a1fbf7532a47 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:57:12 +0200 Subject: [PATCH 06/79] =?UTF-8?q?Docs:=20the=20Properties=20=E2=80=A2?= =?UTF-8?q?=E2=80=A2=E2=80=A2=20sections=20in=20the=20UI=20parity=20checkl?= =?UTF-8?q?ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/ui-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..043633d37 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Properties › Character and Paragraph ••• expand in place; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -27,7 +27,7 @@ basics of editing fail for too many users on Windows and Linux. | Area | InDesign 2026 | DesignCraft | Status | Evidence | |---|---|---|---|---| | Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md | -| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | | +| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); Character and Paragraph ••• expand the section in place (scales, baseline shift, skew, language, OpenType, case buttons; indents, spacing, drop caps) | ✓ | `properties.rs` `more_options_expand_character_and_paragraph_in_place` | | Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 | | Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | | Live corners (yellow handle) | ✓ | ✓ widget on a selected rectangle frame; diamonds drag the corner size (Shift: one corner), Alt-click steps the shape (Shift+Alt: all) | ✓ | #186 | From 13527f9cb73fd58bc3650b0ef446b429611dcc72 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:15:51 +0200 Subject: [PATCH 07/79] Control panel fields: Escape reverts any typed value; parity docs for the text modes Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome.rs | 16 ++++++++++++++++ crates/ui-egui/src/widgets.rs | 8 +++++--- docs/ui-parity.md | 7 ++++--- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4cff2db26..bcdbd62e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,8 +349,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. -- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. +- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ff1b281c7..749115b31 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1643,6 +1643,22 @@ mod tests { h.key_press(egui::Key::Escape); h.run_steps(3); assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic"); + // Escape also reverts a different number, in the leading field and in a plain one. (It + // leaves the text too, so each try selects it again.) + for (shown, typed) in [("(87.6 pt)", "20"), ("73 pt", "50")] { + h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap(); + h.state_mut().app.session.set_tool("type"); + h.run_steps(3); + let r = field(&h, shown); + press(&mut h, r.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(typed.into())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + } + assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "still automatic"); + assert_eq!(size_of(&h), Some(73.0), "the size is unchanged"); } /// The course's chapter 16: the text Control panel has InDesign's two modes. A shows the diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 277d1fc9a..31fe194c2 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -228,10 +228,12 @@ impl<'a> NumField<'a> { } if r.lost_focus() { // An automatic value (in parentheses) typed over commits even when it's the same - // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape cancels it. + // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape reverts + // whatever was typed, as in InDesign. let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); - let typed_over_auto = self.parens && !escaped && !buf.trim_start().starts_with('('); - if let Some(v) = self.parse(&buf).map(clamp) + let typed_over_auto = self.parens && !buf.trim_start().starts_with('('); + if escaped { + } else if let Some(v) = self.parse(&buf).map(clamp) && (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9)) { out = Some(v); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..98912561a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -26,8 +26,8 @@ basics of editing fail for too many users on Windows and Linux. | Area | InDesign 2026 | DesignCraft | Status | Evidence | |---|---|---|---|---| -| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md | -| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | | +| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls, and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 | +| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); ••• expands Character (scales, baseline shift, skew, language, case and position buttons) and Paragraph (indents, spacing, drop cap, baseline grid, hyphenation) in place | ✓ | Learn Skills Daily course, chapter 16 | | Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 | | Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | | Live corners (yellow handle) | ✓ | ✓ widget on a selected rectangle frame; diamonds drag the corner size (Shift: one corner), Alt-click steps the shape (Shift+Alt: all) | ✓ | #186 | @@ -122,6 +122,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) | | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | From 4320235bc6fd52f0d4a6d7cbd5865c8ddfc7333b Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:33:10 +0200 Subject: [PATCH 08/79] Space Before / Space After in the shared translation table Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 3 +++ crates/ui-egui/src/i18n/fr.rs | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8dd4709f9..a7d41aab7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -77,6 +77,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ "对齐基线网格", ], ), + // Properties › Paragraph (•••). + ("Space Before", ["Abstand davor", "Espace avant", "Espacio anterior", "段落前の間隔", "段前间距"]), + ("Space After", ["Abstand danach", "Espace après", "Espacio posterior", "段落後の間隔", "段后间距"]), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..fe1fcce32 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -167,8 +167,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Single Right Quotation Mark", "Guillemet simple fermant"), ("Sixth Space", "Sixième d'espace"), ("Smart Guide Options", "Options des repères commentés"), - ("Space After", "Espace après"), - ("Space Before", "Espace avant"), ("Start Page #", "N° de la première page"), ("Step and Repeat", "Exécuter et répéter"), ("Story Column Direction", "Sens des colonnes de l'article"), From 8ec58cc1cc8b10363ab0f30a533157e7bac93d83 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:38:28 +0200 Subject: [PATCH 09/79] Number fields are labelled by the icon that names them (accessibility) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 25 +++++++++++++++++++++++- crates/ui-egui/src/panels/properties.rs | 7 +++++-- crates/ui-egui/src/widgets.rs | 26 ++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 749115b31..690d8fc32 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -579,8 +579,9 @@ fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Opti let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); icons::paint(ui.painter(), r, icon, t.icon); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + let label = resp.id; resp.on_hover_text(tip); - field.spinner().show(ui) + field.spinner().labelled_by(label).show(ui) } /// A column of two rows (the Control panel's two-row groups). @@ -1367,6 +1368,28 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { #[cfg(test)] mod tests { + + /// A Control panel field is labelled by its icon, so assistive technology reads "Tracking" + /// (or "Font Size", …) with the value. + #[test] + fn icon_fields_are_labelled_by_their_icon() { + use egui::accesskit::Role; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + ctx.enable_accesskit(); + let mut update = None; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + icon_field(ui, "tracking", "Tracking", NumField::number("tracking_test", Some(0.0), "", 0)); + }); + out.textures_delta.clear(); + update = out.platform_output.accesskit_update; + } + let update = update.expect("an AccessKit tree"); + let named = |n: &egui::accesskit::Node| n.label() == Some("Tracking") || n.value() == Some("Tracking"); + let icon = update.nodes.iter().find(|(_, n)| named(n)).map(|(id, _)| *id).expect("the icon's node"); + assert!(update.nodes.iter().any(|(_, n)| n.role() == Role::TextInput && n.labelled_by().contains(&icon)), "the editor names its icon"); + } use egui::{pos2, vec2}; use egui_kittest::kittest::{NodeT, Queryable}; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ff8447aba..8a2d03f65 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1329,8 +1329,11 @@ fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, let x = if right { half + 10.0 } else { 0.0 }; let ir = sub(b, x, y + 1.0, 19.0, 19.0); icons::paint(ui.painter(), ir, icon, t.icon); - ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover()).on_hover_text(tip); - place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).show(ui)) + let resp = ui.interact(ir, ui.id().with(("half_icon", icon)), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + let label = resp.id; + resp.on_hover_text(tip); + place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show(ui)) } /// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 31fe194c2..d8eb6c51e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -80,6 +80,8 @@ pub struct NumField<'a> { range: (f64, f64), enabled: bool, parens: bool, + /// The widget naming the field (an icon or caption beside it), for accessibility. + label: Option, } impl<'a> NumField<'a> { @@ -90,7 +92,19 @@ impl<'a> NumField<'a> { Self::new(id, value, NumKind::Number { suffix, decimals }) } fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self { - NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false } + NumField { + id, + value, + kind, + width: 72.0, + spinner: false, + presets: &[], + step: None, + range: (f64::MIN, f64::MAX), + enabled: true, + parens: false, + label: None, + } } /// Total width (spinner and preset segments included). pub fn width(mut self, w: f32) -> Self { @@ -130,6 +144,12 @@ impl<'a> NumField<'a> { self.enabled = on; self } + /// The editor is labelled by widget `id` (an icon or caption that names the field), so + /// assistive technology reads the field's name with its value. + pub fn labelled_by(mut self, id: egui::Id) -> Self { + self.label = Some(id); + self + } /// Draw the field; returns the new value when the user commits an edit (Enter, focus loss, /// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset). @@ -210,6 +230,10 @@ impl<'a> NumField<'a> { editor_ui.disable(); } let r = editor_ui.put(value_rect, te); + let r = match self.label { + Some(id) => r.labelled_by(id), + None => r, + }; if r.has_focus() { // ↑/↓ step the value while typing. let (up, down, shift) = ui.input_mut(|i| { From 189cccd2fc8f5a53c50ee3b40e9c2519a79bb8c6 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:04:31 +0200 Subject: [PATCH 10/79] Bulleted and Numbered List buttons give InDesign's default hanging indent The Properties buttons and Type > Bulleted & Numbered Lists > Apply Bullets / Apply Numbers (list.bullets, list.numbers) apply the list with Left Indent 0.25 in / First Line Indent -0.25 in when the paragraph has no indents, so the text lines up after the bullet; pressed again, the list and that indent go. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/lists.rs | 125 +++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 16 +++ crates/ui-egui/src/panels/properties.rs | 36 ++++++- docs/target-app-parity.md | 3 +- 16 files changed, 198 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..7936e5797 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-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 9f8824870..cad62f271 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -1,13 +1,37 @@ //! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from //! story to story. Paragraphs join one with `listName` (and restart with `startAt`). -use designcraft_doc::NumberedList; +use designcraft_doc::{ListType, NumberedList}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param}; +use crate::{Result, Session}; + +/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's +/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin, +/// the text after its tab at the left indent. +pub const LIST_HANG: f64 = 18.0; pub fn specs() -> Vec { vec![ + cmd!( + "list.bullets", + "Apply Bullets", + ["Type", "Bulleted & Numbered Lists"], + None, + "{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied", + has_text_or_frames, + |s, p| apply_list(s, p, ListType::Bullets) + ), + cmd!( + "list.numbers", + "Apply Numbers", + ["Type", "Bulleted & Numbered Lists"], + None, + "{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets", + has_text_or_frames, + |s, p| apply_list(s, p, ListType::Numbers) + ), cmd!( "list.define", "Define Lists…", @@ -48,12 +72,109 @@ pub fn specs() -> Vec { ] } +/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and +/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the +/// style rather than set locally, so a paragraph back to its style shows no override. +fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { + let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" }; + let on = match p.get("on") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?), + }; + let targets = super::text::format_targets_pub(s); + if targets.is_empty() { + return Err(bad(name, "select text or a text frame")); + } + let d = &s.doc()?.doc; + // Off when every selected paragraph already has this list, as the button shows it pressed. + let on = on.unwrap_or_else(|| { + !targets.iter().all(|t| { + d.text_story(t.story, t.cell).is_some_and(|st| { + let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start))); + st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind)) + }) + }) + }); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + st.format_paras(t.range.clone(), |pf| { + let (now, _) = styles.resolve_para(pf); + let (style, _) = styles.resolve_para_style(&pf.style); + // A value: left to the style when it gives the same, else set here. + let mut set = |key: &str, v: Value, same: bool| { + let _ = pf.para.set_json(key, if same { &Value::Null } else { &v }); + }; + let list = if on { kind } else { ListType::None }; + set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list); + if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 { + set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG); + set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG); + } else if !on && now.list_type == kind && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG { + set("leftIndent", json!(0.0), style.left_indent == 0.0); + set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0); + } + }); + } + Ok(json!({"on": on})) + }) +} + #[cfg(test)] mod tests { use serde_json::json; use crate::Session; + /// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the + /// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the + /// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them. + #[test] + fn list_buttons_apply_the_default_hanging_indent() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap(); + let para = |s: &Session, i: usize| { + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(sid)).unwrap(); + (d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone()) + }; + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true); + for i in 0..2 { + let (p, _) = para(&s, i); + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0)); + } + assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The number of a bullet's tab: the text starts at the left indent. + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let line = &cs.frames[0].lines[0]; + let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap(); + assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0); + // Numbers instead: the indent stays. + s.execute("list.numbers", &json!({})).unwrap(); + let (p, _) = para(&s, 0); + assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0)); + // Numbers off: list and hanging indent go, and nothing is left set on the paragraph. + assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false); + let (p, own) = para(&s, 0); + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0)); + assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style"); + // A paragraph with its own indents keeps them. + s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + let (p, _) = para(&s, 0); + assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0)); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + assert_eq!(para(&s, 0).0.left_indent, 36.0); + assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err()); + } + /// The digit the story's first line starts with (its list number). fn digit(g: &designcraft_compose::PlacedGlyph) -> char { let face = g.face.get(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..484ffde93 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1633,6 +1633,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Define Lists…", ["Define Lists…", "Définir des listes…", "Define Lists…", "Define Lists…", "定义列表…"]), + ("Apply Bullets", ["Aufzählungszeichen anwenden", "Appliquer des puces", "Aplicar viñetas", "箇条書きを適用", "应用项目符号"]), + ("Apply Numbers", ["Nummerierung anwenden", "Appliquer la numérotation", "Aplicar números", "段落番号を適用", "应用编号"]), ( "Define Text Variable…", ["Define Text Variable…", "Définir une variable de texte…", "Define Text Variable…", "Define Text Variable…", "定义文本变量…"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..bfdf45542 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Apply Bullets", "تطبيق التعداد النقطي"), + ("Apply Numbers", "تطبيق الترقيم"), ("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 e77e72a1c..033c85b51 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Apply Bullets", "Použít odrážky"), + ("Apply Numbers", "Použít číslování"), ("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 efc128858..4f52c8de2 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Apply Bullets", "Aufzählungszeichen anwenden"), + ("Apply Numbers", "Nummerierung anwenden"), ("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 a918a9829..dba32fdd4 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Apply Bullets", "Applica punti elenco"), + ("Apply Numbers", "Applica numeri"), ("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 5647ca940..7a29a55c0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Apply Bullets", "箇条書きを適用"), + ("Apply Numbers", "段落番号を適用"), ("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 d938c2dc7..5ad070a41 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Apply Bullets", "მარკერების გამოყენება"), + ("Apply Numbers", "ნუმერაციის გამოყენება"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..8eca515d9 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Apply Bullets", "Zastosuj wypunktowanie"), + ("Apply Numbers", "Zastosuj numerowanie"), ("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 8521de594..4fcfe85be 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Apply Bullets", "Aplicar marcadores"), + ("Apply Numbers", "Aplicar números"), ("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 251555ca5..d9ee2a929 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Apply Bullets", "Применить маркеры"), + ("Apply Numbers", "Применить нумерацию"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..cce040a48 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Apply Bullets", "Застосувати маркери"), + ("Apply Numbers", "Застосувати нумерацію"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..dde5ca033 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -502,6 +502,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", ">Bulleted & Numbered Lists", + "cmd:list.bullets", + "cmd:list.numbers", + "-", "cmd:list.define", "<", "-", @@ -2377,6 +2380,19 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.hiddenCharacters" => app.ui.hidden_characters, "view.taggedFrames" => app.ui.tagged_frames, "changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes), + // Pressed, as the Bulleted and Numbered List buttons: the selected paragraphs have it. + "list.bullets" | "list.numbers" => { + let kind = if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers }; + app.session + .active() + .and_then(|st| { + let t = st.selection.text?; + let story = st.doc.text_story(t.story, t.cell)?; + let pf = story.paras.get(story.para_at(t.range().start))?; + Some(st.doc.styles.resolve_para(pf).0.list_type == kind) + }) + .unwrap_or(false) + } "window.controlBar" => app.ui.control_bar, "window.split" => app.split, "window.newWindow" => app.second_window, diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..79280f66f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1366,10 +1366,13 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string(); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; - for (icon, kind, tip) in [("bullets", "bullets", "Bulleted List"), ("numbering", "numbers", "Numbered List")] { + // As InDesign's: a list with the default hanging indent, or (pressed) no list. + for (icon, kind, cmd, tip) in + [("bullets", "bullets", "list.bullets", "Bulleted List"), ("numbering", "numbers", "list.numbers", "Numbered List")] + { let on = lt == kind; if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() { - let _ = app.run("type.para", json!({"attrs": {"listType": if on { "none" } else { kind }}})); + let _ = app.run(cmd, json!({"on": !on})); } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -3317,6 +3320,35 @@ mod tests { h } + /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs + /// bullets with InDesign's hanging indent; pressed again, both go. + #[test] + fn the_bulleted_list_button_applies_a_hanging_indent() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app + .run( + "frame.create", + json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs +1 cup milk"}), + ) + .unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + let mut h = panel(app); + h.get_by_label("Bulleted List").click(); + h.run_steps(3); + let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!( + (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()), + (Some("bullets"), Some(18.0), Some(-18.0)) + ); + assert_eq!(crate::menus::checked(h.state(), "list.bullets", &json!({})), Some(true), "Type › Apply Bullets shows it"); + h.get_by_label("Bulleted List").click(); + h.run_steps(3); + let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64()), (Some("none"), Some(0.0))); + } + #[test] fn several_shapes_show_pathfinder_and_intersect_combines_them() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..3d86a3fed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (list buttons with the default hanging indent; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -401,6 +401,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. +- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if it is still as applied. One undo step; values equal to the paragraph style's are left to the style. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. - View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. From 2c1973a6e49ca901fbea25dea9ac717b7328c45d Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:16:25 +0200 Subject: [PATCH 11/79] =?UTF-8?q?Control=20panel=20(=C2=B6):=20Bulleted=20?= =?UTF-8?q?and=20Numbered=20List,=20Shading=20and=20Border,=20as=20InDesig?= =?UTF-8?q?n's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 140 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/icons.rs | 14 ++++ docs/ui-parity.md | 2 +- 4 files changed, 156 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f8c5c2d83..0031bd5ed 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-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 690d8fc32..3af8e9b91 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -912,6 +912,66 @@ fn control_paragraph( let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}})); } vsep(ui); + // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, + // pressed, none). + let lt = para["listType"].as_str().unwrap_or("none").to_string(); + let mut list = None; + rows(ui, |ui, row| { + let (icon, kind, cmd, tip) = if row == 0 { + ("bullets", "bullets", "list.bullets", "Bulleted List") + } else { + ("numbering", "numbers", "list.numbers", "Numbered List") + }; + let on = lt == kind; + if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { + list = Some((cmd, !on)); + } + }); + if let Some((cmd, on)) = list { + let _ = app.run(cmd, json!({"on": on})); + } + vsep(ui); + // Shading over Border: on or off, the Paragraph Borders and Shading dialog on its tab, and the + // colour, as InDesign's. + let doc = app.session.active().map(|d| d.doc.clone()); + let mut shade = None; + let mut dialog = None; + // Both checkboxes as wide as the wider label, so the icons and colours line up in columns. + let check_w = ["Shading", "Border"] + .iter() + .map(|l| ui.fonts_mut(|f| f.layout_no_wrap(tr(l), egui::FontId::proportional(11.5), Color32::WHITE).size().x)) + .fold(0.0, f32::max) + + 28.0; + rows(ui, |ui, row| { + let (on_key, color_key, label, icon, tab) = if row == 0 { + ("shadingOn", "shadingColor", "Shading", "para-shading", "shading") + } else { + ("borderOn", "borderColor", "Border", "para-border", "border") + }; + let mut on = para[on_key].as_bool().unwrap_or(false); + ui.allocate_ui_with_layout(vec2(check_w, crate::widgets::FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(check_w); + if crate::widgets::checkbox(ui, &mut on, &tr(label)).changed() { + shade = Some(json!({ on_key: on })); + } + }); + if crate::widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")).clicked() { + dialog = Some(tab); + } + if let Some(doc) = &doc { + let cur = para[color_key].as_str().unwrap_or("[Black]").to_string(); + if let Some(w) = swatch_field(ui, doc, &cur, 96.0, &tr(if row == 0 { "Paragraph Shading" } else { "Paragraph Border" })) { + shade = Some(json!({ color_key: w })); + } + } + }); + if let Some(attrs) = shade { + let _ = app.run("type.para", json!({ "attrs": attrs })); + } + if let Some(tab) = dialog { + let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab })); + } + vsep(ui); ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 3.0; let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string(); @@ -931,6 +991,35 @@ fn control_paragraph( }); } +/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name, +/// and the document's swatches in its menu. Returns the swatch picked. +fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click()); + let resp = resp.on_hover_text(label); + resp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label); + info.current_text_value = Some(cur.to_string()); + info + }); + let p = ui.painter(); + p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input }); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + let seg = egui::Rect::from_min_max(egui::pos2(r.max.x - crate::widgets::SEG_W, r.min.y), r.max); + p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border)); + icons::paint(p, egui::Rect::from_center_size(seg.center(), vec2(12.0, 12.0)), "chevron-down", t.icon); + let (c, g) = crate::widgets::swatch_colors(doc, cur, 1.0); + let chip = egui::Rect::from_min_size(egui::pos2(r.min.x + 4.0, r.center().y - 6.0), vec2(12.0, 12.0)); + crate::widgets::paint_chip(p, chip, c, g); + let clip = egui::Rect::from_min_max(r.min, egui::pos2(seg.min.x - 2.0, r.max.y)); + p.with_clip_rect(clip).text(egui::pos2(chip.max.x + 5.0, r.center().y), egui::Align2::LEFT_CENTER, cur, egui::FontId::proportional(11.5), t.text); + let mut picked = None; + egui::Popup::menu(&resp).show(|ui| { + picked = crate::panels::swatch_list(ui, doc, cur); + }); + picked +} + /// The widest a document tab grows: a longer name is shortened so its close button and the /// tabs after it stay in view. const DOC_TAB_MAX_W: f32 = 260.0; @@ -1719,6 +1808,57 @@ mod tests { assert!(has(&h, "Small Caps")); } + /// The course's chapter 21: the ¶ Control panel has InDesign's Bulleted and Numbered List + /// buttons (with the default hanging indent), and Shading and Border with their colours and + /// the Paragraph Borders and Shading dialog. + #[test] + fn the_paragraph_control_panel_has_lists_shading_and_border() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app + .run( + "frame.create", + serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "4 eggs +1 cup milk"}), + ) + .unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str, nth: usize| { + let r = h + .query_all_by_label(label) + .map(|n| n.rect()) + .filter(|r| in_bar(*r)) + .nth(nth) + .unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + click(&mut h, "Paragraph Formatting Controls", 0); + click(&mut h, "Bulleted List", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!( + (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()), + (Some("bullets"), Some(18.0), Some(-18.0)) + ); + click(&mut h, "Numbered List", 0); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["listType"], "numbers"); + click(&mut h, "Shading", 0); + click(&mut h, "Border", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!((a["para"]["shadingOn"].as_bool(), a["para"]["borderOn"].as_bool()), (Some(true), Some(true))); + assert!(h.query_all_by_label("Paragraph Shading").any(|n| in_bar(n.rect())), "the shading colour"); + // The icon beside Border opens the dialog on its Border tab. + click(&mut h, "Paragraph Borders and Shading…", 1); + let d = h.state().app.ui.dialog.clone().expect("the dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border")); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 446b412fe..f33e40184 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1046,6 +1046,20 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]); } } + // Paragraph shading: a square with a checked fill (the Control panel's Shading). + "para-shading" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.frect(4.0, 4.0, 10.0, 10.0); + pen.frect(10.0, 10.0, 16.0, 16.0); + } + // Paragraph border: a box with its corner drawn heavy (the Control panel's Border). + "para-border" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.w = 2.5; + pen.line(&[(5.0, 12.0), (5.0, 5.0), (12.0, 5.0)]); + } "pilcrow-menu" => { pen.text(9.0, 10.0, "¶", 14.0); pen.fill(&[(14.0, 15.0), (17.0, 15.0), (15.5, 17.0)]); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 70e42d176..b1facbefd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -26,7 +26,7 @@ basics of editing fail for too many users on Windows and Linux. | Area | InDesign 2026 | DesignCraft | Status | Evidence | |---|---|---|---|---| -| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls, and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 | +| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls (¶ with the Bulleted and Numbered List buttons, Shading and Border), and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 | | Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); ••• expands Character (scales, baseline shift, skew, language, case and position buttons) and Paragraph (indents, spacing, drop cap, baseline grid, hyphenation) in place | ✓ | Learn Skills Daily course, chapter 16; `properties.rs` `more_options_expand_character_and_paragraph_in_place` | | Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 | | Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | From c71bf1a32c65e6a5c53c9ab0a5d4346f1aab8a79 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:12:32 +0200 Subject: [PATCH 12/79] List buttons: pressed when every targeted paragraph has the list; off takes only its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Session::selection_has_list` checks every paragraph the list commands apply to (selected text, cells or text frames). Type › Apply Bullets / Numbers and Properties' Bulleted and Numbered List buttons both use it, so a selection reaching an unbulleted paragraph isn't shown pressed. An explicit `on: false` leaves paragraphs with the other list type, and their hanging indent, alone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/lists.rs | 72 +++++++++++++++++++++---- crates/ui-egui/src/menus.rs | 14 ++--- crates/ui-egui/src/panels/properties.rs | 13 +++-- 3 files changed, 74 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index cad62f271..cf89273f4 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -85,22 +85,18 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { if targets.is_empty() { return Err(bad(name, "select text or a text frame")); } - let d = &s.doc()?.doc; // Off when every selected paragraph already has this list, as the button shows it pressed. - let on = on.unwrap_or_else(|| { - !targets.iter().all(|t| { - d.text_story(t.story, t.cell).is_some_and(|st| { - let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start))); - st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind)) - }) - }) - }); + let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))); s.edit(|d, _| { let styles = d.styles.clone(); for t in &targets { let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; st.format_paras(t.range.clone(), |pf| { let (now, _) = styles.resolve_para(pf); + // Off takes away this list only: a paragraph with the other list keeps it. + if !on && now.list_type != kind { + return; + } let (style, _) = styles.resolve_para_style(&pf.style); // A value: left to the style when it gives the same, else set here. let mut set = |key: &str, v: Value, same: bool| { @@ -121,6 +117,26 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { }) } +/// Whether every paragraph of `targets` has list `kind`. +fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool { + targets.iter().all(|t| { + d.text_story(t.story, t.cell).is_some_and(|st| { + let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start))); + st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind)) + }) + }) +} + +impl Session { + /// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows + /// pressed: every paragraph the list commands apply to has list `kind`, through the selected + /// text, table cells or text frames. + pub fn selection_has_list(&self, kind: ListType) -> bool { + let targets = super::text::format_targets_pub(self); + !targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)) + } +} + #[cfg(test)] mod tests { use serde_json::json; @@ -175,6 +191,44 @@ mod tests { assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err()); } + /// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted + /// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own + /// list only: a bulleted paragraph keeps bullets and indent when Numbers go off. + #[test] + fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() { + use designcraft_doc::ListType; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + let select = |s: &mut Session, a: usize, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + }; + select(&mut s, 0, 3); + s.execute("list.bullets", &json!({})).unwrap(); + assert!(s.selection_has_list(ListType::Bullets)); + assert!(!s.selection_has_list(ListType::Numbers)); + // From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both. + select(&mut s, 0, 7); + assert!(!s.selection_has_list(ListType::Bullets)); + // The frame selected: its whole story, Flour unbulleted. + s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + // Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent. + select(&mut s, 5, 7); + s.execute("list.numbers", &json!({"on": true})).unwrap(); + select(&mut s, 0, 7); + s.execute("list.numbers", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(sid)).unwrap(); + let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0; + assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0)); + assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0)); + // With no text or frame selected, nothing is pressed. + s.execute("selection.set", &json!({"ids": []})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + } + /// The digit the story's first line starts with (its list number). fn digit(g: &designcraft_compose::PlacedGlyph) -> char { let face = g.face.get(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dde5ca033..0bdae7d1e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2380,18 +2380,10 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.hiddenCharacters" => app.ui.hidden_characters, "view.taggedFrames" => app.ui.tagged_frames, "changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes), - // Pressed, as the Bulleted and Numbered List buttons: the selected paragraphs have it. + // Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies + // to (selected text, cells or text frames) has it. "list.bullets" | "list.numbers" => { - let kind = if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers }; - app.session - .active() - .and_then(|st| { - let t = st.selection.text?; - let story = st.doc.text_story(t.story, t.cell)?; - let pf = story.paras.get(story.para_at(t.range().start))?; - Some(st.doc.styles.resolve_para(pf).0.list_type == kind) - }) - .unwrap_or(false) + app.session.selection_has_list(if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers }) } "window.controlBar" => app.ui.control_bar, "window.split" => app.split, diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 79280f66f..f3d31c584 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1362,15 +1362,18 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { } fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { - let Some(a) = text_attrs(app) else { return }; - let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string(); + if text_attrs(app).is_none() { + return; + } ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; - // As InDesign's: a list with the default hanging indent, or (pressed) no list. + // As InDesign's: a list with the default hanging indent, or (pressed: every selected + // paragraph has it, as Type › Apply Bullets shows) no list. + use designcraft_doc::ListType; for (icon, kind, cmd, tip) in - [("bullets", "bullets", "list.bullets", "Bulleted List"), ("numbering", "numbers", "list.numbers", "Numbered List")] + [("bullets", ListType::Bullets, "list.bullets", "Bulleted List"), ("numbering", ListType::Numbers, "list.numbers", "Numbered List")] { - let on = lt == kind; + let on = app.session.selection_has_list(kind); if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() { let _ = app.run(cmd, json!({"on": !on})); } From 874d2be248f046402172eaa0a83711cdbacd69c8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:20:59 +0200 Subject: [PATCH 13/79] Control panel and Properties: a negative Last Line Right Indent; list buttons pressed by every paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Last Line Right Indent field takes negative values, as the First Line Left Indent does. The Control panel's Bulleted and Numbered List buttons show pressed through `Session::selection_has_list`, as Type › Apply Bullets and Properties do. With #1483's fix merged, an explicit off no longer takes the other list type away. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 38 +++++++++++++++++++------ crates/ui-egui/src/panels/properties.rs | 3 +- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3af8e9b91..62b47cd7a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -873,7 +873,8 @@ fn control_paragraph( set = Some((ka, v)); } } else { - let min = if kb == "firstLineIndent" { -10_000.0 } else { 0.0 }; + // The first and last line indents may be negative (a hanging line), as InDesign's. + let min = if matches!(kb, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }; if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) { set = Some((kb, v)); } @@ -913,16 +914,13 @@ fn control_paragraph( } vsep(ui); // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, - // pressed, none). - let lt = para["listType"].as_str().unwrap_or("none").to_string(); + // pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows. + use designcraft_doc::ListType; + let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k)); let mut list = None; rows(ui, |ui, row| { - let (icon, kind, cmd, tip) = if row == 0 { - ("bullets", "bullets", "list.bullets", "Bulleted List") - } else { - ("numbering", "numbers", "list.numbers", "Numbered List") - }; - let on = lt == kind; + let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") }; + let on = pressed.get(row).copied().unwrap_or(false); if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { list = Some((cmd, !on)); } @@ -1857,6 +1855,28 @@ mod tests { click(&mut h, "Paragraph Borders and Shading…", 1); let d = h.state().app.ui.dialog.clone().expect("the dialog"); assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border")); + // The Last Line Right Indent takes a negative value, as the First Line Left Indent does. + h.state_mut().app.ui.dialog = None; + h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + h.state_mut().app.session.set_tool("type"); + h.run_steps(3); + // The field is labelled by its icon. + let at = h + .query_all_by_label("Last Line Right Indent") + .filter(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .find(|r| in_bar(*r)) + .expect("the last line indent") + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("-0p6".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["lastLineIndent"].as_f64(), Some(-6.0)); } #[test] diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c1f2f23a1..9d4052a87 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1497,7 +1497,8 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { for (i, (l, r)) in rows.into_iter().enumerate() { let y = i as f32 * (FIELD_H + 5.0); for (right, (icon, tip, key)) in [(false, l), (true, r)] { - let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 }; + // The first and last line indents may be negative (a hanging line), as InDesign's. + let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }; let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { let _ = app.run("type.para", json!({"attrs": {key: v}})); From dcdabf7f3d9db9fae5a5194a39a0156762654977 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 10:05:00 +0200 Subject: [PATCH 14/79] Properties: the Last Line Right Indent may be negative `para_field_min` gives the first and last line indents a negative minimum, as InDesign and the Paragraph panel allow; the other paragraph fields stay at 0. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9a8cc1133..e6250abfd 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1525,7 +1525,7 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { for (i, (l, r)) in rows.into_iter().enumerate() { let y = i as f32 * (FIELD_H + 5.0); for (right, (icon, tip, key)) in [(false, l), (true, r)] { - let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 }; + let min = para_field_min(key); let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { let _ = app.run("type.para", json!({"attrs": {key: v}})); @@ -3065,6 +3065,12 @@ pub fn digit_label(lang: &str, o: (&str, &str, &str)) -> String { if o.2.is_empty() { name.to_string() } else { format!("{name} {}", o.2) } } +/// The least value a paragraph field takes: the first and last line indents may be negative (a +/// hanging line, a last line past the right indent), as in InDesign; the others not. +pub(crate) fn para_field_min(key: &str) -> f64 { + if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 } +} + #[cfg(test)] mod tests { use egui_kittest::{Harness, kittest::Queryable}; @@ -3139,6 +3145,18 @@ mod tests { } /// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel. + /// The first and last line indents take negative values, as InDesign's (and the Paragraph + /// panel's) do; the left and right indents and the spacing don't. + #[test] + fn last_line_indent_may_be_negative() { + for key in ["firstLineIndent", "lastLineIndent"] { + assert!(super::para_field_min(key) < 0.0, "{key}"); + } + for key in ["leftIndent", "rightIndent", "spaceBefore", "spaceAfter"] { + assert_eq!(super::para_field_min(key), 0.0, "{key}"); + } + } + #[test] fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 047941bc9d53890a018f871c0ea16e8811962bea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 10:07:31 +0200 Subject: [PATCH 15/79] Properties and Control panel: the Last Line Right Indent may be negative Both read their minimum from `para_field_min`: the first and last line indents may be negative, as InDesign and the Paragraph panel allow. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 690d8fc32..455c909f5 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -873,7 +873,7 @@ fn control_paragraph( set = Some((ka, v)); } } else { - let min = if kb == "firstLineIndent" { -10_000.0 } else { 0.0 }; + let min = crate::panels::properties::para_field_min(kb); if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) { set = Some((kb, v)); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2814cb1fa..557db027e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1528,7 +1528,7 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { for (i, (l, r)) in rows.into_iter().enumerate() { let y = i as f32 * (FIELD_H + 5.0); for (right, (icon, tip, key)) in [(false, l), (true, r)] { - let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 }; + let min = para_field_min(key); let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { let _ = app.run("type.para", json!({"attrs": {key: v}})); @@ -3068,6 +3068,12 @@ pub fn digit_label(lang: &str, o: (&str, &str, &str)) -> String { if o.2.is_empty() { name.to_string() } else { format!("{name} {}", o.2) } } +/// The least value a paragraph field takes: the first and last line indents may be negative (a +/// hanging line, a last line past the right indent), as in InDesign; the others not. +pub(crate) fn para_field_min(key: &str) -> f64 { + if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 } +} + #[cfg(test)] mod tests { use egui_kittest::{Harness, kittest::Queryable}; @@ -3142,6 +3148,18 @@ mod tests { } /// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel. + /// The first and last line indents take negative values, as InDesign's (and the Paragraph + /// panel's) do; the left and right indents and the spacing don't. + #[test] + fn last_line_indent_may_be_negative() { + for key in ["firstLineIndent", "lastLineIndent"] { + assert!(super::para_field_min(key) < 0.0, "{key}"); + } + for key in ["leftIndent", "rightIndent", "spaceBefore", "spaceAfter"] { + assert_eq!(super::para_field_min(key), 0.0, "{key}"); + } + } + #[test] fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 0a719bf88809d1d981c8290a0726f9305fe2f5a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 13:36:24 +0200 Subject: [PATCH 16/79] 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 caced43205739f3bf13d9ce19ac8210ad3b8022b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 14:03:59 +0200 Subject: [PATCH 17/79] Paragraph panel: InDesign's layout and panel menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel shows the nine alignment buttons in InDesign's groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; and Hyphenate with Do Not Align / Align to Baseline Grid. The panel menu follows InDesign's order: composers, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, the paragraph dialogs (Keep Options…, Drop Caps and Nested Styles… and GREP Styles… now open for the selected paragraphs), Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/dialogs.rs | 526 ++++++++++++------------ crates/ui-egui/src/i18n.rs | 69 ++++ crates/ui-egui/src/i18n/ar.rs | 12 + crates/ui-egui/src/i18n/cs.rs | 12 + crates/ui-egui/src/i18n/de.rs | 12 + crates/ui-egui/src/i18n/it.rs | 12 + crates/ui-egui/src/i18n/ja.rs | 12 + crates/ui-egui/src/i18n/ka.rs | 12 + crates/ui-egui/src/i18n/pl.rs | 12 + crates/ui-egui/src/i18n/pt_br.rs | 12 + crates/ui-egui/src/i18n/ru.rs | 12 + crates/ui-egui/src/i18n/uk.rs | 12 + crates/ui-egui/src/icons.rs | 15 + crates/ui-egui/src/menus.rs | 18 +- crates/ui-egui/src/panels/properties.rs | 437 +++++++++++++------- crates/ui-egui/src/widgets.rs | 21 +- docs/target-app-parity.md | 2 +- 19 files changed, 802 insertions(+), 409 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 244d6fc9d..02204bda7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,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** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available 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/chrome.rs b/crates/ui-egui/src/chrome.rs index 62b47cd7a..8517bc63e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -991,7 +991,7 @@ fn control_paragraph( /// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name, /// and the document's swatches in its menu. Returns the swatch picked. -fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option { +pub(crate) fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click()); let resp = resp.on_hover_text(label); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 999aed488..32df58f6c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2057,6 +2057,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { match d.s("section").as_str() { "span" => "Span Columns", "bullets" => "Bullets and Numbering", + "keep" => "Keep Options", + "nested" => "Drop Caps and Nested Styles", + "grep" => "GREP Styles", _ => "Paragraph Borders and Shading", }, ), @@ -3897,270 +3900,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo text_field(ui, d, "x.class", 160.0); }); } - "nested" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0))); - egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (label, key, max) in [ - ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), - ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), - ] { - 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).min(max as u64))); - } - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:")); - let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); - let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; - egui::ComboBox::from_id_salt("ps_dropcap_style") - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(160.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() { - d.fields.insert("p.dropCapStyle".into(), json!(c)); - } - } - }); - ui.end_row(); - }); - for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { - let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - d.fields.insert(format!("p.{key}"), json!(on)); - } - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0))); - let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, ns) in list.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = ns["style"].as_str().unwrap_or("[None]").to_string(); - egui::ComboBox::from_id_salt(("ns_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(110.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - ns["style"] = json!(c); - changed = true; - } - } - }); - let through = ns["through"].as_bool().unwrap_or(true); - egui::ComboBox::from_id_salt(("ns_thr", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if through { "through" } else { "up to" }))) - .width(70.0) - .show_ui(ui, |ui| { - for (v, l) in [(true, "through"), (false, "up to")] { - if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { - ns["through"] = json!(v); - changed = true; - } - } - }); - let mut n = ns["count"].as_u64().unwrap_or(1) as u32; - let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - ns["count"] = json!(n); - *changed = true; - } - }; - if !crate::i18n::category_first(&app.ui.language) { - count_value(ui, ns, &mut changed); - } - let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); - egui::ComboBox::from_id_salt(("ns_until", i)) - .selected_text(crate::rtl::widget( - ui, - if crate::i18n::category_first(&app.ui.language) { - format!("{}:", crate::i18n::tr(&app.ui.language, &kind)) - } else { - crate::i18n::tr(&app.ui.language, &kind).to_owned() - }, - )) - .width(100.0) - .show_ui(ui, |ui| { - for k in [ - "sentences", - "words", - "characters", - "letters", - "digits", - "tab", - "forcedLineBreak", - "emSpace", - "enSpace", - "dropcap", - "chars", - ] { - if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { - ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; - changed = true; - } - } - }); - if crate::i18n::category_first(&app.ui.language) { - count_value(ui, ns, &mut changed); - } - if kind == "chars" { - let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { - ns["until"]["chars"] = json!(c); - changed = true; - } - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove { - list.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Nested Style"))).clicked() { - list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}})); - changed = true; - } - if changed { - d.fields.insert("p.nestedStyles".into(), Value::Array(list)); - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Line Styles")).font(semibold(12.0))); - let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, l) in lines.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = l["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("nls_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(130.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - l["style"] = json!(c); - changed = true; - } - } - }); - crate::rtl::label( - ui, - if crate::i18n::category_first(&app.ui.language) { - format!("{}:", crate::i18n::tr(&app.ui.language, "lines")) - } else { - crate::i18n::tr(&app.ui.language, "for").to_owned() - }, - ); - let mut n = l["lines"].as_u64().unwrap_or(1) as u32; - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - l["lines"] = json!(n); - changed = true; - } - if !crate::i18n::category_first(&app.ui.language) { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" })); - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove { - lines.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Line Style"))).clicked() { - lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1})); - changed = true; - } - if changed { - d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines)); - } - } - "grep" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "GREP Styles")).font(semibold(12.0))); - let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, g) in list.iter_mut().enumerate() { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply Style:")); - let style = g["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("gs_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(110.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - g["style"] = json!(c); - changed = true; - } - } - }); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "To Text:")); - let mut pat = g["pattern"].as_str().unwrap_or("").to_string(); - let bad = grep_error(&pat).is_some(); - let r = ui.add( - egui::TextEdit::singleline(&mut pat) - .desired_width(150.0) - .text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))), - ); - if r.changed() { - g["pattern"] = json!(pat); - changed = true; - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) { - crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90))); - } - } - if let Some(i) = remove { - list.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New GREP Style"))).clicked() { - list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"})); - changed = true; - } - if changed { - d.fields.insert("p.grepStyles".into(), Value::Array(list)); - } - } + "nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames), + "grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames), "chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }), "indents" => { egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -4693,6 +4434,254 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc /// Paragraph Style Options › Keep Options: lines kept with the next paragraph, the paragraph's /// lines kept together (all of them, or its first and last lines), and where it starts. +/// Drop Caps and Nested Styles: the drop cap, nested styles and nested line styles (`p.` fields). +fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Drop Caps")).font(semibold(12.0))); + egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (label, key, max) in [ + ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + crate::rtl::label(ui, crate::i18n::tr(lang, 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).min(max as u64))); + } + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:")); + let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); + let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; + egui::ComboBox::from_id_salt("ps_dropcap_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(160.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + d.fields.insert("p.dropCapStyle".into(), json!(c)); + } + } + }); + ui.end_row(); + }); + for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { + let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() { + d.fields.insert(format!("p.{key}"), json!(on)); + } + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Styles")).font(semibold(12.0))); + let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, ns) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = ns["style"].as_str().unwrap_or("[None]").to_string(); + egui::ComboBox::from_id_salt(("ns_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(110.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + ns["style"] = json!(c); + changed = true; + } + } + }); + let through = ns["through"].as_bool().unwrap_or(true); + egui::ComboBox::from_id_salt(("ns_thr", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" }))) + .width(70.0) + .show_ui(ui, |ui| { + for (v, l) in [(true, "through"), (false, "up to")] { + if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(lang, l))).clicked() { + ns["through"] = json!(v); + changed = true; + } + } + }); + let mut n = ns["count"].as_u64().unwrap_or(1) as u32; + let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + ns["count"] = json!(n); + *changed = true; + } + }; + if !crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); + egui::ComboBox::from_id_salt(("ns_until", i)) + .selected_text(crate::rtl::widget( + ui, + if crate::i18n::category_first(lang) { + format!("{}:", crate::i18n::tr(lang, &kind)) + } else { + crate::i18n::tr(lang, &kind).to_owned() + }, + )) + .width(100.0) + .show_ui(ui, |ui| { + for k in + ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "dropcap", "chars"] + { + if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(lang, k))).clicked() { + ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; + changed = true; + } + } + }); + if crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + if kind == "chars" { + let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { + ns["until"]["chars"] = json!(c); + changed = true; + } + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Nested Style"))).clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}})); + changed = true; + } + if changed { + d.fields.insert("p.nestedStyles".into(), Value::Array(list)); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Line Styles")).font(semibold(12.0))); + let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, l) in lines.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = l["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("nls_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(130.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + l["style"] = json!(c); + changed = true; + } + } + }); + crate::rtl::label( + ui, + if crate::i18n::category_first(lang) { + format!("{}:", crate::i18n::tr(lang, "lines")) + } else { + crate::i18n::tr(lang, "for").to_owned() + }, + ); + let mut n = l["lines"].as_u64().unwrap_or(1) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + l["lines"] = json!(n); + changed = true; + } + if !crate::i18n::category_first(lang) { + crate::rtl::label(ui, crate::i18n::tr(lang, if n == 1 { "line" } else { "lines" })); + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + lines.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Line Style"))).clicked() { + lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1})); + changed = true; + } + if changed { + d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines)); + } +} + +/// GREP Styles: a character style applied to text matching each pattern (`p.` fields). +fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "GREP Styles")).font(semibold(12.0))); + let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, g) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Apply Style:")); + let style = g["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("gs_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(110.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + g["style"] = json!(c); + changed = true; + } + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "To Text:")); + let mut pat = g["pattern"].as_str().unwrap_or("").to_string(); + let bad = grep_error(&pat).is_some(); + let r = + ui.add(egui::TextEdit::singleline(&mut pat).desired_width(150.0).text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90)))); + if r.changed() { + g["pattern"] = json!(pat); + changed = true; + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) { + crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90))); + } + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New GREP Style"))).clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"})); + changed = true; + } + if changed { + d.fields.insert("p.grepStyles".into(), Value::Array(list)); + } +} + fn keep_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Keep with Next:")); @@ -5100,6 +5089,15 @@ fn paragraph_local(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.set_min_width(480.0); match d.s("section").as_str() { "span" => span_columns(&lang, ui, d, &pv, &doc), + "keep" => keep_options(&lang, ui, d, &pv), + "nested" | "grep" => { + let cnames: Vec = doc.styles.character.iter().map(|c| c.name.clone()).collect(); + if d.s("section") == "nested" { + drop_caps_and_nested_styles(&lang, ui, d, &pv, &cnames); + } else { + grep_styles(&lang, ui, d, &pv, &cnames); + } + } "bullets" => { let (char_styles, families, fonts, scope) = ListUi::new_parts(app); let (family, style) = (d.s("family"), d.s("style")); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0de369f91..508c8e014 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1065,6 +1065,75 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), + ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), + ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), + ( + "Space Between Paragraphs Using Same Style", + [ + "Abstand zwischen Absätzen mit gleichem Format", + "Espace entre les paragraphes de même style", + "Espacio entre párrafos con el mismo estilo", + "同じスタイルの段落間のスペース", + "使用相同样式的段落间距", + ], + ), + ("Ignore", ["Ignorieren", "Ignorer", "Ignorar", "無視", "忽略"]), + ( + "Do Not Align to Baseline Grid", + [ + "Nicht am Grundlinienraster ausrichten", + "Ne pas aligner sur la grille de ligne de base", + "No alinear con cuadrícula base", + "ベースライングリッドに揃えない", + "不对齐基线网格", + ], + ), + ( + "Only Align First Line to Grid", + [ + "Nur erste Zeile am Raster ausrichten", + "Aligner uniquement la première ligne sur la grille", + "Alinear solo primera línea con cuadrícula", + "最初の行のみグリッドに揃える", + "仅首行对齐网格", + ], + ), + ( + "Balance Ragged Lines", + ["Unausgeglichene Zeilen ausgleichen", "Équilibrer les lignes irrégulières", "Equilibrar líneas desiguales", "行末を調整", "平衡未对齐的行"], + ), + ( + "Drop Caps and Nested Styles…", + [ + "Initialen und verschachtelte Formate …", + "Lettrines et styles imbriqués…", + "Capitulares y estilos anidados…", + "ドロップキャップと先頭文字スタイル…", + "首字下沉和嵌套样式…", + ], + ), + ("GREP Styles…", ["GREP-Stile …", "Styles GREP…", "Estilos GREP…", "正規表現スタイル…", "GREP 样式…"]), + ( + "Restart/Continue Numbering", + [ + "Nummerierung neu beginnen/fortführen", + "Recommencer/Poursuivre la numérotation", + "Reiniciar/Continuar numeración", + "番号の振り直し/継続", + "重新开始/继续编号", + ], + ), + ( + "Convert Bullets and Numbering to Text", + [ + "Aufzählungszeichen und Nummerierung in Text konvertieren", + "Convertir les puces et la numérotation en texte", + "Convertir viñetas y numeración en texto", + "箇条書きと段落番号をテキストに変換", + "将项目符号和编号转换为文本", + ], + ), ("Hyphenation…", ["Silbentrennung …", "Césure…", "Separación por sílabas…", "ハイフネーション…", "连字…"]), ( "Hyphenation Settings", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index e702b89c3..49fd3046e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Restart Numbering", "إعادة الترقيم"), + ("Continue Numbering", "متابعة الترقيم"), + ("Keep Options…", "خيارات الإبقاء…"), + ("Space Between Paragraphs Using Same Style", "المسافة بين الفقرات ذات النمط نفسه"), + ("Ignore", "تجاهل"), + ("Do Not Align to Baseline Grid", "عدم المحاذاة إلى شبكة خط الأساس"), + ("Only Align First Line to Grid", "محاذاة السطر الأول فقط إلى الشبكة"), + ("Balance Ragged Lines", "موازنة الأسطر غير المتساوية"), + ("Drop Caps and Nested Styles…", "الأحرف الاستهلالية والأنماط المتداخلة…"), + ("GREP Styles…", "أنماط GREP…"), + ("Restart/Continue Numbering", "إعادة/متابعة الترقيم"), + ("Convert Bullets and Numbering to Text", "تحويل التعداد النقطي والترقيم إلى نص"), ("Hyphenation…", "الواصلة…"), ("Hyphenation Settings", "إعدادات الواصلة"), ("hyphens", "واصلات"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 34d8cc002..4547a4b37 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Restart Numbering", "Začít číslování znovu"), + ("Continue Numbering", "Pokračovat v číslování"), + ("Keep Options…", "Volby zachování…"), + ("Space Between Paragraphs Using Same Style", "Mezera mezi odstavci se stejným stylem"), + ("Ignore", "Ignorovat"), + ("Do Not Align to Baseline Grid", "Nezarovnávat na mřížku účaří"), + ("Only Align First Line to Grid", "Zarovnat na mřížku jen první řádek"), + ("Balance Ragged Lines", "Vyrovnat nezarovnané řádky"), + ("Drop Caps and Nested Styles…", "Iniciály a vnořené styly…"), + ("GREP Styles…", "Styly GREP…"), + ("Restart/Continue Numbering", "Začít znovu/pokračovat v číslování"), + ("Convert Bullets and Numbering to Text", "Převést odrážky a číslování na text"), ("Hyphenation…", "Dělení slov…"), ("Hyphenation Settings", "Nastavení dělení slov"), ("hyphens", "spojovníky"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index b2d66cbe7..068c32255 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Restart Numbering", "Nummerierung neu beginnen"), + ("Continue Numbering", "Nummerierung fortführen"), + ("Keep Options…", "Umbruchoptionen …"), + ("Space Between Paragraphs Using Same Style", "Abstand zwischen Absätzen mit gleichem Format"), + ("Ignore", "Ignorieren"), + ("Do Not Align to Baseline Grid", "Nicht am Grundlinienraster ausrichten"), + ("Only Align First Line to Grid", "Nur erste Zeile am Raster ausrichten"), + ("Balance Ragged Lines", "Unausgeglichene Zeilen ausgleichen"), + ("Drop Caps and Nested Styles…", "Initialen und verschachtelte Formate …"), + ("GREP Styles…", "GREP-Stile …"), + ("Restart/Continue Numbering", "Nummerierung neu beginnen/fortführen"), + ("Convert Bullets and Numbering to Text", "Aufzählungszeichen und Nummerierung in Text konvertieren"), ("Hyphenation…", "Silbentrennung …"), ("Hyphenation Settings", "Silbentrennungseinstellungen"), ("hyphens", "Trennstriche"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 6adcaa467..48161b64e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Restart Numbering", "Riavvia numerazione"), + ("Continue Numbering", "Continua numerazione"), + ("Keep Options…", "Opzioni di separazione…"), + ("Space Between Paragraphs Using Same Style", "Spazio tra paragrafi con lo stesso stile"), + ("Ignore", "Ignora"), + ("Do Not Align to Baseline Grid", "Non allineare alla griglia linee di base"), + ("Only Align First Line to Grid", "Allinea solo la prima riga alla griglia"), + ("Balance Ragged Lines", "Bilancia righe irregolari"), + ("Drop Caps and Nested Styles…", "Capilettera e stili nidificati…"), + ("GREP Styles…", "Stili GREP…"), + ("Restart/Continue Numbering", "Riavvia/continua numerazione"), + ("Convert Bullets and Numbering to Text", "Converti elenchi puntati e numerati in testo"), ("Hyphenation…", "Sillabazione…"), ("Hyphenation Settings", "Impostazioni sillabazione"), ("hyphens", "trattini"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index c8515805b..413b2d99e 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Restart Numbering", "番号の振り直し"), + ("Continue Numbering", "番号の継続"), + ("Keep Options…", "段落分離禁止オプション…"), + ("Space Between Paragraphs Using Same Style", "同じスタイルの段落間のスペース"), + ("Ignore", "無視"), + ("Do Not Align to Baseline Grid", "ベースライングリッドに揃えない"), + ("Only Align First Line to Grid", "最初の行のみグリッドに揃える"), + ("Balance Ragged Lines", "行末を調整"), + ("Drop Caps and Nested Styles…", "ドロップキャップと先頭文字スタイル…"), + ("GREP Styles…", "正規表現スタイル…"), + ("Restart/Continue Numbering", "番号の振り直し/継続"), + ("Convert Bullets and Numbering to Text", "箇条書きと段落番号をテキストに変換"), ("Hyphenation…", "ハイフネーション…"), ("Hyphenation Settings", "ハイフネーション設定"), ("hyphens", "ハイフン"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index c756cc123..4767b6c30 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), + ("Continue Numbering", "ნუმერაციის გაგრძელება"), + ("Keep Options…", "შენარჩუნების პარამეტრები…"), + ("Space Between Paragraphs Using Same Style", "დაშორება ერთი სტილის აბზაცებს შორის"), + ("Ignore", "უგულებელყოფა"), + ("Do Not Align to Baseline Grid", "საბაზისო ბადეზე არ გასწორდეს"), + ("Only Align First Line to Grid", "ბადეზე მხოლოდ პირველი ხაზის გასწორება"), + ("Balance Ragged Lines", "უსწორმასწორო ხაზების დაბალანსება"), + ("Drop Caps and Nested Styles…", "საწყისი ასოები და ჩადგმული სტილები…"), + ("GREP Styles…", "GREP სტილები…"), + ("Restart/Continue Numbering", "ნუმერაციის თავიდან დაწყება/გაგრძელება"), + ("Convert Bullets and Numbering to Text", "მარკერებისა და ნუმერაციის ტექსტად გარდაქმნა"), ("Hyphenation…", "დამარცვლა…"), ("Hyphenation Settings", "დამარცვლის პარამეტრები"), ("hyphens", "დეფისი"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 6f530abe2..bf0c7600b 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Restart Numbering", "Zacznij numerowanie od nowa"), + ("Continue Numbering", "Kontynuuj numerowanie"), + ("Keep Options…", "Opcje utrzymywania…"), + ("Space Between Paragraphs Using Same Style", "Odstęp między akapitami o tym samym stylu"), + ("Ignore", "Ignoruj"), + ("Do Not Align to Baseline Grid", "Nie wyrównuj do siatki linii bazowych"), + ("Only Align First Line to Grid", "Wyrównaj do siatki tylko pierwszy wiersz"), + ("Balance Ragged Lines", "Wyrównaj nierówne wiersze"), + ("Drop Caps and Nested Styles…", "Inicjały i style zagnieżdżone…"), + ("GREP Styles…", "Style GREP…"), + ("Restart/Continue Numbering", "Zacznij od nowa/kontynuuj numerowanie"), + ("Convert Bullets and Numbering to Text", "Konwertuj wypunktowanie i numerowanie na tekst"), ("Hyphenation…", "Dzielenie wyrazów…"), ("Hyphenation Settings", "Ustawienia dzielenia wyrazów"), ("hyphens", "dywizy"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index b8174f7d8..4faf5cee5 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Restart Numbering", "Reiniciar numeração"), + ("Continue Numbering", "Continuar numeração"), + ("Keep Options…", "Opções de manutenção…"), + ("Space Between Paragraphs Using Same Style", "Espaço entre parágrafos com o mesmo estilo"), + ("Ignore", "Ignorar"), + ("Do Not Align to Baseline Grid", "Não alinhar à grade de linha de base"), + ("Only Align First Line to Grid", "Alinhar somente a primeira linha à grade"), + ("Balance Ragged Lines", "Equilibrar linhas irregulares"), + ("Drop Caps and Nested Styles…", "Capitulares e estilos aninhados…"), + ("GREP Styles…", "Estilos GREP…"), + ("Restart/Continue Numbering", "Reiniciar/continuar numeração"), + ("Convert Bullets and Numbering to Text", "Converter marcadores e numeração em texto"), ("Hyphenation…", "Hifenização…"), ("Hyphenation Settings", "Configurações de hifenização"), ("hyphens", "hifens"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 854fd20a2..412faea7e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Restart Numbering", "Начать нумерацию заново"), + ("Continue Numbering", "Продолжить нумерацию"), + ("Keep Options…", "Параметры удержания…"), + ("Space Between Paragraphs Using Same Style", "Интервал между абзацами одного стиля"), + ("Ignore", "Игнорировать"), + ("Do Not Align to Baseline Grid", "Не выравнивать по базовой сетке"), + ("Only Align First Line to Grid", "Выравнивать по сетке только первую строку"), + ("Balance Ragged Lines", "Сбалансировать рваные строки"), + ("Drop Caps and Nested Styles…", "Буквицы и вложенные стили…"), + ("GREP Styles…", "Стили GREP…"), + ("Restart/Continue Numbering", "Начать заново/продолжить нумерацию"), + ("Convert Bullets and Numbering to Text", "Преобразовать маркеры и нумерацию в текст"), ("Hyphenation…", "Расстановка переносов…"), ("Hyphenation Settings", "Параметры расстановки переносов"), ("hyphens", "дефисов"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8cdf821d3..62c594fd1 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Restart Numbering", "Почати нумерацію знову"), + ("Continue Numbering", "Продовжити нумерацію"), + ("Keep Options…", "Параметри утримання…"), + ("Space Between Paragraphs Using Same Style", "Інтервал між абзацами одного стилю"), + ("Ignore", "Ігнорувати"), + ("Do Not Align to Baseline Grid", "Не вирівнювати за базовою сіткою"), + ("Only Align First Line to Grid", "Вирівнювати за сіткою лише перший рядок"), + ("Balance Ragged Lines", "Збалансувати нерівні рядки"), + ("Drop Caps and Nested Styles…", "Буквиці та вкладені стилі…"), + ("GREP Styles…", "Стилі GREP…"), + ("Restart/Continue Numbering", "Почати знову/продовжити нумерацію"), + ("Convert Bullets and Numbering to Text", "Перетворити маркери й нумерацію на текст"), ("Hyphenation…", "Перенесення…"), ("Hyphenation Settings", "Параметри перенесення"), ("hyphens", "дефісів"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f33e40184..72c37fc83 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -798,6 +798,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(3.0, v), (5.0, v)]); } } + // Do Not Align to Baseline Grid: ragged lines of text, free of any grid. + "grid-none" => { + for (y, x1) in [(5.0, 17.0), (8.5, 12.0), (12.0, 17.0), (15.5, 14.0)] { + pen.line(&[(3.0, y), (x1, y)]); + } + } "grid-baseline" => { pen.rect(3.0, 3.0, 17.0, 17.0); for y in [6.5, 10.0, 13.5] { @@ -998,6 +1004,15 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(2.5, a + 1.5), (4.0, a), (5.5, a + 1.5)]); pen.line(&[(2.5, z - 1.5), (4.0, z), (5.5, z - 1.5)]); } + // Space Between Paragraphs Using Same Style: two paragraphs and the space between them. + "space-same" => { + for y in [2.0, 5.0, 15.0, 18.0] { + pen.line(&[(8.0, y), (18.0, y)]); + } + pen.line(&[(4.0, 6.5), (4.0, 13.5)]); + pen.line(&[(2.5, 8.0), (4.0, 6.5), (5.5, 8.0)]); + pen.line(&[(2.5, 12.0), (4.0, 13.5), (5.5, 12.0)]); + } "drop-lines" | "drop-chars" => { pen.text(5.5, 8.0, "A", 14.0); if name == "drop-chars" { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 96233c418..0b2f0c828 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -108,6 +108,14 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{tab?: border|shading} — border and shading of the selected paragraphs (type.para)", ), ("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"), + ("app.keepOptionsDialog", "Keep Options…", None, "{} — Keep with Next and Keep Lines Together of the selected paragraphs (type.para)"), + ( + "app.nestedStylesDialog", + "Drop Caps and Nested Styles…", + None, + "{} — drop cap, nested styles and nested line styles of the selected paragraphs (type.para)", + ), + ("app.grepStylesDialog", "GREP Styles…", None, "{} — GREP styles of the selected paragraphs (type.para)"), ("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"), ( "app.hyphenationDialog", @@ -1283,11 +1291,19 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + "app.paragraphBordersDialog" + | "app.spanColumnsDialog" + | "app.bulletsNumberingDialog" + | "app.keepOptionsDialog" + | "app.nestedStylesDialog" + | "app.grepStylesDialog" => { let Some(a) = crate::panels::text_attrs(app) else { return Some(Err("select text or a text frame".into())) }; let section = match id { "app.spanColumnsDialog" => "span", "app.bulletsNumberingDialog" => "bullets", + "app.keepOptionsDialog" => "keep", + "app.nestedStylesDialog" => "nested", + "app.grepStylesDialog" => "grep", _ if p.get("tab").and_then(Value::as_str) == Some("shading") => "shading", _ => "border", }; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 469b23251..2de8d370f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1356,6 +1356,12 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { /// A percentage or other number field in one half of a two-field row, with its icon. #[allow(clippy::too_many_arguments)] fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option { + half_field_or_clear(ui, b, right, y, half, icon, tip, field).flatten() +} + +/// [`half_field`] for a value that may be unset: emptying the field commits `Some(None)`. +#[allow(clippy::too_many_arguments)] +fn half_field_or_clear(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option> { let t = Tokens::get(ui.ctx()); let x = if right { half + 10.0 } else { 0.0 }; let ir = sub(b, x, y + 1.0, 19.0, 19.0); @@ -1364,7 +1370,7 @@ fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); let label = resp.id; resp.on_hover_text(tip); - place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show(ui)) + place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show_or_clear(ui)) } /// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew, @@ -1907,163 +1913,234 @@ pub fn show_typography_panel(app: &mut DesignApp) { let _ = app.run("window.panel", json!({"panel": "typography"})); } +/// The paragraph alignments in InDesign's order, with their icons. +const PARA_ALIGNS: [(Align, &str); 9] = [ + (Align::Left, "palign-left"), + (Align::Center, "palign-center"), + (Align::Right, "palign-right"), + (Align::LeftJustified, "palign-justify-left"), + (Align::CenterJustified, "palign-justify-center"), + (Align::RightJustified, "palign-justify-right"), + (Align::FullyJustified, "palign-justify-all"), + (Align::TowardsSpine, "palign-spine-towards"), + (Align::AwayFromSpine, "palign-spine-away"), +]; + +/// Space Between Paragraphs Using Same Style's presets (pt), after Ignore. +const SAME_STYLE_SPACING: &[f64] = &[0.0, 3.0, 6.0, 9.0, 12.0, 18.0, 24.0]; + +/// The Paragraph panel, laid out as InDesign's: the alignment buttons in groups of 3, 4 and 2; +/// indents, spacing and drop cap fields by their icons in two columns; Shading and Border with +/// their colours; Hyphenate and the baseline grid buttons. The rest (composer, grid and ragged +/// lines options, the paragraph dialogs) is in the panel menu. pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(a) = text_attrs(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); return; }; + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); let u = units(app); let p = a["para"].clone(); - ui.horizontal(|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 [ - ("Justification…", "app.justificationDialog"), - ("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() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - }); - }); - }); + let fw = full_width(ui); + let half = (fw - 10.0) / 2.0; + // Alignment, and the panel menu at the right. let cur: Align = serde_json::from_value(p["align"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - for (al, icon) in [ - (Align::Left, "align-left"), - (Align::Center, "align-center"), - (Align::Right, "align-right"), - (Align::LeftJustified, "align-justify"), - (Align::FullyJustified, "align-justify-all"), - ] { - if icons::button(ui, icon, 24.0, cur == al, al.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": al}})); - } + const GAP: f32 = 8.0; + let pitch = ((fw - 24.0 - 2.0 * GAP) / 9.0).clamp(16.0, 24.0); + let b = block(ui, 21.0); + let mut x = 0.0; + for (i, (al, icon)) in PARA_ALIGNS.into_iter().enumerate() { + if i == 3 || i == 7 { + x += GAP; } - }); - // Paragraph Direction (World-Ready): switching also mirrors left/right alignment. - let rtl = p["direction"] == "rightToLeft"; - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Direction")); - for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { - if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on { - let align = match cur { - Align::Left => Align::Right, - Align::Right => Align::Left, - a => a, - }; - let _ = app.run("type.para", json!({"attrs": {"direction": dir, "align": align}})); - } - } - }); - egui::Grid::new("paragrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { - for (row, ((la, ka), (lb, kb))) in [ - (("Left", "leftIndent"), ("Right", "rightIndent")), - (("First", "firstLineIndent"), ("Last", "lastLineIndent")), - (("Before", "spaceBefore"), ("After", "spaceAfter")), - ] - .into_iter() - .enumerate() + if place(ui, sub(b, x, 0.0, pitch, 21.0), |ui| widgets::icon_toggle_sized(ui, icon, cur == al, &tr(al.label()), vec2(pitch, 21.0))).clicked() { - caption(ui, crate::i18n::tr(&app.ui.language, la)); - if let Some(v) = measure(ui, &format!("pp{row}a"), p[ka].as_f64(), u, 64.0) { - let _ = app.run("type.para", json!({"attrs": {ka: v}})); - } - caption(ui, crate::i18n::tr(&app.ui.language, lb)); - if let Some(v) = measure(ui, &format!("pp{row}b"), p[kb].as_f64(), u, 64.0) { - let _ = app.run("type.para", json!({"attrs": {kb: v}})); - } - ui.end_row(); + let _ = app.run("type.para", json!({"attrs": {"align": al}})); } - // Drop cap: number of lines and characters. - for (label, key, max, id) in [ - ("Drop Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES, "ppdl"), - ("Drop Chars", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS, "ppdc"), - ] { - caption(ui, crate::i18n::tr(&app.ui.language, label)); - if let Some(v) = number(ui, id, p[key].as_f64(), "", 64.0, 0) { - let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); - } - } - ui.end_row(); - }); - ui.horizontal(|ui| { - let mut h = p["hyphenate"].as_bool().unwrap_or(true); - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); - } - let mut grid = p["gridAlign"].as_str() == Some("allLines"); - if ui.checkbox(&mut grid, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Align to baseline grid"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}})); - } - }); - let mut optical = p["opticalMargin"].as_bool().unwrap_or(false); - if ui.checkbox(&mut optical, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Optical Margin Alignment"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"opticalMargin": optical}})); + x += pitch; } - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Composer")); - let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str()); - if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": paragraph}})); + place(ui, sub(b, fw - 20.0, 0.0, 20.0, 21.0), |ui| crate::menus::menu_button(ui, "☰", |ui| paragraph_panel_menu(app, ui, &p))); + ui.add_space(6.0); + // Indents, spacing and drop cap, two to a row as in InDesign. + let row = FIELD_H + 5.0; + let b = block(ui, 5.0 * row - 5.0); + let rows = [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")), + (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ]; + for (i, (l, r)) in rows.into_iter().enumerate() { + for (right, (icon, tip, key)) in [(false, l), (true, r)] { + // The first and last line indents may be negative (a hanging line), as InDesign's. + let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }; + let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); + if let Some(v) = half_field(ui, b, right, i as f32 * row, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: v}})); + } } - if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": single_line}})); + } + // Space Between Paragraphs Using Same Style: Ignore (unset) or a space that replaces the + // space after and before between paragraphs of the same style. + let ignore = tr("Ignore"); + let field = NumField::measure("sameParaStyleSpacing", p["sameParaStyleSpacing"].as_f64(), u) + .spinner() + .presets(SAME_STYLE_SPACING) + .empty_text(&ignore) + .range(0.0, 10_000.0); + if let Some(v) = half_field_or_clear(ui, b, false, 3.0 * row, half, "space-same", &tr("Space Between Paragraphs Using Same Style"), field) { + let _ = app.run("type.para", json!({"attrs": {"sameParaStyleSpacing": v}})); + } + for (right, icon, tip, key, max) in [ + (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64); + if let Some(v) = half_field(ui, b, right, 4.0 * row, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); } - }); - // Paragraph Border and Shading. - let swatches: Vec = - app.session.active().map(|d| d.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default(); - for (on_key, color_key, label) in [("shadingOn", "shadingColor", "Shading"), ("borderOn", "borderColor", "Border")] { - ui.horizontal(|ui| { - let mut on = p[on_key].as_bool().unwrap_or(false); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("type.para", json!({ on_key: on })); - } - if on { - let cur = p[color_key].as_str().unwrap_or("[Black]").to_string(); - egui::ComboBox::from_id_salt(("pbs", label)).selected_text(&cur).width(110.0).show_ui(ui, |ui| { - for w in &swatches { - if ui.selectable_label(*w == cur, w).clicked() { - let _ = app.run("type.para", json!({ color_key: w })); - } - } - }); - if label == "Shading" { - if let Some(v) = number(ui, "pshtint", p["shadingTint"].as_f64().map(|t| t * 100.0), "%", 46.0, 0) { - let _ = app.run("type.para", json!({"shadingTint": (v / 100.0).clamp(0.0, 1.0)})); - } - } else if let Some(v) = number(ui, "pbw", p["borderWeights"][0].as_f64(), " pt", 50.0, 2) { - let w = [v.max(0.0); 4]; - let _ = app.run("type.para", json!({ "borderWeights": w })); - } - } - }); } - if p["shadingOn"].as_bool().unwrap_or(false) || p["borderOn"].as_bool().unwrap_or(false) { - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Offsets")); - let o = p["borderOffsets"][0].as_f64().or(p["shadingOffsets"][0].as_f64()); - if let Some(v) = number(ui, "pbo", o, " pt", 50.0, 2) { - let o = [v; 4]; - let _ = app.run("type.para", json!({ "borderOffsets": o, "shadingOffsets": o })); + // Shading and Border: on or off at the left; at the right the Paragraph Borders and Shading + // dialog on its tab and the colour. + ui.add_space(10.0); + let doc = app.session.active().map(|d| d.doc.clone()); + let b = block(ui, 2.0 * row - 5.0); + for (i, (on_key, color_key, label, icon, tab, name)) in [ + ("shadingOn", "shadingColor", "Shading", "para-shading", "shading", "Paragraph Shading"), + ("borderOn", "borderColor", "Border", "para-border", "border", "Paragraph Border"), + ] + .into_iter() + .enumerate() + { + let y = i as f32 * row; + let mut on = p[on_key].as_bool().unwrap_or(false); + if place(ui, sub(b, 0.0, y, half, FIELD_H), |ui| widgets::checkbox(ui, &mut on, &tr(label))).changed() { + let _ = app.run("type.para", json!({"attrs": {on_key: on}})); + } + if place(ui, sub(b, half + 10.0, y, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…"))) + .clicked() + { + let _ = app.run("app.paragraphBordersDialog", json!({"tab": tab})); + } + if let Some(doc) = &doc { + let color = p[color_key].as_str().unwrap_or("[Black]").to_string(); + let w = half - 24.0; + if let Some(sw) = place(ui, sub(b, half + 34.0, y, w, FIELD_H), |ui| crate::chrome::swatch_field(ui, doc, &color, w, &tr(name))) { + let _ = app.run("type.para", json!({"attrs": {color_key: sw}})); } - }); + } + } + // Hyphenate, and Do Not Align / Align to Baseline Grid. + ui.add_space(10.0); + let b = block(ui, FIELD_H); + let mut h = p["hyphenate"].as_bool().unwrap_or(true); + if place(ui, sub(b, 0.0, 0.0, half, FIELD_H), |ui| widgets::checkbox(ui, &mut h, &tr("Hyphenate"))).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + for (i, (icon, tip, on, v)) in + [("grid-none", "Do Not Align to Baseline Grid", !grid, "none"), ("grid-baseline", "Align to Baseline Grid", grid, "allLines")] + .into_iter() + .enumerate() + { + if place(ui, sub(b, fw - 48.0 + i as f32 * 25.0, 0.0, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, on, &tr(tip))).clicked() && !on { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": v}})); + } + } + ui.add_space(4.0); +} + +/// The Paragraph panel menu, in the order of InDesign's. +fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) { + let lang = app.ui.language.clone(); + let item = |ui: &mut Ui, label: &str, check: Option, enabled: bool| -> bool { + let label = crate::i18n::tr(&lang, label); + let text = match check { + Some(true) => format!("✓ {label}"), + Some(false) => format!(" {label}"), + None => label.to_string(), + }; + let clicked = ui.add_enabled(enabled, egui::Button::new(crate::rtl::widget(ui, text))).clicked(); + if clicked { + ui.close(); + } + clicked + }; + let mut attrs = None; + let mut command = None; + let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str()); + if item(ui, "Paragraph Composer", Some(!single), true) { + attrs = Some(json!({"composer": paragraph})); + } + if item(ui, "Single-line Composer", Some(single), true) { + attrs = Some(json!({"composer": single_line})); + } + ui.separator(); + let grid = p["gridAlign"].as_str().unwrap_or("none"); + if item(ui, "Only Align First Line to Grid", Some(grid == "firstLineOnly"), grid != "none") { + attrs = Some(json!({"gridAlign": if grid == "firstLineOnly" { "allLines" } else { "firstLineOnly" }})); + } + let balance = p["balanceRagged"].as_bool().unwrap_or(false); + if item(ui, "Balance Ragged Lines", Some(balance), true) { + attrs = Some(json!({"balanceRagged": !balance})); + } + let optical = p["opticalMargin"].as_bool().unwrap_or(false); + if item(ui, "Optical Margin Alignment", Some(optical), true) { + attrs = Some(json!({"opticalMargin": !optical})); + } + ui.separator(); + for (label, id) in [ + ("Justification…", "app.justificationDialog"), + ("Keep Options…", "app.keepOptionsDialog"), + ("Span Columns…", "app.spanColumnsDialog"), + ("Hyphenation…", "app.hyphenationDialog"), + ("Drop Caps and Nested Styles…", "app.nestedStylesDialog"), + ("GREP Styles…", "app.grepStylesDialog"), + ("Paragraph Rules…", "app.paragraphRulesDialog"), + ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), + ] { + if item(ui, label, None, true) { + command = Some(id); + } + } + ui.separator(); + if item(ui, "Bullets and Numbering…", None, true) { + command = Some("app.bulletsNumberingDialog"); + } + // Restart Numbering at 1, or Continue Numbering from the paragraph before, for a numbered list. + let numbered = p["listType"].as_str() == Some("numbers"); + let restarts = p["startAt"].is_u64(); + let label = match (numbered, restarts) { + (false, _) => "Restart/Continue Numbering", + (true, false) => "Restart Numbering", + (true, true) => "Continue Numbering", + }; + if item(ui, label, None, numbered) { + attrs = Some(json!({"startAt": if restarts { Value::Null } else { json!(1) }})); + } + item(ui, "Convert Bullets and Numbering to Text", None, false); + if item(ui, "Define Lists…", None, true) { + command = Some("list.define"); + } + // Paragraph Direction (World-Ready): switching also mirrors left and right alignment. + ui.separator(); + let rtl = p["direction"] == "rightToLeft"; + for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { + if item(ui, label, Some(on), true) && !on { + let align = match serde_json::from_value(p["align"].clone()).unwrap_or_default() { + Align::Left => Align::Right, + Align::Right => Align::Left, + a => a, + }; + attrs = Some(json!({"direction": dir, "align": align})); + } + } + if let Some(attrs) = attrs { + let _ = app.run("type.para", json!({"attrs": attrs})); + } + if let Some(id) = command { + crate::menus::activate(app, id, &json!({})); } - let ps = a["paragraphStyle"].as_str().unwrap_or("").to_string(); - let ov = a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0); - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Style")); - super::para_style_picker(app, ui, &ps, ov > 0, 190.0); - }); } /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. @@ -3310,6 +3387,84 @@ mod tests { assert!(h.query_by_label("Small Caps").is_none()); } + #[test] + fn paragraph_panel_is_laid_out_as_indesigns_with_its_panel_menu() { + let mut h = panel_harness(text_app(), paragraph_panel); + h.run_steps(3); + for l in [ + "Left Indent", + "Right Indent", + "First Line Left Indent", + "Last Line Right Indent", + "Space Before", + "Space After", + "Space Between Paragraphs Using Same Style", + "Drop Cap Number of Lines", + "Drop Cap One or More Characters", + "Paragraph Shading", + "Paragraph Border", + "Hyphenate", + "Do Not Align to Baseline Grid", + ] { + let _ = h.get_by_label(l); + } + let para = |h: &mut Harness<'static, DesignApp>| text_attrs(h.state_mut()).unwrap()["para"].clone(); + // Space Between Paragraphs Using Same Style starts at Ignore; a value is set, and emptying + // the field ignores it again. + assert!(para(&mut h)["sameParaStyleSpacing"].is_null()); + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap(); + field.focus(); + field.type_text("6 pt"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(para(&mut h)["sameParaStyleSpacing"], 6.0); + h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap().focus(); + h.run_steps(1); + for _ in 0..8 { + h.key_press(egui::Key::Backspace); + } + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(para(&mut h)["sameParaStyleSpacing"].is_null(), "Ignore"); + // Shading on, then the grid buttons and Only Align First Line to Grid from the menu. + h.get_by_label("Shading").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["shadingOn"], true); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "allLines"); + h.get_by_label("☰").click(); + h.run_steps(3); + for l in [ + " Balance Ragged Lines", + "Keep Options…", + "Drop Caps and Nested Styles…", + "GREP Styles…", + "Define Lists…", + "Restart/Continue Numbering", + ] { + let _ = h.get_by_label(l); + } + h.get_by_label(" Only Align First Line to Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "firstLineOnly"); + h.get_by_label("Do Not Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "none"); + // Keep Options… opens the selected paragraphs' Keep Options, and OK applies them. + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label("Keep Options…").click(); + h.run_steps(3); + let app = h.state_mut(); + let d = app.ui.dialog.as_mut().expect("Keep Options"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "keep")); + d.fields.insert("p.keepWithNext".into(), json!(2)); + crate::dialogs::confirm(app).unwrap(); + assert_eq!(para(&mut h)["keepWithNext"], 2); + } + fn kerning(app: &mut DesignApp) -> Value { text_attrs(app).unwrap()["chars"]["kerning"].clone() } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d8eb6c51e..e820e21a8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -82,6 +82,8 @@ pub struct NumField<'a> { parens: bool, /// The widget naming the field (an icon or caption beside it), for accessibility. label: Option, + /// What an unset value shows (InDesign's "Ignore"), and the first of the presets. + empty: Option<&'a str>, } impl<'a> NumField<'a> { @@ -104,6 +106,7 @@ impl<'a> NumField<'a> { enabled: true, parens: false, label: None, + empty: None, } } /// Total width (spinner and preset segments included). @@ -151,6 +154,13 @@ impl<'a> NumField<'a> { self } + /// An unset value shows `text` (dimmed), and the preset menu offers it first: picking it, or + /// emptying the field, commits `Some(None)` from [`NumField::show_or_clear`]. + pub fn empty_text(mut self, text: &'a str) -> Self { + self.empty = Some(text); + self + } + /// Draw the field; returns the new value when the user commits an edit (Enter, focus loss, /// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset). pub fn show(self, ui: &mut Ui) -> Option { @@ -214,13 +224,16 @@ impl<'a> NumField<'a> { // Value. ui.painter().rect_filled(text_rect, 0.0, bg); let mut buf: String = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| self.value.map(|v| self.fmt(v)).unwrap_or_default()); - let te = egui::TextEdit::singleline(&mut buf) + let mut te = egui::TextEdit::singleline(&mut buf) .id(focused_id) .frame(egui::Frame::NONE) .font(label_font()) .text_color(fg) .margin(egui::Margin::ZERO) .vertical_align(egui::Align::Center); + if let Some(empty) = self.empty { + te = te.hint_text(egui::RichText::new(empty).font(label_font()).color(t.text_dim)); + } // The field already allocated its slot. A scoped allocation here would consume another // grid cell and enlarge toolbar rows; the value editor must stay inside that same slot. let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0)); @@ -283,6 +296,12 @@ impl<'a> NumField<'a> { if let Some(resp) = preset_resp { egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(self.width); + if let Some(empty) = self.empty + && ui.selectable_label(self.value.is_none(), empty).clicked() + { + cleared = self.value.is_some(); + ui.close(); + } for v in self.presets { if ui.selectable_label(self.value.is_some_and(|c| (c - v).abs() < 1e-9), self.kind.format(*v)).clicked() { out = Some(*v); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 904484179..c3a5cbbaf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -375,7 +375,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `