From 9c683f3ef864cb705db3ae92f480d10684acfce7 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:58:33 +0200 Subject: [PATCH] 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 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 5 +- crates/engine/src/cmd/mod.rs | 1 + crates/ui-egui/src/dialogs.rs | 86 +++++++- crates/ui-egui/src/i18n.rs | 70 ++++++ crates/ui-egui/src/i18n/it.rs | 13 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/section_options.rs | 298 ++++++++++++++++++++++++++ 9 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/section_options.rs diff --git a/ROADMAP.md b/ROADMAP.md index 75c61a9c2..52e995cfc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 56be6aa21..e1ede44a9 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -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 { +/// 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 { let st = s.doc()?; let set = &st.doc.settings; let book = s diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 68061af0a..cc5844492 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -300,4 +300,5 @@ pub fn file_from(b: &[u8]) -> Result { 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; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1b0e5e66d..4be365a5a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { + pub(crate) fn n(&self, k: &str) -> Option { 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 { 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()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..12699e32c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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. diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..63096fcfd 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -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"), ]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..a51f9be1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 94e5c0971..8eaa424c2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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; diff --git a/crates/ui-egui/src/section_options.rs b/crates/ui-egui/src/section_options.rs new file mode 100644 index 000000000..cf69483bb --- /dev/null +++ b/crates/ui-egui/src/section_options.rs @@ -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 { + 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 { + 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(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 { + 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::>(), ["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})"); + } + } +}