From 73c37fdcca3234af389b50d6cb4ec80fb719062b 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 1/2] 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 --- 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 ++++++++++++++++++++++++++ 8 files changed, 471 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/section_options.rs 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 fa33c2c0f..68ddf7684 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; @@ -3835,6 +3879,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 5976d7d07..4ce251d31 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2812,6 +2812,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 34766cf22..c46344c93 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1493,4 +1493,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 e0fa1dff0..39f620564 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod panels; mod prefs; 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 cb1422394..c5be847ff 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1849,6 +1849,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})"); + } + } +} From 42a4846ba8efd6f2847bcc7a60691ae415cb7bb6 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:59:21 +0200 Subject: [PATCH 2/2] fix(ui): Numbering & Section Options applies in one step and keeps keys for its menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. - OK ran layout.section and then layout.chapterNumbering: two undo steps, and a chapter start the second command refused (over 99 999) left the section applied. `layout.numberingOptions {page, …section, chapter}` checks both parts before the document changes and applies them in one edit; the window's OK runs it. layout.section and layout.chapterNumbering share its parsing. - Enter or Escape with a Style menu open confirmed or closed the whole dialog. Dialogs now leave both keys to an open popup (read before the dialog draws, since egui closes a popup on Escape that frame). - A start page of 4294967295 overflowed page_number (a panic in debug builds, page 0 in release). Page numbers saturate; layout.section refuses start numbers outside 1-99 999 (MAX_PAGE_NUMBER, as chapter numbers) and Document Setup clamps its start page instead of wrapping a 64-bit number into 32 bits. Also rebased on main: the restructured ROADMAP keeps main's text, and the inventory in docs/target-app-parity.md lists chapter numbering and the window. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 11 +- crates/doc/src/tests.rs | 17 ++ crates/engine/src/cmd/layout.rs | 301 ++++++++++++++++++-------- crates/ui-egui/src/dialogs.rs | 43 +++- crates/ui-egui/src/section_options.rs | 35 ++- docs/target-app-parity.md | 1 + 6 files changed, 302 insertions(+), 106 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d48f01abe..f4575fb4c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -303,6 +303,9 @@ impl Default for AdvancedType { } /// File → Document Setup + the guide/grid/unit preferences stored with the document. +/// The largest number a section can start page numbering at. +pub const MAX_PAGE_NUMBER: u32 = 99_999; + /// Where a document's chapter number comes from (Numbering & Section Options › Document Chapter /// Numbering). A book's Update Numbering resolves it in book order. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -667,13 +670,15 @@ impl Document { /// The page number (before formatting) of absolute page `abs`, per sections. A section without /// a start number continues from the previous section. pub fn page_number(&self, abs: usize) -> u32 { - let Some(sec) = self.section_of(abs) else { return abs as u32 + 1 }; + // Start numbers come from files and commands: saturate rather than overflow. + let offset = |n: usize| u32::try_from(n).unwrap_or(u32::MAX); + let Some(sec) = self.section_of(abs) else { return offset(abs).saturating_add(1) }; let start = match sec.start_number { Some(n) => n, None if sec.start == 0 => 1, - None => self.page_number(sec.start - 1) + 1, + None => self.page_number(sec.start - 1).saturating_add(1), }; - start + (abs - sec.start) as u32 + start.saturating_add(offset(abs.saturating_sub(sec.start))) } /// The displayed page name ("1", "iv", "A-3"…) for an absolute page index, per sections. diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 9ee28a139..1bfd4bae9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -115,6 +115,23 @@ fn hit_testing_respects_layers() { assert_eq!(d.hit_item(0, designcraft_geom::Point::new(50.0, 50.0), 2.0), None); } +#[test] +fn page_numbers_saturate_at_the_largest_start() { + // A start number from a file or an old command: page 2 overflowed (a panic in debug builds). + let mut d = crate::Document::new(&crate::build::NewDocument { pages: 3, facing_pages: false, ..Default::default() }); + d.sections = vec![crate::Section { + start: 0, + start_number: Some(u32::MAX), + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }]; + assert_eq!((0..3).map(|i| d.page_number(i)).collect::>(), [u32::MAX; 3]); + d.sections.push(crate::Section { start: 2, start_number: None, ..d.sections[0].clone() }); + assert_eq!(d.page_number(2), u32::MAX, "a section continuing it saturates too"); +} + #[test] fn page_names_follow_sections() { let mut d = Document::new(&NewDocument { pages: 6, ..Default::default() }); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index e1ede44a9..abd45dfde 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -284,7 +284,7 @@ pub fn specs() -> Vec { ); } if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) { - first.start_number = Some(n.max(1) as u32); + first.start_number = Some(n.clamp(1, u64::from(designcraft_doc::MAX_PAGE_NUMBER)) as u32); } } // Number of Pages: add at the end (with the last page's parent) or remove from the end. @@ -385,58 +385,12 @@ pub fn specs() -> Vec { "Numbering & Section Options…", ["Layout"], None, - "{page (1-based; starts a section there), startNumber?: n|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", + "{page (1-based; starts a section there), startNumber?: n (1–99999)|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", has_doc, |s, p| { - let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad("layout.section", "missing page"))? as usize; - let n = s.doc()?.doc.page_count(); - if page == 0 || page > n { - return Err(bad("layout.section", format!("no page {page}"))); - } - let start = page - 1; - if let Some(prefix) = p.get("prefix").and_then(Value::as_str) - && (prefix.chars().count() > 8 || prefix.contains(['+', ','])) - { - return Err(bad("layout.section", "section prefix: up to 8 characters, without + or ,")); - } - let p = p.clone(); + let edit = SectionEdit::parse("layout.section", s, p)?; s.edit(|d, _| { - d.sections.retain(|x| x.start != start || start == 0); - if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 { - return ok(); - } - let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { - start, - start_number: None, - style: Default::default(), - prefix: String::new(), - marker: String::new(), - include_prefix: false, - }); - match p.get("startNumber") { - Some(Value::Null) => sec.start_number = None, - Some(v) => sec.start_number = v.as_u64().map(|v| v.max(1) as u32), - None => {} - } - if let Some(v) = p.get("style") { - sec.style = match serde_json::from_value(v.clone()) { - Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad("layout.section", "symbols don't number pages")), - Ok(style) => style, - Err(e) => return Err(bad("layout.section", e.to_string())), - }; - } - if let Some(v) = p.get("prefix").and_then(Value::as_str) { - sec.prefix = v.into(); - } - if let Some(v) = p.get("includePrefix").and_then(Value::as_bool) { - sec.include_prefix = v; - } - if let Some(v) = p.get("marker").and_then(Value::as_str) { - sec.marker = v.into(); - } - d.sections.retain(|x| x.start != start); - d.sections.push(sec); - d.sections.sort_by_key(|x| x.start); + edit.apply(d); Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>()})) }) } @@ -452,49 +406,36 @@ pub fn specs() -> Vec { if p.as_object().is_none_or(|o| o.is_empty()) { return chapter_numbering(s); } - let style = match p.get("style") { - Some(v) => match serde_json::from_value::(v.clone()) { - Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => { - return Err(bad("layout.chapterNumbering", format!("unknown chapter style {v}"))); - } - Ok(style) => Some(style), - }, - None => None, - }; - let mut source = match p.get("source") { - Some(v) => Some( - serde_json::from_value::(v.clone()) - .map_err(|_| bad("layout.chapterNumbering", format!("unknown chapter number source {v}")))?, - ), - None => None, - }; - let start = match p.get("start") { - Some(v) => Some( - v.as_u64() - .filter(|n| (1..=MAX_CHAPTER).contains(n)) - .ok_or_else(|| bad("layout.chapterNumbering", format!("start: 1 to {MAX_CHAPTER}")))? as u32, - ), - None => None, - }; - // A start number alone means Start Chapter Numbering at. - if start.is_some() && source.is_none() { - source = Some(designcraft_doc::ChapterSource::UserDefined); - } + let edit = ChapterEdit::parse("layout.chapterNumbering", p)?; s.edit(|d, _| { - if let Some(style) = style { - d.settings.chapter_style = style; - } - if let Some(source) = source { - d.settings.chapter_source = source; - } - if let Some(n) = start { - d.settings.chapter_number = n; - } + edit.apply(d); ok() })?; chapter_numbering(s) } ), + cmd!( + "layout.numberingOptions", + "Apply Numbering & Section Options", + [], + None, + "{page, …layout.section's settings, chapter?: {…layout.chapterNumbering's settings}} — the section and the document's chapter numbering in one undo step; if either is refused nothing changes", + has_doc, + |s, p| { + let section = SectionEdit::parse("layout.numberingOptions", s, p)?; + let chapter = match p.get("chapter") { + Some(c) => Some(ChapterEdit::parse("layout.numberingOptions", c)?), + None => None, + }; + s.edit(|d, _| { + section.apply(d); + if let Some(chapter) = &chapter { + chapter.apply(d); + } + Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>(), "chapter": d.chapter_label()})) + }) + } + ), cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); let to = p.get("to").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -772,7 +713,144 @@ fn document_setup(d: &designcraft_doc::Document) -> Value { } /// The largest chapter number (as for page numbers). -const MAX_CHAPTER: u64 = 99_999; +const MAX_CHAPTER: u64 = designcraft_doc::MAX_PAGE_NUMBER as u64; + +/// A `layout.section` edit, checked before the document changes. +struct SectionEdit { + /// Absolute index of the section's first page. + start: usize, + remove: bool, + /// Some(None): continue from the previous section. + start_number: Option>, + style: Option, + prefix: Option, + include_prefix: Option, + marker: Option, +} + +impl SectionEdit { + fn parse(cmd: &str, s: &Session, p: &Value) -> Result { + let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))?; + let n = s.doc()?.doc.page_count() as u64; + if page == 0 || page > n { + return Err(bad(cmd, format!("no page {page}"))); + } + let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string); + if prefix.as_ref().is_some_and(|x| x.chars().count() > 8 || x.contains(['+', ','])) { + return Err(bad(cmd, "section prefix: up to 8 characters, without + or ,")); + } + let max = designcraft_doc::MAX_PAGE_NUMBER; + let start_number = match p.get("startNumber") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => Some(Some( + v.as_u64().filter(|n| (1..=u64::from(max)).contains(n)).ok_or_else(|| bad(cmd, format!("startNumber: 1 to {max}")))? as u32, + )), + }; + let style = match p.get("style") { + None => None, + Some(v) => match serde_json::from_value(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad(cmd, "symbols don't number pages")), + Ok(style) => Some(style), + Err(e) => return Err(bad(cmd, e.to_string())), + }, + }; + Ok(SectionEdit { + start: (page - 1) as usize, + remove: p.get("remove").and_then(Value::as_bool).unwrap_or(false), + start_number, + style, + prefix, + include_prefix: p.get("includePrefix").and_then(Value::as_bool), + marker: p.get("marker").and_then(Value::as_str).map(str::to_string), + }) + } + + fn apply(&self, d: &mut designcraft_doc::Document) { + let start = self.start; + // Page 1 always starts a section. + if self.remove && start != 0 { + d.sections.retain(|x| x.start != start); + return; + } + let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { + start, + start_number: None, + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }); + if let Some(n) = self.start_number { + sec.start_number = n; + } + if let Some(style) = self.style { + sec.style = style; + } + if let Some(prefix) = &self.prefix { + sec.prefix = prefix.clone(); + } + if let Some(on) = self.include_prefix { + sec.include_prefix = on; + } + if let Some(marker) = &self.marker { + sec.marker = marker.clone(); + } + d.sections.retain(|x| x.start != start); + d.sections.push(sec); + d.sections.sort_by_key(|x| x.start); + } +} + +/// A `layout.chapterNumbering` edit, checked before the document changes. +struct ChapterEdit { + style: Option, + source: Option, + start: Option, +} + +impl ChapterEdit { + fn parse(cmd: &str, p: &Value) -> Result { + let style = match p.get("style") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => return Err(bad(cmd, format!("unknown chapter style {v}"))), + Ok(style) => Some(style), + }, + None => None, + }; + let mut source = match p.get("source") { + Some(v) => Some( + serde_json::from_value::(v.clone()) + .map_err(|_| bad(cmd, format!("unknown chapter number source {v}")))?, + ), + None => None, + }; + let start = match p.get("start") { + Some(v) => { + Some(v.as_u64().filter(|n| (1..=MAX_CHAPTER).contains(n)).ok_or_else(|| bad(cmd, format!("chapter start: 1 to {MAX_CHAPTER}")))? + as u32) + } + None => None, + }; + // A start number alone means Start Chapter Numbering at. + if start.is_some() && source.is_none() { + source = Some(designcraft_doc::ChapterSource::UserDefined); + } + Ok(ChapterEdit { style, source, start }) + } + + fn apply(&self, d: &mut designcraft_doc::Document) { + if let Some(style) = self.style { + d.settings.chapter_style = style; + } + if let Some(source) = self.source { + d.settings.chapter_source = source; + } + if let Some(n) = self.start { + d.settings.chapter_number = n; + } + } +} /// The active document's chapter numbering, and the open book that holds it (what /// `layout.chapterNumbering {}` reports, read without running a command). @@ -997,6 +1075,53 @@ mod page_numbering_view_tests { assert_eq!(s.resolve_page("9"), None); } + #[test] + fn numbering_options_apply_in_one_undo_step_or_not_at_all() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let names = |s: &crate::Session| (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect::>(); + let r = s + .execute( + "layout.numberingOptions", + &json!({"page": 3, "startNumber": 5, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "chapter": {"start": 4, "style": "upperRoman"}}), + ) + .unwrap(); + assert_eq!((r["names"].clone(), r["chapter"].clone()), (json!(["1", "2", "A-v", "A-vi"]), json!("IV"))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&s), ["1", "2", "3", "4"], "one undo reverts the section…"); + assert_eq!(s.doc().unwrap().doc.chapter_label(), "1", "…and the chapter numbering"); + // A refused part leaves the other undone too. + for bad in [ + json!({"page": 3, "startNumber": 5, "chapter": {"start": 100_000}}), + json!({"page": 3, "startNumber": 4_294_967_295u64, "chapter": {"start": 2}}), + json!({"page": 3, "prefix": "A+B", "chapter": {"start": 2}}), + json!({"page": 9, "chapter": {"start": 2}}), + ] { + assert!(s.execute("layout.numberingOptions", &bad).is_err(), "{bad}"); + assert_eq!(names(&s), ["1", "2", "3", "4"], "{bad}"); + assert_eq!(s.doc().unwrap().doc.settings.chapter_source, designcraft_doc::ChapterSource::Automatic, "{bad}"); + } + // Removing a section keeps the chapter part. + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + s.execute("layout.numberingOptions", &json!({"page": 3, "remove": true, "chapter": {"source": "userDefined", "start": 2}})).unwrap(); + assert_eq!(names(&s), ["1", "2", "3", "4"]); + assert_eq!(s.doc().unwrap().doc.chapter_label(), "2"); + } + + #[test] + fn start_numbers_are_bounded() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + for n in [0u64, 100_000, 4_294_967_295] { + assert!(s.execute("layout.section", &json!({"page": 1, "startNumber": n})).is_err(), "{n}"); + } + s.execute("layout.section", &json!({"page": 1, "startNumber": 99_999})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_name(1), "100000"); + // Document Setup clamps instead of wrapping into a 32-bit number. + s.execute("layout.documentSetup", &json!({"startPage": 4_294_967_295u64})).unwrap(); + assert_eq!(s.execute("layout.documentSetup", &json!({})).unwrap()["startPage"], 99_999); + } + #[test] fn section_prefixes_are_short_and_avoid_plus_and_comma() { let mut s = crate::Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 68ddf7684..c0deeb564 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1116,6 +1116,9 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; + // Enter and Escape belong to an open popup (a Style menu…) rather than the dialog. Read before + // the dialog draws: a popup closed by this frame's Escape is still open here. + let popup_open = egui::Popup::is_any_open(ctx); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -1202,10 +1205,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { if let Some(ok) = crate::section_options::actions(app, &mut actions) { result = Some(ok); } - if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if !popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter)) { result = Some(true); } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if !popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape)) { result = Some(false); } } @@ -1747,13 +1750,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() - || ui.input(|i| i.key_pressed(egui::Key::Enter)) + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) { result = Some(true); } // An alert only has OK. let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked(); - if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if cancel || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) { result = Some(false); } if d.id == "closeDocument" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Don't Save"))).clicked() { @@ -3879,6 +3882,38 @@ mod tests { } } + #[test] + fn enter_and_escape_close_the_style_menu_not_numbering_and_section_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2})).unwrap(); + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for pressed in [egui::Key::Enter, egui::Key::Escape] { + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + // The section's Style menu (the first of the two). + let style = labels.iter().filter(|(t, r, c)| t.trim() == "1, 2, 3, 4…" && c.contains_rect(*r)).map(|(_, r, _)| *r).next().unwrap(); + click_dialog(&mut app, &ctx, screen, style.center()); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(egui::Popup::is_any_open(&ctx), "the Style menu opened"); + dialog_frame(&mut app, &ctx, screen, vec![key(pressed)]); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(app.ui.dialog.is_some(), "{pressed:?} with the menu open leaves the dialog open"); + assert!(app.session.active().unwrap().doc.sections.iter().all(|s| s.start_number != Some(0))); + if egui::Popup::is_any_open(&ctx) { + egui::Popup::close_all(&ctx); + dialog_frame(&mut app, &ctx, screen, vec![]); + } + } + // With no menu open, Escape closes the dialog. + dialog_frame(&mut app, &ctx, screen, vec![key(egui::Key::Escape)]); + assert!(app.ui.dialog.is_none()); + } + #[test] fn numbering_and_section_options_keep_their_actions_in_view() { for language in ["en", "ar"] { diff --git a/crates/ui-egui/src/section_options.rs b/crates/ui-egui/src/section_options.rs index cf69483bb..899b9fbed 100644 --- a/crates/ui-egui/src/section_options.rs +++ b/crates/ui-egui/src/section_options.rs @@ -60,7 +60,8 @@ 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. +/// Apply the dialog: the section and the chapter numbering together (`layout.numberingOptions`, +/// one undo step). A refused value leaves 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(); @@ -68,18 +69,15 @@ pub(crate) fn confirm(app: &mut DesignApp, d: &Dialog) -> Result 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}))?; + let params = if page != 1 && !d.b("startSection") { + json!({"page": page, "remove": true, "chapter": chapter}) } 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) + 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"), "chapter": chapter}) + }; + app.run("layout.numberingOptions", params) } /// A row that runs right to left in a right-to-left interface. @@ -271,6 +269,14 @@ mod tests { 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"); + // One OK is one undo step. + app.run("edit.undo", json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!( + ((0..4).map(|i| doc.page_name(i)).collect::>(), doc.chapter_label()), + (vec!["1".to_string(), "2".into(), "3".into(), "4".into()], "1".into()) + ); + app.run("edit.redo", json!({})).unwrap(); // 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})); @@ -281,7 +287,14 @@ mod tests { #[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"))] { + for (key, value) in [ + ("prefix", json!("A+B")), + ("prefix", json!("Appendix1")), + ("startNumber", json!("0")), + ("startNumber", json!("4294967295")), + ("chapterStart", json!("x")), + ("chapterStart", json!("100000")), + ] { let mut app = open_app(); set( &mut app, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f7740911..3a7e65ace 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -350,6 +350,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. - Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, chapter numbers resolved in book order (Automatic, Start Chapter Numbering at, Same as Previous Document; `layout.chapterNumbering`, with a style for Chapter Number variables and cross-references), style sync from a style source, one merged PDF. Section prefixes follow InDesign's rule (up to 8 characters, no + or comma). +- Numbering & Section Options window as in InDesign (Start Section, automatic or explicit page numbering up to 99999, prefix, style, marker, Include Prefix; the document's chapter numbering and its book); OK applies both in one undo step (`layout.numberingOptions`). - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`).