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.
This commit is contained in:
aiongg
2026-10-10 14:17:58 +00:00
parent 2e3d954f5b
commit ea3f11b9b7
2 changed files with 108 additions and 0 deletions
+1
View File
@@ -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.
+107
View File
@@ -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<designcraft_doc::TabStop> = 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<Result<Option<usize>, 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));