mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
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:
co-authored by
Claude Opus 5.5
parent
498e3d401c
commit
9c683f3ef8
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user