From ea3f11b9b75b25b231d9fbcb8102f19d8000f68b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 14:17:58 +0000 Subject: [PATCH] UI: Tabs section in Paragraph Style Options The style's stops on the same ruler and fields as the Tabs panel, with Clear All, applied with the other edits on OK. --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 86a435456..024a9f233 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- Tabs panel (Type › Tabs, ⇧⌘T): alignment buttons, X, Leader and Align On fields, a ruler to add, move and delete stops and drag the first-line, left and right indents, Clear All, Delete Tab, Repeat Tab and Reset Indents; it opens over the selected text frame with its ruler on the text column. Paragraph Style Options › Tabs edits a style's stops. Commands: `type.tabs.get`, `type.tabs.add`, `type.tabs.move`, `type.tabs.change`, `type.tabs.remove`, `type.tabs.clear`, `type.tabs.repeat` (with `style` for a paragraph style). - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..57ea8a2fc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2249,6 +2249,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ("general", "General"), ("chars", "Basic Character Formats"), ("indents", "Indents and Spacing"), + ("tabs", "Tabs"), ("rules", "Paragraph Rules"), ("hyph", "Hyphenation"), ("justify", "Justification"), @@ -2535,6 +2536,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); @@ -2650,6 +2652,85 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } +/// Paragraph Style Options › Tabs: the style's stops on a ruler as wide as the first page's +/// column, edited in `p.tabs` (and the indents in `p.leftIndent`…) until OK. +fn style_tabs(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: &designcraft_doc::Document) { + use crate::panels::tabs::{Ruler, RulerEdit, align_buttons, ruler, text_commit}; + use designcraft_engine::cmd::tabs; + let cur = |d: &Dialog, k: &str| d.fields.get(k).cloned().unwrap_or_else(|| pv[k.trim_start_matches("p.")].clone()); + let mut list: Vec = serde_json::from_value(cur(d, "p.tabs")).unwrap_or_default(); + tabs::sort(&mut list); + let num = |d: &Dialog, k: &str| cur(d, k).as_f64().filter(|v| v.is_finite()).unwrap_or(0.0); + let (left, first, right) = (num(d, "p.leftIndent"), num(d, "p.firstLineIndent"), num(d, "p.rightIndent")); + let width = doc.page(0).and_then(|p| p.column_rects().first().map(|r| r.width())).filter(|w| *w > 1.0).unwrap_or(468.0); + let unit = doc.settings.horizontal_units; + let mut sel = d.fields.get("tabsSelected").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < list.len()); + let mut align = d.fields.get("tabsAlign").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let stop = sel.and_then(|i| list.get(i)).cloned(); + // Each control's edit, applied to the list below. + let mut result: Option, String>> = None; + let mut indent: Option<(&str, f64)> = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if let Some(a) = align_buttons(ui, lang, stop.as_ref().map_or(align, |s| s.align)) { + align = a; + if let Some(i) = sel { + result = Some(tabs::change(&mut list, i, Some(a), None, None)); + } + } + ui.add_space(6.0); + crate::rtl::label(ui, crate::i18n::tr(lang, "X:")); + if let Some(x) = crate::widgets::measure(ui, "pstabx", stop.as_ref().map(|s| s.position), unit, 72.0) { + result = Some(match sel { + Some(i) => tabs::move_to(&mut list, i, x), + None => tabs::add(&mut list, designcraft_doc::TabStop { position: x, align, leader: String::new(), align_on: String::new() }), + }); + } + crate::rtl::label(ui, crate::i18n::tr(lang, "Leader:")); + if let (Some(l), Some(i)) = (text_commit(ui, "psleader", stop.as_ref().map_or("", |s| s.leader.as_str()), 36.0, stop.is_some()), sel) { + result = Some(tabs::leader_value(&l, "tabs").map_err(|e| e.to_string()).and_then(|l| tabs::change(&mut list, i, None, Some(l), None))); + } + crate::rtl::label(ui, crate::i18n::tr(lang, "Align On:")); + let on_char = stop.as_ref().is_some_and(|s| s.align == designcraft_doc::TabAlign::Char); + if let (Some(c), Some(i)) = (text_commit(ui, "psalignon", stop.as_ref().map_or("", |s| s.align_on.as_str()), 18.0, on_char), sel) { + result = Some(tabs::align_on_value(&c, "tabs").map_err(|e| e.to_string()).and_then(|c| tabs::change(&mut list, i, None, None, Some(c)))); + } + ui.add_space(6.0); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "Clear All"))).clicked() { + list.clear(); + result = Some(Ok(None)); + } + }); + ui.add_space(6.0); + let r = Ruler { tabs: &list, left, first, right, width, unit, selected: sel, scale: None }; + match ruler(ui, "style", &r) { + Some(RulerEdit::Select(i)) => sel = Some(i), + Some(RulerEdit::Add(x)) => { + result = Some(tabs::add(&mut list, designcraft_doc::TabStop { position: x, align, leader: String::new(), align_on: String::new() })) + } + Some(RulerEdit::Move(i, x)) => result = Some(tabs::move_to(&mut list, i, x)), + Some(RulerEdit::Remove(i)) => result = Some(tabs::remove(&mut list, i)), + Some(RulerEdit::Indent(k, x)) => indent = Some((k, x)), + None => {} + } + match result { + Some(Ok(i)) => { + sel = i; + d.fields.insert("p.tabs".into(), serde_json::to_value(&list).unwrap_or_default()); + d.fields.remove("status"); + } + Some(Err(e)) => { + d.fields.insert("status".into(), json!(e)); + } + None => {} + } + if let Some((k, x)) = indent { + d.fields.insert(format!("p.{k}"), json!(x)); + } + d.fields.insert("tabsSelected".into(), sel.map_or(Value::Null, |i| json!(i))); + d.fields.insert("tabsAlign".into(), serde_json::to_value(align).unwrap_or_default()); +} + /// The section list on the left of the style options dialogs. fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) { ui.vertical(|ui| { @@ -4070,6 +4151,32 @@ mod tests { assert_eq!(resolved(&app)["ruleBelow"], rule); } + #[test] + fn paragraph_style_options_tabs_section_adds_a_stop_on_its_ruler() { + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "tabs"}))); + h.run_steps(4); + h.get_by_label("Clear All"); + // The ruler sits below the row of alignment buttons; its strip takes the click. + let b = h.get_by_label("Right-aligned tab").rect(); + h.get_by_label("Right-aligned tab").click(); + h.run_steps(2); + let left = h.get_by_label("Left-aligned tab").rect(); + let at = egui::pos2(left.min.x + crate::panels::tabs::PAD + 100.0, b.max.y + 14.0); + crate::test_window::click_at(&mut h, at); + let d = h.state().app.ui.dialog.clone().unwrap(); + let tabs = d.fields.get("p.tabs").cloned().unwrap_or_default(); + assert_eq!(tabs.as_array().map(Vec::len), Some(1), "{tabs}"); + assert_eq!(tabs[0]["align"], "right"); + confirm(&mut h.state_mut().app).unwrap(); + let app = &mut h.state_mut().app; + let style = app.session.execute("type.tabs.get", &json!({"style": "Ruled Child"})).unwrap(); + assert_eq!(style["tabs"], tabs); + assert_eq!(app.session.execute("type.tabs.get", &json!({})).unwrap()["tabs"], tabs, "the paragraph follows its style"); + } + fn command_app(id: &str, fields: Value) -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields));