feat(ui): Lay out Numbering & Section Options like InDesign's

Layout > Numbering & Section Options now opens its own window instead of
the generated command form, on the section of the page in view: Start
Section (always on for page 1), Automatic Page Numbering or Start Page
Numbering at, and a Page Numbering group with Section Prefix, Style,
Section Marker and Include Prefix when Numbering Pages; then Document
Chapter Numbering with its style, Automatic Chapter Numbering, Start
Chapter Numbering at, Same as Previous Document in the Book (only in a
book) and the book's name. OK and Cancel sit beside the options, out of
the scrolling body.

OK runs layout.section and layout.chapterNumbering; a refused value
(a prefix over eight characters or with + or a comma, a start number
below 1) keeps the window open and changes nothing. Opening the window
reads the chapter numbering without running a command, so it doesn't
stop a Data Merge preview. New strings are translated (German, French,
Spanish, Japanese, Chinese, Italian).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-10 15:58:33 +02:00
co-authored by Claude Opus 5.5
parent 498e3d401c
commit 9c683f3ef8
9 changed files with 472 additions and 7 deletions
+1
View File
@@ -166,6 +166,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Layout › Numbering & Section Options as in InDesign: Start Section, automatic or explicit page numbering, section prefix, style, marker and Include Prefix, and the document's chapter numbering with its book; opens on the page in view, OK and Cancel beside the options.
- Document chapter numbering (`layout.chapterNumbering`): style (1, I, i, A, a, 01, 001, 0001), Automatic Chapter Numbering, Start Chapter Numbering at, Same as Previous Document in the Book; a book's Update Numbering resolves chapter numbers in order; Chapter Number variables and cross-references use the style.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
+3 -2
View File
@@ -774,8 +774,9 @@ fn document_setup(d: &designcraft_doc::Document) -> Value {
/// The largest chapter number (as for page numbers).
const MAX_CHAPTER: u64 = 99_999;
/// The active document's chapter numbering, and the open book that holds it.
fn chapter_numbering(s: &Session) -> Result<Value> {
/// The active document's chapter numbering, and the open book that holds it (what
/// `layout.chapterNumbering {}` reports, read without running a command).
pub fn chapter_numbering(s: &Session) -> Result<Value> {
let st = s.doc()?;
let set = &st.doc.settings;
let book = s
+1
View File
@@ -300,4 +300,5 @@ pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> {
pub(crate) use datamerge::sync_placeholders;
pub(crate) use file::load_document_fonts;
pub use file::{base64_decode, base64_encode, from_bytes, to_bytes};
pub use layout::chapter_numbering;
pub(crate) use place_text::autoflow as place_text_autoflow;
+81 -5
View File
@@ -77,7 +77,7 @@ impl Dialog {
}
Dialog { id: id.into(), fields }
}
fn s(&self, k: &str) -> String {
pub(crate) fn s(&self, k: &str) -> String {
match self.fields.get(k) {
Some(Value::String(s)) => s.clone(),
Some(v) => v.to_string(),
@@ -99,10 +99,10 @@ impl Dialog {
_ => None,
}
}
fn b(&self, k: &str) -> bool {
pub(crate) fn b(&self, k: &str) -> bool {
self.fields.get(k).and_then(Value::as_bool).unwrap_or(false)
}
fn n(&self, k: &str) -> Option<f64> {
pub(crate) fn n(&self, k: &str) -> Option<f64> {
match self.fields.get(k) {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => s.trim().parse().ok(),
@@ -1127,6 +1127,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"),
"textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"),
"documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"),
"numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'),
"findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"),
"paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"),
"characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"),
@@ -1161,6 +1162,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let preferred_width: f32 = match d.id.as_str() {
"alert" => 440.0,
"closeDocument" => 380.0,
"numberingSection" => 560.0,
"newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0,
_ => 640.0,
};
@@ -1179,9 +1181,38 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.add_space(10.0);
// Keep the title and action buttons outside the scrollable body. Oversized
// forms remain reachable at large UI scales, without shrinking their text.
let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y);
// Numbering & Section Options keeps OK and Cancel in a column beside the options, as
// InDesign does; other dialogs have them in a footer.
let side_actions = d.id == "numberingSection";
let footer_height = if side_actions {
0.0
} else {
ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y)
};
let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0);
egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| {
let mut body_width = width;
if side_actions {
// The column is beside the options, not in the layout: keep the window wide enough.
ui.set_min_width(width);
body_width = (width - crate::section_options::ACTIONS_W - 12.0).max(1.0);
let top = ui.cursor().min;
// On the right in every language, like the other dialogs' OK.
let column = egui::Rect::from_min_size(egui::pos2(top.x + width - crate::section_options::ACTIONS_W, top.y), egui::vec2(crate::section_options::ACTIONS_W, 60.0));
let mut actions = ui.new_child(egui::UiBuilder::new().max_rect(column).layout(egui::Layout::top_down_justified(egui::Align::Center)));
if let Some(ok) = crate::section_options::actions(app, &mut actions) {
result = Some(ok);
}
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
result = Some(true);
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
result = Some(false);
}
}
// A centred window only offers its body last frame's height: let this one grow to its
// options' height (the screen still caps it).
let min_body = if side_actions { body_height.min(crate::section_options::BODY_H) } else { 1.0 };
egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(min_body).max_width(body_width).max_height(body_height).show(ui, |ui| {
match d.id.as_str() {
"newDocument" => {
ui.horizontal(|ui| {
@@ -1690,6 +1721,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
text_frame_options_dialog(app, ui, &mut d);
}
"documentSetup" => document_setup(app, ui, &mut d),
"numberingSection" => crate::section_options::body(app, ui, &mut d),
"pdfExport" => pdf_export(app, ui, &mut d),
"alert" => {
let file = d.s("file");
@@ -1702,6 +1734,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
_ => {}
}
});
if side_actions {
return;
}
ui.add_space(12.0);
let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " };
ui.horizontal(|ui| {
@@ -1788,6 +1823,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,
"headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}),
),
"numberingSection" => {
// On an error the dialog stays open to be corrected.
let r = crate::section_options::confirm(app, &d);
if let Err(e) = &r {
app.status(e.clone());
app.ui.dialog = Some(d);
}
r
}
"textFrameOptions" => {
// On an error the dialog stays open to be corrected.
let mut d = d;
@@ -3832,6 +3876,38 @@ mod tests {
}
}
#[test]
fn numbering_and_section_options_keep_their_actions_in_view() {
for language in ["en", "ar"] {
for size in [egui::vec2(1178.0, 814.0), egui::vec2(392.0, 271.0)] {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.language = language.into();
app.run("file.new", json!({"pages": 2})).unwrap();
crate::menus::activate(&mut app, "layout.section", &Value::Null);
let ctx = dialog_context(language);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
for _ in 0..3 {
dialog_frame(&mut app, &ctx, screen, vec![]);
}
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let tr = |s| crate::i18n::tr(language, s);
for expected in [tr("Numbering & Section Options…").trim_end_matches('…'), tr("OK"), tr("Cancel"), tr("Start Section")] {
visible_label(&labels, screen, expected);
}
if size.x > 1000.0 {
// Every option fits without scrolling, and choosing one then OK applies it.
visible_label(&labels, screen, &format!("{} {}", tr("Book Name:"), tr("N/A")));
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, tr("Automatic Page Numbering")).center());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, tr("OK")).center());
assert!(app.ui.dialog.is_none(), "{language}: OK closes the dialog");
let doc = &app.session.active().unwrap().doc;
assert!(doc.sections.iter().all(|s| s.start != 0 || s.start_number.is_none()), "{language}: page 1 numbers automatically");
}
}
}
}
#[test]
fn preferences_sidebar_scroll_keeps_selected_options_visible() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+70
View File
@@ -2811,6 +2811,76 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]),
("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]),
("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]),
// Layout › Numbering & Section Options
("Start Section", ["Abschnittsanfang", "Début de section", "Iniciar sección", "セクション開始", "开始新章节"]),
(
"Automatic Page Numbering",
[
"Automatische Seitennummerierung",
"Numérotation automatique des pages",
"Numeración de páginas automática",
"自動ページ番号",
"自动编排页码",
],
),
(
"Start Page Numbering at:",
["Seitennummerierung beginnen bei:", "Commencer la numérotation à :", "Iniciar numeración de páginas en:", "開始ページ番号:", "起始页码:"],
),
("Section Prefix:", ["Abschnittspräfix:", "Préfixe de section :", "Prefijo de sección:", "セクションプレフィックス:", "章节前缀:"]),
("Section Marker:", ["Abschnittsmarke:", "Marqueur de section :", "Marcador de sección:", "セクションマーカー:", "章节标志符:"]),
(
"Include Prefix when Numbering Pages",
[
"Präfix in Seitennummerierung einschließen",
"Inclure le préfixe lors de la numérotation des pages",
"Incluir prefijo al numerar páginas",
"ページ番号にプレフィックスを含める",
"编排页码时包含前缀",
],
),
(
"Document Chapter Numbering",
[
"Kapitelnummerierung des Dokuments",
"Numérotation des chapitres du document",
"Numeración de capítulos del documento",
"ドキュメントの章番号",
"文档章节编号",
],
),
(
"Automatic Chapter Numbering",
[
"Automatische Kapitelnummerierung",
"Numérotation automatique des chapitres",
"Numeración de capítulos automática",
"自動章番号",
"自动为章节编号",
],
),
(
"Start Chapter Numbering at:",
[
"Kapitelnummerierung beginnen bei:",
"Commencer la numérotation des chapitres à :",
"Iniciar numeración de capítulos en:",
"開始章番号:",
"起始章节编号:",
],
),
(
"Same as Previous Document in the Book",
[
"Wie vorheriges Dokument im Buch",
"Identique au document précédent du livre",
"Igual que el documento anterior del libro",
"ブック内の前のドキュメントと同じ",
"与书籍中的上一文档相同",
],
),
("Book Name:", ["Buchname:", "Nom du livre :", "Nombre del libro:", "ブック名:", "书籍名称:"]),
("N/A", ["–", "S.O.", "N/D", "なし", "不适用"]),
];
/// Interface direction; independent of document binding and paragraph direction.
+13
View File
@@ -1492,4 +1492,17 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Paragraph Rules", "Filetti paragrafo"),
("Rule Below", "Filetto sotto"),
("Tint:", "Tinta:"),
// Layout › Numbering & Section Options
("Start Section", "Inizia sezione"),
("Automatic Page Numbering", "Numerazione automatica delle pagine"),
("Start Page Numbering at:", "Inizia numerazione pagine da:"),
("Section Prefix:", "Prefisso sezione:"),
("Section Marker:", "Marcatore sezione:"),
("Include Prefix when Numbering Pages", "Includi prefisso nella numerazione delle pagine"),
("Document Chapter Numbering", "Numerazione capitoli del documento"),
("Automatic Chapter Numbering", "Numerazione automatica dei capitoli"),
("Start Chapter Numbering at:", "Inizia numerazione capitoli da:"),
("Same as Previous Document in the Book", "Uguale al documento precedente nel libro"),
("Book Name:", "Nome libro:"),
("N/A", "N/D"),
];
+1
View File
@@ -19,6 +19,7 @@ pub mod menus;
pub mod panels;
pub mod render_worker;
mod rtl;
mod section_options;
pub mod story_editor;
pub mod taskbar;
pub mod theme;
+4
View File
@@ -1813,6 +1813,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
crate::dialogs::open_print(app);
return;
}
if params.is_null() && id == "layout.section" {
crate::section_options::open(app);
return;
}
if params.is_null() && id == "object.textFrameOptions" {
app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
return;
+298
View File
@@ -0,0 +1,298 @@
//! Layout › Numbering & Section Options, laid out like InDesign's: Start Section and the page
//! numbering of the section that starts at the current page, then the document's chapter
//! numbering. OK applies `layout.section` and `layout.chapterNumbering`.
use designcraft_doc::{ChapterSource, NumberStyle};
use egui::{Align, Layout, Ui, vec2};
use serde_json::{Value, json};
use crate::DesignApp;
use crate::dialogs::Dialog;
use crate::theme::Tokens;
/// The page numbering styles and how the Style menus show them.
const STYLES: [(NumberStyle, &str); 8] = [
(NumberStyle::Arabic, "1, 2, 3, 4…"),
(NumberStyle::UpperRoman, "I, II, III, IV…"),
(NumberStyle::LowerRoman, "i, ii, iii, iv…"),
(NumberStyle::UpperLetters, "A, B, C, D…"),
(NumberStyle::LowerLetters, "a, b, c, d…"),
(NumberStyle::ArabicLeadingZero, "01, 02, 03…"),
(NumberStyle::ArabicThreeDigits, "001, 002, 003…"),
(NumberStyle::ArabicFourDigits, "0001, 0002, 0003…"),
];
/// The OK and Cancel column beside the options.
pub(crate) const ACTIONS_W: f32 = 104.0;
/// The options' height, for the window to grow to.
pub(crate) const BODY_H: f32 = 470.0;
const LABEL_W: f32 = 104.0;
const FIELD_W: f32 = 84.0;
/// Open the dialog for the section at the page in view.
pub(crate) fn open(app: &mut DesignApp) {
let Ok(chapter) = designcraft_engine::cmd::chapter_numbering(&app.session) else { return };
let current = crate::canvas::current_page(app).unwrap_or(0);
let Some(st) = app.session.active() else { return };
let doc = &st.doc;
let page = current.min(doc.page_count().saturating_sub(1));
let own = doc.sections.iter().find(|s| s.start == page);
// A new section starts from the one the page is in.
let base = own.or_else(|| doc.section_of(page));
let fields = json!({
"page": page + 1,
"startSection": page == 0 || own.is_some(),
"automatic": own.is_none_or(|s| s.start_number.is_none()),
"startNumber": doc.page_number(page).to_string(),
"prefix": base.map(|s| s.prefix.clone()).unwrap_or_default(),
"style": base.map(|s| s.style).unwrap_or_default(),
"marker": base.map(|s| s.marker.clone()).unwrap_or_default(),
"includePrefix": base.is_some_and(|s| s.include_prefix),
"chapterStyle": chapter.get("style").cloned().unwrap_or(json!("arabic")),
"chapterSource": chapter.get("source").cloned().unwrap_or(json!("automatic")),
"chapterStart": chapter.get("chapterNumber").and_then(Value::as_u64).unwrap_or(1).to_string(),
"book": chapter.get("book").cloned().unwrap_or(Value::Null),
});
app.ui.dialog = Some(Dialog::new("numberingSection", fields));
}
/// A whole number typed in `key` (1 or more).
fn number(d: &Dialog, key: &str) -> Option<u64> {
d.n(key).filter(|v| v.is_finite() && *v >= 1.0 && v.fract() == 0.0).map(|v| v as u64)
}
/// Apply the dialog: the section, then the chapter numbering. Refused values leave both as they were.
pub(crate) fn confirm(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> {
let page = number(d, "page").ok_or("no page")?;
let source: ChapterSource = d.fields.get("chapterSource").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let mut chapter = json!({"style": d.fields.get("chapterStyle").cloned().unwrap_or(json!("arabic")), "source": source});
if source == ChapterSource::UserDefined {
chapter["start"] = json!(number(d, "chapterStart").ok_or("Start Chapter Numbering at: a whole number from 1")?);
}
if page != 1 && !d.b("startSection") {
app.run("layout.section", json!({"page": page, "remove": true}))?;
} else {
let start =
if d.b("automatic") { Value::Null } else { json!(number(d, "startNumber").ok_or("Start Page Numbering at: a whole number from 1")?) };
app.run(
"layout.section",
json!({"page": page, "startNumber": start, "style": d.fields.get("style").cloned().unwrap_or(json!("arabic")),
"prefix": d.s("prefix"), "marker": d.s("marker"), "includePrefix": d.b("includePrefix")}),
)?;
}
app.run("layout.chapterNumbering", chapter)
}
/// A row that runs right to left in a right-to-left interface.
fn row<R>(ui: &mut Ui, rtl: bool, add: impl FnOnce(&mut Ui) -> R) -> R {
let layout = if rtl { Layout::right_to_left(Align::Center) } else { Layout::left_to_right(Align::Center) };
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), layout, add).inner
}
/// A caption ending at the field it names (`Section Prefix:` [ ]).
fn caption(ui: &mut Ui, rtl: bool, text: &str) {
let layout = if rtl { Layout::left_to_right(Align::Center) } else { Layout::right_to_left(Align::Center) };
ui.allocate_ui_with_layout(vec2(LABEL_W, 22.0), layout, |ui| {
ui.set_width(LABEL_W);
crate::rtl::label(ui, text);
});
}
fn text(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) {
let mut s = d.s(key);
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(width)).changed() {
d.fields.insert(key.into(), Value::String(s));
}
}
fn style_menu(ui: &mut Ui, d: &mut Dialog, key: &str) {
let current: NumberStyle = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let shown = STYLES.iter().find(|(s, _)| *s == current).map_or(STYLES[0].1, |(_, l)| l);
egui::ComboBox::from_id_salt(("section_style", key)).selected_text(shown).width(150.0).show_ui(ui, |ui| {
for (style, label) in STYLES {
if ui.selectable_label(style == current, label).clicked() {
d.fields.insert(key.into(), json!(style));
}
}
});
}
fn checkbox(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str) {
let mut on = d.b(key);
if ui.checkbox(&mut on, crate::rtl::widget(ui, label)).changed() {
d.fields.insert(key.into(), json!(on));
}
}
/// A titled, outlined group of options.
fn group(ui: &mut Ui, rtl: bool, title: &str, add: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
row(ui, rtl, |ui| {
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(title).color(t.text_strong));
});
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).corner_radius(3.0).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
add(ui);
});
}
/// The options (beside the OK/Cancel column).
pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) {
let lang = app.ui.language.as_str();
let tr = |s: &'static str| crate::i18n::tr(lang, s);
let rtl = crate::i18n::is_rtl(lang);
ui.spacing_mut().item_spacing.y = 6.0;
let first_page = number(d, "page") == Some(1);
// Page 1 always starts a section.
row(ui, rtl, |ui| {
ui.add_enabled_ui(!first_page, |ui| checkbox(ui, d, "startSection", tr("Start Section")));
});
let section = first_page || d.b("startSection");
ui.add_enabled_ui(section, |ui| {
let (start, end) = if rtl { (0, 18) } else { (18, 0) };
egui::Frame::NONE.inner_margin(egui::Margin { left: start, right: end, top: 0, bottom: 0 }).show(ui, |ui| {
let automatic = d.b("automatic");
row(ui, rtl, |ui| {
if ui.radio(automatic, crate::rtl::widget(ui, tr("Automatic Page Numbering"))).clicked() {
d.fields.insert("automatic".into(), json!(true));
}
});
row(ui, rtl, |ui| {
if ui.radio(!automatic, crate::rtl::widget(ui, tr("Start Page Numbering at:"))).clicked() {
d.fields.insert("automatic".into(), json!(false));
}
ui.add_enabled_ui(!automatic, |ui| text(ui, d, "startNumber", FIELD_W));
});
ui.add_space(4.0);
group(ui, rtl, tr("Page Numbering"), |ui| {
row(ui, rtl, |ui| {
caption(ui, rtl, tr("Section Prefix:"));
text(ui, d, "prefix", FIELD_W);
});
row(ui, rtl, |ui| {
caption(ui, rtl, tr("Style:"));
style_menu(ui, d, "style");
});
row(ui, rtl, |ui| {
caption(ui, rtl, tr("Section Marker:"));
text(ui, d, "marker", 130.0);
});
row(ui, rtl, |ui| checkbox(ui, d, "includePrefix", tr("Include Prefix when Numbering Pages")));
});
});
});
ui.add_space(10.0);
let source: ChapterSource = d.fields.get("chapterSource").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let book = d.fields.get("book").and_then(Value::as_str).map(str::to_string);
group(ui, rtl, tr("Document Chapter Numbering"), |ui| {
row(ui, rtl, |ui| {
crate::rtl::label(ui, tr("Style:"));
style_menu(ui, d, "chapterStyle");
});
for (value, label, enabled) in [
(ChapterSource::Automatic, "Automatic Chapter Numbering", true),
(ChapterSource::UserDefined, "Start Chapter Numbering at:", true),
(ChapterSource::SameAsPrevious, "Same as Previous Document in the Book", book.is_some()),
] {
row(ui, rtl, |ui| {
ui.add_enabled_ui(enabled, |ui| {
if ui.radio(source == value, crate::rtl::widget(ui, tr(label))).clicked() {
d.fields.insert("chapterSource".into(), json!(value));
}
});
if value == ChapterSource::UserDefined {
ui.add_enabled_ui(source == value, |ui| text(ui, d, "chapterStart", FIELD_W));
}
});
}
row(ui, rtl, |ui| {
let name = book.as_deref().map_or_else(|| tr("N/A").to_string(), crate::rtl::isolate);
crate::rtl::label(ui, format!("{} {name}", tr("Book Name:")));
});
});
}
/// OK and Cancel, stacked in their column → Some(true) for OK, Some(false) for Cancel.
pub(crate) fn actions(app: &DesignApp, ui: &mut Ui) -> Option<bool> {
let t = Tokens::get(ui.ctx());
let size = vec2(ACTIONS_W - 8.0, 26.0);
let mut out = None;
let ok = egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "OK")).color(egui::Color32::WHITE)))
.fill(t.accent_strong)
.corner_radius(13.0)
.min_size(size);
if ui.add(ok).clicked() {
out = Some(true);
}
ui.add_space(4.0);
if ui.add(egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).corner_radius(13.0).min_size(size)).clicked() {
out = Some(false);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A four-page document with Numbering & Section Options open (on page 1).
fn open_app() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4})).unwrap();
crate::menus::activate(&mut app, "layout.section", &Value::Null);
app
}
fn set(app: &mut DesignApp, fields: Value) {
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in fields.as_object().unwrap() {
d.fields.insert(k.clone(), v.clone());
}
}
#[test]
fn the_menu_opens_the_page_section_and_ok_applies_both_parts() {
let mut app = open_app();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.id, "numberingSection");
assert_eq!(
(d.n("page"), d.b("startSection"), d.s("chapterSource"), d.fields["book"].clone()),
(Some(1.0), true, "automatic".into(), Value::Null)
);
// A section from page 3 numbered A-v, A-vi; the document is chapter IV.
set(
&mut app,
json!({"page": 3, "startSection": true, "automatic": false, "startNumber": "5", "style": "lowerRoman", "prefix": "A-",
"includePrefix": true, "chapterSource": "userDefined", "chapterStart": "4", "chapterStyle": "upperRoman"}),
);
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let doc = &app.session.active().unwrap().doc;
assert_eq!((0..4).map(|i| doc.page_name(i)).collect::<Vec<_>>(), ["1", "2", "A-v", "A-vi"]);
assert_eq!(doc.chapter_label(), "IV");
// Reopened on that page, it shows the section.
crate::menus::activate(&mut app, "layout.section", &Value::Null);
set(&mut app, json!({"page": 3, "startSection": false}));
crate::dialogs::confirm(&mut app).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.page_name(2), "3", "unchecking Start Section removes the section");
}
#[test]
fn refused_values_keep_the_dialog_open_and_change_nothing() {
for (key, value) in [("prefix", json!("A+B")), ("prefix", json!("Appendix1")), ("startNumber", json!("0")), ("chapterStart", json!("x"))] {
let mut app = open_app();
set(
&mut app,
json!({"page": 3, "startSection": true, "automatic": false, "startNumber": "5", "chapterSource": "userDefined", "chapterStart": "2"}),
);
set(&mut app, json!({key: value}));
assert!(crate::dialogs::confirm(&mut app).is_err(), "{key}: {value}");
assert!(app.ui.dialog.is_some(), "the dialog stays open to be corrected ({key})");
let doc = &app.session.active().unwrap().doc;
assert!(doc.sections.iter().all(|s| s.start != 2), "no section was made ({key})");
assert_eq!(doc.settings.chapter_source, ChapterSource::Automatic, "the chapter numbering is untouched ({key})");
}
}
}